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,33 @@
/**
* The type of (redux) action which signals that a specific App will mount (in
* React terms).
*
* {
* type: APP_WILL_MOUNT,
* app: App
* }
*/
export const APP_WILL_MOUNT = 'APP_WILL_MOUNT';
/**
* The type of (redux) action which signals that a specific App will unmount (in
* React terms).
*
* {
* type: APP_WILL_UNMOUNT,
* app: App
* }
*/
export const APP_WILL_UNMOUNT = 'APP_WILL_UNMOUNT';
/**
* The type of (redux) action which signals that a specific App will navigate using a route (in
* React terms).
*
* {
* type: APP_WILL_NAVIGATE,
* app: App,
* route: Route
* }
*/
export const APP_WILL_NAVIGATE = 'APP_WILL_NAVIGATE';

View File

@@ -0,0 +1,69 @@
import { IStore } from '../../app/types';
import {
APP_WILL_MOUNT,
APP_WILL_NAVIGATE,
APP_WILL_UNMOUNT
} from './actionTypes';
/**
* Signals that a specific App will mount (in the terms of React).
*
* @param {App} app - The App which will mount.
* @returns {{
* type: APP_WILL_MOUNT,
* app: App
* }}
*/
export function appWillMount(app: Object) {
return (dispatch: IStore['dispatch']) => {
// TODO There was a redux action creator appInit which I did not like
// because we already had the redux action creator appWillMount and,
// respectively, the redux action APP_WILL_MOUNT. So I set out to remove
// appInit and managed to move everything it was doing but the
// following. Which is not extremely bad because we haven't moved the
// API module into its own feature yet so we're bound to work on that in
// the future.
typeof APP === 'object' && APP.API.init();
dispatch({
type: APP_WILL_MOUNT,
app
});
};
}
/**
* Signals that a specific App will unmount (in the terms of React).
*
* @param {App} app - The App which will unmount.
* @returns {{
* type: APP_WILL_UNMOUNT,
* app: App
* }}
*/
export function appWillUnmount(app: Object) {
return {
type: APP_WILL_UNMOUNT,
app
};
}
/**
* Signals that a specific App will navigate (in the terms of React).
*
* @param {App} app - The App which will navigate.
* @param {Object} route - The route which will be used.
* @returns {{
* type: APP_WILL_NAVIGATE,
* app: App,
* route: Object
* }}
*/
export function appWillNavigate(app: Object, route: Object) {
return {
type: APP_WILL_NAVIGATE,
app,
route
};
}

View File

@@ -0,0 +1,283 @@
// @ts-expect-error
import { jitsiLocalStorage } from '@jitsi/js-utils';
import { isEqual } from 'lodash-es';
import React, { Component, ComponentType, Fragment } from 'react';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { compose, createStore } from 'redux';
import Thunk from 'redux-thunk';
import { IStore } from '../../../app/types';
import i18next from '../../i18n/i18next';
import MiddlewareRegistry from '../../redux/MiddlewareRegistry';
import PersistenceRegistry from '../../redux/PersistenceRegistry';
import ReducerRegistry from '../../redux/ReducerRegistry';
import StateListenerRegistry from '../../redux/StateListenerRegistry';
import SoundCollection from '../../sounds/components/SoundCollection';
import { appWillMount, appWillUnmount } from '../actions';
import logger from '../logger';
/**
* The type of the React {@code Component} state of {@link BaseApp}.
*/
interface IState {
/**
* The {@code Route} rendered by the {@code BaseApp}.
*/
route: {
component?: ComponentType;
props?: Object;
};
/**
* The redux store used by the {@code BaseApp}.
*/
store?: IStore;
}
/**
* Base (abstract) class for main App component.
*
* @abstract
*/
export default class BaseApp<P> extends Component<P, IState> {
/**
* The deferred for the initialisation {{promise, resolve, reject}}.
*/
_init: PromiseWithResolvers<any>;
/**
* Initializes a new {@code BaseApp} instance.
*
* @param {Object} props - The read-only React {@code Component} props with
* which the new instance is to be initialized.
*/
constructor(props: P) {
super(props);
this.state = {
route: {},
store: undefined
};
}
/**
* Initializes the app.
*
* @inheritdoc
*/
override async componentDidMount() {
/**
* Make the mobile {@code BaseApp} wait until the {@code AsyncStorage}
* implementation of {@code Storage} initializes fully.
*
* @private
* @see {@link #_initStorage}
* @type {Promise}
*/
this._init = Promise.withResolvers();
try {
await this._initStorage();
const setStatePromise = new Promise(resolve => {
this.setState({
// @ts-ignore
store: this._createStore()
}, resolve);
});
await setStatePromise;
await this._extraInit();
} catch (err) {
/* BaseApp should always initialize! */
logger.error(err);
}
this.state.store?.dispatch(appWillMount(this));
// @ts-ignore
this._init.resolve();
}
/**
* De-initializes the app.
*
* @inheritdoc
*/
override componentWillUnmount() {
this.state.store?.dispatch(appWillUnmount(this));
}
/**
* Logs for errors that were not caught.
*
* @param {Error} error - The error that was thrown.
* @param {Object} info - Info about the error(stack trace);.
*
* @returns {void}
*/
override componentDidCatch(error: Error, info: Object) {
logger.error(error, info);
}
/**
* Delays this {@code BaseApp}'s startup until the {@code Storage}
* implementation of {@code localStorage} initializes. While the
* initialization is instantaneous on Web (with Web Storage API), it is
* asynchronous on mobile/react-native.
*
* @private
* @returns {Promise}
*/
_initStorage(): Promise<any> {
const _initializing = jitsiLocalStorage.getItem('_initializing');
return _initializing || Promise.resolve();
}
/**
* Extra initialisation that subclasses might require.
*
* @returns {void}
*/
_extraInit() {
// To be implemented by subclass.
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { route: { component, props }, store } = this.state;
if (store) {
return (
<I18nextProvider i18n = { i18next }>
{/* @ts-ignore */}
<Provider store = { store }>
<Fragment>
{ this._createMainElement(component, props) }
<SoundCollection />
{ this._createExtraElement() }
{ this._renderDialogContainer() }
</Fragment>
</Provider>
</I18nextProvider>
);
}
return null;
}
/**
* Creates an extra {@link ReactElement}s to be added (unconditionally)
* alongside the main element.
*
* @returns {ReactElement}
* @abstract
* @protected
*/
_createExtraElement(): React.ReactElement | null {
return null;
}
/**
* Creates a {@link ReactElement} from the specified component, the
* specified props and the props of this {@code AbstractApp} which are
* suitable for propagation to the children of this {@code Component}.
*
* @param {Component} component - The component from which the
* {@code ReactElement} is to be created.
* @param {Object} props - The read-only React {@code Component} props with
* which the {@code ReactElement} is to be initialized.
* @returns {ReactElement}
* @protected
*/
_createMainElement(component?: ComponentType, props?: Object) {
return component ? React.createElement(component, props || {}) : null;
}
/**
* Initializes a new redux store instance suitable for use by this
* {@code AbstractApp}.
*
* @private
* @returns {Store} - A new redux store instance suitable for use by
* this {@code AbstractApp}.
*/
_createStore() {
// Create combined reducer from all reducers in ReducerRegistry.
const reducer = ReducerRegistry.combineReducers();
// Apply all registered middleware from the MiddlewareRegistry and
// additional 3rd party middleware:
// - Thunk - allows us to dispatch async actions easily. For more info
// @see https://github.com/gaearon/redux-thunk.
const middleware = MiddlewareRegistry.applyMiddleware(Thunk);
// @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer, PersistenceRegistry.getPersistedState(), composeEnhancers(middleware));
// StateListenerRegistry
StateListenerRegistry.subscribe(store);
// This is temporary workaround to be able to dispatch actions from
// non-reactified parts of the code (conference.js for example).
// Don't use in the react code!!!
// FIXME: remove when the reactification is finished!
if (typeof APP !== 'undefined') {
// @ts-ignore
APP.store = store;
}
return store;
}
/**
* Navigates to a specific Route.
*
* @param {Route} route - The Route to which to navigate.
* @returns {Promise}
*/
_navigate(route: {
component?: ComponentType<any>;
href?: string;
props?: Object;
}): Promise<any> {
if (isEqual(route, this.state.route)) {
return Promise.resolve();
}
if (route.href) {
// This navigation requires loading a new URL in the browser.
window.location.href = route.href;
return Promise.resolve();
}
// XXX React's setState is asynchronous which means that the value of
// this.state.route above may not even be correct. If the check is
// performed before setState completes, the app may not navigate to the
// expected route. In order to mitigate the problem, _navigate was
// changed to return a Promise.
return new Promise(resolve => { // @ts-ignore
this.setState({ route }, resolve);
});
}
/**
* Renders the platform specific dialog container.
*
* @returns {React$Element}
*/
_renderDialogContainer(): React.ReactElement | null {
return null;
}
}

View File

@@ -0,0 +1,28 @@
import { toState } from '../redux/functions';
import { IStateful } from './types';
/**
* Gets the value of a specific React {@code Component} prop of the currently
* mounted {@link App}.
*
* @param {IStateful} stateful - The redux store or {@code getState}
* function.
* @param {string} propName - The name of the React {@code Component} prop of
* the currently mounted {@code App} to get.
* @returns {*} The value of the specified React {@code Component} prop of the
* currently mounted {@code App}.
*/
export function getAppProp(stateful: IStateful, propName: string) {
const state = toState(stateful)['features/base/app'];
if (state) {
const { app } = state;
if (app) {
return app.props[propName];
}
}
return undefined;
}

View File

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

View File

@@ -0,0 +1,54 @@
import { AnyAction } from 'redux';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { isEmbedded } from '../util/embedUtils';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from './actionTypes';
import logger from './logger';
/**
* Experimental feature to monitor CPU pressure.
*/
let pressureObserver: typeof window.PressureObserver;
/**
* Middleware which intercepts app actions to handle changes to the related state.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(() => (next: Function) => (action: AnyAction) => {
switch (action.type) {
case APP_WILL_MOUNT: {
// Disable it inside an iframe until Google fixes the origin trial for 3rd party sources:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1504167
if (!isEmbedded() && 'PressureObserver' in window) {
pressureObserver = new window.PressureObserver(
(records: typeof window.PressureRecord) => {
logger.info('Compute pressure state changed:', JSON.stringify(records));
APP.API.notifyComputePressureChanged(records);
}
);
try {
pressureObserver
.observe('cpu', { sampleInterval: 30_000 })
.catch((e: any) => logger.error('CPU pressure observer failed to start', e));
} catch (e: any) {
logger.error('CPU pressure observer failed to start', e);
}
}
break;
}
case APP_WILL_UNMOUNT: {
if (pressureObserver) {
pressureObserver.unobserve('cpu');
}
break;
}
}
return next(action);
});

View File

@@ -0,0 +1,34 @@
import ReducerRegistry from '../redux/ReducerRegistry';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from './actionTypes';
export interface IAppState {
app?: any;
}
ReducerRegistry.register<IAppState>('features/base/app', (state = {}, action): IAppState => {
switch (action.type) {
case APP_WILL_MOUNT: {
const { app } = action;
if (state.app !== app) {
return {
...state,
app
};
}
break;
}
case APP_WILL_UNMOUNT:
if (state.app === action.app) {
return {
...state,
app: undefined
};
}
break;
}
return state;
});

View File

@@ -0,0 +1,3 @@
import { IReduxState, IStore } from '../../app/types';
export type IStateful = (() => IReduxState) | IStore | IReduxState;

View File

@@ -0,0 +1,10 @@
/**
* The type of (redux) action which sets the audio-only flag for the current
* conference.
*
* {
* type: SET_AUDIO_ONLY,
* audioOnly: boolean
* }
*/
export const SET_AUDIO_ONLY = 'SET_AUDIO_ONLY';

View File

@@ -0,0 +1,51 @@
import { createAudioOnlyChangedEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { IStore } from '../../app/types';
import { SET_AUDIO_ONLY } from './actionTypes';
import logger from './logger';
/**
* Sets the audio-only flag for the current JitsiConference.
*
* @param {boolean} audioOnly - True if the conference should be audio only; false, otherwise.
* @returns {{
* type: SET_AUDIO_ONLY,
* audioOnly: boolean
* }}
*/
export function setAudioOnly(audioOnly: boolean) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const { enabled: oldValue } = getState()['features/base/audio-only'];
if (oldValue !== audioOnly) {
sendAnalytics(createAudioOnlyChangedEvent(audioOnly));
logger.log(`Audio-only ${audioOnly ? 'enabled' : 'disabled'}`);
dispatch({
type: SET_AUDIO_ONLY,
audioOnly
});
if (typeof APP !== 'undefined') {
// TODO This should be a temporary solution that lasts only until video
// tracks and all ui is moved into react/redux on the web.
APP.conference.onToggleAudioOnly();
}
}
};
}
/**
* Toggles the audio-only flag for the current JitsiConference.
*
* @returns {Function}
*/
export function toggleAudioOnly() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const { enabled } = getState()['features/base/audio-only'];
dispatch(setAudioOnly(!enabled));
};
}

View File

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

View File

@@ -0,0 +1,25 @@
import ReducerRegistry from '../redux/ReducerRegistry';
import { SET_AUDIO_ONLY } from './actionTypes';
export interface IAudioOnlyState {
enabled: boolean;
}
const DEFAULT_STATE = {
enabled: false
};
ReducerRegistry.register<IAudioOnlyState>('features/base/audio-only',
(state = DEFAULT_STATE, action): IAudioOnlyState => {
switch (action.type) {
case SET_AUDIO_ONLY:
return {
...state,
enabled: action.audioOnly
};
default:
return state;
}
});

View File

@@ -0,0 +1,295 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { IconUser } from '../../icons/svg';
import { getParticipantById } from '../../participants/functions';
import { IParticipant } from '../../participants/types';
import { getAvatarColor, getInitials, isCORSAvatarURL } from '../functions';
import { IAvatarProps as AbstractProps } from '../types';
import { StatelessAvatar } from './';
export interface IProps {
/**
* The URL patterns for URLs that needs to be handled with CORS.
*/
_corsAvatarURLs?: Array<string>;
/**
* Custom avatar backgrounds from branding.
*/
_customAvatarBackgrounds?: Array<string>;
/**
* The string we base the initials on (this is generated from a list of precedences).
*/
_initialsBase?: string;
/**
* An URL that we validated that it can be loaded.
*/
_loadableAvatarUrl?: string;
/**
* Indicates whether _loadableAvatarUrl should use CORS or not.
*/
_loadableAvatarUrlUseCORS?: boolean;
/**
* A prop to maintain compatibility with web.
*/
className?: string;
/**
* A string to override the initials to generate a color of. This is handy if you don't want to make
* the background color match the string that the initials are generated from.
*/
colorBase?: string;
/**
* Indicates the default icon for the avatar.
*/
defaultIcon?: string;
/**
* Display name of the entity to render an avatar for (if any). This is handy when we need
* an avatar for a non-participant entity (e.g. A recent list item).
*/
displayName?: string;
/**
* Whether or not to update the background color of the avatar.
*/
dynamicColor?: boolean;
/**
* ID of the element, if any.
*/
id?: string;
/**
* The ID of the participant to render an avatar for (if it's a participant avatar).
*/
participantId?: string;
/**
* The size of the avatar.
*/
size?: number;
/**
* One of the expected status strings (e.g. 'available') to render a badge on the avatar, if necessary.
*/
status?: string;
/**
* TestId of the element, if any.
*/
testId?: string;
/**
* URL of the avatar, if any.
*/
url?: string;
/**
* Indicates whether to load the avatar using CORS or not.
*/
useCORS?: boolean;
}
interface IState {
avatarFailed: boolean;
isUsingCORS: boolean;
}
export const DEFAULT_SIZE = 65;
/**
* Implements a class to render avatars in the app.
*/
class Avatar<P extends IProps> extends PureComponent<P, IState> {
/**
* Default values for {@code Avatar} component's properties.
*
* @static
*/
static defaultProps = {
defaultIcon: IconUser,
dynamicColor: true
};
/**
* Instantiates a new {@code Component}.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
const {
_corsAvatarURLs,
url,
useCORS
} = props;
this.state = {
avatarFailed: false,
isUsingCORS: Boolean(useCORS) || Boolean(url && isCORSAvatarURL(url, _corsAvatarURLs))
};
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: P) {
const { _corsAvatarURLs, url } = this.props;
if (prevProps.url !== url) {
// URI changed, so we need to try to fetch it again.
// Eslint doesn't like this statement, but based on the React doc, it's safe if it's
// wrapped in a condition: https://reactjs.org/docs/react-component.html#componentdidupdate
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
avatarFailed: false,
isUsingCORS: Boolean(this.props.useCORS) || Boolean(url && isCORSAvatarURL(url, _corsAvatarURLs))
});
}
}
/**
* Implements {@code Componenr#render}.
*
* @inheritdoc
*/
override render() {
const {
_customAvatarBackgrounds,
_initialsBase,
_loadableAvatarUrl,
_loadableAvatarUrlUseCORS,
className,
colorBase,
defaultIcon,
dynamicColor,
id,
size,
status,
testId,
url
} = this.props;
const { avatarFailed, isUsingCORS } = this.state;
const avatarProps: AbstractProps & {
className?: string;
iconUser?: any;
id?: string;
status?: string;
testId?: string;
url?: string;
useCORS?: boolean;
} = {
className,
color: undefined,
id,
initials: undefined,
onAvatarLoadError: undefined,
onAvatarLoadErrorParams: undefined,
size,
status,
testId,
url: undefined,
useCORS: isUsingCORS
};
// _loadableAvatarUrl is validated that it can be loaded, but uri (if present) is not, so
// we still need to do a check for that. And an explicitly provided URI is higher priority than
// an avatar URL anyhow.
const useReduxLoadableAvatarURL = avatarFailed || !url;
const effectiveURL = useReduxLoadableAvatarURL ? _loadableAvatarUrl : url;
if (effectiveURL) {
avatarProps.onAvatarLoadError = this._onAvatarLoadError;
if (useReduxLoadableAvatarURL) {
avatarProps.onAvatarLoadErrorParams = { dontRetry: true };
avatarProps.useCORS = _loadableAvatarUrlUseCORS;
}
avatarProps.url = effectiveURL;
}
const initials = getInitials(_initialsBase);
if (initials) {
if (dynamicColor) {
avatarProps.color = getAvatarColor(colorBase || _initialsBase, _customAvatarBackgrounds ?? []);
}
avatarProps.initials = initials;
}
if (navigator.product !== 'ReactNative') {
avatarProps.iconUser = defaultIcon;
}
return (
<StatelessAvatar
{ ...avatarProps } />
);
}
/**
* Callback to handle the error while loading of the avatar URI.
*
* @param {Object} params - An object with parameters.
* @param {boolean} params.dontRetry - If false we will retry to load the Avatar with different CORS mode.
* @returns {void}
*/
_onAvatarLoadError(params: { dontRetry?: boolean; } = {}) {
const { dontRetry = false } = params;
if (Boolean(this.props.useCORS) === this.state.isUsingCORS && !dontRetry) {
// try different mode of loading the avatar.
this.setState({
isUsingCORS: !this.state.isUsingCORS
});
} else {
// we already have tried loading the avatar with and without CORS and it failed.
this.setState({
avatarFailed: true
});
}
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
const { colorBase, displayName, participantId } = ownProps;
const _participant: IParticipant | undefined = participantId ? getParticipantById(state, participantId) : undefined;
const _initialsBase = _participant?.name ?? displayName;
const { corsAvatarURLs } = state['features/base/config'];
return {
_customAvatarBackgrounds: state['features/dynamic-branding'].avatarBackgrounds,
_corsAvatarURLs: corsAvatarURLs,
_initialsBase,
_loadableAvatarUrl: _participant?.loadableAvatarUrl,
_loadableAvatarUrlUseCORS: _participant?.loadableAvatarUrlUseCORS,
colorBase
};
}
export default connect(_mapStateToProps)(Avatar);

View File

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

View File

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

View File

@@ -0,0 +1,200 @@
import React, { Component } from 'react';
import { Image, Text, TextStyle, View, ViewStyle } from 'react-native';
import Icon from '../../../icons/components/Icon';
import { StyleType } from '../../../styles/functions.native';
import { isIcon } from '../../functions';
import { IAvatarProps } from '../../types';
import styles from './styles';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const DEFAULT_AVATAR = require('../../../../../../images/avatar.png');
interface IProps extends IAvatarProps {
/**
* One of the expected status strings (e.g. 'available') to render a badge on the avatar, if necessary.
*/
status?: string;
/**
* External style passed to the component.
*/
style?: StyleType;
/**
* The URL of the avatar to render.
*/
url?: string;
}
/**
* Implements a stateless avatar component that renders an avatar purely from what gets passed through
* props.
*/
export default class StatelessAvatar extends Component<IProps> {
/**
* Instantiates a new {@code Component}.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const { initials, size, style, url } = this.props;
let avatar;
if (isIcon(url)) {
avatar = this._renderIconAvatar(url);
} else if (url) {
avatar = this._renderURLAvatar();
} else if (initials) {
avatar = this._renderInitialsAvatar();
} else {
avatar = this._renderDefaultAvatar();
}
return (
<View>
<View
style = { [
styles.avatarContainer(size) as ViewStyle,
style
] }>
{ avatar }
</View>
{ this._renderAvatarStatus() }
</View>
);
}
/**
* Renders a badge representing the avatar status.
*
* @returns {React$Elementaa}
*/
_renderAvatarStatus() {
const { size, status } = this.props;
if (!status) {
return null;
}
return (
<View style = { styles.badgeContainer }>
<View style = { styles.badge(size, status) as ViewStyle } />
</View>
);
}
/**
* Renders the default avatar.
*
* @returns {React$Element<*>}
*/
_renderDefaultAvatar() {
const { size } = this.props;
return (
<Image
source = { DEFAULT_AVATAR }
style = { [
styles.avatarContent(size),
styles.staticAvatar
] } />
);
}
/**
* Renders the icon avatar.
*
* @param {Object} icon - The icon component to render.
* @returns {React$Element<*>}
*/
_renderIconAvatar(icon: Function) {
const { color, size } = this.props;
return (
<View
style = { [
styles.initialsContainer as ViewStyle,
{
backgroundColor: color
}
] }>
<Icon
src = { icon }
style = { styles.initialsText(size) } />
</View>
);
}
/**
* Renders the initials-based avatar.
*
* @returns {React$Element<*>}
*/
_renderInitialsAvatar() {
const { color, initials, size } = this.props;
return (
<View
style = { [
styles.initialsContainer as ViewStyle,
{
backgroundColor: color
}
] }>
<Text style = { styles.initialsText(size) as TextStyle }> { initials } </Text>
</View>
);
}
/**
* Renders the url-based avatar.
*
* @returns {React$Element<*>}
*/
_renderURLAvatar() {
const { onAvatarLoadError, size, url } = this.props;
return (
<Image
defaultSource = { DEFAULT_AVATAR }
// @ts-ignore
onError = { onAvatarLoadError }
resizeMode = 'cover'
source = {{ uri: url }}
style = { styles.avatarContent(size) } />
);
}
/**
* Handles avatar load errors.
*
* @returns {void}
*/
_onAvatarLoadError() {
const { onAvatarLoadError, onAvatarLoadErrorParams = {} } = this.props;
if (onAvatarLoadError) {
onAvatarLoadError({
...onAvatarLoadErrorParams,
dontRetry: true
});
}
}
}

View File

@@ -0,0 +1,82 @@
import { StyleSheet } from 'react-native';
import { ColorPalette } from '../../../styles/components/styles/ColorPalette';
import { PRESENCE_AVAILABLE_COLOR, PRESENCE_AWAY_COLOR, PRESENCE_BUSY_COLOR, PRESENCE_IDLE_COLOR } from '../styles';
const DEFAULT_SIZE = 65;
/**
* The styles of the feature base/participants.
*/
export default {
avatarContainer: (size: number = DEFAULT_SIZE) => {
return {
alignItems: 'center',
borderRadius: size / 2,
height: size,
justifyContent: 'center',
overflow: 'hidden',
width: size
};
},
avatarContent: (size: number = DEFAULT_SIZE) => {
return {
height: size,
width: size
};
},
badge: (size: number = DEFAULT_SIZE, status: string) => {
let color;
switch (status) {
case 'available':
color = PRESENCE_AVAILABLE_COLOR;
break;
case 'away':
color = PRESENCE_AWAY_COLOR;
break;
case 'busy':
color = PRESENCE_BUSY_COLOR;
break;
case 'idle':
color = PRESENCE_IDLE_COLOR;
break;
}
return {
backgroundColor: color,
borderRadius: size / 2,
bottom: 0,
height: size * 0.3,
position: 'absolute',
width: size * 0.3
};
},
badgeContainer: {
...StyleSheet.absoluteFillObject
},
initialsContainer: {
alignItems: 'center',
alignSelf: 'stretch',
flex: 1,
justifyContent: 'center'
},
initialsText: (size: number = DEFAULT_SIZE) => {
return {
color: 'white',
fontSize: size * 0.45,
fontWeight: '100'
};
},
staticAvatar: {
backgroundColor: ColorPalette.lightGrey,
opacity: 0.4
}
};

View File

@@ -0,0 +1,5 @@
// Colors for avatar status badge
export const PRESENCE_AVAILABLE_COLOR = 'rgb(110, 176, 5)';
export const PRESENCE_AWAY_COLOR = 'rgb(250, 201, 20)';
export const PRESENCE_BUSY_COLOR = 'rgb(233, 0, 27)';
export const PRESENCE_IDLE_COLOR = 'rgb(172, 172, 172)';

View File

@@ -0,0 +1,220 @@
import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../icons/components/Icon';
import { pixelsToRem } from '../../../ui/functions.any';
import { isIcon } from '../../functions';
import { IAvatarProps } from '../../types';
import { PRESENCE_AVAILABLE_COLOR, PRESENCE_AWAY_COLOR, PRESENCE_BUSY_COLOR, PRESENCE_IDLE_COLOR } from '../styles';
interface IProps extends IAvatarProps {
/**
* External class name passed through props.
*/
className?: string;
/**
* The default avatar URL if we want to override the app bundled one (e.g. AlwaysOnTop).
*/
defaultAvatar?: string;
/**
* ID of the component to be rendered.
*/
id?: string;
/**
* One of the expected status strings (e.g. 'available') to render a badge on the avatar, if necessary.
*/
status?: string;
/**
* TestId of the element, if any.
*/
testId?: string;
/**
* The URL of the avatar to render.
*/
url?: string | Function;
/**
* Indicates whether to load the avatar using CORS or not.
*/
useCORS?: boolean;
}
const useStyles = makeStyles()(theme => {
return {
avatar: {
backgroundColor: '#AAA',
borderRadius: '50%',
color: theme.palette?.text01 || '#fff',
...(theme.typography?.heading1 ?? {}),
fontSize: 'inherit',
objectFit: 'cover',
textAlign: 'center',
overflow: 'hidden',
'&.avatar-small': {
height: '28px !important',
width: '28px !important'
},
'&.avatar-xsmall': {
height: '16px !important',
width: '16px !important'
},
'& .jitsi-icon': {
transform: 'translateY(50%)'
},
'& .avatar-svg': {
height: '100%',
width: '100%'
}
},
initialsContainer: {
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
},
badge: {
position: 'relative',
'&.avatar-badge:after': {
borderRadius: '50%',
content: '""',
display: 'block',
height: '35%',
position: 'absolute',
bottom: 0,
width: '35%'
},
'&.avatar-badge-available:after': {
backgroundColor: PRESENCE_AVAILABLE_COLOR
},
'&.avatar-badge-away:after': {
backgroundColor: PRESENCE_AWAY_COLOR
},
'&.avatar-badge-busy:after': {
backgroundColor: PRESENCE_BUSY_COLOR
},
'&.avatar-badge-idle:after': {
backgroundColor: PRESENCE_IDLE_COLOR
}
}
};
});
const StatelessAvatar = ({
className,
color,
iconUser,
id,
initials,
onAvatarLoadError,
onAvatarLoadErrorParams,
size,
status,
testId,
url,
useCORS
}: IProps) => {
const { classes, cx } = useStyles();
const _getAvatarStyle = (backgroundColor?: string) => {
return {
background: backgroundColor || undefined,
fontSize: size ? pixelsToRem(size * 0.4) : '180%',
height: size || '100%',
width: size || '100%'
};
};
const _getAvatarClassName = (additional?: string) => cx('avatar', additional, className, classes.avatar);
const _getBadgeClassName = () => {
if (status) {
return cx('avatar-badge', `avatar-badge-${status}`, classes.badge);
}
return '';
};
const _onAvatarLoadError = useCallback(() => {
if (typeof onAvatarLoadError === 'function') {
onAvatarLoadError(onAvatarLoadErrorParams);
}
}, [ onAvatarLoadError, onAvatarLoadErrorParams ]);
if (isIcon(url)) {
return (
<div
className = { cx(_getAvatarClassName(), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle(color) }>
<Icon
size = '50%'
src = { url } />
</div>
);
}
if (url) {
return (
<div className = { _getBadgeClassName() }>
<img
alt = 'avatar'
className = { _getAvatarClassName() }
crossOrigin = { useCORS ? '' : undefined }
data-testid = { testId }
id = { id }
onError = { _onAvatarLoadError }
src = { url }
style = { _getAvatarStyle() } />
</div>
);
}
if (initials) {
return (
<div
className = { cx(_getAvatarClassName(), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle(color) }>
<div className = { classes.initialsContainer }>
{initials}
</div>
</div>
);
}
// default avatar
return (
<div
className = { cx(_getAvatarClassName('defaultAvatar'), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle() }>
<Icon
size = { '50%' }
src = { iconUser } />
</div>
);
};
export default StatelessAvatar;

View File

@@ -0,0 +1,4 @@
/**
* The base URL for gravatar images.
*/
export const GRAVATAR_BASE_URL = 'https://www.gravatar.com/avatar/';

View File

@@ -0,0 +1,95 @@
import GraphemeSplitter from 'grapheme-splitter';
import { split } from 'lodash-es';
const AVATAR_COLORS = [
'#6A50D3',
'#FF9B42',
'#DF486F',
'#73348C',
'#B23683',
'#F96E57',
'#4380E2',
'#238561',
'#00A8B3'
];
const wordSplitRegex = (/\s+|\.+|_+|;+|-+|,+|\|+|\/+|\\+|"+|'+|\(+|\)+|#+|&+/);
const splitter = new GraphemeSplitter();
/**
* Generates the background color of an initials based avatar.
*
* @param {string?} initials - The initials of the avatar.
* @param {Array<string>} customAvatarBackgrounds - Custom avatar background values.
* @returns {string}
*/
export function getAvatarColor(initials: string | undefined, customAvatarBackgrounds: Array<string>) {
const hasCustomAvatarBackgronds = customAvatarBackgrounds?.length;
const colorsBase = hasCustomAvatarBackgronds ? customAvatarBackgrounds : AVATAR_COLORS;
let colorIndex = 0;
if (initials) {
let nameHash = 0;
for (const s of initials) {
nameHash += Number(s.codePointAt(0));
}
colorIndex = nameHash % colorsBase.length;
}
return colorsBase[colorIndex];
}
/**
* Returns the first grapheme from a word, uppercased.
*
* @param {string} word - The string to get grapheme from.
* @returns {string}
*/
function getFirstGraphemeUpper(word: string) {
if (!word?.length) {
return '';
}
return splitter.splitGraphemes(word)[0].toUpperCase();
}
/**
* Generates initials for a simple string.
*
* @param {string?} s - The string to generate initials for.
* @returns {string?}
*/
export function getInitials(s?: string) {
// We don't want to use the domain part of an email address, if it is one
const initialsBasis = split(s, '@')[0];
const [ firstWord, ...remainingWords ] = initialsBasis.split(wordSplitRegex).filter(Boolean);
return getFirstGraphemeUpper(firstWord) + getFirstGraphemeUpper(remainingWords.pop() || '');
}
/**
* Checks if the passed URL should be loaded with CORS.
*
* @param {string | Function} url - The URL (on mobile we use a specific Icon component for avatars).
* @param {Array<string>} corsURLs - The URL pattern that matches a URL that needs to be handled with CORS.
* @returns {boolean}
*/
export function isCORSAvatarURL(url: string | Function, corsURLs: Array<string> = []): boolean {
if (typeof url === 'function') {
return false;
}
return corsURLs.some(pattern => url.startsWith(pattern));
}
/**
* Checks if the passed prop is a loaded icon or not.
*
* @param {string? | Object?} iconProp - The prop to check.
* @returns {boolean}
*/
export function isIcon(iconProp?: string | Function): iconProp is Function {
return Boolean(iconProp) && (typeof iconProp === 'object' || typeof iconProp === 'function');
}

View File

@@ -0,0 +1,32 @@
export interface IAvatarProps {
/**
* Color of the (initials based) avatar, if needed.
*/
color?: string;
/**
* The user icon(browser only).
*/
iconUser?: any;
/**
* Initials to be used to render the initials based avatars.
*/
initials?: string;
/**
* Callback to signal the failure of the loading of the URL.
*/
onAvatarLoadError?: Function;
/**
* Additional parameters to be passed to onAvatarLoadError function.
*/
onAvatarLoadErrorParams?: Object;
/**
* Expected size of the avatar.
*/
size?: number;
}

View File

@@ -0,0 +1,244 @@
/* eslint-disable react/jsx-no-bind */
import React, { useEffect, useState } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../icons/components/Icon';
import { IconCheck, IconCopy } from '../icons/svg';
import { copyText } from '../util/copyText.web';
const useStyles = makeStyles()(theme => {
return {
copyButton: {
...theme.typography.bodyShortBold,
borderRadius: theme.shape.borderRadius,
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
padding: `${theme.spacing(2)} ${theme.spacing(3)}`,
width: '100%',
boxSizing: 'border-box',
background: theme.palette.action01,
cursor: 'pointer',
color: theme.palette.text01,
'&:hover': {
backgroundColor: theme.palette.action01Hover
},
'&.clicked': {
background: theme.palette.success02
},
'& > div > svg': {
fill: theme.palette.icon01
}
},
content: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
maxWidth: 292,
marginRight: theme.spacing(3),
'&.selected': {
fontWeight: 600
}
},
icon: {
marginRight: theme.spacing(2)
}
};
});
let mounted: boolean;
interface IProps {
/**
* The invisible text for screen readers.
*
* Intended to give the same info as `displayedText`, but can be customized to give more necessary context.
* If not given, `displayedText` will be used.
*/
accessibilityText?: string;
/**
* Css class to apply on container.
*/
className?: string;
/**
* The displayed text.
*/
displayedText: string;
/**
* The id of the button.
*/
id?: string;
/**
* The text displayed on copy success.
*/
textOnCopySuccess: string;
/**
* The text displayed on mouse hover.
*/
textOnHover: string;
/**
* The text that needs to be copied (might differ from the displayedText).
*/
textToCopy: string;
}
/**
* Component meant to enable users to copy the conference URL.
*
* @returns {React$Element<any>}
*/
function CopyButton({
accessibilityText,
className = '',
displayedText,
textToCopy,
textOnHover,
textOnCopySuccess,
id
}: IProps) {
const { classes, cx } = useStyles();
const [ isClicked, setIsClicked ] = useState(false);
const [ isHovered, setIsHovered ] = useState(false);
useEffect(() => {
mounted = true;
return () => {
mounted = false;
};
}, []);
/**
* Click handler for the element.
*
* @returns {void}
*/
async function onClick() {
setIsHovered(false);
const isCopied = await copyText(textToCopy);
if (isCopied) {
setIsClicked(true);
setTimeout(() => {
// avoid: Can't perform a React state update on an unmounted component
if (mounted) {
setIsClicked(false);
}
}, 2500);
}
}
/**
* Hover handler for the element.
*
* @returns {void}
*/
function onHoverIn() {
if (!isClicked) {
setIsHovered(true);
}
}
/**
* Hover handler for the element.
*
* @returns {void}
*/
function onHoverOut() {
setIsHovered(false);
}
/**
* KeyPress handler for accessibility.
*
* @param {React.KeyboardEventHandler<HTMLDivElement>} e - The key event to handle.
*
* @returns {void}
*/
function onKeyPress(e: React.KeyboardEvent) {
if (onClick && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
onClick();
}
}
/**
* Renders the content of the link based on the state.
*
* @returns {React$Element<any>}
*/
function renderContent() {
if (isClicked) {
return (
<>
<Icon
className = { classes.icon }
size = { 24 }
src = { IconCheck } />
<div className = { cx(classes.content, 'selected') }>
<span role = { 'alert' }>{ textOnCopySuccess }</span>
</div>
</>
);
}
return (
<>
<Icon
className = { classes.icon }
size = { 24 }
src = { IconCopy } />
<div className = { classes.content }>
<span> { isHovered ? textOnHover : displayedText } </span>
</div>
</>
);
}
return (
<>
<div
aria-describedby = { displayedText === textOnHover
? undefined
: `${id}-sr-text` }
aria-label = { displayedText === textOnHover ? accessibilityText : textOnHover }
className = { cx(className, classes.copyButton, isClicked ? ' clicked' : '') }
id = { id }
onBlur = { onHoverOut }
onClick = { onClick }
onFocus = { onHoverIn }
onKeyPress = { onKeyPress }
onMouseOut = { onHoverOut }
onMouseOver = { onHoverIn }
role = 'button'
tabIndex = { 0 }>
{ renderContent() }
</div>
{ displayedText !== textOnHover && (
<span
className = 'sr-only'
id = { `${id}-sr-text` }>
{ accessibilityText }
</span>
)}
</>
);
}
export default CopyButton;

View File

@@ -0,0 +1,162 @@
import { IStateful } from '../app/types';
import { toState } from '../redux/functions';
import { StyleType } from '../styles/functions.any';
import defaultScheme from './defaultScheme';
/**
* A registry class to register styles that need to be color-schemed.
*
* This class uses lazy initialization for scheme-ified style definitions on
* request.
*/
class ColorSchemeRegistry {
/**
* A map of already scheme-ified style definitions.
*/
_schemedStyles = new Map();
/**
* A map of registered style templates.
*/
_styleTemplates = new Map();
/**
* Clears the already scheme-ified style definitions.
*
* @returns {void}
*/
clear() {
this._schemedStyles.clear();
}
/**
* Retrieves the color-scheme applied style definition of a component.
*
* @param {Object | Function} stateful - An object or function that can be
* resolved to Redux state using the {@code toState} function.
* @param {string} componentName - The name of the component whose style we
* want to retrieve.
* @returns {StyleType}
*/
get(stateful: IStateful, componentName: string): StyleType {
let schemedStyle = this._schemedStyles.get(componentName);
if (!schemedStyle) {
schemedStyle
= this._applyColorScheme(
stateful,
componentName,
this._styleTemplates.get(componentName));
this._schemedStyles.set(componentName, schemedStyle);
}
return schemedStyle;
}
/**
* Registers a style definition to the registry for color-scheming.
*
* NOTE: It's suggested to only use this registry on styles where color
* scheming is needed, otherwise just use a static style object as before.
*
* @param {string} componentName - The name of the component to register the
* style to (e.g. {@code 'Toolbox'}).
* @param {StyleType} style - The style definition to register.
* @returns {void}
*/
register(componentName: string, style: any): void {
this._styleTemplates.set(componentName, style);
// If this is a style overwrite, we need to delete the processed version
// of the style from the other map
this._schemedStyles.delete(componentName);
}
/**
* Creates a color schemed style object applying the color scheme to every
* colors in the style object prepared in a special way.
*
* @param {Object | Function} stateful - An object or function that can be
* resolved to Redux state using the {@code toState} function.
* @param {string} componentName - The name of the component to apply the
* color scheme to.
* @param {StyleType} style - The style definition to apply the color scheme
* to.
* @returns {StyleType}
*/
_applyColorScheme(
stateful: IStateful,
componentName: string,
style: StyleType | null): StyleType {
let schemedStyle: any;
if (Array.isArray(style)) {
// The style is an array of styles, we apply the same transformation
// to each, recursively.
schemedStyle = [];
for (const entry of style) {
schemedStyle.push(this._applyColorScheme(
stateful, componentName, entry));
}
} else {
// The style is an object, we create a copy of it to avoid in-place
// modification.
schemedStyle = {
...style
};
for (const [
styleName,
styleValue
] of Object.entries(schemedStyle)) {
if (typeof styleValue === 'object') {
// The value is another style object, we apply the same
// transformation recursively.
schemedStyle[styleName]
= this._applyColorScheme(
stateful, componentName, styleValue as StyleType);
} else if (typeof styleValue === 'function') {
// The value is a function, which indicates that it's a
// dynamic, schemed color we need to resolve.
const value = styleValue();
schemedStyle[styleName]
= this._getColor(stateful, componentName, value);
}
}
}
return schemedStyle;
}
/**
* Function to get the color value for the provided identifier.
*
* @param {Object | Function} stateful - An object or function that can be
* resolved to Redux state using the {@code toState} function.
* @param {string} componentName - The name of the component to get the
* color value for.
* @param {string} colorDefinition - The string identifier of the color,
* e.g. {@code appBackground}.
* @returns {string}
*/
_getColor(
stateful: IStateful,
componentName: string,
colorDefinition: string): string {
const colorScheme = toState(stateful)['features/base/color-scheme'] || {};
return {
...defaultScheme._defaultTheme,
...colorScheme._defaultTheme,
...defaultScheme[componentName as keyof typeof defaultScheme],
...colorScheme[componentName]
}[colorDefinition];
}
}
export default new ColorSchemeRegistry();

View File

@@ -0,0 +1,30 @@
import { ColorPalette } from '../styles/components/styles/ColorPalette';
import { getRGBAFormat } from '../styles/functions.any';
/**
* The default color scheme of the application.
*/
export default {
'_defaultTheme': {
// Generic app theme colors that are used across the entire app.
// All scheme definitions below inherit these values.
background: 'rgb(255, 255, 255)',
errorText: ColorPalette.red,
icon: 'rgb(28, 32, 37)',
text: 'rgb(28, 32, 37)'
},
'Dialog': {},
'Header': {
background: ColorPalette.blue,
icon: ColorPalette.white,
statusBar: ColorPalette.blueHighlight,
statusBarContent: ColorPalette.white,
text: ColorPalette.white
},
'Toolbox': {
button: 'rgb(255, 255, 255)',
buttonToggled: 'rgb(38, 58, 76)',
buttonToggledBorder: getRGBAFormat('#a4b8d1', 0.6),
hangup: 'rgb(227,79,86)'
}
};

View File

@@ -0,0 +1,11 @@
/**
* A special function to be used in the {@code createColorSchemedStyle} call,
* that denotes that the color is a dynamic color.
*
* @param {string} colorDefinition - The definition of the color to mark to be
* resolved.
* @returns {Function}
*/
export function schemeColor(colorDefinition: string): Function {
return () => colorDefinition;
}

View File

@@ -0,0 +1,5 @@
{
"panePadding": 24,
"participantsPaneWidth": 315,
"MD_BREAKPOINT": "580px"
}

View File

@@ -0,0 +1,381 @@
/**
* The type of (redux) action which signals that server authentication has
* becoming available or unavailable or logged in user has changed.
*
* {
* type: AUTH_STATUS_CHANGED,
* authEnabled: boolean,
* authLogin: string
* }
*/
export const AUTH_STATUS_CHANGED = 'AUTH_STATUS_CHANGED';
/**
* The type of (redux) action which signals that a specific conference failed.
*
* {
* type: CONFERENCE_FAILED,
* conference: JitsiConference,
* error: Error
* }
*/
export const CONFERENCE_FAILED = 'CONFERENCE_FAILED';
/**
* The type of (redux) action which signals that a specific conference was
* joined.
*
* {
* type: CONFERENCE_JOINED,
* conference: JitsiConference
* }
*/
export const CONFERENCE_JOINED = 'CONFERENCE_JOINED';
/**
* The type of (redux) action which signals that a specific conference joining is in progress.
* A CONFERENCE_JOINED is guaranteed to follow.
*
* {
* type: CONFERENCE_JOIN_IN_PROGRESS,
* conference: JitsiConference
* }
*/
export const CONFERENCE_JOIN_IN_PROGRESS = 'CONFERENCE_JOIN_IN_PROGRESS';
/**
* The type of (redux) action which signals that a specific conference was left.
*
* {
* type: CONFERENCE_LEFT,
* conference: JitsiConference
* }
*/
export const CONFERENCE_LEFT = 'CONFERENCE_LEFT';
/**
* The type of (redux) action which signals that the conference is out of focus.
* For example, if the user navigates to the Chat screen.
*
* {
* type: CONFERENCE_BLURRED,
* }
*/
export const CONFERENCE_BLURRED = 'CONFERENCE_BLURRED';
/**
* The type of (redux) action which signals that the conference is in focus.
*
* {
* type: CONFERENCE_FOCUSED,
* }
*/
export const CONFERENCE_FOCUSED = 'CONFERENCE_FOCUSED';
/**
* The type of (redux) action, which indicates conference local subject changes.
*
* {
* type: CONFERENCE_LOCAL_SUBJECT_CHANGED
* subject: string
* }
*/
export const CONFERENCE_LOCAL_SUBJECT_CHANGED = 'CONFERENCE_LOCAL_SUBJECT_CHANGED';
/**
* The type of (redux) action, which indicates conference properties change.
*
* {
* type: CONFERENCE_PROPERTIES_CHANGED
* properties: {
* audio-recording-enabled: boolean,
* visitor-count: number
* }
* }
*/
export const CONFERENCE_PROPERTIES_CHANGED = 'CONFERENCE_PROPERTIES_CHANGED';
/**
* The type of (redux) action, which indicates conference subject changes.
*
* {
* type: CONFERENCE_SUBJECT_CHANGED
* subject: string
* }
*/
export const CONFERENCE_SUBJECT_CHANGED = 'CONFERENCE_SUBJECT_CHANGED';
/**
* The type of (redux) action, which indicates conference UTC timestamp changes.
*
* {
* type: CONFERENCE_TIMESTAMP_CHANGED
* timestamp: number
* }
*/
export const CONFERENCE_TIMESTAMP_CHANGED = 'CONFERENCE_TIMESTAMP_CHANGED';
/**
* The type of (redux) action which signals that an uuid for a conference has been set.
*
* {
* type: CONFERENCE_UNIQUE_ID_SET,
* conference: JitsiConference
* }
*/
export const CONFERENCE_UNIQUE_ID_SET = 'CONFERENCE_UNIQUE_ID_SET';
/**
* The type of (redux) action which signals that the end-to-end RTT against a specific remote participant has changed.
*
* {
* type: E2E_RTT_CHANGED,
* e2eRtt: {
* rtt: number,
* participant: Object,
* }
* }
*/
export const E2E_RTT_CHANGED = 'E2E_RTT_CHANGED'
/**
* The type of (redux) action which signals that a conference will be initialized.
*
* {
* type: CONFERENCE_WILL_INIT
* }
*/
export const CONFERENCE_WILL_INIT = 'CONFERENCE_WILL_INIT';
/**
* The type of (redux) action which signals that a specific conference will be
* joined.
*
* {
* type: CONFERENCE_WILL_JOIN,
* conference: JitsiConference
* }
*/
export const CONFERENCE_WILL_JOIN = 'CONFERENCE_WILL_JOIN';
/**
* The type of (redux) action which signals that a specific conference will be
* left.
*
* {
* type: CONFERENCE_WILL_LEAVE,
* conference: JitsiConference
* }
*/
export const CONFERENCE_WILL_LEAVE = 'CONFERENCE_WILL_LEAVE';
/**
* The type of (redux) action which signals that the data channel with the
* bridge has been established.
*
* {
* type: DATA_CHANNEL_OPENED
* }
*/
export const DATA_CHANNEL_OPENED = 'DATA_CHANNEL_OPENED';
/**
* The type of (redux) action which signals that the data channel with the
* bridge has been closed.
*
* {
* type: DATA_CHANNEL_CLOSED,
* code: number,
* reason: string
* }
*/
export const DATA_CHANNEL_CLOSED = 'DATA_CHANNEL_CLOSED';
/**
* The type of (redux) action which indicates that an endpoint message
* sent by another participant to the data channel is received.
*
* {
* type: ENDPOINT_MESSAGE_RECEIVED,
* participant: Object,
* data: Object
* }
*/
export const ENDPOINT_MESSAGE_RECEIVED = 'ENDPOINT_MESSAGE_RECEIVED';
/**
* The type of action which signals that the user has been kicked out from
* the conference.
*
* {
* type: KICKED_OUT,
* conference: JitsiConference
* }
*/
export const KICKED_OUT = 'KICKED_OUT';
/**
* The type of (redux) action which signals that the lock state of a specific
* {@code JitsiConference} changed.
*
* {
* type: LOCK_STATE_CHANGED,
* conference: JitsiConference,
* locked: boolean
* }
*/
export const LOCK_STATE_CHANGED = 'LOCK_STATE_CHANGED';
/**
* The type of (redux) action which signals that a system (non-participant) message has been received.
*
* {
* type: NON_PARTICIPANT_MESSAGE_RECEIVED,
* id: String,
* json: Object
* }
*/
export const NON_PARTICIPANT_MESSAGE_RECEIVED = 'NON_PARTICIPANT_MESSAGE_RECEIVED';
/**
* The type of (redux) action which sets the peer2peer flag for the current
* conference.
*
* {
* type: P2P_STATUS_CHANGED,
* p2p: boolean
* }
*/
export const P2P_STATUS_CHANGED = 'P2P_STATUS_CHANGED';
/**
* The type of (redux) action which signals to play specified touch tones.
*
* {
* type: SEND_TONES,
* tones: string,
* duration: number,
* pause: number
* }
*/
export const SEND_TONES = 'SEND_TONES';
/**
* The type of (redux) action which updates the current known status of the
* Follow Me feature.
*
* {
* type: SET_FOLLOW_ME,
* enabled: boolean
* }
*/
export const SET_FOLLOW_ME = 'SET_FOLLOW_ME';
/**
* The type of (redux) action which updates the current known status of the
* Follow Me feature that is used only by the recorder.
*
* {
* type: SET_FOLLOW_ME_RECORDER,
* enabled: boolean
* }
*/
export const SET_FOLLOW_ME_RECORDER = 'SET_FOLLOW_ME_RECORDER';
/**
* The type of (redux) action which sets the obfuscated room name.
*
* {
* type: SET_OBFUSCATED_ROOM,
* obfuscatedRoom: string
* }
*/
export const SET_OBFUSCATED_ROOM = 'SET_OBFUSCATED_ROOM';
/**
* The type of (redux) action which updates the current known status of the
* Mute Reactions Sound feature.
*
* {
* type: SET_START_REACTIONS_MUTED,
* enabled: boolean
* }
*/
export const SET_START_REACTIONS_MUTED = 'SET_START_REACTIONS_MUTED';
/**
* The type of (redux) action which sets the password to join or lock a specific
* {@code JitsiConference}.
*
* {
* type: SET_PASSWORD,
* conference: JitsiConference,
* method: Function
* password: string
* }
*/
export const SET_PASSWORD = 'SET_PASSWORD';
/**
* The type of (redux) action which signals that setting a password on a
* {@code JitsiConference} failed (with an error).
*
* {
* type: SET_PASSWORD_FAILED,
* error: string
* }
*/
export const SET_PASSWORD_FAILED = 'SET_PASSWORD_FAILED';
/**
* The type of (redux) action which signals for pending subject changes.
*
* {
* type: SET_PENDING_SUBJECT_CHANGE,
* subject: string
* }
*/
export const SET_PENDING_SUBJECT_CHANGE = 'SET_PENDING_SUBJECT_CHANGE';
/**
* The type of (redux) action which sets the name of the room of the
* conference to be joined.
*
* {
* type: SET_ROOM,
* room: string
* }
*/
export const SET_ROOM = 'SET_ROOM';
/**
* The type of (redux) action which updates the current known status of the
* moderator features for starting participants as audio or video muted.
*
* {
* type: SET_START_MUTED_POLICY,
* startAudioMutedPolicy: boolean,
* startVideoMutedPolicy: boolean
* }
*/
export const SET_START_MUTED_POLICY = 'SET_START_MUTED_POLICY';
/**
* The type of (redux) action which updates the assumed bandwidth bps.
*
* {
* type: SET_ASSUMED_BANDWIDTH_BPS,
* assumedBandwidthBps: number
* }
*/
export const SET_ASSUMED_BANDWIDTH_BPS = 'SET_ASSUMED_BANDWIDTH_BPS';
/**
* The type of (redux) action which updated the conference metadata.
*
* {
* type: UPDATE_CONFERENCE_METADATA,
* metadata: Object
* }
*/
export const UPDATE_CONFERENCE_METADATA = 'UPDATE_CONFERENCE_METADATA';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
import { IStore } from '../../app/types';
import { setAudioMuted, setVideoMuted } from '../media/actions';
import { MEDIA_TYPE, MediaType, VIDEO_MUTISM_AUTHORITY } from '../media/constants';
export * from './actions.any';
/**
* Starts audio and/or video for the visitor.
*
* @param {Array<MediaType>} mediaTypes - The media types that need to be started.
* @returns {Function}
*/
export function setupVisitorStartupMedia(mediaTypes: Array<MediaType>) {
return (dispatch: IStore['dispatch']) => {
if (!mediaTypes || !Array.isArray(mediaTypes)) {
return;
}
mediaTypes.forEach(mediaType => {
switch (mediaType) {
case MEDIA_TYPE.AUDIO:
dispatch(setAudioMuted(false, true));
break;
case MEDIA_TYPE.VIDEO:
dispatch(setVideoMuted(false, VIDEO_MUTISM_AUTHORITY.USER, true));
}
});
};
}

View File

@@ -0,0 +1,25 @@
import { IStore } from '../../app/types';
import { gumPending } from '../media/actions';
import { MEDIA_TYPE, MediaType } from '../media/constants';
import { IGUMPendingState } from '../media/types';
import { createAndAddInitialAVTracks } from '../tracks/actions.web';
export * from './actions.any';
/**
* Starts audio and/or video for the visitor.
*
* @param {Array<MediaType>} media - The media types that need to be started.
* @returns {Function}
*/
export function setupVisitorStartupMedia(media: Array<MediaType>) {
return (dispatch: IStore['dispatch']) => {
// Clear the gum pending state in case we have set it to pending since we are starting the
// conference without tracks.
dispatch(gumPending([ MEDIA_TYPE.AUDIO, MEDIA_TYPE.VIDEO ], IGUMPendingState.NONE));
if (media && Array.isArray(media) && media.length > 0) {
dispatch(createAndAddInitialAVTracks(media));
}
};
}

View File

@@ -0,0 +1,47 @@
/**
* The command type for updating a participant's avatar URL.
*
* @type {string}
*/
export const AVATAR_URL_COMMAND = 'avatar-url';
/**
* The command type for updating a participant's email address.
*
* @type {string}
*/
export const EMAIL_COMMAND = 'email';
/**
* The name of the {@code JitsiConference} property which identifies the URL of
* the conference represented by the {@code JitsiConference} instance.
*
* TODO It was introduced in a moment of desperation. Jitsi Meet SDK for Android
* and iOS needs to deliver events from the JavaScript side where they originate
* to the Java and Objective-C sides, respectively, where they are to be
* handled. The URL of the {@code JitsiConference} was chosen as the identifier
* because the Java and Objective-C sides join by URL through their respective
* loadURL methods. But features/base/connection's {@code locationURL} is not
* guaranteed at the time of this writing to match the {@code JitsiConference}
* instance when the events are to be fired. Patching {@code JitsiConference}
* from the outside is not cool but it should suffice for now.
*/
export const JITSI_CONFERENCE_URL_KEY = Symbol('url');
export const TRIGGER_READY_TO_CLOSE_REASONS = {
'dialog.sessTerminatedReason': 'The meeting has been terminated',
'lobby.lobbyClosed': 'Lobby room closed.'
};
/**
* Conference leave reasons.
*/
export const CONFERENCE_LEAVE_REASONS = {
SWITCH_ROOM: 'switch_room',
UNRECOVERABLE_ERROR: 'unrecoverable_error'
};
/**
* The ID of the notification that is shown when the user is muted by focus.
*/
export const START_MUTED_NOTIFICATION_ID = 'start-muted';

View File

@@ -0,0 +1,620 @@
import { sha512_256 as sha512 } from 'js-sha512';
import { upperFirst, words } from 'lodash-es';
import { getName } from '../../app/functions';
import { IReduxState, IStore } from '../../app/types';
import { showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { determineTranscriptionLanguage } from '../../transcribing/functions';
import { IStateful } from '../app/types';
import { JitsiTrackErrors } from '../lib-jitsi-meet';
import { setAudioMuted, setVideoMuted } from '../media/actions';
import { VIDEO_MUTISM_AUTHORITY } from '../media/constants';
import {
participantJoined,
participantLeft
} from '../participants/actions';
import { getLocalParticipant } from '../participants/functions';
import { toState } from '../redux/functions';
import {
appendURLParam,
getBackendSafePath,
safeDecodeURIComponent
} from '../util/uri';
import { setObfuscatedRoom } from './actions';
import {
AVATAR_URL_COMMAND,
EMAIL_COMMAND,
JITSI_CONFERENCE_URL_KEY,
START_MUTED_NOTIFICATION_ID
} from './constants';
import logger from './logger';
import { IJitsiConference } from './reducer';
/**
* Returns root conference state.
*
* @param {IReduxState} state - Global state.
* @returns {Object} Conference state.
*/
export const getConferenceState = (state: IReduxState) => state['features/base/conference'];
/**
* Attach a set of local tracks to a conference.
*
* @param {JitsiConference} conference - Conference instance.
* @param {JitsiLocalTrack[]} localTracks - List of local media tracks.
* @protected
* @returns {Promise}
*/
export function _addLocalTracksToConference(
conference: IJitsiConference,
localTracks: Array<Object>) {
const conferenceLocalTracks = conference.getLocalTracks();
const promises = [];
for (const track of localTracks) {
// XXX The library lib-jitsi-meet may be draconian, for example, when
// adding one and the same video track multiple times.
if (conferenceLocalTracks.indexOf(track) === -1) {
promises.push(
conference.addTrack(track).catch((err: Error) => {
_reportError(
'Failed to add local track to conference',
err);
}));
}
}
return Promise.all(promises);
}
/**
* Logic shared between web and RN which processes the {@code USER_JOINED}
* conference event and dispatches either {@link participantJoined} or
* {@link hiddenParticipantJoined}.
*
* @param {Object} store - The redux store.
* @param {JitsiMeetConference} conference - The conference for which the
* {@code USER_JOINED} event is being processed.
* @param {JitsiParticipant} user - The user who has just joined.
* @returns {void}
*/
export function commonUserJoinedHandling(
{ dispatch }: { dispatch: IStore['dispatch']; },
conference: IJitsiConference,
user: any) {
const id = user.getId();
const displayName = user.getDisplayName();
if (!user.isHidden()) {
const isReplacing = user?.isReplacing();
const isPromoted = conference?.getMetadataHandler().getMetadata()?.visitors?.promoted?.[id];
// the identity and avatar come from jwt and never change in the presence
dispatch(participantJoined({
avatarURL: user.getIdentity()?.user?.avatar,
botType: user.getBotType(),
conference,
id,
name: displayName,
presence: user.getStatus(),
role: user.getRole(),
isPromoted,
isReplacing,
sources: user.getSources()
}));
}
}
/**
* Logic shared between web and RN which processes the {@code USER_LEFT}
* conference event and dispatches either {@link participantLeft} or
* {@link hiddenParticipantLeft}.
*
* @param {Object} store - The redux store.
* @param {JitsiMeetConference} conference - The conference for which the
* {@code USER_LEFT} event is being processed.
* @param {JitsiParticipant} user - The user who has just left.
* @returns {void}
*/
export function commonUserLeftHandling(
{ dispatch }: { dispatch: IStore['dispatch']; },
conference: IJitsiConference,
user: any) {
const id = user.getId();
if (!user.isHidden()) {
const isReplaced = user.isReplaced?.();
dispatch(participantLeft(id, conference, { isReplaced }));
}
}
/**
* Evaluates a specific predicate for each {@link JitsiConference} known to the
* redux state features/base/conference while it returns {@code true}.
*
* @param {IStateful} stateful - The redux store, state, or
* {@code getState} function.
* @param {Function} predicate - The predicate to evaluate for each
* {@code JitsiConference} know to the redux state features/base/conference
* while it returns {@code true}.
* @returns {boolean} If the specified {@code predicate} returned {@code true}
* for all {@code JitsiConference} instances known to the redux state
* features/base/conference.
*/
export function forEachConference(
stateful: IStateful,
predicate: (a: any, b: URL) => boolean) {
const state = getConferenceState(toState(stateful));
for (const v of Object.values(state)) {
// Does the value of the base/conference's property look like a
// JitsiConference?
if (v && typeof v === 'object') {
const url: URL = v[JITSI_CONFERENCE_URL_KEY];
// XXX The Web version of Jitsi Meet does not utilize
// JITSI_CONFERENCE_URL_KEY at the time of this writing. An
// alternative is necessary then to recognize JitsiConference
// instances and myUserId is as good as any other property.
if ((url || typeof v.myUserId === 'function')
&& !predicate(v, url)) {
return false;
}
}
}
return true;
}
/**
* Returns the display name of the conference.
*
* @param {IStateful} stateful - Reference that can be resolved to Redux
* state with the {@code toState} function.
* @returns {string}
*/
export function getConferenceName(stateful: IStateful): string {
const state = toState(stateful);
const { callee } = state['features/base/jwt'];
const { callDisplayName } = state['features/base/config'];
const { localSubject, pendingSubjectChange, room, subject } = getConferenceState(state);
return (localSubject
|| pendingSubjectChange
|| subject
|| callDisplayName
|| callee?.name
|| (room && safeStartCase(safeDecodeURIComponent(room)))) ?? '';
}
/**
* Returns the name of the conference formatted for the title.
*
* @param {IStateful} stateful - Reference that can be resolved to Redux state with the {@code toState}
* function.
* @returns {string} - The name of the conference formatted for the title.
*/
export function getConferenceNameForTitle(stateful: IStateful) {
return safeStartCase(safeDecodeURIComponent(getConferenceState(toState(stateful)).room ?? ''));
}
/**
* Returns an object aggregating the conference options.
*
* @param {IStateful} stateful - The redux store state.
* @returns {Object} - Options object.
*/
export function getConferenceOptions(stateful: IStateful) {
const state = toState(stateful);
const config = state['features/base/config'];
const { locationURL } = state['features/base/connection'];
const { defaultTranscriptionLanguage } = state['features/dynamic-branding'];
const { tenant } = state['features/base/jwt'];
const { email, name: nick } = getLocalParticipant(state) ?? {};
const options: any = { ...config };
if (tenant) {
options.siteID = tenant;
}
if (options.enableDisplayNameInStats && nick) {
options.statisticsDisplayName = nick;
}
if (options.enableEmailInStats && email) {
options.statisticsId = email;
}
if (locationURL) {
options.confID = `${locationURL.host}${getBackendSafePath(locationURL.pathname)}`;
}
options.applicationName = getName();
options.transcriptionLanguage
= defaultTranscriptionLanguage ?? determineTranscriptionLanguage(options);
// Disable analytics, if requested.
if (options.disableThirdPartyRequests) {
delete config.analytics?.scriptURLs;
delete config.analytics?.amplitudeAPPKey;
}
return options;
}
/**
* Returns the restored conference options if anything is available to be restored or undefined.
*
* @param {IStateful} stateful - The redux store state.
* @returns {Object?}
*/
export function restoreConferenceOptions(stateful: IStateful) {
const config = toState(stateful)['features/base/config'];
if (config.oldConfig) {
return {
hosts: {
domain: config.oldConfig.hosts.domain,
muc: config.oldConfig.hosts.muc
},
focusUserJid: config.oldConfig.focusUserJid,
disableFocus: false,
bosh: config.oldConfig.bosh,
websocket: config.oldConfig.websocket,
oldConfig: undefined
};
}
// nothing to return
return;
}
/**
* Override the global config (that is, window.config) with XMPP configuration required to join as a visitor.
*
* @param {IStateful} stateful - The redux store state.
* @param {string|undefined} vnode - The received parameters.
* @param {string} focusJid - The received parameters.
* @param {string|undefined} username - The received parameters.
* @returns {Object}
*/
export function getVisitorOptions(stateful: IStateful, vnode: string, focusJid: string, username: string) {
const config = toState(stateful)['features/base/config'];
if (!config?.hosts) {
logger.warn('Wrong configuration, missing hosts.');
return;
}
if (!vnode) {
// this is redirecting back to main, lets restore config
// not updating disableFocus, as if the room capacity is full the promotion to the main room will fail
// and the visitor will be redirected back to a vnode from jicofo
if (config.oldConfig && username) {
return {
hosts: config.oldConfig.hosts,
focusUserJid: focusJid,
disableLocalStatsBroadcast: false,
bosh: config.oldConfig.bosh && appendURLParam(config.oldConfig.bosh, 'customusername', username),
p2p: config.oldConfig.p2p,
websocket: config.oldConfig.websocket
&& appendURLParam(config.oldConfig.websocket, 'customusername', username),
oldConfig: undefined // clears it up
};
}
return;
}
const oldConfig = {
hosts: {
domain: ''
},
focusUserJid: config.focusUserJid,
bosh: config.bosh,
p2p: config.p2p,
websocket: config.websocket
};
// copy original hosts, to make sure we do not use a modified one later
Object.assign(oldConfig.hosts, config.hosts);
const domain = `${vnode}.meet.jitsi`;
return {
oldConfig,
hosts: {
domain,
muc: config.hosts.muc.replace(oldConfig.hosts.domain, domain)
},
focusUserJid: focusJid,
disableFocus: true, // This flag disables sending the initial conference request
disableLocalStatsBroadcast: true,
bosh: config.bosh && appendURLParam(config.bosh, 'vnode', vnode),
p2p: {
...config.p2p,
enabled: false
},
websocket: config.websocket && appendURLParam(config.websocket, 'vnode', vnode)
};
}
/**
* Returns the UTC timestamp when the first participant joined the conference.
*
* @param {IStateful} stateful - Reference that can be resolved to Redux
* state with the {@code toState} function.
* @returns {number}
*/
export function getConferenceTimestamp(stateful: IStateful) {
const state = toState(stateful);
const { conferenceTimestamp } = getConferenceState(state);
return conferenceTimestamp;
}
/**
* Returns the current {@code JitsiConference} which is joining or joined and is
* not leaving. Please note the contrast with merely reading the
* {@code conference} state of the feature base/conference which is not joining
* but may be leaving already.
*
* @param {IStateful} stateful - The redux store, state, or
* {@code getState} function.
* @returns {JitsiConference|undefined}
*/
export function getCurrentConference(stateful: IStateful): IJitsiConference | undefined {
const { conference, joining, leaving, membersOnly, passwordRequired }
= getConferenceState(toState(stateful));
// There is a precedence
if (conference) {
return conference === leaving ? undefined : conference;
}
return joining || passwordRequired || membersOnly;
}
/**
* Returns whether the current conference is a P2P connection.
* Will return `false` if it's a JVB one, and `null` if there is no conference.
*
* @param {IStateful} stateful - The redux store, state, or
* {@code getState} function.
* @returns {boolean|null}
*/
export function isP2pActive(stateful: IStateful): boolean | null {
const conference = getCurrentConference(toState(stateful));
if (!conference) {
return null;
}
return conference.isP2PActive();
}
/**
* Returns the stored room name.
*
* @param {IReduxState} state - The current state of the app.
* @returns {string}
*/
export function getRoomName(state: IReduxState) {
return getConferenceState(state).room;
}
/**
* Get an obfuscated room name or create and persist it if it doesn't exists.
*
* @param {IReduxState} state - The current state of the app.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {string} - Obfuscated room name.
*/
export function getOrCreateObfuscatedRoomName(state: IReduxState, dispatch: IStore['dispatch']) {
let { obfuscatedRoom } = getConferenceState(state);
const { obfuscatedRoomSource } = getConferenceState(state);
const room = getRoomName(state);
if (!room) {
return;
}
// On native mobile the store doesn't clear when joining a new conference so we might have the obfuscatedRoom
// stored even though a different room was joined.
// Check if the obfuscatedRoom was already computed for the current room.
if (!obfuscatedRoom || (obfuscatedRoomSource !== room)) {
obfuscatedRoom = sha512(room);
dispatch(setObfuscatedRoom(obfuscatedRoom, room));
}
return obfuscatedRoom;
}
/**
* Analytics may require an obfuscated room name, this functions decides based on a config if the normal or
* obfuscated room name should be returned.
*
* @param {IReduxState} state - The current state of the app.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {string} - Analytics room name.
*/
export function getAnalyticsRoomName(state: IReduxState, dispatch: IStore['dispatch']) {
const { analysis: { obfuscateRoomName = false } = {} } = state['features/base/config'];
if (obfuscateRoomName) {
return getOrCreateObfuscatedRoomName(state, dispatch);
}
return getRoomName(state);
}
/**
* Handle an error thrown by the backend (i.e. {@code lib-jitsi-meet}) while
* manipulating a conference participant (e.g. Pin or select participant).
*
* @param {Error} err - The Error which was thrown by the backend while
* manipulating a conference participant and which is to be handled.
* @protected
* @returns {void}
*/
export function _handleParticipantError(err: Error) {
// XXX DataChannels are initialized at some later point when the conference
// has multiple participants, but code that pins or selects a participant
// might be executed before. So here we're swallowing a particular error.
// TODO Lib-jitsi-meet should be fixed to not throw such an exception in
// these scenarios.
if (err.message !== 'Data channels support is disabled!') {
throw err;
}
}
/**
* Determines whether a specific string is a valid room name.
*
* @param {(string|undefined)} room - The name of the conference room to check
* for validity.
* @returns {boolean} If the specified room name is valid, then true; otherwise,
* false.
*/
export function isRoomValid(room?: string) {
return typeof room === 'string' && room !== '';
}
/**
* Remove a set of local tracks from a conference.
*
* @param {JitsiConference} conference - Conference instance.
* @param {JitsiLocalTrack[]} localTracks - List of local media tracks.
* @protected
* @returns {Promise}
*/
export function _removeLocalTracksFromConference(
conference: IJitsiConference,
localTracks: Array<Object>) {
return Promise.all(localTracks.map(track =>
conference.removeTrack(track)
.catch((err: Error) => {
// Local track might be already disposed by direct
// JitsiTrack#dispose() call. So we should ignore this error
// here.
if (err.name !== JitsiTrackErrors.TRACK_IS_DISPOSED) {
_reportError(
'Failed to remove local track from conference',
err);
}
})
));
}
/**
* Reports a specific Error with a specific error message. While the
* implementation merely logs the specified msg and err via the console at the
* time of this writing, the intention of the function is to abstract the
* reporting of errors and facilitate elaborating on it in the future.
*
* @param {string} msg - The error message to report.
* @param {Error} err - The Error to report.
* @private
* @returns {void}
*/
function _reportError(msg: string, err: Error) {
// TODO This is a good point to call some global error handler when we have
// one.
logger.error(msg, err);
}
/**
* Sends a representation of the local participant such as her avatar (URL),
* email address, and display name to (the remote participants of) a specific
* conference.
*
* @param {Function|Object} stateful - The redux store, state, or
* {@code getState} function.
* @param {JitsiConference} conference - The {@code JitsiConference} to which
* the representation of the local participant is to be sent.
* @returns {void}
*/
export function sendLocalParticipant(
stateful: IStateful,
conference?: IJitsiConference) {
const {
avatarURL,
email,
features,
name
} = getLocalParticipant(stateful) ?? {};
avatarURL && conference?.sendCommand(AVATAR_URL_COMMAND, {
value: avatarURL
});
email && conference?.sendCommand(EMAIL_COMMAND, {
value: email
});
if (features && features['screen-sharing'] === 'true') {
conference?.setLocalParticipantProperty('features_screen-sharing', true);
}
conference?.setDisplayName(name);
}
/**
* A safe implementation of lodash#startCase that doesn't deburr the string.
*
* NOTE: According to lodash roadmap, lodash v5 will have this function.
*
* Code based on https://github.com/lodash/lodash/blob/master/startCase.js.
*
* @param {string} s - The string to do start case on.
* @returns {string}
*/
function safeStartCase(s = '') {
return words(`${s}`.replace(/['\u2019]/g, '')).reduce(
(result, word, index) => result + (index ? ' ' : '') + upperFirst(word)
, '');
}
/**
* Updates the mute state of the track based on the start muted policy.
*
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
* @param {Function} dispatch - Redux dispatch function.
* @param {boolean} isAudio - Whether the track is audio or video.
* @returns {void}
*/
export function updateTrackMuteState(stateful: IStateful, dispatch: IStore['dispatch'], isAudio: boolean) {
const state = toState(stateful);
const mutedPolicyKey = isAudio ? 'startAudioMutedPolicy' : 'startVideoMutedPolicy';
const mutedPolicyValue = state['features/base/conference'][mutedPolicyKey];
// Currently, the policy only supports force muting others, not unmuting them.
if (!mutedPolicyValue) {
return;
}
let muteStateUpdated = false;
const { muted } = isAudio ? state['features/base/media'].audio : state['features/base/media'].video;
if (isAudio && !Boolean(muted)) {
dispatch(setAudioMuted(mutedPolicyValue, true));
muteStateUpdated = true;
} else if (!isAudio && !Boolean(muted)) {
// TODO: Add a new authority for video mutism for the moderator case.
dispatch(setVideoMuted(mutedPolicyValue, VIDEO_MUTISM_AUTHORITY.USER, true));
muteStateUpdated = true;
}
if (muteStateUpdated) {
dispatch(showNotification({
titleKey: 'notify.mutedTitle',
descriptionKey: 'notify.muted',
uid: START_MUTED_NOTIFICATION_ID // use the same id, to make sure we show one notification
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
}
}

View File

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

View File

@@ -0,0 +1,795 @@
import i18n from 'i18next';
import { AnyAction } from 'redux';
// @ts-ignore
import { MIN_ASSUMED_BANDWIDTH_BPS } from '../../../../modules/API/constants';
import {
ACTION_PINNED,
ACTION_UNPINNED,
createNotAllowedErrorEvent,
createOfferAnswerFailedEvent,
createPinnedEvent
} from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { reloadNow } from '../../app/actions';
import { IStore } from '../../app/types';
import { removeLobbyChatParticipant } from '../../chat/actions.any';
import { openDisplayNamePrompt } from '../../display-name/actions';
import { isVpaasMeeting } from '../../jaas/functions';
import { clearNotifications, showErrorNotification, showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { INotificationProps } from '../../notifications/types';
import { hasDisplayName } from '../../prejoin/utils';
import { stopLocalVideoRecording } from '../../recording/actions.any';
import LocalRecordingManager from '../../recording/components/Recording/LocalRecordingManager';
import { AudioMixerEffect } from '../../stream-effects/audio-mixer/AudioMixerEffect';
import { iAmVisitor } from '../../visitors/functions';
import { overwriteConfig } from '../config/actions';
import { CONNECTION_ESTABLISHED, CONNECTION_FAILED, CONNECTION_WILL_CONNECT } from '../connection/actionTypes';
import { connectionDisconnected, disconnect } from '../connection/actions';
import { validateJwt } from '../jwt/functions';
import { JitsiConferenceErrors, JitsiConferenceEvents, JitsiConnectionErrors } from '../lib-jitsi-meet';
import { MEDIA_TYPE } from '../media/constants';
import { PARTICIPANT_UPDATED, PIN_PARTICIPANT } from '../participants/actionTypes';
import { PARTICIPANT_ROLE } from '../participants/constants';
import {
getLocalParticipant,
getParticipantById,
getPinnedParticipant
} from '../participants/functions';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { TRACK_ADDED, TRACK_REMOVED } from '../tracks/actionTypes';
import { parseURIString } from '../util/uri';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_SUBJECT_CHANGED,
CONFERENCE_WILL_LEAVE,
P2P_STATUS_CHANGED,
SEND_TONES,
SET_ASSUMED_BANDWIDTH_BPS,
SET_PENDING_SUBJECT_CHANGE,
SET_ROOM
} from './actionTypes';
import {
authStatusChanged,
conferenceFailed,
conferenceWillLeave,
createConference,
setLocalSubject,
setSubject,
updateConferenceMetadata
} from './actions';
import { CONFERENCE_LEAVE_REASONS } from './constants';
import {
_addLocalTracksToConference,
_removeLocalTracksFromConference,
forEachConference,
getCurrentConference,
restoreConferenceOptions
} from './functions';
import logger from './logger';
import { IConferenceMetadata } from './reducer';
/**
* Handler for before unload event.
*/
let beforeUnloadHandler: ((e?: any) => void) | undefined;
/**
* Implements the middleware of the feature base/conference.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case CONFERENCE_FAILED:
return _conferenceFailed(store, next, action);
case CONFERENCE_JOINED:
return _conferenceJoined(store, next, action);
case CONNECTION_ESTABLISHED:
return _connectionEstablished(store, next, action);
case CONNECTION_FAILED:
return _connectionFailed(store, next, action);
case CONNECTION_WILL_CONNECT:
// we are starting a new join process, let's clear the error notifications if any from any previous attempt
store.dispatch(clearNotifications());
break;
case CONFERENCE_SUBJECT_CHANGED:
return _conferenceSubjectChanged(store, next, action);
case CONFERENCE_WILL_LEAVE:
_conferenceWillLeave(store);
break;
case P2P_STATUS_CHANGED:
return _p2pStatusChanged(next, action);
case PARTICIPANT_UPDATED:
return _updateLocalParticipantInConference(store, next, action);
case PIN_PARTICIPANT:
return _pinParticipant(store, next, action);
case SEND_TONES:
return _sendTones(store, next, action);
case SET_ROOM:
return _setRoom(store, next, action);
case TRACK_ADDED:
case TRACK_REMOVED:
return _trackAddedOrRemoved(store, next, action);
case SET_ASSUMED_BANDWIDTH_BPS:
return _setAssumedBandwidthBps(store, next, action);
}
return next(action);
});
/**
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference): void => {
if (conference && !previousConference) {
conference.on(JitsiConferenceEvents.METADATA_UPDATED, (metadata: IConferenceMetadata) => {
dispatch(updateConferenceMetadata(metadata));
});
}
if (conference !== previousConference) {
dispatch(updateConferenceMetadata(null));
}
});
/**
* Makes sure to leave a failed conference in order to release any allocated
* resources like peer connections, emit participant left events, etc.
*
* @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} to the specified {@code store}.
* @param {Action} action - The redux action {@code CONFERENCE_FAILED} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _conferenceFailed({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const { conference, error } = action;
const result = next(action);
const { enableForcedReload } = getState()['features/base/config'];
if (LocalRecordingManager.isRecordingLocally()) {
dispatch(stopLocalVideoRecording());
}
// Handle specific failure reasons.
switch (error.name) {
case JitsiConferenceErrors.CONFERENCE_RESTARTED: {
if (enableForcedReload) {
dispatch(showErrorNotification({
description: 'Restart initiated because of a bridge failure',
titleKey: 'dialog.sessionRestarted'
}));
}
break;
}
case JitsiConferenceErrors.CONNECTION_ERROR: {
const [ msg ] = error.params;
dispatch(connectionDisconnected(getState()['features/base/connection'].connection));
dispatch(showErrorNotification({
descriptionArguments: { msg },
descriptionKey: msg ? 'dialog.connectErrorWithMsg' : 'dialog.connectError',
titleKey: 'connection.CONNFAIL'
}));
break;
}
case JitsiConferenceErrors.CONFERENCE_MAX_USERS: {
dispatch(showErrorNotification({
hideErrorSupportLink: true,
descriptionKey: 'dialog.maxUsersLimitReached',
titleKey: 'dialog.maxUsersLimitReachedTitle'
}));
// In case of max users(it can be from a visitor node), let's restore
// oldConfig if any as we will be back to the main prosody.
const newConfig = restoreConferenceOptions(getState);
if (newConfig) {
dispatch(overwriteConfig(newConfig));
dispatch(conferenceWillLeave(conference));
conference.leave()
.then(() => dispatch(disconnect()));
}
break;
}
case JitsiConferenceErrors.NOT_ALLOWED_ERROR: {
const [ type, msg ] = error.params;
let descriptionKey;
let titleKey = 'dialog.tokenAuthFailed';
if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.NO_MAIN_PARTICIPANTS) {
descriptionKey = 'visitors.notification.noMainParticipantsDescription';
titleKey = 'visitors.notification.noMainParticipantsTitle';
} else if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.NO_VISITORS_LOBBY) {
descriptionKey = 'visitors.notification.noVisitorLobby';
} else if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.PROMOTION_NOT_ALLOWED) {
descriptionKey = 'visitors.notification.notAllowedPromotion';
} else if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.ROOM_CREATION_RESTRICTION) {
descriptionKey = 'dialog.errorRoomCreationRestriction';
}
dispatch(showErrorNotification({
descriptionKey,
hideErrorSupportLink: true,
titleKey
}));
sendAnalytics(createNotAllowedErrorEvent(type, msg));
break;
}
case JitsiConferenceErrors.OFFER_ANSWER_FAILED:
sendAnalytics(createOfferAnswerFailedEvent());
break;
}
!error.recoverable
&& conference?.leave(CONFERENCE_LEAVE_REASONS.UNRECOVERABLE_ERROR).catch((reason: Error) => {
// Even though we don't care too much about the failure, it may be
// good to know that it happen, so log it (on the info level).
logger.info('JitsiConference.leave() rejected with:', reason);
});
// FIXME: Workaround for the web version. Currently, the creation of the
// conference is handled by /conference.js and appropriate failure handlers
// are set there.
if (typeof APP !== 'undefined') {
_removeUnloadHandler(getState);
}
if (enableForcedReload
&& (error?.name === JitsiConferenceErrors.CONFERENCE_RESTARTED
|| error?.name === JitsiConnectionErrors.SHARD_CHANGED_ERROR)) {
dispatch(conferenceWillLeave(conference));
dispatch(reloadNow());
}
return result;
}
/**
* Does extra sync up on properties that may need to be updated after the
* conference was joined.
*
* @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} to the specified {@code store}.
* @param {Action} action - The redux action {@code CONFERENCE_JOINED} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _conferenceJoined({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const result = next(action);
const { conference } = action;
const { pendingSubjectChange } = getState()['features/base/conference'];
const {
disableBeforeUnloadHandlers = false,
requireDisplayName
} = getState()['features/base/config'];
dispatch(removeLobbyChatParticipant(true));
pendingSubjectChange && dispatch(setSubject(pendingSubjectChange));
// FIXME: Very dirty solution. This will work on web only.
// When the user closes the window or quits the browser, lib-jitsi-meet
// handles the process of leaving the conference. This is temporary solution
// that should cover the described use case as part of the effort to
// implement the conferenceWillLeave action for web.
beforeUnloadHandler = (e?: any) => {
if (LocalRecordingManager.isRecordingLocally()) {
dispatch(stopLocalVideoRecording());
if (e) {
e.preventDefault();
e.returnValue = null;
}
}
dispatch(conferenceWillLeave(conference));
};
if (!iAmVisitor(getState())) {
// if a visitor is promoted back to main room and want to join an empty breakout room
// we need to send iq to jicofo, so it can join/create the breakout room
dispatch(overwriteConfig({ disableFocus: false }));
}
window.addEventListener(disableBeforeUnloadHandlers ? 'unload' : 'beforeunload', beforeUnloadHandler);
if (requireDisplayName
&& !getLocalParticipant(getState)?.name
&& !conference.isHidden()) {
dispatch(openDisplayNamePrompt({
validateInput: hasDisplayName
}));
}
return result;
}
/**
* Notifies the feature base/conference that the action
* {@code CONNECTION_ESTABLISHED} is being dispatched within a specific redux
* 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} to the specified {@code store}.
* @param {Action} action - The redux action {@code CONNECTION_ESTABLISHED}
* which is being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
async function _connectionEstablished({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const result = next(action);
const { tokenAuthUrl = false } = getState()['features/base/config'];
// if there is token auth URL defined and local participant is using jwt
// this means it is logged in when connection is established, so we can change the state
if (tokenAuthUrl && !isVpaasMeeting(getState())) {
let email;
if (getState()['features/base/jwt'].jwt) {
email = getLocalParticipant(getState())?.email;
}
dispatch(authStatusChanged(true, email || ''));
}
// FIXME: Workaround for the web version. Currently, the creation of the
// conference is handled by /conference.js.
if (typeof APP === 'undefined') {
dispatch(createConference());
return result;
}
return result;
}
/**
* Logs jwt validation errors from xmpp and from the client-side validator.
*
* @param {string} message - The error message from xmpp.
* @param {string} errors - The detailed errors.
* @returns {void}
*/
function _logJwtErrors(message: string, errors: string) {
message && logger.error(`JWT error: ${message}`);
errors && logger.error('JWT parsing errors:', errors);
}
/**
* Notifies the feature base/conference that the action
* {@code CONNECTION_FAILED} is being dispatched within a specific redux
* 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} to the specified {@code store}.
* @param {Action} action - The redux action {@code CONNECTION_FAILED} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _connectionFailed({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const { connection, error } = action;
const { jwt } = getState()['features/base/jwt'];
if (jwt) {
const errors: string = validateJwt(jwt).map((err: any) =>
i18n.t(`dialog.tokenAuthFailedReason.${err.key}`, err.args))
.join(' ');
_logJwtErrors(error.message, errors);
// do not show the notification when we will prompt the user
// for username and password
if (error.name === JitsiConnectionErrors.PASSWORD_REQUIRED) {
dispatch(showErrorNotification({
descriptionKey: errors ? 'dialog.tokenAuthFailedWithReasons' : 'dialog.tokenAuthFailed',
descriptionArguments: { reason: errors },
titleKey: 'dialog.tokenAuthFailedTitle'
}));
}
}
if (error.name === JitsiConnectionErrors.CONFERENCE_REQUEST_FAILED) {
let notificationAction: Function = showNotification;
const notificationProps = {
customActionNameKey: [ 'dialog.rejoinNow' ],
customActionHandler: [ () => dispatch(reloadNow()) ],
descriptionKey: 'notify.connectionFailed'
} as INotificationProps;
const { locationURL = { href: '' } as URL } = getState()['features/base/connection'];
const { tenant = '' } = parseURIString(locationURL.href) || {};
if (tenant.startsWith('-') || tenant.endsWith('-')) {
notificationProps.descriptionKey = 'notify.invalidTenantHyphenDescription';
notificationProps.titleKey = 'notify.invalidTenant';
notificationAction = showErrorNotification;
} else if (tenant.length > 63) {
notificationProps.descriptionKey = 'notify.invalidTenantLengthDescription';
notificationProps.titleKey = 'notify.invalidTenant';
notificationAction = showErrorNotification;
}
dispatch(notificationAction(notificationProps, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}
const result = next(action);
_removeUnloadHandler(getState);
forEachConference(getState, conference => {
// TODO: revisit this
// It feels that it would make things easier if JitsiConference
// in lib-jitsi-meet would monitor it's connection and emit
// CONFERENCE_FAILED when it's dropped. It has more knowledge on
// whether it can recover or not. But because the reload screen
// and the retry logic is implemented in the app maybe it can be
// left this way for now.
if (conference.getConnection() === connection) {
// XXX Note that on mobile the error type passed to
// connectionFailed is always an object with .name property.
// This fact needs to be checked prior to enabling this logic on
// web.
const conferenceAction = conferenceFailed(conference, error.name);
// Copy the recoverable flag if set on the CONNECTION_FAILED
// action to not emit recoverable action caused by
// a non-recoverable one.
if (typeof error.recoverable !== 'undefined') {
conferenceAction.error.recoverable = error.recoverable;
}
dispatch(conferenceAction);
}
return true;
});
return result;
}
/**
* Notifies the feature base/conference that the action
* {@code CONFERENCE_SUBJECT_CHANGED} is being dispatched within a specific
* redux 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} to the specified {@code store}.
* @param {Action} action - The redux action {@code CONFERENCE_SUBJECT_CHANGED}
* which is being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _conferenceSubjectChanged({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const result = next(action);
const { subject } = getState()['features/base/conference'];
if (subject) {
dispatch({
type: SET_PENDING_SUBJECT_CHANGE,
subject: undefined
});
}
typeof APP === 'object' && APP.API.notifySubjectChanged(subject);
return result;
}
/**
* Notifies the feature base/conference that the action
* {@code CONFERENCE_WILL_LEAVE} is being dispatched within a specific redux
* store.
*
* @private
* @param {Object} store - The redux store.
* @returns {void}
*/
function _conferenceWillLeave({ getState }: IStore) {
_removeUnloadHandler(getState);
}
/**
* Notifies the feature base/conference that the action {@code PIN_PARTICIPANT}
* is being dispatched within a specific redux store. Pins the specified remote
* participant in the associated conference, ignores the local participant.
*
* @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} to the specified {@code store}.
* @param {Action} action - The redux action {@code PIN_PARTICIPANT} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _pinParticipant({ getState }: IStore, next: Function, action: AnyAction) {
const state = getState();
const { conference } = state['features/base/conference'];
if (!conference) {
return next(action);
}
const id = action.participant.id;
const participantById = getParticipantById(state, id);
const pinnedParticipant = getPinnedParticipant(state);
const actionName = id ? ACTION_PINNED : ACTION_UNPINNED;
const local
= participantById?.local
|| (!id && pinnedParticipant?.local);
let participantIdForEvent;
if (local) {
participantIdForEvent = local;
} else {
participantIdForEvent
= actionName === ACTION_PINNED ? id : pinnedParticipant?.id;
}
sendAnalytics(createPinnedEvent(
actionName,
participantIdForEvent,
{
local,
'participant_count': conference.getParticipantCount()
}));
return next(action);
}
/**
* Removes the unload handler.
*
* @param {Function} getState - The redux getState function.
* @returns {void}
*/
function _removeUnloadHandler(getState: IStore['getState']) {
if (typeof beforeUnloadHandler !== 'undefined') {
const { disableBeforeUnloadHandlers = false } = getState()['features/base/config'];
window.removeEventListener(disableBeforeUnloadHandlers ? 'unload' : 'beforeunload', beforeUnloadHandler);
beforeUnloadHandler = undefined;
}
}
/**
* Requests the specified tones to be played.
*
* @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} to the specified {@code store}.
* @param {Action} action - The redux action {@code SEND_TONES} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _sendTones({ getState }: IStore, next: Function, action: AnyAction) {
const state = getState();
const { conference } = state['features/base/conference'];
if (conference) {
const { duration, tones, pause } = action;
conference.sendTones(tones, duration, pause);
}
return next(action);
}
/**
* Notifies the feature base/conference that the action
* {@code SET_ROOM} is being dispatched within a specific
* redux 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} to the specified {@code store}.
* @param {Action} action - The redux action {@code SET_ROOM}
* which is being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _setRoom({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const state = getState();
const { localSubject, subject } = state['features/base/config'];
const { room } = action;
if (room) {
// Set the stored subject.
localSubject && dispatch(setLocalSubject(localSubject));
subject && dispatch(setSubject(subject));
}
return next(action);
}
/**
* Notifies the feature base/conference that the action {@code TRACK_ADDED}
* or {@code TRACK_REMOVED} is being dispatched within a specific redux 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} to the specified {@code store}.
* @param {Action} action - The redux action {@code TRACK_ADDED} or
* {@code TRACK_REMOVED} which is being dispatched in the specified
* {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
async function _trackAddedOrRemoved(store: IStore, next: Function, action: AnyAction) {
const track = action.track;
// TODO All track swapping should happen here instead of conference.js.
if (track?.local) {
const { getState } = store;
const state = getState();
const conference = getCurrentConference(state);
if (conference) {
const jitsiTrack = action.track.jitsiTrack;
if (action.type === TRACK_ADDED) {
// If gUM is slow and tracks are created after the user has already joined the conference, avoid
// adding the tracks to the conference if the user is a visitor.
if (!iAmVisitor(state)) {
const { desktopAudioTrack } = state['features/screen-share'];
// If the user is sharing their screen and has a desktop audio track, we need to replace that with
// the audio mixer effect so that the desktop audio is mixed in with the microphone audio.
if (typeof APP !== 'undefined' && desktopAudioTrack && track.mediaType === MEDIA_TYPE.AUDIO) {
await conference.replaceTrack(desktopAudioTrack, null);
const audioMixerEffect = new AudioMixerEffect(desktopAudioTrack);
await jitsiTrack.setEffect(audioMixerEffect);
await conference.replaceTrack(null, jitsiTrack);
} else {
await _addLocalTracksToConference(conference, [ jitsiTrack ]);
}
}
} else {
await _removeLocalTracksFromConference(conference, [ jitsiTrack ]);
}
}
}
return next(action);
}
/**
* Updates the conference object when the local participant is updated.
*
* @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} to the specified {@code store}.
* @param {Action} action - The redux action which is being dispatched in the
* specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _updateLocalParticipantInConference({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const { conference } = getState()['features/base/conference'];
const { participant } = action;
const result = next(action);
const localParticipant = getLocalParticipant(getState);
if (conference && participant.id === localParticipant?.id) {
if ('name' in participant) {
conference.setDisplayName(participant.name);
}
if ('isSilent' in participant) {
conference.setIsSilent(participant.isSilent);
}
if ('role' in participant && participant.role === PARTICIPANT_ROLE.MODERATOR) {
const { pendingSubjectChange, subject } = getState()['features/base/conference'];
// When the local user role is updated to moderator and we have a pending subject change
// which was not reflected we need to set it (the first time we tried was before becoming moderator).
if (typeof pendingSubjectChange !== 'undefined' && pendingSubjectChange !== subject) {
dispatch(setSubject(pendingSubjectChange));
}
}
}
return result;
}
/**
* Notifies the external API that the action {@code P2P_STATUS_CHANGED}
* is being dispatched within a specific redux store.
*
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified {@code action} to the specified {@code store}.
* @param {Action} action - The redux action {@code P2P_STATUS_CHANGED}
* which is being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _p2pStatusChanged(next: Function, action: AnyAction) {
const result = next(action);
if (typeof APP !== 'undefined') {
APP.API.notifyP2pStatusChanged(action.p2p);
}
return result;
}
/**
* Notifies the feature base/conference that the action
* {@code SET_ASSUMED_BANDWIDTH_BPS} is being dispatched within a specific
* redux 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} to the specified {@code store}.
* @param {Action} action - The redux action {@code SET_ASSUMED_BANDWIDTH_BPS}
* which is being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _setAssumedBandwidthBps({ getState }: IStore, next: Function, action: AnyAction) {
const state = getState();
const conference = getCurrentConference(state);
const payload = Number(action.assumedBandwidthBps);
const assumedBandwidthBps = isNaN(payload) || payload < MIN_ASSUMED_BANDWIDTH_BPS
? MIN_ASSUMED_BANDWIDTH_BPS
: payload;
if (conference) {
conference.setAssumedBandwidthBps(assumedBandwidthBps);
}
return next(action);
}

View File

@@ -0,0 +1,46 @@
import { appNavigate } from '../../app/actions.native';
import { notifyConferenceFailed } from '../../conference/actions.native';
import { JitsiConferenceErrors } from '../lib-jitsi-meet';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { CONFERENCE_FAILED } from './actionTypes';
import { conferenceLeft } from './actions.native';
import { TRIGGER_READY_TO_CLOSE_REASONS } from './constants';
import './middleware.any';
MiddlewareRegistry.register(store => next => action => {
const { dispatch } = store;
const { error } = action;
switch (action.type) {
case CONFERENCE_FAILED: {
const { getState } = store;
const state = getState();
const { notifyOnConferenceDestruction = true } = state['features/base/config'];
if (error?.name !== JitsiConferenceErrors.CONFERENCE_DESTROYED) {
break;
}
if (!notifyOnConferenceDestruction) {
dispatch(conferenceLeft(action.conference));
dispatch(appNavigate(undefined));
break;
}
const [ reason ] = error.params;
const reasonKey = Object.keys(TRIGGER_READY_TO_CLOSE_REASONS)[
Object.values(TRIGGER_READY_TO_CLOSE_REASONS).indexOf(reason)
];
dispatch(notifyConferenceFailed(reasonKey, () => {
dispatch(conferenceLeft(action.conference));
dispatch(appNavigate(undefined));
}));
}
}
return next(action);
});

View File

@@ -0,0 +1,218 @@
import i18next from 'i18next';
import {
setPrejoinPageVisibility,
setSkipPrejoinOnReload
} from '../../prejoin/actions.web';
import { isPrejoinPageVisible } from '../../prejoin/functions';
import { iAmVisitor } from '../../visitors/functions';
import { CONNECTION_DISCONNECTED, CONNECTION_ESTABLISHED } from '../connection/actionTypes';
import { hangup } from '../connection/actions.web';
import { JitsiConferenceErrors, JitsiConnectionErrors, browser } from '../lib-jitsi-meet';
import { gumPending, setInitialGUMPromise } from '../media/actions';
import { MEDIA_TYPE } from '../media/constants';
import { IGUMPendingState } from '../media/types';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { replaceLocalTrack } from '../tracks/actions.any';
import { getLocalTracks } from '../tracks/functions.any';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_JOIN_IN_PROGRESS,
CONFERENCE_LEFT,
KICKED_OUT
} from './actionTypes';
import { TRIGGER_READY_TO_CLOSE_REASONS } from './constants';
import logger from './logger';
import './middleware.any';
let screenLock: WakeLockSentinel | undefined;
/**
* Releases the screen lock.
*
* @returns {Promise}
*/
async function releaseScreenLock() {
if (screenLock) {
if (!screenLock.released) {
logger.debug('Releasing wake lock.');
try {
await screenLock.release();
} catch (e) {
logger.error(`Error while releasing the screen wake lock: ${e}.`);
}
}
screenLock.removeEventListener('release', onWakeLockReleased);
screenLock = undefined;
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
}
/**
* Requests a new screen wake lock.
*
* @returns {void}
*/
function requestWakeLock() {
if (navigator.wakeLock?.request) {
navigator.wakeLock.request('screen')
.then(lock => {
screenLock = lock;
screenLock.addEventListener('release', onWakeLockReleased);
document.addEventListener('visibilitychange', handleVisibilityChange);
logger.debug('Wake lock created.');
})
.catch(e => {
logger.error(`Error while requesting wake lock for screen: ${e}`);
});
}
}
/**
* Page visibility change handler that re-requests the wake lock if it has been released by the OS.
*
* @returns {void}
*/
async function handleVisibilityChange() {
if (screenLock?.released && document.visibilityState === 'visible') {
// The screen lock have been released by the OS because of document visibility change. Lets try to request the
// wake lock again.
await releaseScreenLock();
requestWakeLock();
}
}
/**
* Wake lock released handler.
*
* @returns {void}
*/
function onWakeLockReleased() {
logger.debug('Wake lock released');
}
MiddlewareRegistry.register(store => next => action => {
const { dispatch, getState } = store;
const { enableForcedReload } = getState()['features/base/config'];
switch (action.type) {
case CONFERENCE_JOIN_IN_PROGRESS: {
dispatch(setPrejoinPageVisibility(false));
break;
}
case CONFERENCE_JOINED: {
if (enableForcedReload) {
dispatch(setSkipPrejoinOnReload(false));
}
requestWakeLock();
break;
}
case CONFERENCE_FAILED: {
const errorName = action.error?.name;
if (enableForcedReload
&& (errorName === JitsiConferenceErrors.CONFERENCE_RESTARTED
|| errorName === JitsiConnectionErrors.SHARD_CHANGED_ERROR)) {
dispatch(setSkipPrejoinOnReload(true));
}
if (errorName === JitsiConferenceErrors.CONFERENCE_DESTROYED) {
const state = getState();
const { notifyOnConferenceDestruction = true } = state['features/base/config'];
const [ reason ] = action.error.params;
const titlekey = Object.keys(TRIGGER_READY_TO_CLOSE_REASONS)[
Object.values(TRIGGER_READY_TO_CLOSE_REASONS).indexOf(reason)
];
dispatch(hangup(true, i18next.t(titlekey) || reason, notifyOnConferenceDestruction));
}
releaseScreenLock();
break;
}
case CONFERENCE_LEFT:
case KICKED_OUT:
releaseScreenLock();
break;
case CONNECTION_DISCONNECTED: {
const { initialGUMPromise } = getState()['features/base/media'];
if (initialGUMPromise) {
store.dispatch(setInitialGUMPromise());
}
break;
}
case CONNECTION_ESTABLISHED: {
const { initialGUMPromise } = getState()['features/base/media'];
const promise = initialGUMPromise ? initialGUMPromise.promise : Promise.resolve({ tracks: [] });
const prejoinVisible = isPrejoinPageVisible(getState());
logger.debug(`On connection established: prejoinVisible: ${prejoinVisible}, initialGUMPromiseExists=${
Boolean(initialGUMPromise)}, promiseExists=${Boolean(promise)}`);
if (prejoinVisible) {
promise.then(() => {
const state = getState();
let localTracks = getLocalTracks(state['features/base/tracks']);
const trackReplacePromises = [];
// Do not signal audio/video tracks if the user joins muted.
for (const track of localTracks) {
// Always add the audio track on Safari because of a known issue where audio playout doesn't happen
// if the user joins audio and video muted.
if ((track.muted && !(browser.isWebKitBased() && track.jitsiTrack
&& track.jitsiTrack.getType() === MEDIA_TYPE.AUDIO)) || iAmVisitor(state)) {
trackReplacePromises.push(dispatch(replaceLocalTrack(track.jitsiTrack, null))
.catch((error: any) => {
logger.error(`Failed to replace local track (${track.jitsiTrack}) with null: ${error}`);
}));
}
}
Promise.allSettled(trackReplacePromises).then(() => {
// Re-fetch the local tracks after muted tracks have been removed above.
// This is needed, because the tracks are effectively disposed by the replaceLocalTrack and should
// not be used anymore.
localTracks = getLocalTracks(getState()['features/base/tracks']);
const jitsiTracks = localTracks.map((t: any) => t.jitsiTrack);
return APP.conference.startConference(jitsiTracks);
})
.catch(logger.error);
});
} else {
promise.then(({ tracks }) => {
let tracksToUse = tracks ?? [];
if (iAmVisitor(getState())) {
tracksToUse = [];
tracks.forEach(track => track.dispose().catch(logger.error));
dispatch(gumPending([ MEDIA_TYPE.AUDIO, MEDIA_TYPE.VIDEO ], IGUMPendingState.NONE));
}
dispatch(setInitialGUMPromise());
return APP.conference.startConference(tracksToUse);
})
.catch(logger.error);
}
break;
}
}
return next(action);
});

View File

@@ -0,0 +1,704 @@
import { AnyAction } from 'redux';
import { FaceLandmarks } from '../../face-landmarks/types';
import { LOCKED_LOCALLY, LOCKED_REMOTELY } from '../../room-lock/constants';
import { ISpeakerStats } from '../../speaker-stats/reducer';
import { SET_CONFIG } from '../config/actionTypes';
import { IConfig } from '../config/configType';
import { CONNECTION_WILL_CONNECT, SET_LOCATION_URL } from '../connection/actionTypes';
import { JitsiConferenceErrors } from '../lib-jitsi-meet';
import ReducerRegistry from '../redux/ReducerRegistry';
import { assign, equals, set } from '../redux/functions';
import {
AUTH_STATUS_CHANGED,
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_LEFT,
CONFERENCE_LOCAL_SUBJECT_CHANGED,
CONFERENCE_PROPERTIES_CHANGED,
CONFERENCE_SUBJECT_CHANGED,
CONFERENCE_TIMESTAMP_CHANGED,
CONFERENCE_WILL_JOIN,
CONFERENCE_WILL_LEAVE,
DATA_CHANNEL_CLOSED,
DATA_CHANNEL_OPENED,
LOCK_STATE_CHANGED,
P2P_STATUS_CHANGED,
SET_ASSUMED_BANDWIDTH_BPS,
SET_FOLLOW_ME,
SET_FOLLOW_ME_RECORDER,
SET_OBFUSCATED_ROOM,
SET_PASSWORD,
SET_PENDING_SUBJECT_CHANGE,
SET_ROOM,
SET_START_MUTED_POLICY,
SET_START_REACTIONS_MUTED,
UPDATE_CONFERENCE_METADATA
} from './actionTypes';
import { isRoomValid } from './functions';
const DEFAULT_STATE = {
assumedBandwidthBps: undefined,
conference: undefined,
dataChannelOpen: undefined,
e2eeSupported: undefined,
joining: undefined,
leaving: undefined,
locked: undefined,
membersOnly: undefined,
metadata: undefined,
password: undefined,
passwordRequired: undefined,
properties: undefined
};
export interface IConferenceMetadata {
files: {
[fileId: string]: {
authorParticipantJid: string;
authorParticipantName: string;
conferenceFullName: string;
fileId: string;
fileName: string;
fileSize: number;
fileType: string;
progress?: number;
timestamp: number;
};
};
recording?: {
isTranscribingEnabled: boolean;
};
visitors?: {
live: boolean;
};
whiteboard?: {
collabDetails: {
roomId: string;
roomKey: string;
};
};
}
export interface IJitsiConference {
addCommandListener: Function;
addLobbyMessageListener: Function;
addTrack: Function;
authenticateAndUpgradeRole: Function;
avModerationApprove: Function;
avModerationReject: Function;
callUUID?: string;
createVideoSIPGWSession: Function;
dial: Function;
disableAVModeration: Function;
disableLobby: Function;
enableAVModeration: Function;
enableLobby: Function;
end: Function;
getBreakoutRooms: Function;
getConnection: Function;
getFileSharing: Function;
getLocalParticipantProperty: Function;
getLocalTracks: Function;
getMeetingUniqueId: Function;
getMetadataHandler: Function;
getName: Function;
getParticipantById: Function;
getParticipantCount: Function;
getParticipants: Function;
getRole: Function;
getShortTermCredentials: Function;
getSpeakerStats: () => ISpeakerStats;
getSsrcByTrack: Function;
getTranscriptionStatus: Function;
grantOwner: Function;
isAVModerationSupported: Function;
isE2EEEnabled: Function;
isE2EESupported: Function;
isEndConferenceSupported: Function;
isLobbySupported: Function;
isP2PActive: Function;
isSIPCallingSupported: Function;
join: Function;
joinLobby: Function;
kickParticipant: Function;
leave: Function;
lobbyApproveAccess: Function;
lobbyDenyAccess: Function;
lock: Function;
markParticipantVerified: Function;
muteParticipant: Function;
myLobbyUserId: Function;
myUserId: Function;
off: Function;
on: Function;
options: any;
removeTrack: Function;
replaceTrack: Function;
room: IJitsiConferenceRoom;
sendApplicationLog: Function;
sendCommand: Function;
sendCommandOnce: Function;
sendEndpointMessage: Function;
sendFaceLandmarks: (faceLandmarks: FaceLandmarks) => void;
sendFeedback: Function;
sendLobbyMessage: Function;
sendMessage: Function;
sendPrivateTextMessage: Function;
sendReaction: Function;
sendTextMessage: Function;
sendTones: Function;
sessionId: string;
setAssumedBandwidthBps: (value: number) => void;
setDesktopSharingFrameRate: Function;
setDisplayName: Function;
setIsSilent: Function;
setLocalParticipantProperty: Function;
setMediaEncryptionKey: Function;
setReceiverConstraints: Function;
setSenderVideoConstraint: Function;
setStartMutedPolicy: Function;
setSubject: Function;
setTranscriptionLanguage: Function;
startRecording: Function;
startVerification: Function;
stopRecording: Function;
toggleE2EE: Function;
}
export interface IConferenceState {
assumedBandwidthBps?: number;
authEnabled?: boolean;
authLogin?: string;
authRequired?: IJitsiConference;
conference?: IJitsiConference;
conferenceTimestamp?: number;
dataChannelOpen?: boolean;
e2eeSupported?: boolean;
error?: Error;
followMeEnabled?: boolean;
followMeRecorderEnabled?: boolean;
joining?: IJitsiConference;
leaving?: IJitsiConference;
lobbyError?: boolean;
lobbyWaitingForHost?: boolean;
localSubject?: string;
locked?: string;
membersOnly?: IJitsiConference;
metadata?: IConferenceMetadata;
obfuscatedRoom?: string;
obfuscatedRoomSource?: string;
p2p?: Object;
password?: string;
passwordRequired?: IJitsiConference;
pendingSubjectChange?: string;
properties?: object;
room?: string;
startAudioMutedPolicy?: boolean;
startReactionsMuted?: boolean;
startVideoMutedPolicy?: boolean;
subject?: string;
}
export interface IJitsiConferenceRoom {
locked: boolean;
myroomjid: string;
roomjid: string;
xmpp: {
moderator: {
logout: Function;
};
};
}
interface IConferenceFailedError extends Error {
params: Array<any>;
}
/**
* Listen for actions that contain the conference object, so that it can be
* stored for use by other action creators.
*/
ReducerRegistry.register<IConferenceState>('features/base/conference',
(state = DEFAULT_STATE, action): IConferenceState => {
switch (action.type) {
case AUTH_STATUS_CHANGED:
return _authStatusChanged(state, action);
case CONFERENCE_FAILED:
return _conferenceFailed(state, action);
case CONFERENCE_JOINED:
return _conferenceJoined(state, action);
case CONFERENCE_SUBJECT_CHANGED:
return set(state, 'subject', action.subject);
case CONFERENCE_LOCAL_SUBJECT_CHANGED:
return set(state, 'localSubject', action.localSubject);
case CONFERENCE_PROPERTIES_CHANGED:
return _conferencePropertiesChanged(state, action);
case CONFERENCE_TIMESTAMP_CHANGED:
return set(state, 'conferenceTimestamp', action.conferenceTimestamp);
case CONFERENCE_LEFT:
case CONFERENCE_WILL_LEAVE:
return _conferenceLeftOrWillLeave(state, action);
case CONFERENCE_WILL_JOIN:
return _conferenceWillJoin(state, action);
case CONNECTION_WILL_CONNECT:
return set(state, 'authRequired', undefined);
case DATA_CHANNEL_CLOSED:
return set(state, 'dataChannelOpen', false);
case DATA_CHANNEL_OPENED:
return set(state, 'dataChannelOpen', true);
case LOCK_STATE_CHANGED:
return _lockStateChanged(state, action);
case P2P_STATUS_CHANGED:
return _p2pStatusChanged(state, action);
case SET_ASSUMED_BANDWIDTH_BPS: {
const assumedBandwidthBps = action.assumedBandwidthBps >= 0
? Number(action.assumedBandwidthBps)
: undefined;
return set(state, 'assumedBandwidthBps', assumedBandwidthBps);
}
case SET_FOLLOW_ME:
return set(state, 'followMeEnabled', action.enabled);
case SET_FOLLOW_ME_RECORDER:
return { ...state,
followMeRecorderEnabled: action.enabled,
followMeEnabled: action.enabled
};
case SET_START_REACTIONS_MUTED:
return set(state, 'startReactionsMuted', action.muted);
case SET_LOCATION_URL:
return set(state, 'room', undefined);
case SET_OBFUSCATED_ROOM:
return { ...state,
obfuscatedRoom: action.obfuscatedRoom,
obfuscatedRoomSource: action.obfuscatedRoomSource
};
case SET_PASSWORD:
return _setPassword(state, action);
case SET_PENDING_SUBJECT_CHANGE:
return set(state, 'pendingSubjectChange', action.subject);
case SET_ROOM:
return _setRoom(state, action);
case SET_START_MUTED_POLICY:
return {
...state,
startAudioMutedPolicy: action.startAudioMutedPolicy,
startVideoMutedPolicy: action.startVideoMutedPolicy
};
case UPDATE_CONFERENCE_METADATA:
return {
...state,
metadata: action.metadata
};
case SET_CONFIG:
return _setConfig(state, action);
}
return state;
});
/**
* Processes subject and local subject of the conference based on the new config.
*
* @param {Object} state - The Redux state of feature base/conference.
* @param {Action} action - The Redux action SET_CONFIG to reduce.
* @private
* @returns {Object} The new state after the reduction of the specified action.
*/
function _setConfig(state: IConferenceState, { config }: { config: IConfig; }) {
const { localSubject, subject } = config;
return {
...state,
localSubject,
pendingSubjectChange: subject,
subject: undefined
};
}
/**
* Reduces a specific Redux action AUTH_STATUS_CHANGED of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action AUTH_STATUS_CHANGED to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _authStatusChanged(state: IConferenceState,
{ authEnabled, authLogin }: { authEnabled: boolean; authLogin: string; }) {
return assign(state, {
authEnabled,
authLogin
});
}
/**
* Reduces a specific Redux action CONFERENCE_FAILED of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action CONFERENCE_FAILED to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _conferenceFailed(state: IConferenceState, { conference, error }: {
conference: IJitsiConference; error: IConferenceFailedError; }) {
// The current (similar to getCurrentConference in
// base/conference/functions.any.js) conference which is joining or joined:
const conference_ = state.conference || state.joining;
if (conference_ && conference_ !== conference) {
return state;
}
let authRequired;
let membersOnly;
let passwordRequired;
let lobbyWaitingForHost;
let lobbyError;
switch (error.name) {
case JitsiConferenceErrors.AUTHENTICATION_REQUIRED:
authRequired = conference;
break;
/**
* Access denied while waiting in the lobby.
* A conference error when we tried to join into a room with no display name when lobby is enabled in the room.
*/
case JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED:
case JitsiConferenceErrors.DISPLAY_NAME_REQUIRED: {
lobbyError = true;
break;
}
case JitsiConferenceErrors.MEMBERS_ONLY_ERROR: {
membersOnly = conference;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [ _lobbyJid, _lobbyWaitingForHost ] = error.params;
lobbyWaitingForHost = _lobbyWaitingForHost;
break;
}
case JitsiConferenceErrors.PASSWORD_REQUIRED:
passwordRequired = conference;
break;
}
return assign(state, {
authRequired,
conference: undefined,
e2eeSupported: undefined,
error,
joining: undefined,
leaving: undefined,
lobbyError,
lobbyWaitingForHost,
/**
* The indicator of how the conference/room is locked. If falsy, the
* conference/room is unlocked; otherwise, it's either
* {@code LOCKED_LOCALLY} or {@code LOCKED_REMOTELY}.
*
* @type {string}
*/
locked: passwordRequired ? LOCKED_REMOTELY : undefined,
membersOnly,
password: undefined,
/**
* The JitsiConference instance which requires a password to join.
*
* @type {JitsiConference}
*/
passwordRequired
});
}
/**
* Reduces a specific Redux action CONFERENCE_JOINED of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action CONFERENCE_JOINED to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _conferenceJoined(state: IConferenceState, { conference }: { conference: IJitsiConference; }) {
// FIXME The indicator which determines whether a JitsiConference is locked
// i.e. password-protected is private to lib-jitsi-meet. However, the
// library does not fire LOCK_STATE_CHANGED upon joining a JitsiConference
// with a password.
// FIXME Technically JitsiConference.room is a private field.
const locked = conference.room?.locked ? LOCKED_REMOTELY : undefined;
return assign(state, {
authRequired: undefined,
/**
* The JitsiConference instance represented by the Redux state of the
* feature base/conference.
*
* @type {JitsiConference}
*/
conference,
e2eeSupported: conference.isE2EESupported(),
joining: undefined,
membersOnly: undefined,
leaving: undefined,
lobbyError: undefined,
lobbyWaitingForHost: undefined,
/**
* The indicator which determines whether the conference is locked.
*
* @type {boolean}
*/
locked,
passwordRequired: undefined
});
}
/**
* Reduces a specific redux action {@link CONFERENCE_LEFT} or
* {@link CONFERENCE_WILL_LEAVE} for the feature base/conference.
*
* @param {Object} state - The redux state of the feature base/conference.
* @param {Action} action - The redux action {@code CONFERENCE_LEFT} or
* {@code CONFERENCE_WILL_LEAVE} to reduce.
* @private
* @returns {Object} The next/new state of the feature base/conference after the
* reduction of the specified action.
*/
function _conferenceLeftOrWillLeave(state: IConferenceState, { conference, type }:
{ conference: IJitsiConference; type: string; }) {
const nextState = { ...state };
// The redux action CONFERENCE_LEFT is the last time that we should be
// hearing from a JitsiConference instance.
//
// The redux action CONFERENCE_WILL_LEAVE represents the order of the user
// to leave a JitsiConference instance. From the user's perspective, there's
// no going back (with respect to the instance itself). The app will perform
// due clean-up like leaving the associated room, but the instance is no
// longer the focus of the attention of the user and, consequently, the app.
for (const p in state) {
if (state[p as keyof IConferenceState] === conference) {
nextState[p as keyof IConferenceState] = undefined;
switch (p) {
case 'conference':
case 'passwordRequired':
// XXX Clear/unset locked & password for a conference which has
// been LOCKED_LOCALLY or LOCKED_REMOTELY.
delete nextState.locked;
delete nextState.password;
break;
}
}
}
if (type === CONFERENCE_WILL_LEAVE) {
// A CONFERENCE_WILL_LEAVE is of further consequence only if it is
// expected i.e. if the specified conference is joining or joined.
if (conference === state.joining || conference === state.conference) {
/**
* The JitsiConference instance which is currently in the process of
* being left.
*
* @type {JitsiConference}
*/
nextState.leaving = conference;
}
}
return nextState;
}
/**
* Reduces a specific Redux action CONFERENCE_PROPERTIES_CHANGED of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action CONFERENCE_PROPERTIES_CHANGED to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _conferencePropertiesChanged(state: IConferenceState, { properties }: { properties: Object; }) {
if (!equals(state.properties, properties)) {
return assign(state, {
properties
});
}
return state;
}
/**
* Reduces a specific Redux action CONFERENCE_WILL_JOIN of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action CONFERENCE_WILL_JOIN to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _conferenceWillJoin(state: IConferenceState, { conference }: { conference: IJitsiConference; }) {
return assign(state, {
error: undefined,
joining: conference
});
}
/**
* Reduces a specific Redux action LOCK_STATE_CHANGED of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action LOCK_STATE_CHANGED to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _lockStateChanged(state: IConferenceState, { conference, locked }: { conference: Object; locked: boolean; }) {
if (state.conference !== conference) {
return state;
}
return assign(state, {
locked: locked ? state.locked || LOCKED_REMOTELY : undefined,
password: locked ? state.password : undefined
});
}
/**
* Reduces a specific Redux action P2P_STATUS_CHANGED of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action P2P_STATUS_CHANGED to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _p2pStatusChanged(state: IConferenceState, action: AnyAction) {
return set(state, 'p2p', action.p2p);
}
/**
* Reduces a specific Redux action SET_PASSWORD of the feature base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action SET_PASSWORD to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _setPassword(state: IConferenceState, { conference, method, password }: {
conference: IJitsiConference; method: Object; password: string; }) {
switch (method) {
case conference.join:
return assign(state, {
// 1. The JitsiConference which transitions away from
// passwordRequired MUST remain in the redux state
// features/base/conference until it transitions into
// conference; otherwise, there is a span of time during which
// the redux state does not even know that there is a
// JitsiConference whatsoever.
//
// 2. The redux action setPassword will attempt to join the
// JitsiConference so joining is an appropriate transitional
// redux state.
//
// 3. The redux action setPassword will perform the same check
// before it proceeds with the re-join.
joining: state.conference ? state.joining : conference,
locked: LOCKED_REMOTELY,
/**
* The password with which the conference is to be joined.
*
* @type {string}
*/
password
});
case conference.lock:
return assign(state, {
locked: password ? LOCKED_LOCALLY : undefined,
password
});
}
return state;
}
/**
* Reduces a specific Redux action SET_ROOM of the feature base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action SET_ROOM to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _setRoom(state: IConferenceState, action: AnyAction) {
let { room } = action;
if (!isRoomValid(room)) {
// Technically, there are multiple values which don't represent valid
// room names. Practically, each of them is as bad as the rest of them
// because we can't use any of them to join a conference.
room = undefined;
}
/**
* The name of the room of the conference (to be) joined.
*
* @type {string}
*/
return assign(state, {
error: undefined,
room
});
}

View File

@@ -0,0 +1,61 @@
/**
* The redux action which signals that a configuration (commonly known in Jitsi
* Meet as config.js) will be loaded for a specific locationURL.
*
* {
* type: CONFIG_WILL_LOAD,
* locationURL: URL,
* room: string
* }
*/
export const CONFIG_WILL_LOAD = 'CONFIG_WILL_LOAD';
/**
* The redux action which signals that a configuration (commonly known in Jitsi
* Meet as config.js) could not be loaded due to a specific error.
*
* {
* type: LOAD_CONFIG_ERROR,
* error: Error,
* locationURL: URL
* }
*/
export const LOAD_CONFIG_ERROR = 'LOAD_CONFIG_ERROR';
/**
* The redux action which sets the configuration represented by the feature
* base/config. The configuration is defined and consumed by the library
* lib-jitsi-meet but some of its properties are consumed by the application
* jitsi-meet as well.
*
* {
* type: SET_CONFIG,
* config: Object
* }
*/
export const SET_CONFIG = 'SET_CONFIG';
/**
* The redux action which updates the configuration represented by the feature
* base/config. The configuration is defined and consumed by the library
* lib-jitsi-meet but some of its properties are consumed by the application
* jitsi-meet as well. A merge operation is performed between the existing config
* and the passed object.
*
* {
* type: UPDATE_CONFIG,
* config: Object
* }
*/
export const UPDATE_CONFIG = 'UPDATE_CONFIG';
/**
* The redux action which overwrites configurations represented by the feature
* base/config. The passed on config values overwrite the current values for given props.
*
* {
* type: OVERWRITE_CONFIG,
* config: Object
* }
*/
export const OVERWRITE_CONFIG = 'OVERWRITE_CONFIG';

View File

@@ -0,0 +1,185 @@
// @ts-expect-error
import { jitsiLocalStorage } from '@jitsi/js-utils';
import { IStore } from '../../app/types';
import { addKnownDomains } from '../known-domains/actions';
import { parseURIString } from '../util/uri';
import {
CONFIG_WILL_LOAD,
LOAD_CONFIG_ERROR,
OVERWRITE_CONFIG,
SET_CONFIG,
UPDATE_CONFIG
} from './actionTypes';
import { IConfig } from './configType';
import { _CONFIG_STORE_PREFIX } from './constants';
import { setConfigFromURLParams } from './functions.any';
/**
* Updates the config with new options.
*
* @param {Object} config - The new options (to add).
* @returns {Function}
*/
export function updateConfig(config: IConfig) {
return {
type: UPDATE_CONFIG,
config
};
}
/**
* Signals that the configuration (commonly known in Jitsi Meet as config.js)
* for a specific locationURL will be loaded now.
*
* @param {URL} locationURL - The URL of the location which necessitated the
* loading of a configuration.
* @param {string} room - The name of the room (conference) for which we're loading the config for.
* @returns {{
* type: CONFIG_WILL_LOAD,
* locationURL: URL,
* room: string
* }}
*/
export function configWillLoad(locationURL: URL, room: string) {
return {
type: CONFIG_WILL_LOAD,
locationURL,
room
};
}
/**
* Signals that a configuration (commonly known in Jitsi Meet as config.js)
* could not be loaded due to a specific error.
*
* @param {Error} error - The {@code Error} which prevented the successful
* loading of a configuration.
* @param {URL} locationURL - The URL of the location which necessitated the
* loading of a configuration.
* @returns {{
* type: LOAD_CONFIG_ERROR,
* error: Error,
* locationURL: URL
* }}
*/
export function loadConfigError(error: Error, locationURL: URL) {
return {
type: LOAD_CONFIG_ERROR,
error,
locationURL
};
}
/**
* Overwrites some config values.
*
* @param {Object} config - The new options (to overwrite).
* @returns {{
* type: OVERWRITE_CONFIG,
* config: Object
* }}
*/
export function overwriteConfig(config: Object) {
return {
type: OVERWRITE_CONFIG,
config
};
}
/**
* Sets the configuration represented by the feature base/config. The
* configuration is defined and consumed by the library lib-jitsi-meet but some
* of its properties are consumed by the application jitsi-meet as well.
*
* @param {Object} config - The configuration to be represented by the feature
* base/config.
* @returns {Function}
*/
export function setConfig(config: IConfig = {}) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const { locationURL } = getState()['features/base/connection'];
// Now that the loading of the config was successful override the values
// with the parameters passed in the hash part of the location URI.
// TODO We're still in the middle ground between old Web with config,
// and interfaceConfig used via global variables and new
// Web and mobile reading the respective values from the redux store.
// Only the config will be overridden on React Native, as the other
// globals will be undefined here. It's intentional - we do not care to
// override those configs yet.
locationURL
&& setConfigFromURLParams(
// On Web the config also comes from the window.config global,
// but it is resolved in the loadConfig procedure.
config,
window.interfaceConfig,
locationURL);
let { bosh } = config;
if (bosh) {
// Normalize the BOSH URL.
if (bosh.startsWith('//')) {
// By default our config.js doesn't include the protocol.
bosh = `${locationURL?.protocol}${bosh}`;
} else if (bosh.startsWith('/')) {
// Handle relative URLs, which won't work on mobile.
const {
protocol,
host,
contextRoot
} = parseURIString(locationURL?.href);
bosh = `${protocol}//${host}${contextRoot || '/'}${bosh.substr(1)}`;
}
config.bosh = bosh;
}
dispatch({
type: SET_CONFIG,
config
});
};
}
/**
* Stores a specific Jitsi Meet config.js object into {@code localStorage}.
*
* @param {string} baseURL - The base URL from which the config.js was
* downloaded.
* @param {Object} config - The Jitsi Meet config.js to store.
* @returns {Function}
*/
export function storeConfig(baseURL: string, config: Object) {
return (dispatch: IStore['dispatch']) => {
// Try to store the configuration in localStorage. If the deployment
// specified 'getroom' as a function, for example, it does not make
// sense to and it will not be stored.
let b = false;
try {
if (typeof window.config === 'undefined' || window.config !== config) {
jitsiLocalStorage.setItem(`${_CONFIG_STORE_PREFIX}/${baseURL}`, JSON.stringify(config));
b = true;
}
} catch (e) {
// Ignore the error because the caching is optional.
}
// If base/config knows a domain, then the app knows it.
if (b) {
try {
dispatch(addKnownDomains(parseURIString(baseURL)?.host));
} catch (e) {
// Ignore the error because the fiddling with "known domains" is
// a side effect here.
}
}
return b;
};
}

View File

@@ -0,0 +1,665 @@
import { ToolbarButton } from '../../toolbox/types';
import { ILoggingConfig } from '../logging/types';
import { DesktopSharingSourceType } from '../tracks/types';
type ButtonsWithNotifyClick = 'camera' |
'chat' |
'closedcaptions' |
'desktop' |
'download' |
'embedmeeting' |
'end-meeting' |
'etherpad' |
'feedback' |
'filmstrip' |
'fullscreen' |
'hangup' |
'hangup-menu' |
'help' |
'invite' |
'livestreaming' |
'microphone' |
'mute-everyone' |
'mute-video-everyone' |
'participants-pane' |
'profile' |
'raisehand' |
'recording' |
'security' |
'select-background' |
'settings' |
'shareaudio' |
'sharedvideo' |
'shortcuts' |
'stats' |
'tileview' |
'toggle-camera' |
'videoquality' |
'add-passcode' |
'__end';
type ParticipantMenuButtonsWithNotifyClick = 'allow-video' |
'ask-unmute' |
'conn-status' |
'flip-local-video' |
'grant-moderator' |
'hide-self-view' |
'kick' |
'mute' |
'mute-others' |
'mute-others-video' |
'mute-video' |
'pinToStage' |
'privateMessage' |
'remote-control' |
'send-participant-to-room' |
'verify';
type NotifyClickButtonKey = string |
ButtonsWithNotifyClick |
ParticipantMenuButtonsWithNotifyClick;
export type NotifyClickButton = NotifyClickButtonKey |
{
key: NotifyClickButtonKey;
preventExecution: boolean;
};
export type Sounds = 'ASKED_TO_UNMUTE_SOUND' |
'E2EE_OFF_SOUND' |
'E2EE_ON_SOUND' |
'INCOMING_MSG_SOUND' |
'KNOCKING_PARTICIPANT_SOUND' |
'LIVE_STREAMING_OFF_SOUND' |
'LIVE_STREAMING_ON_SOUND' |
'NO_AUDIO_SIGNAL_SOUND' |
'NOISY_AUDIO_INPUT_SOUND' |
'OUTGOING_CALL_EXPIRED_SOUND' |
'OUTGOING_CALL_REJECTED_SOUND' |
'OUTGOING_CALL_RINGING_SOUND' |
'OUTGOING_CALL_START_SOUND' |
'PARTICIPANT_JOINED_SOUND' |
'PARTICIPANT_LEFT_SOUND' |
'RAISE_HAND_SOUND' |
'REACTION_SOUND' |
'RECORDING_OFF_SOUND' |
'RECORDING_ON_SOUND' |
'TALK_WHILE_MUTED_SOUND';
export interface IDeeplinkingPlatformConfig {
appName: string;
appScheme: string;
}
export interface IDeeplinkingMobileConfig extends IDeeplinkingPlatformConfig {
appPackage?: string;
downloadLink: string;
fDroidUrl?: string;
}
export interface IDesktopDownloadConfig {
linux?: string;
macos?: string;
windows?: string;
}
export interface IDeeplinkingDesktopConfig extends IDeeplinkingPlatformConfig {
download?: IDesktopDownloadConfig;
enabled: boolean;
}
export interface IDeeplinkingConfig {
android?: IDeeplinkingMobileConfig;
desktop?: IDeeplinkingDesktopConfig;
disabled?: boolean;
hideLogo?: boolean;
ios?: IDeeplinkingMobileConfig;
}
export type PartialRecord<K extends keyof any, T> = {
[P in K]?: T;
};
export interface INoiseSuppressionConfig {
krisp?: {
bufferOverflowMS?: number;
bvc?: {
allowedDevices?: string;
allowedDevicesExt?: string;
};
debugLogs: boolean;
enableSessionStats?: boolean;
enabled: boolean;
inboundModels?: PartialRecord<string, string>;
logProcessStats?: boolean;
models?: PartialRecord<string, string>;
preloadInboundModels?: PartialRecord<string, string>;
preloadModels?: PartialRecord<string, string>;
useBVC?: boolean;
useSharedArrayBuffer?: boolean;
};
}
export interface IWhiteboardConfig {
collabServerBaseUrl?: string;
enabled?: boolean;
limitUrl?: string;
userLimit?: number;
}
export interface IWatchRTCConfiguration {
allowBrowserLogCollection?: boolean;
collectionInterval?: number;
console?: {
level: string;
override: boolean;
};
debug?: boolean;
keys?: any;
logGetStats?: boolean;
proxyUrl?: string;
rtcApiKey: string;
rtcPeerId?: string;
rtcRoomId?: string;
rtcTags?: string[];
rtcToken?: string;
wsUrl?: string;
}
export interface IConfig {
_desktopSharingSourceDevice?: string;
_immediateReloadThreshold?: string;
_screenshotHistoryRegionUrl?: number;
analytics?: {
amplitudeAPPKey?: string;
blackListedEvents?: string[];
disabled?: boolean;
matomoEndpoint?: string;
matomoSiteID?: string;
obfuscateRoomName?: boolean;
rtcstatsEnabled?: boolean;
rtcstatsEndpoint?: string;
rtcstatsLogFlushSizeBytes?: number;
rtcstatsPollInterval?: number;
rtcstatsSendSdp?: boolean;
rtcstatsStoreLogs?: boolean;
scriptURLs?: Array<string>;
watchRTCEnabled?: boolean;
whiteListedEvents?: string[];
};
apiLogLevels?: Array<'warn' | 'log' | 'error' | 'info' | 'debug'>;
appId?: string;
audioLevelsInterval?: number;
audioQuality?: {
opusMaxAverageBitrate?: number | null;
stereo?: boolean;
};
autoCaptionOnRecord?: boolean;
autoKnockLobby?: boolean;
backgroundAlpha?: number;
bosh?: string;
brandingDataUrl?: string;
brandingRoomAlias?: string;
breakoutRooms?: {
hideAddRoomButton?: boolean;
hideAutoAssignButton?: boolean;
hideJoinRoomButton?: boolean;
};
bridgeChannel?: {
ignoreDomain?: string;
preferSctp?: boolean;
};
buttonsWithNotifyClick?: Array<ButtonsWithNotifyClick | {
key: ButtonsWithNotifyClick;
preventExecution: boolean;
}>;
callDisplayName?: string;
callFlowsEnabled?: boolean;
callHandle?: string;
callUUID?: string;
cameraFacingMode?: string;
channelLastN?: number;
chromeExtensionBanner?: {
chromeExtensionsInfo?: Array<{ id: string; path: string; }>;
edgeUrl?: string;
url?: string;
};
conferenceInfo?: {
alwaysVisible?: Array<string>;
autoHide?: Array<string>;
};
conferenceRequestUrl?: string;
connectionIndicators?: {
autoHide?: boolean;
autoHideTimeout?: number;
disableDetails?: boolean;
disabled?: boolean;
inactiveDisabled?: boolean;
};
constraints?: {
video?: {
height?: {
ideal?: number;
max?: number;
min?: number;
};
};
};
corsAvatarURLs?: Array<string>;
customParticipantMenuButtons?: Array<{ icon: string; id: string; text: string; }>;
customToolbarButtons?: Array<{ backgroundColor?: string; icon: string; id: string; text: string; }>;
deeplinking?: IDeeplinkingConfig;
defaultLanguage?: string;
defaultLocalDisplayName?: string;
defaultLogoUrl?: string;
defaultRemoteDisplayName?: string;
deploymentInfo?: {
envType?: string;
environment?: string;
product?: string;
region?: string;
shard?: string;
userRegion?: string;
};
deploymentUrls?: {
downloadAppsUrl?: string;
userDocumentationURL?: string;
};
desktopSharingFrameRate?: {
max?: number;
min?: number;
};
desktopSharingSources?: Array<DesktopSharingSourceType>;
dialInConfCodeUrl?: string;
dialInNumbersUrl?: string;
dialOutAuthUrl?: string;
dialOutRegionUrl?: string;
disable1On1Mode?: boolean | null;
disableAEC?: boolean;
disableAGC?: boolean;
disableAP?: boolean;
disableAddingBackgroundImages?: boolean;
disableAudioLevels?: boolean;
disableBeforeUnloadHandlers?: boolean;
disableCameraTintForeground?: boolean;
disableChatSmileys?: boolean;
disableDeepLinking?: boolean;
disableFilmstripAutohiding?: boolean;
disableFocus?: boolean;
disableIframeAPI?: boolean;
disableIncomingMessageSound?: boolean;
disableInitialGUM?: boolean;
disableInviteFunctions?: boolean;
disableJoinLeaveSounds?: boolean;
disableLocalVideoFlip?: boolean;
disableModeratorIndicator?: boolean;
disableNS?: boolean;
disablePolls?: boolean;
disableProfile?: boolean;
disableReactions?: boolean;
disableReactionsInChat?: boolean;
disableReactionsModeration?: boolean;
disableRecordAudioNotification?: boolean;
disableRemoteControl?: boolean;
disableRemoteMute?: boolean;
disableRemoveRaisedHandOnFocus?: boolean;
disableResponsiveTiles?: boolean;
disableRtx?: boolean;
disableSelfDemote?: boolean;
disableSelfView?: boolean;
disableSelfViewSettings?: boolean;
disableShortcuts?: boolean;
disableShowMoreStats?: boolean;
disableSimulcast?: boolean;
disableSpeakerStatsSearch?: boolean;
disableThirdPartyRequests?: boolean;
disableTileEnlargement?: boolean;
disableTileView?: boolean;
disableVirtualBackground?: boolean;
disabledNotifications?: Array<string>;
disabledSounds?: Array<Sounds>;
displayJids?: boolean;
doNotFlipLocalVideo?: boolean;
doNotStoreRoom?: boolean;
dropbox?: {
appKey: string;
redirectURI?: string;
};
dynamicBrandingUrl?: string;
e2ee?: {
disabled?: boolean;
externallyManagedKey?: boolean;
labels?: {
description?: string;
label?: string;
tooltip?: string;
warning?: string;
};
};
e2eeLabels?: {
description?: string;
label?: string;
tooltip?: string;
warning?: string;
};
e2eping?: {
enabled?: boolean;
maxConferenceSize?: number;
maxMessagesPerSecond?: number;
numRequests?: number;
};
enableCalendarIntegration?: boolean;
enableClosePage?: boolean;
enableDisplayNameInStats?: boolean;
enableEmailInStats?: boolean;
enableEncodedTransformSupport?: boolean;
enableForcedReload?: boolean;
enableInsecureRoomNameWarning?: boolean;
enableLobbyChat?: boolean;
enableNoAudioDetection?: boolean;
enableNoisyMicDetection?: boolean;
enableOpusRed?: boolean;
enableRemb?: boolean;
enableSaveLogs?: boolean;
enableTalkWhileMuted?: boolean;
enableTcc?: boolean;
enableWebHIDFeature?: boolean;
enableWelcomePage?: boolean;
etherpad_base?: string;
faceLandmarks?: {
captureInterval?: number;
enableDisplayFaceExpressions?: boolean;
enableFaceCentering?: boolean;
enableFaceExpressionsDetection?: boolean;
enableRTCStats?: boolean;
faceCenteringThreshold?: number;
};
feedbackPercentage?: number;
fileRecordingsServiceEnabled?: boolean;
fileRecordingsServiceSharingEnabled?: boolean;
fileSharing?: {
apiUrl?: string;
enabled?: boolean;
maxFileSize?: number;
};
filmstrip?: {
alwaysShowResizeBar?: boolean;
disableResizable?: boolean;
disableStageFilmstrip?: boolean;
disableTopPanel?: boolean;
disabled?: boolean;
initialWidth?: number;
minParticipantCountForTopPanel?: number;
};
flags?: {
ssrcRewritingEnabled: boolean;
};
focusUserJid?: string;
forceTurnRelay?: boolean;
gatherStats?: boolean;
giphy?: {
displayMode?: 'all' | 'tile' | 'chat';
enabled?: boolean;
rating?: 'g' | 'pg' | 'pg-13' | 'r';
sdkKey?: string;
tileTime?: number;
};
googleApiApplicationClientID?: string;
gravatar?: {
baseUrl?: string;
disabled?: boolean;
};
gravatarBaseURL?: string;
guestDialOutStatusUrl?: string;
guestDialOutUrl?: string;
helpCentreURL?: string;
hiddenDomain?: string;
hiddenPremeetingButtons?: Array<'microphone' | 'camera' | 'select-background' | 'invite' | 'settings'>;
hideAddRoomButton?: boolean;
hideConferenceSubject?: boolean;
hideConferenceTimer?: boolean;
hideDisplayName?: boolean;
hideDominantSpeakerBadge?: boolean;
hideEmailInSettings?: boolean;
hideLobbyButton?: boolean;
hideLoginButton?: boolean;
hideParticipantsStats?: boolean;
hideRecordingLabel?: boolean;
hosts?: {
anonymousdomain?: string;
authdomain?: string;
domain: string;
focus?: string;
muc: string;
visitorFocus?: string;
};
iAmRecorder?: boolean;
iAmSipGateway?: boolean;
iAmSpot?: boolean;
ignoreStartMuted?: boolean;
inviteAppName?: string | null;
inviteServiceCallFlowsUrl?: string;
inviteServiceUrl?: string;
jaasActuatorUrl?: string;
jaasConferenceCreatorUrl?: string;
jaasFeedbackMetadataURL?: string;
jaasTokenUrl?: string;
legalUrls?: {
helpCentre: string;
privacy: string;
security: string;
terms: string;
};
liveStreaming?: {
dataPrivacyLink?: string;
enabled?: boolean;
helpLink?: string;
termsLink?: string;
validatorRegExpString?: string;
};
liveStreamingEnabled?: boolean;
lobby?: {
autoKnock?: boolean;
enableChat?: boolean;
};
localRecording?: {
disable?: boolean;
disableSelfRecording?: boolean;
notifyAllParticipants?: boolean;
};
localSubject?: string;
locationURL?: URL;
logging?: ILoggingConfig;
mainToolbarButtons?: Array<Array<string>>;
maxFullResolutionParticipants?: number;
microsoftApiApplicationClientID?: string;
moderatedRoomServiceUrl?: string;
mouseMoveCallbackInterval?: number;
noiseSuppression?: INoiseSuppressionConfig;
noticeMessage?: string;
notificationTimeouts?: {
extraLong?: number;
long?: number;
medium?: number;
short?: number;
sticky?: number;
};
notifications?: Array<string>;
notifyOnConferenceDestruction?: boolean;
openSharedDocumentOnJoin?: boolean;
opusMaxAverageBitrate?: number;
p2p?: {
backToP2PDelay?: number;
codecPreferenceOrder?: Array<string>;
enabled?: boolean;
iceTransportPolicy?: string;
mobileCodecPreferenceOrder?: Array<string>;
mobileScreenshareCodec?: string;
stunServers?: Array<{ urls: string; }>;
};
participantMenuButtonsWithNotifyClick?: Array<string | ParticipantMenuButtonsWithNotifyClick | {
key: string | ParticipantMenuButtonsWithNotifyClick;
preventExecution: boolean;
}>;
participantsPane?: {
enabled?: boolean;
hideModeratorSettingsTab?: boolean;
hideMoreActionsButton?: boolean;
hideMuteAllButton?: boolean;
};
pcStatsInterval?: number;
peopleSearchQueryTypes?: string[];
peopleSearchTokenLocation?: string;
peopleSearchUrl?: string;
preferBosh?: boolean;
preferVisitor?: boolean;
preferredTranscribeLanguage?: string;
prejoinConfig?: {
enabled?: boolean;
hideDisplayName?: boolean;
hideExtraJoinButtons?: Array<string>;
preCallTestEnabled?: boolean;
preCallTestICEUrl?: string;
};
raisedHands?: {
disableLowerHandByModerator?: boolean;
disableLowerHandNotification?: boolean;
disableNextSpeakerNotification?: boolean;
disableRemoveRaisedHandOnFocus?: boolean;
};
readOnlyName?: boolean;
recordingLimit?: {
appName?: string;
appURL?: string;
limit?: number;
};
recordingService?: {
enabled?: boolean;
hideStorageWarning?: boolean;
sharingEnabled?: boolean;
};
recordingSharingUrl?: string;
recordings?: {
consentLearnMoreLink?: string;
recordAudioAndVideo?: boolean;
requireConsent?: boolean;
showPrejoinWarning?: boolean;
showRecordingLink?: boolean;
skipConsentInMeeting?: boolean;
suggestRecording?: boolean;
};
remoteVideoMenu?: {
disableDemote?: boolean;
disableGrantModerator?: boolean;
disableKick?: boolean;
disablePrivateChat?: 'all' | 'allow-moderator-chat' | 'disable-visitor-chat';
disabled?: boolean;
};
replaceParticipant?: string;
requireDisplayName?: boolean;
resolution?: number;
roomPasswordNumberOfDigits?: number;
salesforceUrl?: string;
screenshotCapture?: {
enabled?: boolean;
mode?: 'always' | 'recording';
};
securityUi?: {
disableLobbyPassword?: boolean;
hideLobbyButton?: boolean;
};
serviceUrl?: string;
sharedVideoAllowedURLDomains?: Array<string>;
sipInviteUrl?: string;
speakerStats?: {
disableSearch?: boolean;
disabled?: boolean;
order?: Array<'role' | 'name' | 'hasLeft'>;
};
speakerStatsOrder?: Array<'role' | 'name' | 'hasLeft'>;
startAudioMuted?: number;
startAudioOnly?: boolean;
startLastN?: number;
startScreenSharing?: boolean;
startSilent?: boolean;
startVideoMuted?: number;
startWithAudioMuted?: boolean;
startWithVideoMuted?: boolean;
stereo?: boolean;
subject?: string;
testing?: {
assumeBandwidth?: boolean;
debugAudioLevels?: boolean;
dumpTranscript?: boolean;
failICE?: boolean;
noAutoPlayVideo?: boolean;
p2pTestMode?: boolean;
showSpotConsentDialog?: boolean;
skipInterimTranscriptions?: boolean;
testMode?: boolean;
};
tileView?: {
disabled?: boolean;
numberOfVisibleTiles?: number;
};
tokenAuthUrl?: string;
tokenAuthUrlAutoRedirect?: string;
tokenGetUserInfoOutOfContext?: boolean;
tokenLogoutUrl?: string;
tokenRespectTenant?: boolean;
toolbarButtons?: Array<ToolbarButton>;
toolbarConfig?: {
alwaysVisible?: boolean;
autoHideWhileChatIsOpen?: boolean;
initialTimeout?: number;
timeout?: number;
};
transcribeWithAppLanguage?: boolean;
transcribingEnabled?: boolean;
transcription?: {
autoCaptionOnTranscribe?: boolean;
autoTranscribeOnRecord?: boolean;
disableClosedCaptions?: boolean;
enabled?: boolean;
inviteJigasiOnBackendTranscribing?: boolean;
preferredLanguage?: string;
translationLanguages?: Array<string>;
translationLanguagesHead?: Array<string>;
useAppLanguage?: boolean;
};
useHostPageLocalStorage?: boolean;
useTurnUdp?: boolean;
videoQuality?: {
codecPreferenceOrder?: Array<string>;
maxBitratesVideo?: {
[key: string]: {
high?: number;
low?: number;
standard?: number;
};
};
minHeightForQualityLvl?: {
[key: number]: string;
};
mobileCodecPreferenceOrder?: Array<string>;
persist?: boolean;
};
visitors?: {
enableMediaOnPromote?: {
audio?: boolean;
video?: boolean;
};
queueService: string;
};
watchRTCConfigParams?: IWatchRTCConfiguration;
webhookProxyUrl?: string;
webrtcIceTcpDisable?: boolean;
webrtcIceUdpDisable?: boolean;
websocket?: string;
websocketKeepAliveUrl?: string;
welcomePage?: {
customUrl?: string;
disabled?: boolean;
};
whiteboard?: IWhiteboardConfig;
}

View File

@@ -0,0 +1,254 @@
import { isEmbedded } from '../util/embedUtils';
import extraConfigWhitelist from './extraConfigWhitelist';
import isEmbeddedConfigWhitelist from './isEmbeddedConfigWhitelist';
/**
* The config keys to whitelist, the keys that can be overridden.
* Whitelisting a key allows all properties under that key to be overridden.
* For example whitelisting 'p2p' allows 'p2p.enabled' to be overridden, and
* overriding 'p2p.enabled' does not modify any other keys under 'p2p'.
* The whitelist is used only for config.js.
*
* @type Array
*/
export default [
'_desktopSharingSourceDevice',
'_peerConnStatusOutOfLastNTimeout',
'_peerConnStatusRtcMuteTimeout',
'analytics.disabled',
'analytics.rtcstatsEnabled',
'analytics.watchRTCEnabled',
'audioLevelsInterval',
'audioQuality',
'autoKnockLobby',
'apiLogLevels',
'avgRtpStatsN',
'backgroundAlpha',
'brandingRoomAlias',
'breakoutRooms',
'bridgeChannel',
'buttonsWithNotifyClick',
/**
* The display name of the CallKit call representing the conference/meeting
* associated with this config.js including while the call is ongoing in the
* UI presented by CallKit and in the system-wide call history. The property
* is meant for use cases in which the room name is not desirable as a
* display name for CallKit purposes and the desired display name is not
* provided in the form of a JWT callee. As the value is associated with a
* conference/meeting, the value makes sense not as a deployment-wide
* configuration, only as a runtime configuration override/overwrite
* provided by, for example, Jitsi Meet SDK for iOS.
*
* @type string
*/
'callDisplayName',
'callFlowsEnabled',
/**
* The handle
* ({@link https://developer.apple.com/documentation/callkit/cxhandle}) of
* the CallKit call representing the conference/meeting associated with this
* config.js. The property is meant for use cases in which the room URL is
* not desirable as the handle for CallKit purposes. As the value is
* associated with a conference/meeting, the value makes sense not as a
* deployment-wide configuration, only as a runtime configuration
* override/overwrite provided by, for example, Jitsi Meet SDK for iOS.
*
* @type string
*/
'callHandle',
/**
* The UUID of the CallKit call representing the conference/meeting
* associated with this config.js. The property is meant for use cases in
* which Jitsi Meet is to work with a CallKit call created outside of Jitsi
* Meet and to be adopted by Jitsi Meet such as, for example, an incoming
* and/or outgoing CallKit call created by Jitsi Meet SDK for iOS
* clients/consumers prior to giving control to Jitsi Meet. As the value is
* associated with a conference/meeting, the value makes sense not as a
* deployment-wide configuration, only as a runtime configuration
* override/overwrite provided by, for example, Jitsi Meet SDK for iOS.
*
* @type string
*/
'callUUID',
'cameraFacingMode',
'conferenceInfo',
'channelLastN',
'connectionIndicators',
'constraints',
'deeplinking.disabled',
'deeplinking.desktop.enabled',
'defaultLocalDisplayName',
'defaultRemoteDisplayName',
'desktopSharingFrameRate',
'desktopSharingSources',
'disable1On1Mode',
'disableAEC',
'disableAGC',
'disableAP',
'disableAddingBackgroundImages',
'disableAudioLevels',
'disableBeforeUnloadHandlers',
'disableCameraTintForeground',
'disableChatSmileys',
'disableDeepLinking',
'disabledNotifications',
'disabledSounds',
'disableFilmstripAutohiding',
'disableInitialGUM',
'disableInviteFunctions',
'disableIncomingMessageSound',
'disableJoinLeaveSounds',
'disableLocalVideoFlip',
'disableModeratorIndicator',
'disableNS',
'disablePolls',
'disableProfile',
'disableReactions',
'disableReactionsInChat',
'disableReactionsModeration',
'disableRecordAudioNotification',
'disableRemoteControl',
'disableRemoteMute',
'disableResponsiveTiles',
'disableRtx',
'disableSelfDemote',
'disableSelfView',
'disableSelfViewSettings',
'disableShortcuts',
'disableShowMoreStats',
'disableRemoveRaisedHandOnFocus',
'disableSpeakerStatsSearch',
'speakerStatsOrder',
'disableSimulcast',
'disableThirdPartyRequests',
'disableTileView',
'disableTileEnlargement',
'disableVirtualBackground',
'displayJids',
'doNotStoreRoom',
'doNotFlipLocalVideo',
'dropbox.appKey',
'e2eeLabels',
'e2ee',
'e2eping',
'enableCalendarIntegration',
'enableDisplayNameInStats',
'enableEmailInStats',
'enableEncodedTransformSupport',
'enableInsecureRoomNameWarning',
'enableLobbyChat',
'enableOpusRed',
'enableRemb',
'enableSaveLogs',
'enableTalkWhileMuted',
'enableNoAudioDetection',
'enableNoisyMicDetection',
'enableTcc',
'faceLandmarks',
'feedbackPercentage',
'fileSharing.enabled',
'filmstrip',
'flags',
'forceTurnRelay',
'gatherStats',
'giphy',
'googleApiApplicationClientID',
'gravatar.disabled',
'hiddenPremeetingButtons',
'hideConferenceSubject',
'hideDisplayName',
'hideDominantSpeakerBadge',
'hideRecordingLabel',
'hideParticipantsStats',
'hideConferenceTimer',
'hideAddRoomButton',
'hideEmailInSettings',
'hideLobbyButton',
'iAmRecorder',
'iAmSipGateway',
'iAmSpot',
'ignoreStartMuted',
'inviteAppName',
'liveStreaming.enabled',
'liveStreamingEnabled',
'lobby',
'localRecording',
'localSubject',
'logging',
'mainToolbarButtons',
'maxFullResolutionParticipants',
'mouseMoveCallbackInterval',
'notifications',
'notificationTimeouts',
'notifyOnConferenceDestruction',
'openSharedDocumentOnJoin',
'opusMaxAverageBitrate',
'p2p.backToP2PDelay',
'p2p.codecPreferenceOrder',
'p2p.enabled',
'p2p.iceTransportPolicy',
'p2p.mobileCodecPreferenceOrder',
'p2p.mobileScreenshareCodec',
'participantMenuButtonsWithNotifyClick',
'participantsPane',
'pcStatsInterval',
'preferBosh',
'preferVisitor',
'prejoinConfig.enabled',
'prejoinConfig.hideDisplayName',
'prejoinConfig.hideExtraJoinButtons',
'raisedHands',
'recordingService',
'requireDisplayName',
'remoteVideoMenu',
'roomPasswordNumberOfDigits',
'readOnlyName',
'recordings.recordAudioAndVideo',
'recordings.showPrejoinWarning',
'recordings.showRecordingLink',
'recordings.suggestRecording',
'replaceParticipant',
'resolution',
'screenshotCapture',
'securityUi',
'speakerStats',
'startAudioMuted',
'startAudioOnly',
'startLastN',
'startScreenSharing',
'startSilent',
'startVideoMuted',
'startWithAudioMuted',
'startWithVideoMuted',
'stereo',
'subject',
'testing',
'toolbarButtons',
'toolbarConfig',
'tileView',
'transcribingEnabled',
'transcription',
'useHostPageLocalStorage',
'useTurnUdp',
'videoQuality',
'visitors.enableMediaOnPromote',
'watchRTCConfigParams.allowBrowserLogCollection',
'watchRTCConfigParams.collectionInterval',
'watchRTCConfigParams.console',
'watchRTCConfigParams.debug',
'watchRTCConfigParams.keys',
'watchRTCConfigParams.logGetStats',
'watchRTCConfigParams.rtcApiKey',
'watchRTCConfigParams.rtcPeerId',
'watchRTCConfigParams.rtcRoomId',
'watchRTCConfigParams.rtcTags',
'watchRTCConfigParams.rtcToken',
'webrtcIceTcpDisable',
'webrtcIceUdpDisable',
'whiteboard.enabled'
].concat(extraConfigWhitelist).concat(isEmbedded() ? isEmbeddedConfigWhitelist : []);

View File

@@ -0,0 +1,43 @@
/**
* The prefix of the {@code localStorage} key into which {@link storeConfig}
* stores and from which {@link restoreConfig} restores.
*
* @protected
* @type string
*/
export const _CONFIG_STORE_PREFIX = 'config.js';
/**
* The toolbar buttons to show on premeeting screens.
*/
export const PREMEETING_BUTTONS = [ 'microphone', 'camera', 'select-background', 'invite', 'settings' ];
/**
* The toolbar buttons to show on 3rdParty prejoin screen.
*/
export const THIRD_PARTY_PREJOIN_BUTTONS = [ 'microphone', 'camera', 'select-background' ];
/**
* The set of feature flags.
*
* @enum {string}
*/
export const FEATURE_FLAGS = {
SSRC_REWRITING: 'ssrcRewritingEnabled'
};
/**
* The URL at which the terms (of service/use) are available to the user.
*/
export const DEFAULT_TERMS_URL = 'https://jitsi.org/meet/terms';
/**
* The URL at which the privacy policy is available to the user.
*/
export const DEFAULT_PRIVACY_URL = 'https://jitsi.org/meet/privacy';
/**
* The URL at which the help centre is available to the user.
*/
export const DEFAULT_HELP_CENTRE_URL = 'https://web-cdn.jitsi.net/faq/meet-faq.html';

View File

@@ -0,0 +1,4 @@
/**
* Deploy-specific configuration whitelists.
*/
export default [];

View File

@@ -0,0 +1,4 @@
/**
* Deploy-specific interface_config whitelists.
*/
export default [];

View File

@@ -0,0 +1,453 @@
// @ts-ignore
import { jitsiLocalStorage } from '@jitsi/js-utils';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import { safeJsonParse } from '@jitsi/js-utils/json';
import { isEmpty, mergeWith, pick } from 'lodash-es';
import { IReduxState } from '../../app/types';
import { getLocalParticipant } from '../participants/functions';
import { isEmbedded } from '../util/embedUtils';
import { parseURLParams } from '../util/parseURLParams';
import { IConfig } from './configType';
import CONFIG_WHITELIST from './configWhitelist';
import {
DEFAULT_HELP_CENTRE_URL,
DEFAULT_PRIVACY_URL,
DEFAULT_TERMS_URL,
FEATURE_FLAGS,
_CONFIG_STORE_PREFIX
} from './constants';
import INTERFACE_CONFIG_WHITELIST from './interfaceConfigWhitelist';
import logger from './logger';
// XXX The function getRoomName is split out of
// functions.any.js because it is bundled in both app.bundle and
// do_external_connect, webpack 1 does not support tree shaking, and we don't
// want all functions to be bundled in do_external_connect.
export { default as getRoomName } from './getRoomName';
/**
* Create a "fake" configuration object for the given base URL. This is used in case the config
* couldn't be loaded in the welcome page, so at least we have something to try with.
*
* @param {string} baseURL - URL of the deployment for which we want the fake config.
* @returns {Object}
*/
export function createFakeConfig(baseURL: string) {
const url = new URL(baseURL);
return {
hosts: {
domain: url.hostname,
muc: `conference.${url.hostname}`
},
bosh: `${baseURL}http-bind`,
p2p: {
enabled: true
}
};
}
/**
* Selector used to get the meeting region.
*
* @param {Object} state - The global state.
* @returns {string}
*/
export function getMeetingRegion(state: IReduxState) {
return state['features/base/config']?.deploymentInfo?.region || '';
}
/**
* Selector used to get the SSRC-rewriting feature flag.
*
* @param {Object} state - The global state.
* @returns {boolean}
*/
export function getSsrcRewritingFeatureFlag(state: IReduxState) {
return getFeatureFlag(state, FEATURE_FLAGS.SSRC_REWRITING) ?? true;
}
/**
* Selector used to get a feature flag.
*
* @param {Object} state - The global state.
* @param {string} featureFlag - The name of the feature flag.
* @returns {boolean}
*/
export function getFeatureFlag(state: IReduxState, featureFlag: string) {
const featureFlags = state['features/base/config']?.flags || {};
return featureFlags[featureFlag as keyof typeof featureFlags];
}
/**
* Selector used to get the disableRemoveRaisedHandOnFocus.
*
* @param {Object} state - The global state.
* @returns {boolean}
*/
export function getDisableRemoveRaisedHandOnFocus(state: IReduxState) {
return state['features/base/config']?.raisedHands?.disableRemoveRaisedHandOnFocus || false;
}
/**
* Selector used to get the disableLowerHandByModerator.
*
* @param {Object} state - The global state.
* @returns {boolean}
*/
export function getDisableLowerHandByModerator(state: IReduxState) {
return state['features/base/config']?.raisedHands?.disableLowerHandByModerator || false;
}
/**
* Selector used to get the disableLowerHandNotification.
*
* @param {Object} state - The global state.
* @returns {boolean}
*/
export function getDisableLowerHandNotification(state: IReduxState) {
return state['features/base/config']?.raisedHands?.disableLowerHandNotification || true;
}
/**
* Selector used to get the disableNextSpeakerNotification.
*
* @param {Object} state - The global state.
* @returns {boolean}
*/
export function getDisableNextSpeakerNotification(state: IReduxState) {
return state['features/base/config']?.raisedHands?.disableNextSpeakerNotification || false;
}
/**
* Selector used to get the endpoint used for fetching the recording.
*
* @param {Object} state - The global state.
* @returns {string}
*/
export function getRecordingSharingUrl(state: IReduxState) {
return state['features/base/config'].recordingSharingUrl;
}
/**
* Overrides JSON properties in {@code config} and
* {@code interfaceConfig} Objects with the values from {@code newConfig}.
* Overrides only the whitelisted keys.
*
* @param {Object} config - The config Object in which we'll be overriding
* properties.
* @param {Object} interfaceConfig - The interfaceConfig Object in which we'll
* be overriding properties.
* @param {Object} json - Object containing configuration properties.
* Destination object is selected based on root property name:
* {
* config: {
* // config.js properties here
* },
* interfaceConfig: {
* // interface_config.js properties here
* }
* }.
* @returns {void}
*/
export function overrideConfigJSON(config: IConfig, interfaceConfig: any, json: any) {
for (const configName of Object.keys(json)) {
let configObj;
if (configName === 'config') {
configObj = config;
} else if (configName === 'interfaceConfig') {
configObj = interfaceConfig;
}
if (configObj) {
const configJSON
= getWhitelistedJSON(configName as 'interfaceConfig' | 'config', json[configName]);
if (!isEmpty(configJSON)) {
logger.info(`Extending ${configName} with: ${JSON.stringify(configJSON)}`);
// eslint-disable-next-line arrow-body-style
mergeWith(configObj, configJSON, (oldValue, newValue) => {
// XXX We don't want to merge the arrays, we want to
// overwrite them.
return Array.isArray(oldValue) ? newValue : undefined;
});
}
}
}
}
/* eslint-enable max-params, no-shadow */
/**
* Apply whitelist filtering for configs with whitelists.
* Only extracts overridden values for keys we allow to be overridden.
*
* @param {string} configName - The config name, one of config or interfaceConfig.
* @param {Object} configJSON - The object with keys and values to override.
* @returns {Object} - The result object only with the keys
* that are whitelisted.
*/
export function getWhitelistedJSON(configName: 'interfaceConfig' | 'config', configJSON: any): Object {
// Disable whitelisting in dev mode.
if (typeof __DEV__ !== 'undefined' && __DEV__) {
logger.warn('Whitelisting is disabled in dev mode, accepting any overrides');
return configJSON;
}
if (configName === 'interfaceConfig') {
return pick(configJSON, INTERFACE_CONFIG_WHITELIST);
} else if (configName === 'config') {
return pick(configJSON, CONFIG_WHITELIST);
}
return configJSON;
}
/**
* Selector for determining if the display name is read only.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function isNameReadOnly(state: IReduxState): boolean {
return Boolean(state['features/base/config'].disableProfile
|| state['features/base/config'].readOnlyName);
}
/**
* Selector for determining if the participant is the next one in the queue to speak.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function isNextToSpeak(state: IReduxState): boolean {
const raisedHandsQueue = state['features/base/participants'].raisedHandsQueue || [];
const participantId = getLocalParticipant(state)?.id;
return participantId === raisedHandsQueue[0]?.id;
}
/**
* Selector for determining if the next to speak participant in the queue has been notified.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function hasBeenNotified(state: IReduxState): boolean {
const raisedHandsQueue = state['features/base/participants'].raisedHandsQueue;
return Boolean(raisedHandsQueue[0]?.hasBeenNotified);
}
/**
* Selector for determining if the display name is visible.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function isDisplayNameVisible(state: IReduxState): boolean {
return !state['features/base/config'].hideDisplayName;
}
/**
* Restores a Jitsi Meet config.js from {@code localStorage} if it was
* previously downloaded from a specific {@code baseURL} and stored with
* {@link storeConfig}.
*
* @param {string} baseURL - The base URL from which the config.js was
* previously downloaded and stored with {@code storeConfig}.
* @returns {?Object} The Jitsi Meet config.js which was previously downloaded
* from {@code baseURL} and stored with {@code storeConfig} if it was restored;
* otherwise, {@code undefined}.
*/
export function restoreConfig(baseURL: string) {
const key = `${_CONFIG_STORE_PREFIX}/${baseURL}`;
const config = jitsiLocalStorage.getItem(key);
if (config) {
try {
return safeJsonParse(config) || undefined;
} catch (e) {
// Somehow incorrect data ended up in the storage. Clean it up.
jitsiLocalStorage.removeItem(key);
}
}
return undefined;
}
/**
* Inspects the hash part of the location URI and overrides values specified
* there in the corresponding config objects given as the arguments. The syntax
* is: {@code https://server.com/room#config.debug=true
* &interfaceConfig.showButton=false}.
*
* In the hash part each parameter will be parsed to JSON and then the root
* object will be matched with the corresponding config object given as the
* argument to this function.
*
* @param {Object} config - This is the general config.
* @param {Object} interfaceConfig - This is the interface config.
* @param {URI} location - The new location to which the app is navigating to.
* @returns {void}
*/
export function setConfigFromURLParams(
config: IConfig, interfaceConfig: any, location: string | URL) {
const params = parseURLParams(location);
const json: any = {};
// At this point we have:
// params = {
// "config.disableAudioLevels": false,
// "config.channelLastN": -1,
// "interfaceConfig.APP_NAME": "Jitsi Meet"
// }
// We want to have:
// json = {
// config: {
// "disableAudioLevels": false,
// "channelLastN": -1
// },
// interfaceConfig: {
// "APP_NAME": "Jitsi Meet"
// }
// }
config && (json.config = {});
interfaceConfig && (json.interfaceConfig = {});
for (const param of Object.keys(params)) {
let base = json;
const names = param.split('.');
const last = names.pop() ?? '';
for (const name of names) {
base = base[name] = base[name] || {};
}
base[last] = params[param];
}
overrideConfigJSON(config, interfaceConfig, json);
// Print warning about deprecated URL params
if ('interfaceConfig.SUPPORT_URL' in params) {
logger.warn('Using SUPPORT_URL interfaceConfig URL overwrite is deprecated.'
+ ' Please use supportUrl from advanced branding!');
}
if ('config.defaultLogoUrl' in params) {
logger.warn('Using defaultLogoUrl config URL overwrite is deprecated.'
+ ' Please use logoImageUrl from advanced branding!');
}
const deploymentUrlsConfig = params['config.deploymentUrls'] ?? {};
if ('config.deploymentUrls.downloadAppsUrl' in params || 'config.deploymentUrls.userDocumentationURL' in params
|| (typeof deploymentUrlsConfig === 'object'
&& ('downloadAppsUrl' in deploymentUrlsConfig || 'userDocumentationURL' in deploymentUrlsConfig))) {
logger.warn('Using deploymentUrls config URL overwrite is deprecated.'
+ ' Please use downloadAppsUrl and/or userDocumentationURL from advanced branding!');
}
const liveStreamingConfig = params['config.liveStreaming'] ?? {};
if (('interfaceConfig.LIVE_STREAMING_HELP_LINK' in params)
|| ('config.liveStreaming.termsLink' in params)
|| ('config.liveStreaming.dataPrivacyLink' in params)
|| ('config.liveStreaming.helpLink' in params)
|| (typeof params['config.liveStreaming'] === 'object' && 'config.liveStreaming' in params
&& (
'termsLink' in liveStreamingConfig
|| 'dataPrivacyLink' in liveStreamingConfig
|| 'helpLink' in liveStreamingConfig
)
)) {
logger.warn('Using liveStreaming config URL overwrite and/or LIVE_STREAMING_HELP_LINK interfaceConfig URL'
+ ' overwrite is deprecated. Please use liveStreaming from advanced branding!');
}
// When not in an iframe, start without media if the pre-join page is not enabled.
if (!isEmbedded()
&& 'config.prejoinConfig.enabled' in params && config.prejoinConfig?.enabled === false) {
logger.warn('Using prejoinConfig.enabled config URL overwrite implies starting without media.');
config.disableInitialGUM = true;
}
}
/* eslint-enable max-params */
/**
* Returns the dial out url.
*
* @param {Object} state - The state of the app.
* @returns {string}
*/
export function getDialOutStatusUrl(state: IReduxState) {
return state['features/base/config'].guestDialOutStatusUrl;
}
/**
* Returns the dial out status url.
*
* @param {Object} state - The state of the app.
* @returns {string}
*/
export function getDialOutUrl(state: IReduxState) {
return state['features/base/config'].guestDialOutUrl;
}
/**
* Selector to return the security UI config.
*
* @param {IReduxState} state - State object.
* @returns {Object}
*/
export function getSecurityUiConfig(state: IReduxState) {
return state['features/base/config']?.securityUi || {};
}
/**
* Returns the terms, privacy and help centre URL's.
*
* @param {IReduxState} state - The state of the application.
* @returns {{
* privacy: string,
* helpCentre: string,
* terms: string
* }}
*/
export function getLegalUrls(state: IReduxState) {
const helpCentreURL = state['features/base/config']?.helpCentreURL;
const configLegalUrls = state['features/base/config']?.legalUrls;
return {
privacy: configLegalUrls?.privacy || DEFAULT_PRIVACY_URL,
helpCentre: helpCentreURL || configLegalUrls?.helpCentre || DEFAULT_HELP_CENTRE_URL,
terms: configLegalUrls?.terms || DEFAULT_TERMS_URL
};
}
/**
* Utility function to debounce the execution of a callback function.
*
* @param {Function} callback - The callback to debounce.
* @param {number} delay - The debounce delay in milliseconds.
* @returns {Function} - A debounced function that delays the execution of the callback.
*/
export function debounce(callback: (...args: any[]) => void, delay: number) {
let timerId: any;
return (...args: any[]) => {
if (timerId) {
clearTimeout(timerId);
}
timerId = setTimeout(() => callback(...args), delay);
};
}

View File

@@ -0,0 +1,53 @@
import { NativeModules } from 'react-native';
import { IReduxState } from '../../app/types';
import { REPLACE_PARTICIPANT } from '../flags/constants';
import { getFeatureFlag } from '../flags/functions';
import { IConfig, IDeeplinkingConfig } from './configType';
export * from './functions.any';
/**
* Removes all analytics related options from the given configuration, in case of a libre build.
*
* @param {*} config - The configuration which needs to be cleaned up.
* @returns {void}
*/
export function _cleanupConfig(config: IConfig) {
config.analytics = config.analytics ?? {};
config.analytics.scriptURLs = [];
if (NativeModules.AppInfo.LIBRE_BUILD) {
delete config.analytics?.amplitudeAPPKey;
delete config.analytics?.rtcstatsEnabled;
delete config.analytics?.rtcstatsEndpoint;
delete config.analytics?.rtcstatsPollInterval;
delete config.analytics?.rtcstatsSendSdp;
delete config.analytics?.obfuscateRoomName;
delete config.analytics?.watchRTCEnabled;
delete config.watchRTCConfigParams;
config.giphy = { enabled: false };
}
}
/**
* Returns the replaceParticipant config.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function getReplaceParticipant(state: IReduxState): string {
return getFeatureFlag(state, REPLACE_PARTICIPANT, false);
}
/**
* Sets the defaults for deeplinking.
*
* @param {IDeeplinkingConfig} _deeplinking - The deeplinking config.
* @returns {void}
*/
export function _setDeeplinkingDefaults(_deeplinking: IDeeplinkingConfig) {
return;
}

View File

@@ -0,0 +1,87 @@
import { IReduxState } from '../../app/types';
import JitsiMeetJS from '../../base/lib-jitsi-meet';
import {
IConfig,
IDeeplinkingConfig,
IDeeplinkingDesktopConfig,
IDeeplinkingMobileConfig
} from './configType';
export * from './functions.any';
/**
* Removes all analytics related options from the given configuration, in case of a libre build.
*
* @param {*} _config - The configuration which needs to be cleaned up.
* @returns {void}
*/
export function _cleanupConfig(_config: IConfig) {
return;
}
/**
* Returns the replaceParticipant config.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function getReplaceParticipant(state: IReduxState): string | undefined {
return state['features/base/config'].replaceParticipant;
}
/**
* Returns the configuration value of web-hid feature.
*
* @param {Object} state - The state of the app.
* @returns {boolean} True if web-hid feature should be enabled, otherwise false.
*/
export function getWebHIDFeatureConfig(state: IReduxState): boolean {
return state['features/base/config'].enableWebHIDFeature || false;
}
/**
* Returns whether audio level measurement is enabled or not.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function areAudioLevelsEnabled(state: IReduxState): boolean {
return !state['features/base/config'].disableAudioLevels && JitsiMeetJS.isCollectingLocalStats();
}
/**
* Sets the defaults for deeplinking.
*
* @param {IDeeplinkingConfig} deeplinking - The deeplinking config.
* @returns {void}
*/
export function _setDeeplinkingDefaults(deeplinking: IDeeplinkingConfig) {
deeplinking.desktop = deeplinking.desktop || {} as IDeeplinkingDesktopConfig;
deeplinking.android = deeplinking.android || {} as IDeeplinkingMobileConfig;
deeplinking.ios = deeplinking.ios || {} as IDeeplinkingMobileConfig;
const { android, desktop, ios } = deeplinking;
desktop.appName = desktop.appName || 'Jitsi Meet';
desktop.appScheme = desktop.appScheme || 'jitsi-meet';
desktop.download = desktop.download || {};
desktop.download.windows = desktop.download.windows
|| 'https://github.com/jitsi/jitsi-meet-electron/releases/latest/download/jitsi-meet.exe';
desktop.download.macos = desktop.download.macos
|| 'https://github.com/jitsi/jitsi-meet-electron/releases/latest/download/jitsi-meet.dmg';
desktop.download.linux = desktop.download.linux
|| 'https://github.com/jitsi/jitsi-meet-electron/releases/latest/download/jitsi-meet-x86_64.AppImage';
ios.appName = ios.appName || 'Jitsi Meet';
ios.appScheme = ios.appScheme || 'org.jitsi.meet';
ios.downloadLink = ios.downloadLink
|| 'https://itunes.apple.com/us/app/jitsi-meet/id1165103905';
android.appName = android.appName || 'Jitsi Meet';
android.appScheme = android.appScheme || 'org.jitsi.meet';
android.downloadLink = android.downloadLink
|| 'https://play.google.com/store/apps/details?id=org.jitsi.meet';
android.appPackage = android.appPackage || 'org.jitsi.meet';
android.fDroidUrl = android.fDroidUrl || 'https://f-droid.org/packages/org.jitsi.meet/';
}

View File

@@ -0,0 +1,15 @@
import { getBackendSafeRoomName } from '../util/uri';
/**
* Builds and returns the room name.
*
* @returns {string}
*/
export default function getRoomName(): string | undefined {
const path = window.location.pathname;
// The last non-directory component of the path (name) is the room.
const roomName = path.substring(path.lastIndexOf('/') + 1) || undefined;
return getBackendSafeRoomName(roomName);
}

View File

@@ -0,0 +1,57 @@
import { isEmbedded } from '../util/embedUtils';
import extraInterfaceConfigWhitelistCopy from './extraInterfaceConfigWhitelist';
import isEmbeddedInterfaceConfigWhitelist from './isEmbeddedInterfaceConfigWhitelist';
/**
* The interface config keys to whitelist, the keys that can be overridden.
*
* @private
* @type Array
*/
export default [
'AUDIO_LEVEL_PRIMARY_COLOR',
'AUDIO_LEVEL_SECONDARY_COLOR',
'AUTO_PIN_LATEST_SCREEN_SHARE',
'CLOSE_PAGE_GUEST_HINT',
'CONNECTION_INDICATOR_AUTO_HIDE_ENABLED',
'CONNECTION_INDICATOR_AUTO_HIDE_TIMEOUT',
'CONNECTION_INDICATOR_DISABLED',
'DEFAULT_BACKGROUND',
'DISABLE_PRESENCE_STATUS',
'DISABLE_JOIN_LEAVE_NOTIFICATIONS',
'DEFAULT_LOCAL_DISPLAY_NAME',
'DEFAULT_REMOTE_DISPLAY_NAME',
'DISABLE_DOMINANT_SPEAKER_INDICATOR',
'DISABLE_FOCUS_INDICATOR',
'DISABLE_PRIVATE_MESSAGES',
'DISABLE_TRANSCRIPTION_SUBTITLES',
'DISABLE_VIDEO_BACKGROUND',
'DISPLAY_WELCOME_PAGE_CONTENT',
'ENABLE_DIAL_OUT',
'FILM_STRIP_MAX_HEIGHT',
'GENERATE_ROOMNAMES_ON_WELCOME_PAGE',
'INDICATOR_FONT_SIZES',
'INITIAL_TOOLBAR_TIMEOUT',
'LANG_DETECTION',
'LOCAL_THUMBNAIL_RATIO',
'MAXIMUM_ZOOMING_COEFFICIENT',
'NATIVE_APP_NAME',
'OPTIMAL_BROWSERS',
'PHONE_NUMBER_REGEX',
'PROVIDER_NAME',
'RECENT_LIST_ENABLED',
'REMOTE_THUMBNAIL_RATIO',
'SETTINGS_SECTIONS',
'SHARING_FEATURES',
'SHOW_CHROME_EXTENSION_BANNER',
'SHOW_POWERED_BY',
'TILE_VIEW_MAX_COLUMNS',
'TOOLBAR_ALWAYS_VISIBLE',
'TOOLBAR_BUTTONS',
'TOOLBAR_TIMEOUT',
'UNSUPPORTED_BROWSERS',
'VERTICAL_FILMSTRIP',
'VIDEO_LAYOUT_FIT',
'VIDEO_QUALITY_LABEL_DISABLED'
].concat(extraInterfaceConfigWhitelistCopy).concat(isEmbedded() ? isEmbeddedInterfaceConfigWhitelist : []);

View File

@@ -0,0 +1,10 @@
/**
* Additional config whitelist extending the original whitelist applied when Jitsi Meet is embedded
* in another app be that with an iframe or a mobile SDK.
*/
export default [
'customToolbarButtons',
'defaultLogoUrl',
'deploymentUrls',
'liveStreaming'
];

View File

@@ -0,0 +1,6 @@
/**
* Additional interface config whitelist extending the original whitelist applied when Jitsi Meet is embedded
* in another app be that with an iframe or a mobile SDK.
*/
export default [
];

View File

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

View File

@@ -0,0 +1,225 @@
import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { SET_DYNAMIC_BRANDING_DATA } from '../../dynamic-branding/actionTypes';
import { setUserFilmstripWidth } from '../../filmstrip/actions.web';
import { getFeatureFlag } from '../flags/functions';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { updateSettings } from '../settings/actions';
import { OVERWRITE_CONFIG, SET_CONFIG } from './actionTypes';
import { updateConfig } from './actions';
import { IConfig } from './configType';
/**
* The middleware of the feature {@code base/config}.
*
* @param {Store} store - The redux store.
* @private
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case SET_CONFIG:
return _setConfig(store, next, action);
case SET_DYNAMIC_BRANDING_DATA:
return _setDynamicBrandingData(store, next, action);
case OVERWRITE_CONFIG:
return _updateSettings(store, next, action);
}
return next(action);
});
/**
* Notifies the feature {@code base/config} that the {@link SET_CONFIG} redux
* action is being {@code dispatch}ed in a specific redux 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 which is being {@code dispatch}ed
* in the specified {@code store}.
* @private
* @returns {*} The return value of {@code next(action)}.
*/
function _setConfig({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
// The reducer is doing some alterations to the config passed in the action,
// so make sure it's the final state by waiting for the action to be
// reduced.
const result = next(action);
const state = getState();
// Update the config with user defined settings.
const settings = state['features/base/settings'];
const config: IConfig = {};
if (typeof settings.disableP2P !== 'undefined') {
config.p2p = { enabled: !settings.disableP2P };
}
const resolutionFlag = getFeatureFlag(state, 'resolution');
if (typeof resolutionFlag !== 'undefined') {
config.resolution = resolutionFlag;
}
if (action.config.doNotFlipLocalVideo === true) {
dispatch(updateSettings({
localFlipX: false
}));
}
if (action.config.disableSelfView !== undefined) {
dispatch(updateSettings({
disableSelfView: action.config.disableSelfView
}));
}
const { initialWidth, stageFilmstripParticipants } = action.config.filmstrip || {};
if (stageFilmstripParticipants !== undefined) {
dispatch(updateSettings({
maxStageParticipants: stageFilmstripParticipants
}));
}
if (initialWidth) {
dispatch(setUserFilmstripWidth(initialWidth));
}
dispatch(updateConfig(config));
// FIXME On Web we rely on the global 'config' variable which gets altered
// multiple times, before it makes it to the reducer. At some point it may
// not be the global variable which is being modified anymore due to
// different merge methods being used along the way. The global variable
// must be synchronized with the final state resolved by the reducer.
if (typeof window.config !== 'undefined') {
window.config = state['features/base/config'];
}
return result;
}
/**
* Updates config based on dynamic branding data.
*
* @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 {@code dispatch}ed
* in the specified {@code store}.
* @private
* @returns {*} The return value of {@code next(action)}.
*/
function _setDynamicBrandingData({ dispatch }: IStore, next: Function, action: AnyAction) {
const config: IConfig = {};
const {
customParticipantMenuButtons,
customToolbarButtons,
downloadAppsUrl,
etherpadBase,
liveStreamingDialogUrls = {},
preCallTest = {},
salesforceUrl,
userDocumentationUrl,
peopleSearchUrl,
} = action.value;
const { helpUrl, termsUrl, dataPrivacyUrl } = liveStreamingDialogUrls;
if (helpUrl || termsUrl || dataPrivacyUrl) {
config.liveStreaming = {};
if (helpUrl) {
config.liveStreaming.helpLink = helpUrl;
}
if (termsUrl) {
config.liveStreaming.termsLink = termsUrl;
}
if (dataPrivacyUrl) {
config.liveStreaming.dataPrivacyLink = dataPrivacyUrl;
}
}
if (downloadAppsUrl || userDocumentationUrl) {
config.deploymentUrls = {};
if (downloadAppsUrl) {
config.deploymentUrls.downloadAppsUrl = downloadAppsUrl;
}
if (userDocumentationUrl) {
config.deploymentUrls.userDocumentationURL = userDocumentationUrl;
}
}
if (salesforceUrl) {
config.salesforceUrl = salesforceUrl;
}
if (peopleSearchUrl) {
config.peopleSearchUrl = peopleSearchUrl;
}
const { enabled, iceUrl } = preCallTest;
if (typeof enabled === 'boolean') {
config.prejoinConfig = {
preCallTestEnabled: enabled
};
}
if (etherpadBase) {
// eslint-disable-next-line camelcase
config.etherpad_base = etherpadBase;
}
if (iceUrl) {
config.prejoinConfig = config.prejoinConfig || {};
config.prejoinConfig.preCallTestICEUrl = iceUrl;
}
if (customToolbarButtons) {
config.customToolbarButtons = customToolbarButtons;
}
if (customParticipantMenuButtons) {
config.customParticipantMenuButtons = customParticipantMenuButtons;
}
dispatch(updateConfig(config));
return next(action);
}
/**
* Updates settings based on some config values.
*
* @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 {@code dispatch}ed
* in the specified {@code store}.
* @private
* @returns {*} The return value of {@code next(action)}.
*/
function _updateSettings({ dispatch }: IStore, next: Function, action: AnyAction) {
const { config: { doNotFlipLocalVideo } } = action;
if (doNotFlipLocalVideo === true) {
dispatch(updateSettings({
localFlipX: false
}));
}
return next(action);
}

View File

@@ -0,0 +1,592 @@
import { merge, union } from 'lodash-es';
import { CONFERENCE_INFO } from '../../conference/components/constants';
import { TOOLBAR_BUTTONS } from '../../toolbox/constants';
import { ToolbarButton } from '../../toolbox/types';
import { CONNECTION_PROPERTIES_UPDATED } from '../connection/actionTypes';
import ReducerRegistry from '../redux/ReducerRegistry';
import { equals } from '../redux/functions';
import {
CONFIG_WILL_LOAD,
LOAD_CONFIG_ERROR,
OVERWRITE_CONFIG,
SET_CONFIG,
UPDATE_CONFIG
} from './actionTypes';
import {
IConfig,
IDeeplinkingConfig,
IDeeplinkingDesktopConfig,
IDeeplinkingMobileConfig
} from './configType';
import { _cleanupConfig, _setDeeplinkingDefaults } from './functions';
/**
* The initial state of the feature base/config when executing in a
* non-React Native environment. The mandatory configuration to be passed to
* JitsiMeetJS#init(). The app will download config.js from the Jitsi Meet
* deployment and take its values into account but the values below will be
* enforced (because they are essential to the correct execution of the
* application).
*
* @type {Object}
*/
const INITIAL_NON_RN_STATE: IConfig = {
};
/**
* The initial state of the feature base/config when executing in a React Native
* environment. The mandatory configuration to be passed to JitsiMeetJS#init().
* The app will download config.js from the Jitsi Meet deployment and take its
* values into account but the values below will be enforced (because they are
* essential to the correct execution of the application).
*
* @type {Object}
*/
const INITIAL_RN_STATE: IConfig = {
};
/**
* Mapping between old configs controlling the conference info headers visibility and the
* new configs. Needed in order to keep backwards compatibility.
*/
const CONFERENCE_HEADER_MAPPING = {
hideConferenceTimer: [ 'conference-timer' ],
hideConferenceSubject: [ 'subject' ],
hideParticipantsStats: [ 'participants-count' ],
hideRecordingLabel: [ 'recording' ]
};
export interface IConfigState extends IConfig {
analysis?: {
obfuscateRoomName?: boolean;
};
error?: Error;
oldConfig?: {
bosh?: string;
focusUserJid?: string;
hosts: {
domain: string;
muc: string;
};
p2p?: object;
websocket?: string;
};
}
ReducerRegistry.register<IConfigState>('features/base/config', (state = _getInitialState(), action): IConfigState => {
switch (action.type) {
case UPDATE_CONFIG:
return _updateConfig(state, action);
case CONFIG_WILL_LOAD:
return {
error: undefined,
/**
* The URL of the location associated with/configured by this
* configuration.
*
* @type URL
*/
locationURL: action.locationURL
};
case CONNECTION_PROPERTIES_UPDATED: {
const { region, shard } = action.properties;
const { deploymentInfo } = state;
if (deploymentInfo?.region === region && deploymentInfo?.shard === shard) {
return state;
}
return {
...state,
deploymentInfo: JSON.parse(JSON.stringify({
...deploymentInfo,
region,
shard
}))
};
}
case LOAD_CONFIG_ERROR:
// XXX LOAD_CONFIG_ERROR is one of the settlement execution paths of
// the asynchronous "loadConfig procedure/process" started with
// CONFIG_WILL_LOAD. Due to the asynchronous nature of it, whoever
// is settling the process needs to provide proof that they have
// started it and that the iteration of the process being completed
// now is still of interest to the app.
if (state.locationURL === action.locationURL) {
return {
/**
* The {@link Error} which prevented the loading of the
* configuration of the associated {@code locationURL}.
*
* @type Error
*/
error: action.error
};
}
break;
case SET_CONFIG:
return _setConfig(state, action);
case OVERWRITE_CONFIG:
return {
...state,
...action.config
};
}
return state;
});
/**
* Gets the initial state of the feature base/config. The mandatory
* configuration to be passed to JitsiMeetJS#init(). The app will download
* config.js from the Jitsi Meet deployment and take its values into account but
* the values below will be enforced (because they are essential to the correct
* execution of the application).
*
* @returns {Object}
*/
function _getInitialState() {
return (
navigator.product === 'ReactNative'
? INITIAL_RN_STATE
: INITIAL_NON_RN_STATE);
}
/**
* Reduces a specific Redux action SET_CONFIG of the feature
* base/lib-jitsi-meet.
*
* @param {IConfig} state - The Redux state of the feature base/config.
* @param {Action} action - The Redux action SET_CONFIG to reduce.
* @private
* @returns {Object} The new state after the reduction of the specified action.
*/
function _setConfig(state: IConfig, { config }: { config: IConfig; }) {
// eslint-disable-next-line no-param-reassign
config = _translateLegacyConfig(config);
const { audioQuality } = config;
const hdAudioOptions = {};
if (audioQuality?.stereo) {
Object.assign(hdAudioOptions, {
disableAP: true,
enableNoAudioDetection: false,
enableNoisyMicDetection: false,
enableTalkWhileMuted: false
});
}
const { alwaysShowResizeBar, disableResizable } = config.filmstrip || {};
if (alwaysShowResizeBar && disableResizable) {
config.filmstrip = {
...config.filmstrip,
alwaysShowResizeBar: false
};
}
const newState = merge(
{},
config,
hdAudioOptions,
{ error: undefined },
// The config of _getInitialState() is meant to override the config
// downloaded from the Jitsi Meet deployment because the former contains
// values that are mandatory.
_getInitialState()
);
_cleanupConfig(newState);
return equals(state, newState) ? state : newState;
}
/**
* Processes the conferenceInfo object against the defaults.
*
* @param {IConfig} config - The old config.
* @returns {Object} The processed conferenceInfo object.
*/
function _getConferenceInfo(config: IConfig) {
const { conferenceInfo } = config;
if (conferenceInfo) {
return {
alwaysVisible: conferenceInfo.alwaysVisible ?? [ ...CONFERENCE_INFO.alwaysVisible ],
autoHide: conferenceInfo.autoHide ?? [ ...CONFERENCE_INFO.autoHide ]
};
}
return {
...CONFERENCE_INFO
};
}
/**
* Constructs a new config {@code Object}, if necessary, out of a specific
* interface_config {@code Object} which is in the latest format supported by jitsi-meet.
*
* @param {Object} oldValue - The config {@code Object} which may or may not be
* in the latest form supported by jitsi-meet and from which a new config
* {@code Object} is to be constructed if necessary.
* @returns {Object} A config {@code Object} which is in the latest format
* supported by jitsi-meet.
*/
function _translateInterfaceConfig(oldValue: IConfig) {
const newValue = oldValue;
if (!Array.isArray(oldValue.toolbarButtons)
&& typeof interfaceConfig === 'object' && Array.isArray(interfaceConfig.TOOLBAR_BUTTONS)) {
newValue.toolbarButtons = interfaceConfig.TOOLBAR_BUTTONS;
}
if (!oldValue.toolbarConfig) {
oldValue.toolbarConfig = {};
}
newValue.toolbarConfig = oldValue.toolbarConfig || {};
if (typeof oldValue.toolbarConfig.alwaysVisible !== 'boolean'
&& typeof interfaceConfig === 'object'
&& typeof interfaceConfig.TOOLBAR_ALWAYS_VISIBLE === 'boolean') {
newValue.toolbarConfig.alwaysVisible = interfaceConfig.TOOLBAR_ALWAYS_VISIBLE;
}
if (typeof oldValue.toolbarConfig.initialTimeout !== 'number'
&& typeof interfaceConfig === 'object'
&& typeof interfaceConfig.INITIAL_TOOLBAR_TIMEOUT === 'number') {
newValue.toolbarConfig.initialTimeout = interfaceConfig.INITIAL_TOOLBAR_TIMEOUT;
}
if (typeof oldValue.toolbarConfig.timeout !== 'number'
&& typeof interfaceConfig === 'object'
&& typeof interfaceConfig.TOOLBAR_TIMEOUT === 'number') {
newValue.toolbarConfig.timeout = interfaceConfig.TOOLBAR_TIMEOUT;
}
if (!oldValue.connectionIndicators
&& typeof interfaceConfig === 'object'
&& (interfaceConfig.hasOwnProperty('CONNECTION_INDICATOR_DISABLED')
|| interfaceConfig.hasOwnProperty('CONNECTION_INDICATOR_AUTO_HIDE_ENABLED')
|| interfaceConfig.hasOwnProperty('CONNECTION_INDICATOR_AUTO_HIDE_TIMEOUT'))) {
newValue.connectionIndicators = {
disabled: interfaceConfig.CONNECTION_INDICATOR_DISABLED,
autoHide: interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED,
autoHideTimeout: interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_TIMEOUT
};
}
if (oldValue.disableModeratorIndicator === undefined
&& typeof interfaceConfig === 'object'
&& interfaceConfig.hasOwnProperty('DISABLE_FOCUS_INDICATOR')) {
newValue.disableModeratorIndicator = interfaceConfig.DISABLE_FOCUS_INDICATOR;
}
if (oldValue.defaultLocalDisplayName === undefined
&& typeof interfaceConfig === 'object'
&& interfaceConfig.hasOwnProperty('DEFAULT_LOCAL_DISPLAY_NAME')) {
newValue.defaultLocalDisplayName = interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME;
}
if (oldValue.defaultRemoteDisplayName === undefined
&& typeof interfaceConfig === 'object'
&& interfaceConfig.hasOwnProperty('DEFAULT_REMOTE_DISPLAY_NAME')) {
newValue.defaultRemoteDisplayName = interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME;
}
if (oldValue.defaultLogoUrl === undefined) {
if (typeof interfaceConfig === 'object'
&& interfaceConfig.hasOwnProperty('DEFAULT_LOGO_URL')) {
newValue.defaultLogoUrl = interfaceConfig.DEFAULT_LOGO_URL;
} else {
newValue.defaultLogoUrl = 'images/watermark.svg';
}
}
// if we have `deeplinking` defined, ignore deprecated values, except `disableDeepLinking`.
// Otherwise, compose the config.
if (oldValue.deeplinking && newValue.deeplinking) { // make TS happy
newValue.deeplinking.disabled = oldValue.deeplinking.hasOwnProperty('disabled')
? oldValue.deeplinking.disabled
: Boolean(oldValue.disableDeepLinking);
} else {
const disabled = Boolean(oldValue.disableDeepLinking);
const deeplinking: IDeeplinkingConfig = {
desktop: {} as IDeeplinkingDesktopConfig,
hideLogo: false,
disabled,
android: {} as IDeeplinkingMobileConfig,
ios: {} as IDeeplinkingMobileConfig
};
if (typeof interfaceConfig === 'object') {
if (deeplinking.desktop) {
deeplinking.desktop.appName = interfaceConfig.NATIVE_APP_NAME;
}
deeplinking.hideLogo = Boolean(interfaceConfig.HIDE_DEEP_LINKING_LOGO);
deeplinking.android = {
appName: interfaceConfig.NATIVE_APP_NAME,
appScheme: interfaceConfig.APP_SCHEME,
downloadLink: interfaceConfig.MOBILE_DOWNLOAD_LINK_ANDROID,
appPackage: interfaceConfig.ANDROID_APP_PACKAGE,
fDroidUrl: interfaceConfig.MOBILE_DOWNLOAD_LINK_F_DROID
};
deeplinking.ios = {
appName: interfaceConfig.NATIVE_APP_NAME,
appScheme: interfaceConfig.APP_SCHEME,
downloadLink: interfaceConfig.MOBILE_DOWNLOAD_LINK_IOS
};
}
newValue.deeplinking = deeplinking;
}
return newValue;
}
/**
* Constructs a new config {@code Object}, if necessary, out of a specific
* config {@code Object} which is in the latest format supported by jitsi-meet.
* Such a translation from an old config format to a new/the latest config
* format is necessary because the mobile app bundles jitsi-meet and
* lib-jitsi-meet at build time and does not download them at runtime from the
* deployment on which it will join a conference.
*
* @param {Object} oldValue - The config {@code Object} which may or may not be
* in the latest form supported by jitsi-meet and from which a new config
* {@code Object} is to be constructed if necessary.
* @returns {Object} A config {@code Object} which is in the latest format
* supported by jitsi-meet.
*/
function _translateLegacyConfig(oldValue: IConfig) {
const newValue = _translateInterfaceConfig(oldValue);
// Translate deprecated config values to new config values.
const filteredConferenceInfo = Object.keys(CONFERENCE_HEADER_MAPPING).filter(key => oldValue[key as keyof IConfig]);
if (filteredConferenceInfo.length) {
newValue.conferenceInfo = _getConferenceInfo(oldValue);
filteredConferenceInfo.forEach(key => {
newValue.conferenceInfo = oldValue.conferenceInfo ?? {};
// hideRecordingLabel does not mean not render it at all, but autoHide it
if (key === 'hideRecordingLabel') {
newValue.conferenceInfo.alwaysVisible
= (newValue.conferenceInfo?.alwaysVisible ?? [])
.filter(c => !CONFERENCE_HEADER_MAPPING[key].includes(c));
newValue.conferenceInfo.autoHide
= union(newValue.conferenceInfo.autoHide, CONFERENCE_HEADER_MAPPING[key]);
} else {
newValue.conferenceInfo.alwaysVisible
= (newValue.conferenceInfo.alwaysVisible ?? [])
.filter(c => !CONFERENCE_HEADER_MAPPING[key as keyof typeof CONFERENCE_HEADER_MAPPING].includes(c));
newValue.conferenceInfo.autoHide
= (newValue.conferenceInfo.autoHide ?? []).filter(c =>
!CONFERENCE_HEADER_MAPPING[key as keyof typeof CONFERENCE_HEADER_MAPPING].includes(c));
}
});
}
newValue.welcomePage = oldValue.welcomePage || {};
if (oldValue.hasOwnProperty('enableWelcomePage')
&& !newValue.welcomePage.hasOwnProperty('disabled')
) {
newValue.welcomePage.disabled = !oldValue.enableWelcomePage;
}
newValue.disabledSounds = newValue.disabledSounds || [];
if (oldValue.disableJoinLeaveSounds) {
newValue.disabledSounds.unshift('PARTICIPANT_LEFT_SOUND', 'PARTICIPANT_JOINED_SOUND');
}
if (oldValue.disableRecordAudioNotification) {
newValue.disabledSounds.unshift(
'RECORDING_ON_SOUND',
'RECORDING_OFF_SOUND',
'LIVE_STREAMING_ON_SOUND',
'LIVE_STREAMING_OFF_SOUND'
);
}
if (oldValue.disableIncomingMessageSound) {
newValue.disabledSounds.unshift('INCOMING_MSG_SOUND');
}
newValue.raisedHands = newValue.raisedHands || {};
if (oldValue.disableRemoveRaisedHandOnFocus) {
newValue.raisedHands.disableRemoveRaisedHandOnFocus = oldValue.disableRemoveRaisedHandOnFocus;
}
if (oldValue.stereo || oldValue.opusMaxAverageBitrate) {
newValue.audioQuality = {
opusMaxAverageBitrate: oldValue.audioQuality?.opusMaxAverageBitrate ?? oldValue.opusMaxAverageBitrate,
stereo: oldValue.audioQuality?.stereo ?? oldValue.stereo
};
}
newValue.e2ee = newValue.e2ee || {};
if (oldValue.e2eeLabels) {
newValue.e2ee.labels = oldValue.e2eeLabels;
}
newValue.defaultLocalDisplayName
= newValue.defaultLocalDisplayName || 'me';
if (oldValue.hideAddRoomButton) {
newValue.breakoutRooms = {
/* eslint-disable-next-line no-extra-parens */
...(newValue.breakoutRooms || {}),
hideAddRoomButton: oldValue.hideAddRoomButton
};
}
newValue.defaultRemoteDisplayName
= newValue.defaultRemoteDisplayName || 'Fellow Jitster';
newValue.transcription = newValue.transcription || {};
if (oldValue.transcribingEnabled !== undefined) {
newValue.transcription = {
...newValue.transcription,
enabled: oldValue.transcribingEnabled
};
}
if (oldValue.transcribeWithAppLanguage !== undefined) {
newValue.transcription = {
...newValue.transcription,
useAppLanguage: oldValue.transcribeWithAppLanguage
};
}
if (oldValue.preferredTranscribeLanguage !== undefined) {
newValue.transcription = {
...newValue.transcription,
preferredLanguage: oldValue.preferredTranscribeLanguage
};
}
if (oldValue.autoCaptionOnRecord !== undefined) {
newValue.transcription = {
...newValue.transcription,
autoTranscribeOnRecord: oldValue.autoCaptionOnRecord
};
}
newValue.recordingService = newValue.recordingService || {};
if (oldValue.fileRecordingsServiceEnabled !== undefined
&& newValue.recordingService.enabled === undefined) {
newValue.recordingService = {
...newValue.recordingService,
enabled: oldValue.fileRecordingsServiceEnabled
};
}
if (oldValue.fileRecordingsServiceSharingEnabled !== undefined
&& newValue.recordingService.sharingEnabled === undefined) {
newValue.recordingService = {
...newValue.recordingService,
sharingEnabled: oldValue.fileRecordingsServiceSharingEnabled
};
}
newValue.liveStreaming = newValue.liveStreaming || {};
// Migrate config.liveStreamingEnabled
if (oldValue.liveStreamingEnabled !== undefined) {
newValue.liveStreaming = {
...newValue.liveStreaming,
enabled: oldValue.liveStreamingEnabled
};
}
// Migrate interfaceConfig.LIVE_STREAMING_HELP_LINK
if (oldValue.liveStreaming === undefined
&& typeof interfaceConfig === 'object'
&& interfaceConfig.hasOwnProperty('LIVE_STREAMING_HELP_LINK')) {
newValue.liveStreaming = {
...newValue.liveStreaming,
helpLink: interfaceConfig.LIVE_STREAMING_HELP_LINK
};
}
newValue.speakerStats = newValue.speakerStats || {};
if (oldValue.disableSpeakerStatsSearch !== undefined
&& newValue.speakerStats.disableSearch === undefined
) {
newValue.speakerStats = {
...newValue.speakerStats,
disableSearch: oldValue.disableSpeakerStatsSearch
};
}
if (oldValue.speakerStatsOrder !== undefined
&& newValue.speakerStats.order === undefined) {
newValue.speakerStats = {
...newValue.speakerStats,
order: oldValue.speakerStatsOrder
};
}
if (oldValue.autoKnockLobby !== undefined
&& newValue.lobby?.autoKnock === undefined) {
newValue.lobby = {
...newValue.lobby || {},
autoKnock: oldValue.autoKnockLobby
};
}
if (oldValue.enableLobbyChat !== undefined
&& newValue.lobby?.enableChat === undefined) {
newValue.lobby = {
...newValue.lobby || {},
enableChat: oldValue.enableLobbyChat
};
}
if (oldValue.hideLobbyButton !== undefined
&& newValue.securityUi?.hideLobbyButton === undefined) {
newValue.securityUi = {
...newValue.securityUi || {},
hideLobbyButton: oldValue.hideLobbyButton
};
}
// Profile button is not available on mobile
if (navigator.product !== 'ReactNative') {
if (oldValue.disableProfile) {
newValue.toolbarButtons = (newValue.toolbarButtons || TOOLBAR_BUTTONS)
.filter((button: ToolbarButton) => button !== 'profile');
}
}
_setDeeplinkingDefaults(newValue.deeplinking as IDeeplinkingConfig);
return newValue;
}
/**
* Updates the stored configuration with the given extra options.
*
* @param {Object} state - The Redux state of the feature base/config.
* @param {Action} action - The Redux action to reduce.
* @private
* @returns {Object} The new state after the reduction of the specified action.
*/
function _updateConfig(state: IConfig, { config }: { config: IConfig; }) {
const newState = merge({}, state, config);
_cleanupConfig(newState);
return equals(state, newState) ? state : newState;
}

View File

@@ -0,0 +1,84 @@
/**
* The type of (redux) action which signals that a connection disconnected.
*
* {
* type: CONNECTION_DISCONNECTED,
* connection: JitsiConnection
* }
*/
export const CONNECTION_DISCONNECTED = 'CONNECTION_DISCONNECTED';
/**
* The type of (redux) action which signals that a connection was successfully
* established.
*
* {
* type: CONNECTION_ESTABLISHED,
* connection: JitsiConnection,
* timeEstablished: number,
* }
*/
export const CONNECTION_ESTABLISHED = 'CONNECTION_ESTABLISHED';
/**
* The type of (redux) action which signals that a connection failed.
*
* {
* type: CONNECTION_FAILED,
* connection: JitsiConnection,
* error: Object | string
* }
*/
export const CONNECTION_FAILED = 'CONNECTION_FAILED';
/**
* The type of (redux) action which signals that connection properties were updated.
*
* {
* type: CONNECTION_PROPERTIES_UPDATED,
* properties: Object
* }
*/
export const CONNECTION_PROPERTIES_UPDATED = 'CONNECTION_PROPERTIES_UPDATED';
/**
* The type of (redux) action which signals that a connection will connect.
*
* {
* type: CONNECTION_WILL_CONNECT,
* connection: JitsiConnection
* }
*/
export const CONNECTION_WILL_CONNECT = 'CONNECTION_WILL_CONNECT';
/**
* The type of (redux) action which sets the location URL of the application,
* connection, conference, etc.
*
* {
* type: SET_LOCATION_URL,
* locationURL: ?URL
* }
*/
export const SET_LOCATION_URL = 'SET_LOCATION_URL';
/**
* The type of (redux) action which sets the preferVisitor in store.
*
* {
* type: SET_PREFER_VISITOR,
* preferVisitor: ?boolean
* }
*/
export const SET_PREFER_VISITOR = 'SET_PREFER_VISITOR';
/**
* The type of (redux) action which tells whether connection info should be displayed
* on context menu.
*
* {
* type: SHOW_CONNECTION_INFO,
* showConnectionInfo: boolean
* }
*/
export const SHOW_CONNECTION_INFO = 'SHOW_CONNECTION_INFO';

View File

@@ -0,0 +1,442 @@
import { cloneDeep } from 'lodash-es';
import { IReduxState, IStore } from '../../app/types';
import { conferenceLeft, conferenceWillLeave, redirect } from '../conference/actions';
import { getCurrentConference } from '../conference/functions';
import { IConfigState } from '../config/reducer';
import JitsiMeetJS, { JitsiConnectionEvents } from '../lib-jitsi-meet';
import { isEmbedded } from '../util/embedUtils';
import { parseURLParams } from '../util/parseURLParams';
import {
appendURLParam,
getBackendSafeRoomName
} from '../util/uri';
import {
CONNECTION_DISCONNECTED,
CONNECTION_ESTABLISHED,
CONNECTION_FAILED,
CONNECTION_PROPERTIES_UPDATED,
CONNECTION_WILL_CONNECT,
SET_LOCATION_URL,
SET_PREFER_VISITOR
} from './actionTypes';
import { JITSI_CONNECTION_URL_KEY } from './constants';
import logger from './logger';
import { ConnectionFailedError, IIceServers } from './types';
/**
* The options that will be passed to the JitsiConnection instance.
*/
interface IOptions extends IConfigState {
iceServersOverride?: IIceServers;
preferVisitor?: boolean;
}
/**
* Create an action for when the signaling connection has been lost.
*
* @param {JitsiConnection} connection - The {@code JitsiConnection} which
* disconnected.
* @private
* @returns {{
* type: CONNECTION_DISCONNECTED,
* connection: JitsiConnection
* }}
*/
export function connectionDisconnected(connection?: Object) {
return {
type: CONNECTION_DISCONNECTED,
connection
};
}
/**
* Create an action for when the signaling connection has been established.
*
* @param {JitsiConnection} connection - The {@code JitsiConnection} which was
* established.
* @param {number} timeEstablished - The time at which the
* {@code JitsiConnection} which was established.
* @public
* @returns {{
* type: CONNECTION_ESTABLISHED,
* connection: JitsiConnection,
* timeEstablished: number
* }}
*/
export function connectionEstablished(
connection: Object, timeEstablished: number) {
return {
type: CONNECTION_ESTABLISHED,
connection,
timeEstablished
};
}
/**
* Create an action for when the signaling connection could not be created.
*
* @param {JitsiConnection} connection - The {@code JitsiConnection} which
* failed.
* @param {ConnectionFailedError} error - Error.
* @public
* @returns {{
* type: CONNECTION_FAILED,
* connection: JitsiConnection,
* error: ConnectionFailedError
* }}
*/
export function connectionFailed(
connection: Object,
error: ConnectionFailedError) {
const { credentials } = error;
if (credentials && !Object.keys(credentials).length) {
error.credentials = undefined;
}
return {
type: CONNECTION_FAILED,
connection,
error
};
}
/**
* Constructs options to be passed to the constructor of {@code JitsiConnection}
* based on the redux state.
*
* @param {Object} state - The redux state.
* @returns {Object} The options to be passed to the constructor of
* {@code JitsiConnection}.
*/
export function constructOptions(state: IReduxState) {
// Deep clone the options to make sure we don't modify the object in the
// redux store.
const options: IOptions = cloneDeep(state['features/base/config']);
const { locationURL, preferVisitor } = state['features/base/connection'];
const params = parseURLParams(locationURL || '');
const iceServersOverride = params['iceServers.replace'];
// Allow iceServersOverride only when jitsi-meet is in an iframe.
if (isEmbedded() && iceServersOverride) {
options.iceServersOverride = iceServersOverride;
}
const { bosh, preferBosh, flags } = options;
let { websocket } = options;
if (preferBosh) {
websocket = undefined;
}
// WebSocket is preferred over BOSH.
const serviceUrl = websocket || bosh;
logger.log(`Using service URL ${serviceUrl}`);
// Append room to the URL's search.
const { room } = state['features/base/conference'];
if (serviceUrl && room) {
const roomName = getBackendSafeRoomName(room);
options.serviceUrl = appendURLParam(serviceUrl, 'room', roomName ?? '');
if (options.websocketKeepAliveUrl) {
options.websocketKeepAliveUrl = appendURLParam(options.websocketKeepAliveUrl, 'room', roomName ?? '');
}
if (options.conferenceRequestUrl) {
options.conferenceRequestUrl = appendURLParam(options.conferenceRequestUrl, 'room', roomName ?? '');
}
}
if (preferVisitor) {
options.preferVisitor = true;
}
// Enable ssrc-rewriting by default.
if (typeof flags?.ssrcRewritingEnabled === 'undefined') {
const { ...otherFlags } = flags ?? {};
options.flags = {
...otherFlags,
ssrcRewritingEnabled: true
};
}
return options;
}
/**
* Sets the location URL of the application, connection, conference, etc.
*
* @param {URL} [locationURL] - The location URL of the application,
* connection, conference, etc.
* @returns {{
* type: SET_LOCATION_URL,
* locationURL: URL
* }}
*/
export function setLocationURL(locationURL?: URL) {
return {
type: SET_LOCATION_URL,
locationURL
};
}
/**
* To change prefer visitor in the store. Used later to decide what to request from jicofo on connection.
*
* @param {boolean} preferVisitor - The value to set.
* @returns {{
* type: SET_PREFER_VISITOR,
* preferVisitor: boolean
* }}
*/
export function setPreferVisitor(preferVisitor: boolean) {
return {
type: SET_PREFER_VISITOR,
preferVisitor
};
}
/**
* Opens new connection.
*
* @param {string} [id] - The XMPP user's ID (e.g. {@code user@server.com}).
* @param {string} [password] - The XMPP user's password.
* @returns {Function}
*/
export function _connectInternal(id?: string, password?: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const options = constructOptions(state);
const { locationURL } = state['features/base/connection'];
const { jwt } = state['features/base/jwt'];
const connection = new JitsiMeetJS.JitsiConnection(options.appId, jwt, options);
connection[JITSI_CONNECTION_URL_KEY] = locationURL;
dispatch(_connectionWillConnect(connection));
return new Promise((resolve, reject) => {
connection.addEventListener(
JitsiConnectionEvents.CONNECTION_DISCONNECTED,
_onConnectionDisconnected);
connection.addEventListener(
JitsiConnectionEvents.CONNECTION_ESTABLISHED,
_onConnectionEstablished);
connection.addEventListener(
JitsiConnectionEvents.CONNECTION_FAILED,
_onConnectionFailed);
connection.addEventListener(
JitsiConnectionEvents.CONNECTION_REDIRECTED,
_onConnectionRedirected);
connection.addEventListener(
JitsiConnectionEvents.PROPERTIES_UPDATED,
_onPropertiesUpdate);
/**
* Unsubscribe the connection instance from
* {@code CONNECTION_DISCONNECTED} and {@code CONNECTION_FAILED} events.
*
* @returns {void}
*/
function unsubscribe() {
connection.removeEventListener(
JitsiConnectionEvents.CONNECTION_DISCONNECTED, _onConnectionDisconnected);
connection.removeEventListener(JitsiConnectionEvents.CONNECTION_FAILED, _onConnectionFailed);
connection.removeEventListener(JitsiConnectionEvents.PROPERTIES_UPDATED, _onPropertiesUpdate);
}
/**
* Dispatches {@code CONNECTION_DISCONNECTED} action when connection is
* disconnected.
*
* @private
* @returns {void}
*/
function _onConnectionDisconnected() {
unsubscribe();
dispatch(connectionDisconnected(connection));
resolve(connection);
}
/**
* Rejects external promise when connection fails.
*
* @param {JitsiConnectionErrors} err - Connection error.
* @param {string} [message] - Error message supplied by lib-jitsi-meet.
* @param {Object} [credentials] - The invalid credentials that were
* used to authenticate and the authentication failed.
* @param {string} [credentials.jid] - The XMPP user's ID.
* @param {string} [credentials.password] - The XMPP user's password.
* @param {Object} details - Additional information about the error.
* @private
* @returns {void}
*/
function _onConnectionFailed( // eslint-disable-line max-params
err: string,
message: string,
credentials: any,
details: Object) {
unsubscribe();
dispatch(connectionFailed(connection, {
credentials,
details,
name: err,
message
}));
reject(err);
}
/**
* Resolves external promise when connection is established.
*
* @private
* @returns {void}
*/
function _onConnectionEstablished() {
connection.removeEventListener(JitsiConnectionEvents.CONNECTION_ESTABLISHED, _onConnectionEstablished);
dispatch(connectionEstablished(connection, Date.now()));
resolve(connection);
}
/**
* Connection was redirected.
*
* @param {string|undefined} vnode - The vnode to connect to.
* @param {string} focusJid - The focus jid to use.
* @param {string|undefined} username - The username to use when joining. This is after promotion from
* visitor to main participant.
* @private
* @returns {void}
*/
function _onConnectionRedirected(vnode: string, focusJid: string, username: string) {
connection.removeEventListener(JitsiConnectionEvents.CONNECTION_REDIRECTED, _onConnectionRedirected);
dispatch(redirect(vnode, focusJid, username));
}
/**
* Connection properties were updated.
*
* @param {Object} properties - The properties which were updated.
* @private
* @returns {void}
*/
function _onPropertiesUpdate(properties: object) {
dispatch(_propertiesUpdate(properties));
}
// in case of configured http url for conference request we need the room name
const name = getBackendSafeRoomName(state['features/base/conference'].room);
connection.connect({
id,
password,
name
});
});
};
}
/**
* Create an action for when a connection will connect.
*
* @param {JitsiConnection} connection - The {@code JitsiConnection} which will
* connect.
* @private
* @returns {{
* type: CONNECTION_WILL_CONNECT,
* connection: JitsiConnection
* }}
*/
function _connectionWillConnect(connection: Object) {
return {
type: CONNECTION_WILL_CONNECT,
connection
};
}
/**
* Create an action for when connection properties are updated.
*
* @param {Object} properties - The properties which were updated.
* @private
* @returns {{
* type: CONNECTION_PROPERTIES_UPDATED,
* properties: Object
* }}
*/
function _propertiesUpdate(properties: object) {
return {
type: CONNECTION_PROPERTIES_UPDATED,
properties
};
}
/**
* Closes connection.
*
* @param {boolean} isRedirect - Indicates if the action has been dispatched as part of visitor promotion.
*
* @returns {Function}
*/
export function disconnect(isRedirect?: boolean) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']): Promise<void> => {
const state = getState();
// The conference we have already joined or are joining.
const conference_ = getCurrentConference(state);
// Promise which completes when the conference has been left and the
// connection has been disconnected.
let promise;
// Leave the conference.
if (conference_) {
// In a fashion similar to JitsiConference's CONFERENCE_LEFT event
// (and the respective Redux action) which is fired after the
// conference has been left, notify the application about the
// intention to leave the conference.
dispatch(conferenceWillLeave(conference_, isRedirect));
promise
= conference_.leave()
.catch((error: Error) => {
logger.warn(
'JitsiConference.leave() rejected with:',
error);
// The library lib-jitsi-meet failed to make the
// JitsiConference leave. Which may be because
// JitsiConference thinks it has already left.
// Regardless of the failure reason, continue in
// jitsi-meet as if the leave has succeeded.
dispatch(conferenceLeft(conference_));
});
} else {
promise = Promise.resolve();
}
// Disconnect the connection.
const { connecting, connection } = state['features/base/connection'];
// The connection we have already connected or are connecting.
const connection_ = connection || connecting;
if (connection_) {
promise = promise.then(() => connection_.disconnect());
} else {
logger.info('No connection found while disconnecting.');
}
return promise;
};
}

View File

@@ -0,0 +1,62 @@
import { appNavigate } from '../../app/actions.native';
import { IStore } from '../../app/types';
import { getCustomerDetails } from '../../jaas/actions.any';
import { getJaasJWT, isVpaasMeeting } from '../../jaas/functions';
import { navigateRoot } from '../../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../../mobile/navigation/routes';
import { setJWT } from '../jwt/actions';
import { JitsiConnectionErrors } from '../lib-jitsi-meet';
import { _connectInternal } from './actions.native';
import logger from './logger';
export * from './actions.any';
/**
* Opens new connection.
*
* @param {string} [id] - The XMPP user's ID (e.g. {@code user@server.com}).
* @param {string} [password] - The XMPP user's password.
* @returns {Function}
*/
export function connect(id?: string, password?: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { jwt } = state['features/base/jwt'];
if (isVpaasMeeting(state)) {
return dispatch(getCustomerDetails())
.then(() => {
if (!jwt) {
return getJaasJWT(state);
}
})
.then(j => {
j && dispatch(setJWT(j));
return dispatch(_connectInternal(id, password));
}).catch(e => {
logger.error('Connection error', e);
});
}
dispatch(_connectInternal(id, password))
.catch(error => {
if (error === JitsiConnectionErrors.NOT_LIVE_ERROR) {
navigateRoot(screen.visitorsQueue);
}
});
};
}
/**
* Hangup.
*
* @param {boolean} [_requestFeedback] - Whether to attempt showing a
* request for call feedback.
* @returns {Function}
*/
export function hangup(_requestFeedback = false) {
return (dispatch: IStore['dispatch']) => dispatch(appNavigate(undefined));
}

View File

@@ -0,0 +1,90 @@
// @ts-expect-error
import { jitsiLocalStorage } from '@jitsi/js-utils';
import { IStore } from '../../app/types';
import { getCustomerDetails } from '../../jaas/actions.any';
import { getJaasJWT, isVpaasMeeting } from '../../jaas/functions';
import { showWarningNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { stopLocalVideoRecording } from '../../recording/actions.any';
import LocalRecordingManager from '../../recording/components/Recording/LocalRecordingManager.web';
import { setJWT } from '../jwt/actions';
import { _connectInternal } from './actions.any';
import logger from './logger';
export * from './actions.any';
/**
* Opens new connection.
*
* @param {string} [id] - The XMPP user's ID (e.g. {@code user@server.com}).
* @param {string} [password] - The XMPP user's password.
* @returns {Function}
*/
export function connect(id?: string, password?: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { jwt } = state['features/base/jwt'];
const { iAmRecorder, iAmSipGateway } = state['features/base/config'];
if (!iAmRecorder && !iAmSipGateway && isVpaasMeeting(state)) {
return dispatch(getCustomerDetails())
.then(() => {
if (!jwt) {
return getJaasJWT(state);
}
})
.then(j => {
j && dispatch(setJWT(j));
return dispatch(_connectInternal(id, password));
}).catch(e => {
logger.error('Connection error', e);
});
}
// used by jibri
const usernameOverride = jitsiLocalStorage.getItem('xmpp_username_override');
const passwordOverride = jitsiLocalStorage.getItem('xmpp_password_override');
if (usernameOverride && usernameOverride.length > 0) {
id = usernameOverride; // eslint-disable-line no-param-reassign
}
if (passwordOverride && passwordOverride.length > 0) {
password = passwordOverride; // eslint-disable-line no-param-reassign
}
return dispatch(_connectInternal(id, password));
};
}
/**
* Closes connection.
*
* @param {boolean} [requestFeedback] - Whether to attempt showing a
* request for call feedback.
* @param {string} [feedbackTitle] - The feedback title.
* @param {boolean} [notifyOnConferenceTermination] - Whether to notify
* the user on conference termination.
* @returns {Function}
*/
export function hangup(requestFeedback = false, feedbackTitle?: string, notifyOnConferenceTermination?: boolean) {
// XXX For web based version we use conference hanging up logic from the old app.
return async (dispatch: IStore['dispatch']) => {
if (LocalRecordingManager.isRecordingLocally()) {
dispatch(stopLocalVideoRecording());
dispatch(showWarningNotification({
titleKey: 'localRecording.stopping',
descriptionKey: 'localRecording.wait'
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
// wait 1000ms for the recording to end and start downloading
await new Promise(res => {
setTimeout(res, 1000);
});
}
return APP.conference.hangup(requestFeedback, feedbackTitle, notifyOnConferenceTermination);
};
}

View File

@@ -0,0 +1,17 @@
/**
* The name of the {@code JitsiConnection} property which identifies the {@code JitsiConference} currently associated
* with it.
*
* FIXME: This is a hack. It was introduced to solve the following case: if a user presses hangup quickly, they may
* "leave the conference" before the actual conference was ever created. While we might have a connection in place,
* there is no conference which can be left, thus no CONFERENCE_LEFT action will ever be fired.
*
* This is problematic because the external API module used to send events to the native SDK won't know what to send.
* So, in order to detect this situation we are attaching the conference object to the connection which runs it.
*/
export const JITSI_CONNECTION_CONFERENCE_KEY = Symbol('conference');
/**
* The name of the {@code JitsiConnection} property which identifies the location URL where the connection will be made.
*/
export const JITSI_CONNECTION_URL_KEY = Symbol('url');

View File

@@ -0,0 +1,101 @@
import { IStateful } from '../app/types';
import { toState } from '../redux/functions';
import { toURLString } from '../util/uri';
import { getURLWithoutParams } from './utils';
/**
* Figures out what's the current conference URL which is supposed to indicate what conference is currently active.
* When not currently in any conference and not trying to join any then 'undefined' is returned.
*
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
* @returns {string|undefined}
* @private
*/
export function getCurrentConferenceUrl(stateful: IStateful) {
const state = toState(stateful);
let currentUrl;
if (isInviteURLReady(state)) {
currentUrl = toURLString(getInviteURL(state));
}
// Check if the URL doesn't end with a slash
if (currentUrl && currentUrl.substr(-1) === '/') {
currentUrl = undefined;
}
return currentUrl ? currentUrl : undefined;
}
/**
* Retrieves a simplified version of the conference/location URL stripped of URL params (i.e. Query/search and hash)
* which should be used for sending invites.
* NOTE that the method will throw an error if called too early. That is before the conference is joined or before
* the process of joining one has started. This limitation does not apply to the case when called with the URL object
* instance. Use {@link isInviteURLReady} to check if it's safe to call the method already.
*
* @param {Function|Object} stateOrGetState - The redux state or redux's {@code getState} function or the URL object
* to be stripped.
* @returns {string}
*/
export function getInviteURL(stateOrGetState: IStateful): string {
const state = toState(stateOrGetState);
let locationURL
= state instanceof URL
? state
: state['features/base/connection'].locationURL;
// If there's no locationURL on the base/connection feature try the base/config where it's set earlier.
if (!locationURL) {
locationURL = state['features/base/config'].locationURL;
}
if (!locationURL) {
throw new Error('Can not get invite URL - the app is not ready');
}
const { inviteDomain } = state['features/dynamic-branding'];
const urlWithoutParams = getURLWithoutParams(locationURL);
if (inviteDomain) {
const meetingId
= state['features/base/config'].brandingRoomAlias || urlWithoutParams.pathname.replace(/\//, '');
return `${inviteDomain}/${meetingId}`;
}
return urlWithoutParams.href;
}
/**
* Checks whether or not is safe to call the {@link getInviteURL} method already.
*
* @param {Function|Object} stateOrGetState - The redux state or redux's {@code getState} function.
* @returns {boolean}
*/
export function isInviteURLReady(stateOrGetState: IStateful): boolean {
const state = toState(stateOrGetState);
return Boolean(state['features/base/connection'].locationURL || state['features/base/config'].locationURL);
}
/**
* Converts a specific id to jid if it's not jid yet.
*
* @param {string} id - User id or jid.
* @param {Object} configHosts - The {@code hosts} part of the {@code config}
* object.
* @returns {string} A string in the form of a JID (i.e.
* {@code user@server.com}).
*/
export function toJid(id: string, { authdomain, domain }: {
anonymousdomain?: string;
authdomain?: string;
domain?: string;
focus?: string;
muc?: string;
visitorFocus?: string;
}): string {
return id.indexOf('@') >= 0 ? id : `${id}@${authdomain || domain}`;
}

View File

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

View File

@@ -0,0 +1,30 @@
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { CONNECTION_WILL_CONNECT } from './actionTypes';
/**
* The feature announced so we can distinguish jibri participants.
*
* @type {string}
*/
export const DISCO_JIBRI_FEATURE = 'http://jitsi.org/protocol/jibri';
MiddlewareRegistry.register(({ getState }) => next => action => {
switch (action.type) {
case CONNECTION_WILL_CONNECT: {
const { connection } = action;
const { iAmRecorder } = getState()['features/base/config'];
if (iAmRecorder) {
connection.addFeature(DISCO_JIBRI_FEATURE);
}
// @ts-ignore
APP.connection = connection;
break;
}
}
return next(action);
});

View File

@@ -0,0 +1,269 @@
import { SET_ROOM } from '../conference/actionTypes';
import { SET_JWT } from '../jwt/actionTypes';
import { JitsiConnectionErrors } from '../lib-jitsi-meet';
import ReducerRegistry from '../redux/ReducerRegistry';
import { assign, set } from '../redux/functions';
import {
CONNECTION_DISCONNECTED,
CONNECTION_ESTABLISHED,
CONNECTION_FAILED,
CONNECTION_WILL_CONNECT,
SET_LOCATION_URL,
SET_PREFER_VISITOR,
SHOW_CONNECTION_INFO
} from './actionTypes';
import { ConnectionFailedError } from './types';
export interface IConnectionState {
connecting?: any;
connection?: {
addFeature: Function;
disconnect: Function;
getJid: () => string;
getLogs: () => Object;
initJitsiConference: Function;
removeFeature: Function;
};
error?: ConnectionFailedError;
locationURL?: URL;
passwordRequired?: Object;
preferVisitor?: boolean;
showConnectionInfo?: boolean;
timeEstablished?: number;
}
/**
* Reduces the Redux actions of the feature base/connection.
*/
ReducerRegistry.register<IConnectionState>(
'features/base/connection',
(state = {}, action): IConnectionState => {
switch (action.type) {
case CONNECTION_DISCONNECTED:
return _connectionDisconnected(state, action);
case CONNECTION_ESTABLISHED:
return _connectionEstablished(state, action);
case CONNECTION_FAILED:
return _connectionFailed(state, action);
case CONNECTION_WILL_CONNECT:
return _connectionWillConnect(state, action);
case SET_JWT:
return _setJWT(state, action);
case SET_LOCATION_URL:
return _setLocationURL(state, action);
case SET_PREFER_VISITOR:
return assign(state, {
preferVisitor: action.preferVisitor
});
case SET_ROOM:
return _setRoom(state);
case SHOW_CONNECTION_INFO:
return _setShowConnectionInfo(state, action);
}
return state;
});
/**
* Reduces a specific Redux action CONNECTION_DISCONNECTED of the feature
* base/connection.
*
* @param {IConnectionState} state - The Redux state of the feature base/connection.
* @param {Action} action - The Redux action CONNECTION_DISCONNECTED to reduce.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _connectionDisconnected(
state: IConnectionState,
{ connection }: { connection: Object; }) {
const connection_ = _getCurrentConnection(state);
if (connection_ !== connection) {
return state;
}
return assign(state, {
connecting: undefined,
connection: undefined,
preferVisitor: undefined,
timeEstablished: undefined
});
}
/**
* Reduces a specific Redux action CONNECTION_ESTABLISHED of the feature
* base/connection.
*
* @param {IConnectionState} state - The Redux state of the feature base/connection.
* @param {Action} action - The Redux action CONNECTION_ESTABLISHED to reduce.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _connectionEstablished(
state: IConnectionState,
{ connection, timeEstablished }: {
connection: any;
timeEstablished: number;
}) {
return assign(state, {
connecting: undefined,
connection,
error: undefined,
passwordRequired: undefined,
timeEstablished
});
}
/**
* Reduces a specific Redux action CONNECTION_FAILED of the feature
* base/connection.
*
* @param {IConnectionState} state - The Redux state of the feature base/connection.
* @param {Action} action - The Redux action CONNECTION_FAILED to reduce.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _connectionFailed(
state: IConnectionState,
{ connection, error }: {
connection: Object;
error: ConnectionFailedError;
}) {
const connection_ = _getCurrentConnection(state);
if (connection_ && connection_ !== connection) {
return state;
}
let preferVisitor;
if (error.name === JitsiConnectionErrors.NOT_LIVE_ERROR) {
// we want to keep the state for the moment when the meeting is live
preferVisitor = state.preferVisitor;
}
return assign(state, {
connecting: undefined,
connection: undefined,
error,
passwordRequired:
error.name === JitsiConnectionErrors.PASSWORD_REQUIRED
? connection : undefined,
preferVisitor
});
}
/**
* Reduces a specific Redux action CONNECTION_WILL_CONNECT of the feature
* base/connection.
*
* @param {IConnectionState} state - The Redux state of the feature base/connection.
* @param {Action} action - The Redux action CONNECTION_WILL_CONNECT to reduce.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _connectionWillConnect(
state: IConnectionState,
{ connection }: { connection: Object; }) {
return assign(state, {
connecting: connection,
// We don't care if the previous connection has been closed already,
// because it's an async process and there's no guarantee if it'll be
// done before the new one is established.
connection: undefined,
error: undefined,
passwordRequired: undefined,
timeEstablished: undefined
});
}
/**
* The current (similar to getCurrentConference in base/conference/functions.any.js)
* connection which is {@code connection} or {@code connecting}.
*
* @param {IConnectionState} baseConnectionState - The current state of the
* {@code 'base/connection'} feature.
* @returns {JitsiConnection} - The current {@code JitsiConnection} if any.
* @private
*/
function _getCurrentConnection(baseConnectionState: IConnectionState): IConnectionState | undefined {
return baseConnectionState.connection || baseConnectionState.connecting;
}
/**
* Reduces a specific redux action {@link SET_JWT} of the feature
* base/connection.
*
* @param {IConnectionState} state - The redux state of the feature base/connection.
* @param {Action} action - The Redux action SET_JWT to reduce.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _setJWT(state: IConnectionState, { preferVisitor }: { preferVisitor: boolean; }) {
return assign(state, {
preferVisitor
});
}
/**
* Reduces a specific redux action {@link SET_LOCATION_URL} of the feature
* base/connection.
*
* @param {IConnectionState} state - The redux state of the feature base/connection.
* @param {Action} action - The redux action {@code SET_LOCATION_URL} to reduce.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _setLocationURL(
state: IConnectionState,
{ locationURL }: { locationURL?: URL; }) {
return set(state, 'locationURL', locationURL);
}
/**
* Reduces a specific redux action {@link SET_ROOM} of the feature
* base/connection.
*
* @param {IConnectionState} state - The redux state of the feature base/connection.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _setRoom(state: IConnectionState) {
return assign(state, {
error: undefined,
passwordRequired: undefined
});
}
/**
* Reduces a specific redux action {@link SHOW_CONNECTION_INFO} of the feature
* base/connection.
*
* @param {IConnectionState} state - The redux state of the feature base/connection.
* @param {Action} action - The redux action {@code SHOW_CONNECTION_INFO} to reduce.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _setShowConnectionInfo(
state: IConnectionState,
{ showConnectionInfo }: { showConnectionInfo: boolean; }) {
return set(state, 'showConnectionInfo', showConnectionInfo);
}

View File

@@ -0,0 +1,113 @@
/**
* The error structure passed to the {@link connectionFailed} action.
*
* Note there was an intention to make the error resemble an Error instance (to
* the extent that jitsi-meet needs it).
*/
export type ConnectionFailedError = {
/**
* The invalid credentials that were used to authenticate and the
* authentication failed.
*/
credentials?: {
/**
* The XMPP user's ID.
*/
jid: string;
/**
* The XMPP user's password.
*/
password: string;
};
/**
* The details about the connection failed event.
*/
details?: Object;
/**
* Error message.
*/
message?: string;
/**
* One of {@link JitsiConnectionError} constants (defined in
* lib-jitsi-meet).
*/
name: string;
/**
* Indicates whether this event is recoverable or not.
*/
recoverable?: boolean;
};
/**
* The value for the username or credential property.
*/
type ReplaceIceServersField = string | null;
/**
* The value for the urls property.
*/
type IceServerUrls = null | string | Array<string>;
/**
* The types of ice servers.
*/
enum IceServerType {
STUN = 'stun',
TURN = 'turn',
TURNS = 'turns'
}
/**
* Represents a single override rule.
*/
interface IReplaceIceServer {
/**
* The value the credential prop will be replaced with.
*
* NOTE: If the value is null we will remove the credential property in entry that matches the target type. If the
* value is undefined or missing we won't change the credential property in the entry that matches the target type.
*/
credential?: ReplaceIceServersField;
/**
* Target type that will be used to match the already received ice server and modify/remove it based on the values
* of credential, urls and username.
*/
targetType: IceServerType;
/**
* The value the urls prop will be replaced with.
*
* NOTE: If the value is null we will remove the whole entry that matches the target type. If the value is undefined
* or missing we won't change the urls property in the entry that matches the target type.
*/
urls?: IceServerUrls;
/**
* The value the username prop will be replaced with.
*
* NOTE: If the value is null we will remove the username property in entry that matches the target type. If the
* value is undefined or missing we won't change the username property in the entry that matches the target type.
*/
username?: ReplaceIceServersField;
}
/**
* An object with rules for changing the existing ice server configuration.
*/
export interface IIceServers {
/**
* An array of rules for replacing parts from the existing ice server configuration.
*/
replace: Array<IReplaceIceServer>;
}

View File

@@ -0,0 +1,49 @@
/**
* Gets a {@link URL} without hash and query/search params from a specific
* {@code URL}.
*
* @param {URL} url - The {@code URL} which may have hash and query/search
* params.
* @returns {URL}
*/
export function getURLWithoutParams(url: URL): URL {
const { hash, search } = url;
if ((hash && hash.length > 1) || (search && search.length > 1)) {
url = new URL(url.href); // eslint-disable-line no-param-reassign
url.hash = '';
url.search = '';
// XXX The implementation of URL at least on React Native appends ? and
// # at the end of the href which is not desired.
let { href } = url;
if (href) {
href.endsWith('#') && (href = href.substring(0, href.length - 1));
href.endsWith('?') && (href = href.substring(0, href.length - 1));
// eslint-disable-next-line no-param-reassign
url.href === href || (url = new URL(href));
}
}
return url;
}
/**
* Gets a URL string without hash and query/search params from a specific
* {@code URL}.
*
* @param {URL} url - The {@code URL} which may have hash and query/search
* params.
* @returns {string}
*/
export function getURLWithoutParamsNormalized(url: URL): string {
const urlWithoutParams = getURLWithoutParams(url).href;
if (urlWithoutParams) {
return urlWithoutParams.toLowerCase();
}
return '';
}

View File

@@ -0,0 +1,96 @@
/**
* The type of Redux action which signals that an error occurred while obtaining
* a camera.
*
* {
* type: NOTIFY_CAMERA_ERROR,
* error: Object
* }
*/
export const NOTIFY_CAMERA_ERROR = 'NOTIFY_CAMERA_ERROR';
/**
* The type of Redux action which signals that an error occurred while obtaining
* a microphone.
*
* {
* type: NOTIFY_MIC_ERROR,
* error: Object
* }
*/
export const NOTIFY_MIC_ERROR = 'NOTIFY_MIC_ERROR';
/**
* The type of Redux action which signals that the currently used audio
* input device should be changed.
*
* {
* type: SET_AUDIO_INPUT_DEVICE,
* deviceId: string,
* }
*/
export const SET_AUDIO_INPUT_DEVICE = 'SET_AUDIO_INPUT_DEVICE';
/**
* The type of Redux action which signals that the currently used video
* input device should be changed.
*
* {
* type: SET_VIDEO_INPUT_DEVICE,
* deviceId: string,
* }
*/
export const SET_VIDEO_INPUT_DEVICE = 'SET_VIDEO_INPUT_DEVICE';
/**
* The type of Redux action which signals that the list of known available
* audio and video sources has changed.
*
* {
* type: UPDATE_DEVICE_LIST,
* devices: Array<MediaDeviceInfo>,
* }
*/
export const UPDATE_DEVICE_LIST = 'UPDATE_DEVICE_LIST';
/**
* The type of Redux action which will add a pending device requests that will
* be executed later when it is possible (when the conference is joined).
*
* {
* type: ADD_PENDING_DEVICE_REQUEST,
* request: Object
* }
*/
export const ADD_PENDING_DEVICE_REQUEST = 'ADD_PENDING_DEVICE_REQUEST';
/**
* The type of Redux action which will remove all pending device requests.
*
* {
* type: REMOVE_PENDING_DEVICE_REQUESTS
* }
*/
export const REMOVE_PENDING_DEVICE_REQUESTS = 'REMOVE_PENDING_DEVICE_REQUESTS';
/**
* The type of Redux action which will check passed old and passed new devices
* and if needed will show notifications asking the user whether to use those.
*
* {
* type: CHECK_AND_NOTIFY_FOR_NEW_DEVICE
* newDevices: Array<MediaDeviceInfo>
* oldDevices: Array<MediaDeviceInfo>
* }
*/
export const CHECK_AND_NOTIFY_FOR_NEW_DEVICE = 'CHECK_AND_NOTIFY_FOR_NEW_DEVICE';
/**
* The type of Redux action which signals that the device permissions have changed.
*
* {
* type: CHECK_AND_NOTIFY_FOR_NEW_DEVICE
* permissions: Object
* }
*/
export const DEVICE_PERMISSIONS_CHANGED = 'DEVICE_PERMISSIONS_CHANGED';

View File

@@ -0,0 +1,356 @@
import { IStore } from '../../app/types';
import JitsiMeetJS from '../lib-jitsi-meet';
import { updateSettings } from '../settings/actions';
import { getUserSelectedOutputDeviceId } from '../settings/functions.web';
import {
ADD_PENDING_DEVICE_REQUEST,
CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
DEVICE_PERMISSIONS_CHANGED,
NOTIFY_CAMERA_ERROR,
NOTIFY_MIC_ERROR,
REMOVE_PENDING_DEVICE_REQUESTS,
SET_AUDIO_INPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST
} from './actionTypes';
import {
areDeviceLabelsInitialized,
areDevicesDifferent,
filterIgnoredDevices,
flattenAvailableDevices,
getDeviceIdByLabel,
getDeviceLabelById,
getDevicesFromURL,
logDevices,
setAudioOutputDeviceId
} from './functions';
import logger from './logger';
/**
* Maps the WebRTC string for device type to the keys used to store configure,
* within redux, which devices should be used by default.
*/
const DEVICE_TYPE_TO_SETTINGS_KEYS = {
audioInput: {
currentDeviceId: 'micDeviceId',
userSelectedDeviceId: 'userSelectedMicDeviceId',
userSelectedDeviceLabel: 'userSelectedMicDeviceLabel'
},
audioOutput: {
currentDeviceId: 'audioOutputDeviceId',
userSelectedDeviceId: 'userSelectedAudioOutputDeviceId',
userSelectedDeviceLabel: 'userSelectedAudioOutputDeviceLabel'
},
videoInput: {
currentDeviceId: 'cameraDeviceId',
userSelectedDeviceId: 'userSelectedCameraDeviceId',
userSelectedDeviceLabel: 'userSelectedCameraDeviceLabel'
}
};
/**
* Adds a pending device request.
*
* @param {Object} request - The request to be added.
* @returns {{
* type: ADD_PENDING_DEVICE_REQUEST,
* request: Object
* }}
*/
export function addPendingDeviceRequest(request: Object) {
return {
type: ADD_PENDING_DEVICE_REQUEST,
request
};
}
/**
* Configures the initial A/V devices before the conference has started.
*
* @returns {Function}
*/
export function configureInitialDevices() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const deviceLabels = getDevicesFromURL(getState());
let updateSettingsPromise;
logger.debug(`(TIME) configureInitialDevices: deviceLabels=${
Boolean(deviceLabels)}, performance.now=${window.performance.now()}`);
if (deviceLabels) {
updateSettingsPromise = dispatch(getAvailableDevices()).then(() => {
const state = getState();
if (!areDeviceLabelsInitialized(state)) {
// The labels are not available if the A/V permissions are
// not yet granted.
Object.keys(deviceLabels).forEach(key => {
dispatch(addPendingDeviceRequest({
type: 'devices',
name: 'setDevice',
device: {
kind: key.toLowerCase(),
label: deviceLabels[key as keyof typeof deviceLabels]
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
responseCallback() {}
}));
});
return;
}
const newSettings: any = {};
Object.keys(deviceLabels).forEach(key => {
const label = deviceLabels[key as keyof typeof deviceLabels];
// @ts-ignore
const deviceId = getDeviceIdByLabel(state, label, key);
if (deviceId) {
const settingsTranslationMap = DEVICE_TYPE_TO_SETTINGS_KEYS[
key as keyof typeof DEVICE_TYPE_TO_SETTINGS_KEYS];
newSettings[settingsTranslationMap.currentDeviceId] = deviceId;
newSettings[settingsTranslationMap.userSelectedDeviceId] = deviceId;
newSettings[settingsTranslationMap.userSelectedDeviceLabel] = label;
}
});
dispatch(updateSettings(newSettings));
});
} else {
updateSettingsPromise = Promise.resolve();
}
return updateSettingsPromise
.then(() => {
const userSelectedAudioOutputDeviceId = getUserSelectedOutputDeviceId(getState());
logger.debug(`(TIME) configureInitialDevices -> setAudioOutputDeviceId: performance.now=${
window.performance.now()}`);
return setAudioOutputDeviceId(userSelectedAudioOutputDeviceId, dispatch)
.catch(ex => logger.warn(`Failed to set audio output device.
Default audio output device will be used instead ${ex}`));
});
};
}
/**
* Queries for connected A/V input and output devices and updates the redux
* state of known devices.
*
* @returns {Function}
*/
export function getAvailableDevices() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => new Promise(resolve => {
const { mediaDevices } = JitsiMeetJS;
if (mediaDevices.isDeviceChangeAvailable()) {
mediaDevices.enumerateDevices((devices: MediaDeviceInfo[]) => {
const { filteredDevices, ignoredDevices } = filterIgnoredDevices(devices);
const oldDevices = flattenAvailableDevices(getState()['features/base/devices'].availableDevices);
if (areDevicesDifferent(oldDevices, filteredDevices)) {
logDevices(ignoredDevices, 'Ignored devices on device list changed:');
dispatch(updateDeviceList(filteredDevices));
}
resolve(filteredDevices);
});
} else {
resolve([]);
}
});
}
/**
* Signals that an error occurred while trying to obtain a track from a camera.
*
* @param {Object} error - The device error, as provided by lib-jitsi-meet.
* @param {string} error.name - The constant for the type of the error.
* @param {string} error.message - Optional additional information about the
* error.
* @returns {{
* type: NOTIFY_CAMERA_ERROR,
* error: Object
* }}
*/
export function notifyCameraError(error: Error) {
return {
type: NOTIFY_CAMERA_ERROR,
error
};
}
/**
* Signals that an error occurred while trying to obtain a track from a mic.
*
* @param {Object} error - The device error, as provided by lib-jitsi-meet.
* @param {Object} error.name - The constant for the type of the error.
* @param {string} error.message - Optional additional information about the
* error.
* @returns {{
* type: NOTIFY_MIC_ERROR,
* error: Object
* }}
*/
export function notifyMicError(error: Error) {
return {
type: NOTIFY_MIC_ERROR,
error
};
}
/**
* Remove all pending device requests.
*
* @returns {{
* type: REMOVE_PENDING_DEVICE_REQUESTS
* }}
*/
export function removePendingDeviceRequests() {
return {
type: REMOVE_PENDING_DEVICE_REQUESTS
};
}
/**
* Signals to update the currently used audio input device.
*
* @param {string} deviceId - The id of the new audio input device.
* @returns {{
* type: SET_AUDIO_INPUT_DEVICE,
* deviceId: string
* }}
*/
export function setAudioInputDevice(deviceId: string) {
return {
type: SET_AUDIO_INPUT_DEVICE,
deviceId
};
}
/**
* Sets the audio input device id and updates the settings
* so they are persisted across sessions.
*
* @param {string} deviceId - The id of the new audio input device.
* @returns {Function}
*/
export function setAudioInputDeviceAndUpdateSettings(deviceId: string) {
return function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const deviceLabel = getDeviceLabelById(getState(), deviceId, 'audioInput');
dispatch(setAudioInputDevice(deviceId));
dispatch(updateSettings({
userSelectedMicDeviceId: deviceId,
userSelectedMicDeviceLabel: deviceLabel
}));
};
}
/**
* Updates the output device id.
*
* @param {string} deviceId - The id of the new output device.
* @returns {Function}
*/
export function setAudioOutputDevice(deviceId: string) {
return function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const deviceLabel = getDeviceLabelById(getState(), deviceId, 'audioOutput');
return setAudioOutputDeviceId(deviceId, dispatch, true, deviceLabel);
};
}
/**
* Signals to update the currently used video input device.
*
* @param {string} deviceId - The id of the new video input device.
* @returns {{
* type: SET_VIDEO_INPUT_DEVICE,
* deviceId: string
* }}
*/
export function setVideoInputDevice(deviceId: string) {
return {
type: SET_VIDEO_INPUT_DEVICE,
deviceId
};
}
/**
* Sets the video input device id and updates the settings
* so they are persisted across sessions.
*
* @param {string} deviceId - The id of the new video input device.
* @returns {Function}
*/
export function setVideoInputDeviceAndUpdateSettings(deviceId: string) {
return function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const deviceLabel = getDeviceLabelById(getState(), deviceId, 'videoInput');
dispatch(setVideoInputDevice(deviceId));
dispatch(updateSettings({
userSelectedCameraDeviceId: deviceId,
userSelectedCameraDeviceLabel: deviceLabel
}));
};
}
/**
* Signals to update the list of known audio and video devices.
*
* @param {Array<MediaDeviceInfo>} devices - All known available audio input,
* audio output, and video input devices.
* @returns {{
* type: UPDATE_DEVICE_LIST,
* devices: Array<MediaDeviceInfo>
* }}
*/
export function updateDeviceList(devices: MediaDeviceInfo[]) {
return {
type: UPDATE_DEVICE_LIST,
devices
};
}
/**
* Signals to check new and old devices for newly added devices and notify.
*
* @param {Array<MediaDeviceInfo>} newDevices - Array of the new devices.
* @param {Array<MediaDeviceInfo>} oldDevices - Array of the old devices.
* @returns {{
* type: CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
* newDevices: Array<MediaDeviceInfo>,
* oldDevices: Array<MediaDeviceInfo>
* }}
*/
export function checkAndNotifyForNewDevice(newDevices: MediaDeviceInfo[], oldDevices: MediaDeviceInfo[]) {
return {
type: CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
newDevices,
oldDevices
};
}
/**
* Signals that the device permissions have changed.
*
* @param {Object} permissions - Object with the permissions.
* @returns {{
* type: DEVICE_PERMISSIONS_CHANGED,
* permissions: Object
* }}
*/
export function devicePermissionsChanged(permissions: Object) {
return {
type: DEVICE_PERMISSIONS_CHANGED,
permissions
};
}

View File

@@ -0,0 +1,11 @@
/**
* Prefixes of devices that will be filtered from the device list.
*
* NOTE: It seems that the filtered devices can't be set
* as default device on the OS level and this use case is not handled in the code. If we add more device prefixes that
* can be default devices we should make sure to handle the default device use case.
*/
export const DEVICE_LABEL_PREFIXES_TO_IGNORE = [
'Microsoft Teams Audio Device',
'ZoomAudioDevice'
];

View File

@@ -0,0 +1,19 @@
import { IReduxState } from '../../app/types';
/**
* Returns true if there are devices of a specific type or on native platform.
*
* @param {Object} state - The state of the application.
* @param {string} type - The type of device: VideoOutput | audioOutput | audioInput.
*
* @returns {boolean}
*/
export function hasAvailableDevices(state: IReduxState, type: string) {
if (state['features/base/devices'] === undefined) {
return true;
}
const availableDevices = state['features/base/devices'].availableDevices;
return Number(availableDevices[type as keyof typeof availableDevices]?.length) > 0;
}

View File

@@ -0,0 +1 @@
export * from './functions.any';

View File

@@ -0,0 +1,383 @@
/* eslint-disable require-jsdoc */
import { IReduxState, IStore } from '../../app/types';
import JitsiMeetJS from '../lib-jitsi-meet';
import { updateSettings } from '../settings/actions';
import { ISettingsState } from '../settings/reducer';
import { setNewAudioOutputDevice } from '../sounds/functions.web';
import { parseURLParams } from '../util/parseURLParams';
import { DEVICE_LABEL_PREFIXES_TO_IGNORE } from './constants';
import logger from './logger';
import { IDevicesState } from './types';
export * from './functions.any';
const webrtcKindToJitsiKindTranslator = {
audioinput: 'audioInput',
audiooutput: 'audioOutput',
videoinput: 'videoInput'
};
/**
* Detects the use case when the labels are not available if the A/V permissions
* are not yet granted.
*
* @param {Object} state - The redux state.
* @returns {boolean} - True if the labels are already initialized and false
* otherwise.
*/
export function areDeviceLabelsInitialized(state: IReduxState) {
// TODO: Replace with something that doesn't use APP when the conference.js logic is reactified.
if (APP.conference._localTracksInitialized) {
return true;
}
for (const type of [ 'audioInput', 'audioOutput', 'videoInput' ]) {
const availableDevices = state['features/base/devices'].availableDevices;
if ((availableDevices[type as keyof typeof availableDevices] || []).find(d => Boolean(d.label))) {
return true;
}
}
return false;
}
/**
* Get device id of the audio output device which is currently in use.
* Empty string stands for default device.
*
* @returns {string}
*/
export function getAudioOutputDeviceId() {
return JitsiMeetJS.mediaDevices.getAudioOutputDevice();
}
/**
* Finds the real device id of the default device of the given type.
*
* @param {Object} state - The redux state.
* @param {*} kind - The type of the device. One of "audioInput",
* "audioOutput", and "videoInput". Also supported is all lowercase versions
* of the preceding types.
* @returns {string|undefined}
*/
export function getDefaultDeviceId(state: IReduxState, kind: string) {
const kindToSearch = webrtcKindToJitsiKindTranslator[kind as keyof typeof webrtcKindToJitsiKindTranslator] || kind;
const availableDevices = state['features/base/devices'].availableDevices;
const defaultDevice = (availableDevices[kindToSearch as keyof typeof availableDevices] || [])
.find(d => d.deviceId === 'default');
// Find the device with a matching group id.
const matchingDevice = (availableDevices[kindToSearch as keyof typeof availableDevices] || [])
.find(d => d.deviceId !== 'default' && d.groupId === defaultDevice?.groupId);
if (matchingDevice) {
return matchingDevice.deviceId;
}
}
/**
* Finds a device with a label that matches the passed label and returns its id.
*
* @param {Object} state - The redux state.
* @param {string} label - The label.
* @param {string} kind - The type of the device. One of "audioInput",
* "audioOutput", and "videoInput". Also supported is all lowercase versions
* of the preceding types.
* @returns {string|undefined}
*/
export function getDeviceIdByLabel(state: IReduxState, label: string, kind: string) {
const kindToSearch = webrtcKindToJitsiKindTranslator[kind as keyof typeof webrtcKindToJitsiKindTranslator] || kind;
const availableDevices = state['features/base/devices'].availableDevices;
const device
= (availableDevices[kindToSearch as keyof typeof availableDevices] || [])
.find(d => d.label === label);
if (device) {
return device.deviceId;
}
}
/**
* Finds a device with a label that matches the passed id and returns its label.
*
* @param {Object} state - The redux state.
* @param {string} id - The device id.
* @param {string} kind - The type of the device. One of "audioInput",
* "audioOutput", and "videoInput". Also supported is all lowercase versions
* of the preceding types.
* @returns {string|undefined}
*/
export function getDeviceLabelById(state: IReduxState, id: string, kind: string) {
const kindToSearch = webrtcKindToJitsiKindTranslator[kind as keyof typeof webrtcKindToJitsiKindTranslator] || kind;
const availableDevices = state['features/base/devices'].availableDevices;
const device
= (availableDevices[kindToSearch as keyof typeof availableDevices] || [])
.find(d => d.deviceId === id);
if (device) {
return device.label;
}
}
/**
* Returns the devices set in the URL.
*
* @param {Object} state - The redux state.
* @returns {Object|undefined}
*/
export function getDevicesFromURL(state: IReduxState) {
const urlParams
= parseURLParams(state['features/base/connection'].locationURL ?? '');
const audioOutput = urlParams['devices.audioOutput'];
const videoInput = urlParams['devices.videoInput'];
const audioInput = urlParams['devices.audioInput'];
if (!audioOutput && !videoInput && !audioInput) {
return undefined;
}
const devices: IDevicesState['availableDevices'] = {};
audioOutput && (devices.audioOutput = audioOutput);
videoInput && (devices.videoInput = videoInput);
audioInput && (devices.audioInput = audioInput);
return devices;
}
/**
* Converts an array of media devices into an object organized by device kind.
*
* @param {Array<MediaDeviceInfo>} devices - Available media devices.
* @private
* @returns {Object} An object with the media devices split by type. The keys
* are device type and the values are arrays with devices matching the device
* type.
*/
// @ts-ignore
export function groupDevicesByKind(devices: MediaDeviceInfo[]): IDevicesState['availableDevices'] {
return {
audioInput: devices.filter(device => device.kind === 'audioinput'),
audioOutput: devices.filter(device => device.kind === 'audiooutput'),
videoInput: devices.filter(device => device.kind === 'videoinput')
};
}
/**
* Filters the devices that start with one of the prefixes from DEVICE_LABEL_PREFIXES_TO_IGNORE.
*
* @param {MediaDeviceInfo[]} devices - The devices to be filtered.
* @returns {MediaDeviceInfo[]} - The filtered devices.
*/
// @ts-ignore
export function filterIgnoredDevices(devices: MediaDeviceInfo[] = []) {
// @ts-ignore
const ignoredDevices: MediaDeviceInfo[] = [];
const filteredDevices = devices.filter(device => {
if (!device.label) {
return true;
}
if (DEVICE_LABEL_PREFIXES_TO_IGNORE.find(prefix => device.label?.startsWith(prefix))) {
ignoredDevices.push(device);
return false;
}
return true;
});
return {
filteredDevices,
ignoredDevices
};
}
/**
* Check if the passed device arrays are different.
*
* @param {MediaDeviceInfo[]} devices1 - Array with devices to be compared.
* @param {MediaDeviceInfo[]} devices2 - Array with devices to be compared.
* @returns {boolean} - True if the device arrays are different and false otherwise.
*/
// @ts-ignore
export function areDevicesDifferent(devices1: MediaDeviceInfo[] = [], devices2: MediaDeviceInfo[] = []) {
if (devices1.length !== devices2.length) {
return true;
}
for (let i = 0; i < devices1.length; i++) {
const device1 = devices1[i];
const found = devices2.find(({ deviceId, groupId, kind, label }) =>
device1.deviceId === deviceId
&& device1.groupId === groupId
&& device1.kind === kind
&& device1.label === label
);
if (!found) {
return true;
}
}
return false;
}
/**
* Flattens the availableDevices from redux.
*
* @param {IDevicesState.availableDevices} devices - The available devices from redux.
* @returns {MediaDeviceInfo[]} - The flattened array of devices.
*/
export function flattenAvailableDevices(
{ audioInput = [], audioOutput = [], videoInput = [] }: IDevicesState['availableDevices']) {
return audioInput.concat(audioOutput).concat(videoInput);
}
/**
* We want to strip any device details that are not very user friendly, like usb ids put in brackets at the end.
*
* @param {string} label - Device label to format.
*
* @returns {string} - Formatted string.
*/
export function formatDeviceLabel(label: string) {
let formattedLabel = label;
// Remove braked description at the end as it contains non user friendly strings i.e.
// Microsoft® LifeCam HD-3000 (045e:0779:31dg:d1231)
const ix = formattedLabel.lastIndexOf('(');
if (ix !== -1) {
formattedLabel = formattedLabel.substr(0, ix);
}
return formattedLabel;
}
/**
* Returns a list of objects containing all the microphone device ids and labels.
*
* @param {Object} state - The state of the application.
* @returns {Object[]}
*/
export function getAudioInputDeviceData(state: IReduxState) {
return state['features/base/devices'].availableDevices.audioInput?.map(
({ deviceId, label }) => {
return {
deviceId,
label
};
});
}
/**
* Returns a list of objectes containing all the output device ids and labels.
*
* @param {Object} state - The state of the application.
* @returns {Object[]}
*/
export function getAudioOutputDeviceData(state: IReduxState) {
return state['features/base/devices'].availableDevices.audioOutput?.map(
({ deviceId, label }) => {
return {
deviceId,
label
};
});
}
/**
* Returns a list of all the camera device ids.
*
* @param {Object} state - The state of the application.
* @returns {string[]}
*/
export function getVideoDeviceIds(state: IReduxState) {
return state['features/base/devices'].availableDevices.videoInput?.map(({ deviceId }) => deviceId);
}
/**
* Converts an array of device info objects into string.
*
* @param {MediaDeviceInfo[]} devices - The devices.
* @returns {string}
*/
// @ts-ignore
function devicesToStr(devices?: MediaDeviceInfo[]) {
return devices?.map(device => `\t\t${device.label}[${device.deviceId}]`).join('\n');
}
/**
* Logs an array of devices.
*
* @param {MediaDeviceInfo[]} devices - The array of devices.
* @param {string} title - The title that will be printed in the log.
* @returns {void}
*/
// @ts-ignore
export function logDevices(devices: MediaDeviceInfo[], title = '') {
const deviceList = groupDevicesByKind(devices);
const audioInputs = devicesToStr(deviceList.audioInput);
const audioOutputs = devicesToStr(deviceList.audioOutput);
const videoInputs = devicesToStr(deviceList.videoInput);
logger.debug(`${title}:\n`
+ `audioInput:\n${audioInputs}\n`
+ `audioOutput:\n${audioOutputs}\n`
+ `videoInput:\n${videoInputs}`);
}
/**
* Set device id of the audio output device which is currently in use.
* Empty string stands for default device.
*
* @param {string} newId - New audio output device id.
* @param {Function} dispatch - The Redux dispatch function.
* @param {boolean} userSelection - Whether this is a user selection update.
* @param {?string} newLabel - New audio output device label to store.
* @returns {Promise}
*/
export function setAudioOutputDeviceId(
newId = 'default',
dispatch: IStore['dispatch'],
userSelection = false,
newLabel?: string): Promise<any> {
logger.debug(`setAudioOutputDevice: ${String(newLabel)}[${newId}]`);
if (!JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output')) {
logger.warn('Adjusting audio output is not supported');
return Promise.resolve();
}
return JitsiMeetJS.mediaDevices.setAudioOutputDevice(newId)
.then(() => {
dispatch(setNewAudioOutputDevice(newId));
const newSettings: Partial<ISettingsState> = {
audioOutputDeviceId: newId,
userSelectedAudioOutputDeviceId: undefined,
userSelectedAudioOutputDeviceLabel: undefined
};
if (userSelection) {
newSettings.userSelectedAudioOutputDeviceId = newId;
newSettings.userSelectedAudioOutputDeviceLabel = newLabel;
} else {
// a flow workaround, I needed to add 'userSelectedAudioOutputDeviceId: undefined'
delete newSettings.userSelectedAudioOutputDeviceId;
delete newSettings.userSelectedAudioOutputDeviceLabel;
}
return dispatch(updateSettings(newSettings));
});
}

View File

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

View File

@@ -0,0 +1,349 @@
import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { processExternalDeviceRequest } from '../../device-selection/functions';
import { showNotification, showWarningNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { replaceAudioTrackById, replaceVideoTrackById, setDeviceStatusWarning } from '../../prejoin/actions';
import { isPrejoinPageVisible } from '../../prejoin/functions';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app/actionTypes';
import { isMobileBrowser } from '../environment/utils';
import JitsiMeetJS, { JitsiMediaDevicesEvents, JitsiTrackErrors } from '../lib-jitsi-meet';
import { MEDIA_TYPE } from '../media/constants';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { updateSettings } from '../settings/actions';
import { getLocalTrack } from '../tracks/functions';
import {
CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
NOTIFY_CAMERA_ERROR,
NOTIFY_MIC_ERROR,
SET_AUDIO_INPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST
} from './actionTypes';
import {
devicePermissionsChanged,
removePendingDeviceRequests,
setAudioInputDevice,
setVideoInputDevice
} from './actions';
import {
areDeviceLabelsInitialized,
formatDeviceLabel,
logDevices,
setAudioOutputDeviceId
} from './functions';
import logger from './logger';
const JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP = {
microphone: {
[JitsiTrackErrors.CONSTRAINT_FAILED]: 'dialog.micConstraintFailedError',
[JitsiTrackErrors.GENERAL]: 'dialog.micUnknownError',
[JitsiTrackErrors.NOT_FOUND]: 'dialog.micNotFoundError',
[JitsiTrackErrors.PERMISSION_DENIED]: 'dialog.micPermissionDeniedError',
[JitsiTrackErrors.TIMEOUT]: 'dialog.micTimeoutError'
},
camera: {
[JitsiTrackErrors.CONSTRAINT_FAILED]: 'dialog.cameraConstraintFailedError',
[JitsiTrackErrors.GENERAL]: 'dialog.cameraUnknownError',
[JitsiTrackErrors.NOT_FOUND]: 'dialog.cameraNotFoundError',
[JitsiTrackErrors.PERMISSION_DENIED]: 'dialog.cameraPermissionDeniedError',
[JitsiTrackErrors.UNSUPPORTED_RESOLUTION]: 'dialog.cameraUnsupportedResolutionError',
[JitsiTrackErrors.TIMEOUT]: 'dialog.cameraTimeoutError'
}
};
/**
* A listener for device permissions changed reported from lib-jitsi-meet.
*/
let permissionsListener: Function | undefined;
/**
* Implements the middleware of the feature base/devices.
*
* @param {Store} store - Redux store.
* @returns {Function}
*/
// eslint-disable-next-line no-unused-vars
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT: {
const _permissionsListener = (permissions: Object) => {
store.dispatch(devicePermissionsChanged(permissions));
};
const { mediaDevices } = JitsiMeetJS;
permissionsListener = _permissionsListener;
mediaDevices.addEventListener(JitsiMediaDevicesEvents.PERMISSIONS_CHANGED, permissionsListener);
Promise.all([
mediaDevices.isDevicePermissionGranted('audio'),
mediaDevices.isDevicePermissionGranted('video')
])
.then(results => {
_permissionsListener({
audio: results[0],
video: results[1]
});
})
.catch(() => {
// Ignore errors.
});
break;
}
case APP_WILL_UNMOUNT:
if (typeof permissionsListener === 'function') {
JitsiMeetJS.mediaDevices.removeEventListener(
JitsiMediaDevicesEvents.PERMISSIONS_CHANGED, permissionsListener);
permissionsListener = undefined;
}
break;
case NOTIFY_CAMERA_ERROR: {
if (!action.error) {
break;
}
const { message, name } = action.error;
const cameraJitsiTrackErrorMsg
= JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.camera[name];
const cameraErrorMsg = cameraJitsiTrackErrorMsg
|| JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP
.camera[JitsiTrackErrors.GENERAL];
const additionalCameraErrorMsg = cameraJitsiTrackErrorMsg ? null : message;
const titleKey = name === JitsiTrackErrors.PERMISSION_DENIED
? 'deviceError.cameraPermission' : 'deviceError.cameraError';
store.dispatch(showWarningNotification({
description: additionalCameraErrorMsg,
descriptionKey: cameraErrorMsg,
titleKey
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(setDeviceStatusWarning(titleKey));
}
break;
}
case NOTIFY_MIC_ERROR: {
if (!action.error) {
break;
}
const { message, name } = action.error;
const micJitsiTrackErrorMsg
= JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.microphone[name];
const micErrorMsg = micJitsiTrackErrorMsg
|| JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP
.microphone[JitsiTrackErrors.GENERAL];
const additionalMicErrorMsg = micJitsiTrackErrorMsg ? null : message;
const titleKey = name === JitsiTrackErrors.PERMISSION_DENIED
? 'deviceError.microphonePermission'
: 'deviceError.microphoneError';
store.dispatch(showWarningNotification({
description: additionalMicErrorMsg,
descriptionKey: micErrorMsg,
titleKey
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(setDeviceStatusWarning(titleKey));
}
break;
}
case SET_AUDIO_INPUT_DEVICE:
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(replaceAudioTrackById(action.deviceId));
} else {
APP.conference.onAudioDeviceChanged(action.deviceId);
}
break;
case SET_VIDEO_INPUT_DEVICE: {
const localTrack = getLocalTrack(store.getState()['features/base/tracks'], MEDIA_TYPE.VIDEO);
// on mobile devices the video stream has to be stopped before replacing it
if (isMobileBrowser() && localTrack && !localTrack.muted) {
localTrack.jitsiTrack.stopStream();
}
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(replaceVideoTrackById(action.deviceId));
} else {
APP.conference.onVideoDeviceChanged(action.deviceId);
}
break;
}
case UPDATE_DEVICE_LIST:
logDevices(action.devices, 'Device list updated');
if (areDeviceLabelsInitialized(store.getState())) {
return _processPendingRequests(store, next, action);
}
break;
case CHECK_AND_NOTIFY_FOR_NEW_DEVICE:
_checkAndNotifyForNewDevice(store, action.newDevices, action.oldDevices);
break;
}
return next(action);
});
/**
* Does extra sync up on properties that may need to be updated after the
* conference was joined.
*
* @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} to the specified {@code store}.
* @param {Action} action - The redux action {@code CONFERENCE_JOINED} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _processPendingRequests({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const result = next(action);
const state = getState();
const { pendingRequests } = state['features/base/devices'];
if (!pendingRequests || pendingRequests.length === 0) {
return result;
}
pendingRequests.forEach((request: any) => {
processExternalDeviceRequest(
dispatch,
getState,
request,
request.responseCallback);
});
dispatch(removePendingDeviceRequests());
return result;
}
/**
* Finds a new device by comparing new and old array of devices and dispatches
* notification with the new device. For new devices with same groupId only one
* notification will be shown, this is so to avoid showing multiple notifications
* for audio input and audio output devices.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {MediaDeviceInfo[]} newDevices - The array of new devices we received.
* @param {MediaDeviceInfo[]} oldDevices - The array of the old devices we have.
* @private
* @returns {void}
*/
function _checkAndNotifyForNewDevice(store: IStore, newDevices: MediaDeviceInfo[], oldDevices: MediaDeviceInfo[]) {
const { dispatch } = store;
// let's intersect both newDevices and oldDevices and handle thew newly
// added devices
const onlyNewDevices = newDevices.filter(
nDevice => !oldDevices.find(
device => device.deviceId === nDevice.deviceId));
// we group devices by groupID which normally is the grouping by physical device
// plugging in headset we provide normally two device, one input and one output
// and we want to show only one notification for this physical audio device
const devicesGroupBy: {
[key: string]: MediaDeviceInfo[];
} = onlyNewDevices.reduce((accumulated: any, value) => {
accumulated[value.groupId] = accumulated[value.groupId] || [];
accumulated[value.groupId].push(value);
return accumulated;
}, {});
Object.values(devicesGroupBy).forEach(devicesArray => {
if (devicesArray.length < 1) {
return;
}
// let's get the first device as a reference, we will use it for
// label and type
const newDevice = devicesArray[0];
// we want to strip any device details that are not very
// user friendly, like usb ids put in brackets at the end
const description = formatDeviceLabel(newDevice.label);
let titleKey;
switch (newDevice.kind) {
case 'videoinput': {
titleKey = 'notify.newDeviceCameraTitle';
break;
}
case 'audioinput' :
case 'audiooutput': {
titleKey = 'notify.newDeviceAudioTitle';
break;
}
}
if (!isPrejoinPageVisible(store.getState())) {
dispatch(showNotification({
description,
titleKey,
customActionNameKey: [ 'notify.newDeviceAction' ],
customActionHandler: [ _useDevice.bind(undefined, store, devicesArray) ]
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
}
});
}
/**
* Set a device to be currently used, selected by the user.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Array<MediaDeviceInfo|InputDeviceInfo>} devices - The devices to save.
* @returns {boolean} - Returns true in order notifications to be dismissed.
* @private
*/
function _useDevice({ dispatch }: IStore, devices: MediaDeviceInfo[]) {
devices.forEach(device => {
switch (device.kind) {
case 'videoinput': {
dispatch(updateSettings({
userSelectedCameraDeviceId: device.deviceId,
userSelectedCameraDeviceLabel: device.label
}));
dispatch(setVideoInputDevice(device.deviceId));
break;
}
case 'audioinput': {
dispatch(updateSettings({
userSelectedMicDeviceId: device.deviceId,
userSelectedMicDeviceLabel: device.label
}));
dispatch(setAudioInputDevice(device.deviceId));
break;
}
case 'audiooutput': {
setAudioOutputDeviceId(
device.deviceId,
dispatch,
true,
device.label)
.then(() => logger.log('changed audio output device'))
.catch(err => {
logger.warn(
'Failed to change audio output device.',
'Default or previously set audio output device will',
' be used instead.',
err);
});
break;
}
}
});
return true;
}

View File

@@ -0,0 +1,88 @@
import ReducerRegistry from '../redux/ReducerRegistry';
import {
ADD_PENDING_DEVICE_REQUEST,
DEVICE_PERMISSIONS_CHANGED,
REMOVE_PENDING_DEVICE_REQUESTS,
SET_AUDIO_INPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST
} from './actionTypes';
import { groupDevicesByKind } from './functions.web';
import logger from './logger';
import { IDevicesState } from './types';
const DEFAULT_STATE: IDevicesState = {
availableDevices: {
audioInput: [],
audioOutput: [],
videoInput: []
},
pendingRequests: [],
permissions: {
audio: false,
video: false
}
};
/**
* Listen for actions which changes the state of known and used devices.
*
* @param {IDevicesState} state - The Redux state of the feature features/base/devices.
* @param {Object} action - Action object.
* @param {string} action.type - Type of action.
* @param {Array<MediaDeviceInfo>} action.devices - All available audio and
* video devices.
* @returns {Object}
*/
ReducerRegistry.register<IDevicesState>(
'features/base/devices',
(state = DEFAULT_STATE, action): IDevicesState => {
switch (action.type) {
case UPDATE_DEVICE_LIST: {
const deviceList = groupDevicesByKind(action.devices);
return {
...state,
availableDevices: deviceList
};
}
case ADD_PENDING_DEVICE_REQUEST:
return {
...state,
pendingRequests: [
...state.pendingRequests,
action.request
]
};
case REMOVE_PENDING_DEVICE_REQUESTS:
return {
...state,
pendingRequests: [ ]
};
// TODO: Changing of current audio and video device id is currently handled outside of react/redux.
case SET_AUDIO_INPUT_DEVICE: {
logger.debug(`set audio input device: ${action.deviceId}`);
return state;
}
case SET_VIDEO_INPUT_DEVICE: {
logger.debug(`set video input device: ${action.deviceId}`);
return state;
}
case DEVICE_PERMISSIONS_CHANGED: {
return {
...state,
permissions: action.permissions
};
}
default:
return state;
}
});

View File

@@ -0,0 +1,17 @@
/* eslint-disable lines-around-comment */
export interface IDevicesState {
availableDevices: {
// @ts-ignore
audioInput?: MediaDeviceInfo[];
// @ts-ignore
audioOutput?: MediaDeviceInfo[];
// @ts-ignore
videoInput?: MediaDeviceInfo[];
};
pendingRequests: any[];
permissions: {
audio: boolean;
video: boolean;
};
}

View File

@@ -0,0 +1,41 @@
/**
* The type of Redux action which closes a dialog
*
* {
* type: HIDE_DIALOG
* }
*/
export const HIDE_DIALOG = 'HIDE_DIALOG';
/**
* The type of Redux action which closes a sheet.
*
* {
* type: HIDE_SHEET
* }
*/
export const HIDE_SHEET = 'HIDE_SHEET';
/**
* The type of Redux action which begins a request to open a dialog.
*
* {
* type: OPEN_DIALOG,
* component: React.Component,
* props: PropTypes
* }
*
*/
export const OPEN_DIALOG = 'OPEN_DIALOG';
/**
* The type of Redux action which begins a request to open a sheet.
*
* {
* type: OPEN_SHEET,
* component: React.Component,
* props: PropTypes
* }
*
*/
export const OPEN_SHEET = 'OPEN_SHEET';

View File

@@ -0,0 +1,126 @@
import { ComponentType } from 'react';
import { IStore } from '../../app/types';
import {
HIDE_DIALOG,
HIDE_SHEET,
OPEN_DIALOG,
OPEN_SHEET
} from './actionTypes';
import { isDialogOpen } from './functions';
import logger from './logger';
/**
* Signals Dialog to close its dialog.
*
* @param {Object} [component] - The {@code Dialog} component to close/hide. If
* {@code undefined}, closes/hides {@code Dialog} regardless of which
* component it's rendering; otherwise, closes/hides {@code Dialog} only if
* it's rendering the specified {@code component}.
* @returns {{
* type: HIDE_DIALOG,
* component: (React.Component | undefined)
* }}
*/
export function hideDialog(component?: ComponentType<any>) {
logger.info(`Hide dialog: ${getComponentDisplayName(component)}`);
return {
type: HIDE_DIALOG,
component
};
}
/**
* Closes the active sheet.
*
* @returns {{
* type: HIDE_SHEET,
* }}
*/
export function hideSheet() {
return {
type: HIDE_SHEET
};
}
/**
* Signals Dialog to open dialog.
*
* @param {Object} component - The component to display as dialog.
* @param {Object} [componentProps] - The React {@code Component} props of the
* specified {@code component}.
* @returns {{
* type: OPEN_DIALOG,
* component: React.Component,
* componentProps: (Object | undefined)
* }}
*/
export function openDialog(component: ComponentType<any>, componentProps?: Object) {
logger.info(`Open dialog: ${getComponentDisplayName(component)}`);
return {
type: OPEN_DIALOG,
component,
componentProps
};
}
/**
* Opens the requested sheet.
*
* @param {Object} component - The component to display as a sheet.
* @param {Object} [componentProps] - The React {@code Component} props of the
* specified {@code component}.
* @returns {{
* type: OPEN_SHEET,
* component: React.Component,
* componentProps: (Object | undefined)
* }}
*/
export function openSheet(component: ComponentType<any>, componentProps?: Object) {
return {
type: OPEN_SHEET,
component,
componentProps
};
}
/**
* Signals Dialog to open a dialog with the specified component if the component
* is not already open. If it is open, then Dialog is signaled to close its
* dialog.
*
* @param {Object} component - The component to display as dialog.
* @param {Object} [componentProps] - The React {@code Component} props of the
* specified {@code component}.
* @returns {Function}
*/
export function toggleDialog(component: ComponentType<any>, componentProps?: Object) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (isDialogOpen(getState, component)) {
dispatch(hideDialog(component));
} else {
dispatch(openDialog(component, componentProps));
}
};
}
/**
* Extracts a printable name for a dialog component.
*
* @param {Object} component - The component to extract the name for.
*
* @returns {string} The display name.
*/
function getComponentDisplayName(component?: ComponentType<any>) {
if (!component) {
return '';
}
const name = component.displayName ?? component.name ?? 'Component';
return name.replace('withI18nextTranslation(Connect(', '') // dialogs with translations
.replace('))', ''); // dialogs with translations suffix
}

View File

@@ -0,0 +1,72 @@
import React, { Component, ComponentType } from 'react';
import { IReduxState } from '../../../app/types';
import { IReactionEmojiProps } from '../../../reactions/constants';
/**
* The type of the React {@code Component} props of {@link DialogContainer}.
*/
interface IProps {
/**
* The component to render.
*/
_component?: ComponentType<any>;
/**
* The props to pass to the component that will be rendered.
*/
_componentProps?: Object;
/**
* Array of reactions to be displayed.
*/
_reactionsQueue: Array<IReactionEmojiProps>;
/**
* True if the UI is in a compact state where we don't show dialogs.
*/
_reducedUI: boolean;
}
/**
* Implements a DialogContainer responsible for showing all dialogs.
*/
export default class AbstractDialogContainer extends Component<IProps> {
/**
* Returns the dialog to be displayed.
*
* @private
* @returns {ReactElement|null}
*/
_renderDialogContent() {
const {
_component: component,
_reducedUI: reducedUI
} = this.props;
return (
component && !reducedUI
? React.createElement(component, this.props._componentProps)
: null);
}
}
/**
* Maps (parts of) the redux state to the associated
* {@code AbstractDialogContainer}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {IProps}
*/
export function abstractMapStateToProps(state: IReduxState) {
const stateFeaturesBaseDialog = state['features/base/dialog'];
const { reducedUI } = state['features/base/responsive-ui'];
return {
_component: stateFeaturesBaseDialog.component,
_componentProps: stateFeaturesBaseDialog.componentProps,
_reducedUI: reducedUI
};
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { Text, TextStyle } from 'react-native';
import { brandedDialog as styles } from './native/styles';
/**
* Renders a specific {@code string} which may contain HTML.
*
* @param {string|undefined} html - The {@code string} which may
* contain HTML to render.
* @returns {ReactElement[]|string}
*/
export function renderHTML(html?: string) {
if (typeof html === 'string') {
// At the time of this writing, the specified HTML contains a couple
// of spaces one after the other. They do not cause a visible
// problem on Web, because the specified HTML is rendered as, well,
// HTML. However, we're not rendering HTML here.
// eslint-disable-next-line no-param-reassign
html = html.replace(/\s{2,}/gi, ' ');
// Render text in <b>text</b> in bold.
const opening = /<\s*b\s*>/gi;
const closing = /<\s*\/\s*b\s*>/gi;
let o;
let c;
let prevClosingLastIndex = 0;
const r = [];
// eslint-disable-next-line no-cond-assign
while (o = opening.exec(html)) {
closing.lastIndex = opening.lastIndex;
// eslint-disable-next-line no-cond-assign
if (c = closing.exec(html)) {
r.push(html.substring(prevClosingLastIndex, o.index));
r.push(
<Text style = { (styles.boldDialogText as TextStyle) }>
{ html.substring(opening.lastIndex, c.index) }
</Text>);
opening.lastIndex
= prevClosingLastIndex
= closing.lastIndex;
} else {
break;
}
}
if (prevClosingLastIndex < html.length) {
r.push(html.substring(prevClosingLastIndex));
}
return r;
}
return html;
}

View File

@@ -0,0 +1,168 @@
import { Component } from 'react';
import { IStore } from '../../../../app/types';
import { hideDialog } from '../../actions';
import { DialogProps } from '../../constants';
/**
* The type of the React {@code Component} props of {@link AbstractDialog}.
*/
export interface IProps extends DialogProps {
/**
* Used to show/hide the dialog on cancel.
*/
dispatch: IStore['dispatch'];
}
/**
* The type of the React {@code Component} state of {@link AbstractDialog}.
*/
export interface IState {
submitting?: boolean;
}
/**
* An abstract implementation of a dialog on Web/React and mobile/react-native.
*/
export default class AbstractDialog<P extends IProps, S extends IState = IState>
extends Component<P, S> {
_mounted: boolean;
/**
* Initializes a new {@code AbstractDialog} instance.
*
* @param {Object} props - The read-only React {@code Component} props with
* which the new instance is to be initialized.
*/
constructor(props: P) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onBack = this._onBack.bind(this);
this._onCancel = this._onCancel.bind(this);
this._onSubmit = this._onSubmit.bind(this);
this._onSubmitFulfilled = this._onSubmitFulfilled.bind(this);
this._onSubmitRejected = this._onSubmitRejected.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}. Invoked
* immediately before mounting occurs.
*
* @inheritdoc
*/
override componentDidMount() {
this._mounted = true;
}
/**
* Implements React's {@link Component#componentWillUnmount()}. Invoked
* immediately before this component is unmounted and destroyed.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._mounted = false;
}
/**
* Dispatches a redux action to hide this dialog.
*
* @returns {*} The return value of {@link hideDialog}.
*/
_hide() {
return this.props.dispatch(hideDialog());
}
_onBack() {
const { backDisabled = false, onBack } = this.props;
if (!backDisabled && (!onBack || onBack())) {
this._hide();
}
}
/**
* Dispatches a redux action to hide this dialog when it's canceled.
*
* @protected
* @returns {void}
*/
_onCancel() {
const { cancelDisabled = false, onCancel } = this.props;
if (!cancelDisabled && (!onCancel || onCancel())) {
this._hide();
}
}
/**
* Submits this {@code Dialog}. If the React {@code Component} prop
* {@code onSubmit} is defined, the function that is the value of the prop
* is invoked. If the function returns a {@code thenable}, then the
* resolution of the {@code thenable} is awaited. If the submission
* completes successfully, a redux action will be dispatched to hide this
* dialog.
*
* @protected
* @param {string} [value] - The submitted value if any.
* @returns {void}
*/
_onSubmit(value?: string) {
const { okDisabled = false, onSubmit } = this.props;
if (!okDisabled) {
this.setState({ submitting: true });
// Invoke the React Component prop onSubmit if any.
const r = !onSubmit || onSubmit(value);
// If the invocation returns a thenable, await its resolution;
// otherwise, treat the return value as a boolean indicating whether
// the submission has completed successfully.
let then;
if (r) {
switch (typeof r) {
case 'function':
case 'object':
then = r.then;
break;
}
}
if (typeof then === 'function' && then.length === 2) {
then.call(r, this._onSubmitFulfilled, this._onSubmitRejected);
} else if (r) {
this._onSubmitFulfilled();
} else {
this._onSubmitRejected();
}
}
}
/**
* Notifies this {@code AbstractDialog} that it has been submitted
* successfully. Dispatches a redux action to hide this dialog after it has
* been submitted.
*
* @private
* @returns {void}
*/
_onSubmitFulfilled() {
this._mounted && this.setState({ submitting: false });
this._hide();
}
/**
* Notifies this {@code AbstractDialog} that its submission has failed.
*
* @private
* @returns {void}
*/
_onSubmitRejected() {
this._mounted && this.setState({ submitting: false });
}
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import Dialog from 'react-native-dialog';
import { connect } from 'react-redux';
import { translate } from '../../../i18n/functions';
import { _abstractMapStateToProps } from '../../functions';
import { renderHTML } from '../functions.native';
import AbstractDialog, { IProps as AbstractProps } from './AbstractDialog';
interface IProps extends AbstractProps, WithTranslation {
/**
* Untranslated i18n key of the content to be displayed.
*
* NOTE: This dialog also adds support to Object type keys that will be
* translated using the provided params. See i18n function
* {@code translate(string, Object)} for more details.
*/
contentKey: string | { key: string; params: Object; };
}
/**
* Implements an alert dialog, to simply show an error or a message,
* then disappear on dismiss.
*/
class AlertDialog extends AbstractDialog<IProps> {
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
override render() {
const { contentKey, t } = this.props;
const content
= typeof contentKey === 'string'
? t(contentKey)
: renderHTML(t(contentKey.key, contentKey.params));
return (
<Dialog.Container
coverScreen = { false }
visible = { true }>
<Dialog.Description>
{ content }
</Dialog.Description>
<Dialog.Button
label = { t('dialog.Ok') }
onPress = { this._onSubmit } />
</Dialog.Container>
);
}
}
export default translate(connect(_abstractMapStateToProps)(AlertDialog));

View File

@@ -0,0 +1,150 @@
import React, { PureComponent, ReactNode } from 'react';
import { SafeAreaView, ScrollView, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IStore } from '../../../../app/types';
import SlidingView from '../../../react/components/native/SlidingView';
import { hideSheet } from '../../actions';
import { bottomSheetStyles as styles } from './styles';
/**
* The type of {@code BottomSheet}'s React {@code Component} prop types.
*/
type Props = {
/**
* Whether to add padding to scroll view.
*/
addScrollViewPadding?: boolean;
/**
* The children to be displayed within this component.
*/
children: ReactNode;
/**
* Redux Dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Handler for the cancel event, which happens when the user dismisses
* the sheet.
*/
onCancel?: Function;
/**
* Function to render a bottom sheet footer element, if necessary.
*/
renderFooter?: () => React.ReactNode;
/**
* Function to render a bottom sheet header element, if necessary.
*/
renderHeader?: Function;
/**
* Whether to show sliding view or not.
*/
showSlidingView?: boolean;
/**
* The component's external style.
*/
style?: Object;
};
/**
* A component emulating Android's BottomSheet.
*/
class BottomSheet extends PureComponent<Props> {
/**
* Default values for {@code BottomSheet} component's properties.
*
* @static
*/
static defaultProps = {
addScrollViewPadding: true,
showSlidingView: true
};
/**
* Initializes a new instance.
*
* @param {Props} props - The React {@code Component} props to initialize
* the new instance with.
*/
constructor(props: Props) {
super(props);
this._onCancel = this._onCancel.bind(this);
}
/**
* Handles the cancel event, when the user dismissed the sheet. By default we close it.
*
* @returns {void}
*/
_onCancel() {
if (this.props.onCancel) {
this.props.onCancel();
} else {
this.props.dispatch(hideSheet());
}
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
addScrollViewPadding,
renderHeader,
renderFooter,
showSlidingView,
style
} = this.props;
return (
<SlidingView
onHide = { this._onCancel }
position = 'bottom'
show = { Boolean(showSlidingView) }>
<View
pointerEvents = 'box-none'
style = { styles.sheetContainer as ViewStyle }>
<View
pointerEvents = 'box-none'
style = { styles.sheetAreaCover } />
{ renderHeader?.() }
<SafeAreaView
style = { [
styles.sheetItemContainer,
renderHeader
? styles.sheetHeader
: styles.sheet,
renderFooter && styles.sheetFooter,
style
] }>
<ScrollView
bounces = { false }
showsVerticalScrollIndicator = { false }
style = { [
renderFooter && styles.sheet,
addScrollViewPadding && styles.scrollView
] } >
{ this.props.children }
</ScrollView>
{ renderFooter?.() }
</SafeAreaView>
</View>
</SlidingView>
);
}
}
export default connect()(BottomSheet);

View File

@@ -0,0 +1,22 @@
import React, { Fragment } from 'react';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../../app/types';
const BottomSheetContainer: () => JSX.Element | null = (): JSX.Element | null => {
const { sheet, sheetProps } = useSelector((state: IReduxState) => state['features/base/dialog']);
const { reducedUI } = useSelector((state: IReduxState) => state['features/base/responsive-ui']);
if (!sheet || reducedUI) {
return null;
}
return (
<Fragment>
{ React.createElement(sheet, sheetProps) }
</Fragment>
);
};
export default BottomSheetContainer;

View File

@@ -0,0 +1,172 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import Dialog from 'react-native-dialog';
import { connect } from 'react-redux';
import { translate } from '../../../i18n/functions';
import { renderHTML } from '../functions.native';
import AbstractDialog, { IProps as AbstractProps } from './AbstractDialog';
import styles from './styles';
/**
* The type of the React {@code Component} props of
* {@link ConfirmDialog}.
*/
interface IProps extends AbstractProps, WithTranslation {
/**
* The i18n key of the text label for the back button.
*/
backLabel?: string;
/**
* The i18n key of the text label for the cancel button.
*/
cancelLabel?: string;
/**
* The React {@code Component} children.
*/
children?: React.ReactNode;
/**
* The i18n key of the text label for the confirm button.
*/
confirmLabel?: string;
/**
* Dialog description key for translations.
*/
descriptionKey?: string | { key: string; params: string; };
/**
* Whether the back button is hidden.
*/
isBackHidden?: Boolean;
/**
* Whether the cancel button is hidden.
*/
isCancelHidden?: Boolean;
/**
* Whether or not the nature of the confirm button is destructive.
*/
isConfirmDestructive?: Boolean;
/**
* Whether or not the confirm button is hidden.
*/
isConfirmHidden?: Boolean;
/**
* Dialog title.
*/
title?: string;
/**
* Renders buttons vertically.
*/
verticalButtons?: boolean;
}
/**
* React Component for getting confirmation to stop a file recording session in
* progress.
*
* @augments Component
*/
class ConfirmDialog extends AbstractDialog<IProps> {
/**
* Default values for {@code ConfirmDialog} component's properties.
*
* @static
*/
static defaultProps = {
isConfirmDestructive: false,
isConfirmHidden: false
};
/**
* Renders the dialog description.
*
* @returns {React$Component}
*/
_renderDescription() {
const { descriptionKey, t } = this.props;
const description
= typeof descriptionKey === 'string'
? t(descriptionKey)
: renderHTML(
t(descriptionKey?.key ?? '', descriptionKey?.params)
);
return (
<Dialog.Description>
{ description }
</Dialog.Description>
);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const {
backLabel,
cancelLabel,
children,
confirmLabel,
isBackHidden = true,
isCancelHidden,
isConfirmDestructive,
isConfirmHidden,
t,
title,
verticalButtons
} = this.props;
const dialogButtonStyle
= isConfirmDestructive
? styles.destructiveDialogButton : styles.dialogButton;
return (
<Dialog.Container
coverScreen = { false }
verticalButtons = { verticalButtons }
visible = { true }>
{
title && <Dialog.Title>
{ t(title) }
</Dialog.Title>
}
{ this._renderDescription() }
{ children }
{
!isBackHidden && <Dialog.Button
label = { t(backLabel || 'dialog.confirmBack') }
onPress = { this._onBack }
style = { styles.dialogButton } />
}
{
!isCancelHidden && <Dialog.Button
label = { t(cancelLabel || 'dialog.confirmNo') }
onPress = { this._onCancel }
style = { styles.dialogButton } />
}
{
!isConfirmHidden && <Dialog.Button
label = { t(confirmLabel || 'dialog.confirmYes') }
onPress = { this._onSubmit }
style = { dialogButtonStyle } />
}
</Dialog.Container>
);
}
}
export default translate(connect()(ConfirmDialog));

View File

@@ -0,0 +1,58 @@
import React, { Fragment } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import ReactionEmoji from '../../../../reactions/components/native/ReactionEmoji';
import { getReactionsQueue } from '../../../../reactions/functions.native';
import AbstractDialogContainer, {
abstractMapStateToProps
} from '../AbstractDialogContainer';
/**
* Implements a DialogContainer responsible for showing all dialogs. We will
* need a separate container so we can handle multiple dialogs by showing them
* simultaneously or queueing them.
*
* @augments AbstractDialogContainer
*/
class DialogContainer extends AbstractDialogContainer {
/**
* Returns the reactions to be displayed.
*
* @returns {Array<React$Element>}
*/
_renderReactions() {
const { _reactionsQueue } = this.props;
return _reactionsQueue.map(({ reaction, uid }, index) => (<ReactionEmoji
index = { index }
key = { uid }
reaction = { reaction }
uid = { uid } />));
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Fragment>
{this._renderReactions()}
{this._renderDialogContent()}
</Fragment>
);
}
}
const mapStateToProps = (state: IReduxState) => {
return {
...abstractMapStateToProps(state),
_reactionsQueue: getReactionsQueue(state)
};
};
export default connect(mapStateToProps)(DialogContainer);

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { TextStyle } from 'react-native';
import Dialog from 'react-native-dialog';
import { connect } from 'react-redux';
import { translate } from '../../../i18n/functions';
import { _abstractMapStateToProps } from '../../functions';
import AbstractDialog, {
IProps as AbstractProps,
IState as AbstractState
} from './AbstractDialog';
import { inputDialog as styles } from './styles';
interface IProps extends AbstractProps, WithTranslation {
/**
* The dialog descriptionKey.
*/
descriptionKey?: string;
/**
* Whether to display the cancel button.
*/
disableCancel?: boolean;
/**
* An optional initial value to initiate the field with.
*/
initialValue?: string;
/**
* A message key to be shown for the user (e.g. An error that is defined after submitting the form).
*/
messageKey?: string;
/**
* Props for the text input.
*/
textInputProps?: Object;
/**
* The untranslated i18n key for the dialog title.
*/
titleKey?: string;
/**
* Validating of the input.
*/
validateInput?: Function;
}
interface IState extends AbstractState {
/**
* The current value of the field.
*/
fieldValue?: string;
/**
* The result of the input validation.
*/
isValid: boolean;
}
/**
* Implements a single field input dialog component.
*/
class InputDialog extends AbstractDialog<IProps, IState> {
/**
* Instantiates a new {@code InputDialog}.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
fieldValue: props.initialValue,
isValid: props.validateInput ? props.validateInput(props.initialValue) : true,
submitting: false
};
this._onChangeText = this._onChangeText.bind(this);
this._onSubmitValue = this._onSubmitValue.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const {
descriptionKey,
messageKey,
t,
titleKey
} = this.props;
return (
<Dialog.Container
coverScreen = { false }
visible = { true }>
<Dialog.Title>
{ t(titleKey ?? '') }
</Dialog.Title>
{
descriptionKey && (
<Dialog.Description>
{ t(descriptionKey) }
</Dialog.Description>
)
}
<Dialog.Input
autoFocus = { true }
onChangeText = { this._onChangeText }
value = { this.state.fieldValue }
{ ...this.props.textInputProps } />
{
messageKey && (
<Dialog.Description
style = { styles.formMessage as TextStyle }>
{ t(messageKey) }
</Dialog.Description>
)
}
{!this.props.disableCancel && <Dialog.Button
label = { t('dialog.Cancel') }
onPress = { this._onCancel } />}
<Dialog.Button
disabled = { !this.state.isValid }
label = { t('dialog.Ok') }
onPress = { this._onSubmitValue } />
</Dialog.Container>
);
}
/**
* Callback to be invoked when the text in the field changes.
*
* @param {string} fieldValue - The updated field value.
* @returns {void}
*/
_onChangeText(fieldValue: string) {
if (this.props.validateInput) {
this.setState({
isValid: this.props.validateInput(fieldValue),
fieldValue
});
return;
}
this.setState({
fieldValue
});
}
/**
* Callback to be invoked when the value of this dialog is submitted.
*
* @returns {boolean}
*/
_onSubmitValue() {
return this._onSubmit(this.state.fieldValue);
}
}
export default translate(connect(_abstractMapStateToProps)(InputDialog));

View File

@@ -0,0 +1,221 @@
// @ts-expect-error
import { randomInt } from '@jitsi/js-utils/random';
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { appNavigate, reloadNow } from '../../../../app/actions.native';
import { IReduxState, IStore } from '../../../../app/types';
import { translate } from '../../../i18n/functions';
import { isFatalJitsiConnectionError } from '../../../lib-jitsi-meet/functions.native';
import { hideDialog } from '../../actions';
import logger from '../../logger';
import ConfirmDialog from './ConfirmDialog';
/**
* The type of the React {@code Component} props of
* {@link PageReloadDialog}.
*/
interface IProps extends WithTranslation {
conferenceError?: Error;
configError?: Error;
connectionError?: Error;
dispatch: IStore['dispatch'];
isNetworkFailure: boolean;
reason?: string;
}
/**
* The type of the React {@code Component} state of
* {@link PageReloadDialog}.
*/
interface IPageReloadDialogState {
timeLeft: number;
}
/**
* Implements a React Component that is shown before the
* conference is reloaded.
* Shows a warning message and counts down towards the re-load.
*/
class PageReloadDialog extends Component<IProps, IPageReloadDialogState> {
_interval?: number;
_timeoutSeconds: number;
/**
* Initializes a new PageReloadOverlay instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
* @public
*/
constructor(props: IProps) {
super(props);
this._timeoutSeconds = 10 + randomInt(0, 20);
this.state = {
timeLeft: this._timeoutSeconds
};
this._onCancel = this._onCancel.bind(this);
this._onReloadNow = this._onReloadNow.bind(this);
this._onReconnecting = this._onReconnecting.bind(this);
}
/**
* React Component method that executes once component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const { timeLeft } = this.state;
logger.info(
`The conference will be reloaded after ${timeLeft} seconds.`
);
this._interval = setInterval(() =>
this._onReconnecting(), 1000);
}
/**
* Clears the timer interval.
*
* @inheritdoc
* @returns {void}
*/
override componentWillUnmount() {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
/**
* Handle clicking of the "Cancel" button. It will navigate back to the
* welcome page.
*
* @private
* @returns {boolean}
*/
_onCancel() {
const { dispatch } = this.props;
clearInterval(this._interval ?? 0);
dispatch(appNavigate(undefined));
return true;
}
/**
* Handles automatic reconnection.
*
* @private
* @returns {void}
*/
_onReconnecting() {
const { dispatch } = this.props;
const { timeLeft } = this.state;
if (timeLeft === 0) {
if (this._interval) {
dispatch(hideDialog());
this._onReloadNow();
this._interval = undefined;
}
}
this.setState({
timeLeft: timeLeft - 1
});
}
/**
* Handle clicking on the "Reload Now" button. It will navigate to the same
* conference URL as before immediately, without waiting for the timer to
* kick in.
*
* @private
* @returns {boolean}
*/
_onReloadNow() {
const { dispatch } = this.props;
clearInterval(this._interval ?? 0);
dispatch(reloadNow());
return true;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { isNetworkFailure, t } = this.props;
const { timeLeft } = this.state;
let message, title;
if (isNetworkFailure) {
title = 'dialog.conferenceDisconnectTitle';
message = 'dialog.conferenceDisconnectMsg';
} else {
title = 'dialog.conferenceReloadTitle';
message = 'dialog.conferenceReloadMsg';
}
return (
<ConfirmDialog
cancelLabel = 'dialog.Cancel'
confirmLabel = 'dialog.rejoinNow'
descriptionKey = { `${t(message, { seconds: timeLeft })}` }
onCancel = { this._onCancel }
onSubmit = { this._onReloadNow }
title = { title } />
);
}
}
/**
* Maps (parts of) the redux state to the associated component's props.
*
* @param {Object} state - The redux state.
* @param {IProps} ownProps - The own props of the component.
* @protected
* @returns {{
* isNetworkFailure: boolean,
* reason: string
* }}
*/
function mapStateToProps(state: IReduxState, ownProps: IProps) {
const { conferenceError, configError, connectionError } = ownProps;
const fatalConnectionError
= connectionError && isFatalJitsiConnectionError(connectionError);
const isNetworkFailure = Boolean(configError || fatalConnectionError);
let reason;
if (conferenceError) {
reason = `error.conference.${conferenceError.name}`;
} else if (connectionError) {
reason = `error.conference.${connectionError.name}`;
} else if (configError) {
reason = `error.config.${configError.name}`;
} else {
logger.error('No reload reason defined!');
}
return {
isNetworkFailure,
reason
};
}
export default translate(connect(mapStateToProps)(PageReloadDialog));

View File

@@ -0,0 +1,265 @@
import { StyleSheet } from 'react-native';
import ColorSchemeRegistry from '../../../color-scheme/ColorSchemeRegistry';
import { schemeColor } from '../../../color-scheme/functions';
import { BoxModel } from '../../../styles/components/styles/BoxModel';
import BaseTheme from '../../../ui/components/BaseTheme.native';
import { PREFERRED_DIALOG_SIZE } from '../../constants';
const BORDER_RADIUS = 5;
/**
* NOTE: These Material guidelines based values are currently only used in
* dialogs (and related) but later on it would be nice to export it into a base
* Material feature.
*/
export const MD_FONT_SIZE = 16;
export const MD_ITEM_HEIGHT = 48;
export const MD_ITEM_MARGIN_PADDING = BaseTheme.spacing[3];
/**
* Reusable (colored) style for text in any branded dialogs.
*/
const brandedDialogText = {
color: schemeColor('text'),
fontSize: MD_FONT_SIZE,
textAlign: 'center'
};
const brandedDialogLabelStyle = {
color: BaseTheme.palette.text01,
flexShrink: 1,
fontSize: MD_FONT_SIZE,
opacity: 0.90
};
const brandedDialogItemContainerStyle = {
alignItems: 'center',
flexDirection: 'row',
height: MD_ITEM_HEIGHT
};
const brandedDialogIconStyle = {
color: BaseTheme.palette.icon01,
fontSize: 24
};
export const inputDialog = {
formMessage: {
alignSelf: 'flex-start',
fontStyle: 'italic',
fontWeight: 'bold',
marginTop: BaseTheme.spacing[3]
}
};
/**
* The React {@code Component} styles of {@code BottomSheet}. These have
* been implemented as per the Material Design guidelines:
* {@link https://material.io/guidelines/components/bottom-sheets.html}.
*/
export const bottomSheetStyles = {
sheetAreaCover: {
backgroundColor: 'transparent',
flex: 1
},
scrollView: {
paddingHorizontal: 0
},
/**
* Style for the container of the sheet.
*/
sheetContainer: {
alignItems: 'stretch',
flex: 1,
flexDirection: 'column',
justifyContent: 'flex-end',
maxWidth: 500,
marginLeft: 'auto',
marginRight: 'auto',
width: '100%'
},
sheetItemContainer: {
flex: -1,
maxHeight: '75%'
},
buttons: {
/**
* Style for the {@code Icon} element in a generic item of the menu.
*/
iconStyle: {
...brandedDialogIconStyle
},
/**
* Style for the label in a generic item rendered in the menu.
*/
labelStyle: {
...brandedDialogLabelStyle,
marginLeft: 16
},
/**
* Container style for a generic item rendered in the menu.
*/
style: {
...brandedDialogItemContainerStyle,
paddingHorizontal: MD_ITEM_MARGIN_PADDING
},
/**
* Additional style that is not directly used as a style object.
*/
underlayColor: BaseTheme.palette.ui04
},
/**
* Bottom sheet's base style.
*/
sheet: {
backgroundColor: BaseTheme.palette.ui02,
borderTopLeftRadius: 16,
borderTopRightRadius: 16
},
/**
* Bottom sheet's base style with header.
*/
sheetHeader: {
backgroundColor: BaseTheme.palette.ui02
},
/**
* Bottom sheet's background color with footer.
*/
sheetFooter: {
backgroundColor: BaseTheme.palette.ui01
}
};
export default {
dialogButton: {
...BaseTheme.typography.bodyLongBold
},
destructiveDialogButton: {
...BaseTheme.typography.bodyLongBold,
color: BaseTheme.palette.actionDanger
}
};
export const brandedDialog = {
/**
* The style of bold {@code Text} rendered by the {@code Dialog}s of the
* feature authentication.
*/
boldDialogText: {
fontWeight: 'bold'
},
buttonFarRight: {
borderBottomRightRadius: BORDER_RADIUS
},
buttonWrapper: {
alignItems: 'stretch',
borderRadius: BORDER_RADIUS,
flexDirection: 'row'
},
mainWrapper: {
alignSelf: 'stretch',
padding: BoxModel.padding * 2,
// The added bottom padding is to compensate the empty space around the
// close icon.
paddingBottom: BoxModel.padding * 3
},
overlayTouchable: {
...StyleSheet.absoluteFillObject
}
};
/**
* Color schemed styles for all the component based on the abstract dialog.
*/
ColorSchemeRegistry.register('Dialog', {
button: {
backgroundColor: '#44A5FF',
flex: 1,
padding: BoxModel.padding * 1.5
},
/**
* Separator line for the buttons in a dialog.
*/
buttonSeparator: {
borderRightColor: schemeColor('border'),
borderRightWidth: 1
},
buttonLabel: {
color: schemeColor('buttonLabel'),
fontSize: MD_FONT_SIZE,
textAlign: 'center'
},
/**
* Style of the close icon on a dialog.
*/
closeStyle: {
color: schemeColor('icon'),
fontSize: MD_FONT_SIZE
},
/**
* Base style of the dialogs.
*/
dialog: {
alignItems: 'stretch',
backgroundColor: schemeColor('background'),
borderColor: schemeColor('border'),
borderRadius: BORDER_RADIUS,
borderWidth: 1,
flex: 1,
flexDirection: 'column',
maxWidth: PREFERRED_DIALOG_SIZE
},
/**
* Field on an input dialog.
*/
field: {
...brandedDialogText,
borderBottomWidth: 1,
borderColor: schemeColor('border'),
margin: BoxModel.margin,
textAlign: 'left'
},
/**
* Style for the field label on an input dialog.
*/
fieldLabel: {
...brandedDialogText,
margin: BoxModel.margin,
textAlign: 'left'
},
text: {
...brandedDialogText,
color: BaseTheme.palette.text01
},
topBorderContainer: {
borderTopColor: BaseTheme.palette.ui07,
borderTopWidth: 1
}
});

View File

@@ -0,0 +1,58 @@
import { Component } from 'react';
/**
* The type of the React {@code Component} props of {@link AbstractDialogTab}.
*/
export interface IProps {
/**
* Callback to invoke on change.
*/
onTabStateChange: Function;
/**
* The id of the tab.
*/
tabId: number;
}
/**
* Abstract React {@code Component} for tabs of the DialogWithTabs component.
*
* @augments Component
*/
class AbstractDialogTab<P extends IProps, S> extends Component<P, S> {
/**
* Initializes a new {@code AbstractDialogTab} instance.
*
* @param {P} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: P) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onChange = this._onChange.bind(this);
}
/**
* Uses the onTabStateChange function to pass the changed state of the
* controlled tab component to the controlling DialogWithTabs component.
*
* @param {Object} change - Object that contains the changed property and
* value.
* @returns {void}
*/
_onChange(change: Object) {
const { onTabStateChange, tabId } = this.props;
onTabStateChange(tabId, {
...this.props,
...change
});
}
}
export default AbstractDialogTab;

View File

@@ -0,0 +1,83 @@
import { ReactNode } from 'react';
export type DialogProps = {
/**
* Whether back button is disabled. Enabled by default.
*/
backDisabled?: boolean;
/**
* Optional i18n key to change the back button title.
*/
backKey?: string;
/**
* Whether cancel button is disabled. Enabled by default.
*/
cancelDisabled?: boolean;
/**
* Optional i18n key to change the cancel button title.
*/
cancelKey?: string;
/**
* The React {@code Component} children which represents the dialog's body.
*/
children?: ReactNode;
/**
* Is ok button enabled/disabled. Enabled by default.
*/
okDisabled?: boolean;
/**
* Optional i18n key to change the ok button title.
*/
okKey?: string;
/**
* The handler for onBack event.
*/
onBack?: Function;
/**
* The handler for onCancel event.
*/
onCancel?: Function;
/**
* The handler for the event when submitting the dialog.
*/
onSubmit?: Function;
/**
* Additional style to be applied on the dialog.
*
* NOTE: Not all dialog types support this!
*/
style?: Object;
/**
* Key to use for showing a title.
*/
titleKey?: string;
/**
* The string to use as a title instead of {@code titleKey}. If a truthy
* value is specified, it takes precedence over {@code titleKey} i.e.
* The latter is unused.
*/
titleString?: string;
};
/**
* A preferred (or optimal) dialog size. This constant is reused in many
* components, where dialog size optimization is suggested.
*
* NOTE: Even though we support valious devices, including tablets, we don't
* want the dialogs to be oversized even on larger devices. This number seems
* to be a good compromise, but also easy to update.
*/
export const PREFERRED_DIALOG_SIZE = 300;

View File

@@ -0,0 +1,45 @@
import { ComponentType } from 'react';
import { IReduxState } from '../../app/types';
import { IStateful } from '../app/types';
import ColorSchemeRegistry from '../color-scheme/ColorSchemeRegistry';
import { toState } from '../redux/functions';
/**
* Checks if any {@code Dialog} is currently open.
*
* @param {IStateful} stateful - The redux store, the redux
* {@code getState} function, or the redux state itself.
* @returns {boolean}
*/
export function isAnyDialogOpen(stateful: IStateful) {
return Boolean(toState(stateful)['features/base/dialog'].component);
}
/**
* Checks if a {@code Dialog} with a specific {@code component} is currently
* open.
*
* @param {IStateful} stateful - The redux store, the redux
* {@code getState} function, or the redux state itself.
* @param {React.Component} component - The {@code component} of a
* {@code Dialog} to be checked.
* @returns {boolean}
*/
export function isDialogOpen(stateful: IStateful, component: ComponentType<any>) {
return toState(stateful)['features/base/dialog'].component === component;
}
/**
* Maps part of the Redux state to the props of any Dialog based component.
*
* @param {IReduxState} state - The Redux state.
* @returns {{
* _dialogStyles: StyleType
* }}
*/
export function _abstractMapStateToProps(state: IReduxState) {
return {
_dialogStyles: ColorSchemeRegistry.get(state, 'Dialog')
};
}

View File

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

View File

@@ -0,0 +1,63 @@
import { ComponentType } from 'react';
import ReducerRegistry from '../redux/ReducerRegistry';
import { assign } from '../redux/functions';
import {
HIDE_DIALOG,
HIDE_SHEET,
OPEN_DIALOG,
OPEN_SHEET
} from './actionTypes';
export interface IDialogState {
component?: ComponentType;
componentProps?: Object;
sheet?: ComponentType;
sheetProps?: Object;
}
/**
* Reduces redux actions which show or hide dialogs.
*
* @param {IDialogState} state - The current redux state.
* @param {Action} action - The redux action to reduce.
* @param {string} action.type - The type of the redux action to reduce..
* @returns {State} The next redux state that is the result of reducing the
* specified action.
*/
ReducerRegistry.register<IDialogState>('features/base/dialog', (state = {}, action): IDialogState => {
switch (action.type) {
case HIDE_DIALOG: {
const { component } = action;
if (typeof component === 'undefined' || state.component === component) {
return assign(state, {
component: undefined,
componentProps: undefined
});
}
break;
}
case OPEN_DIALOG:
return assign(state, {
component: action.component,
componentProps: action.componentProps
});
case HIDE_SHEET:
return assign(state, {
sheet: undefined,
sheetProps: undefined
});
case OPEN_SHEET:
return assign(state, {
sheet: action.component,
sheetProps: action.componentProps
});
}
return state;
});

View File

@@ -0,0 +1,10 @@
/**
* Checks whether the chrome extensions defined in the config file are installed or not.
*
* @param {Object} _config - Objects containing info about the configured extensions.
*
* @returns {Promise[]}
*/
export default function checkChromeExtensionsInstalled(_config: any = {}) {
return Promise.resolve([]);
}

View File

@@ -0,0 +1,26 @@
/**
* Checks whether the chrome extensions defined in the config file are installed or not.
*
* @param {Object} config - Objects containing info about the configured extensions.
*
* @returns {Promise[]}
*/
export default function checkChromeExtensionsInstalled(config: any = {}) {
const isExtensionInstalled = (info: any) => new Promise(resolve => {
const img = new Image();
img.src = `chrome-extension://${info.id}/${info.path}`;
img.setAttribute('aria-hidden', 'true');
img.onload = function() {
resolve(true);
};
img.onerror = function() {
resolve(false);
};
});
const extensionInstalledFunction = (info: any) => isExtensionInstalled(info);
return Promise.all(
(config.chromeExtensionsInfo || []).map((info: any) => extensionInstalledFunction(info))
);
}

View File

@@ -0,0 +1,122 @@
import JitsiMeetJS from '../lib-jitsi-meet';
import Platform from '../react/Platform';
import { isMobileBrowser } from './utils';
const { browser } = JitsiMeetJS.util;
const DEFAULT_OPTIMAL_BROWSERS = [
'chrome',
'chromium',
'electron',
'firefox',
'safari',
'webkit'
];
const DEFAULT_UNSUPPORTED_BROWSERS: string[] = [];
const browserNameToCheck = {
chrome: browser.isChrome.bind(browser),
chromium: browser.isChromiumBased.bind(browser),
electron: browser.isElectron.bind(browser),
firefox: browser.isFirefox.bind(browser),
safari: browser.isSafari.bind(browser),
webkit: browser.isWebKitBased.bind(browser)
};
/**
* Returns whether or not jitsi is optimized and targeted for the provided
* browser name.
*
* @param {string} browserName - The name of the browser to check.
* @returns {boolean}
*/
export function isBrowsersOptimal(browserName: string) {
return (interfaceConfig.OPTIMAL_BROWSERS || DEFAULT_OPTIMAL_BROWSERS)
.includes(browserName);
}
/**
* Returns whether or not the current OS is Mac.
*
* @returns {boolean}
*/
export function isMacOS() {
return Platform.OS === 'macos';
}
/**
* Returns whether or not the current OS is Windows.
*
* @returns {boolean}
*/
export function isWindows() {
return Platform.OS === 'windows';
}
/**
* Returns whether or not the current browser or the list of passed in browsers
* is considered suboptimal. Suboptimal means it is a supported browser but has
* not been explicitly listed as being optimal, possibly due to functionality
* issues.
*
* @param {Array<string>} [browsers] - A list of browser names to check. Will
* default to a whitelist.
* @returns {boolean}
*/
export function isSuboptimalBrowser() {
const optimalBrowsers
= interfaceConfig.OPTIMAL_BROWSERS || DEFAULT_OPTIMAL_BROWSERS;
return !_isCurrentBrowserInList(optimalBrowsers) && isSupportedBrowser();
}
/**
* Returns whether or not the current browser should allow the app to display.
* A supported browser is assumed to be able to support WebRtc.
*
* @returns {boolean}
*/
export function isSupportedBrowser() {
if (navigator.product === 'ReactNative') {
return false;
}
// Blacklists apply to desktop browsers only right now.
if (!isMobileBrowser() && _isCurrentBrowserInList(
interfaceConfig.UNSUPPORTED_BROWSERS || DEFAULT_UNSUPPORTED_BROWSERS
)) {
return false;
}
return isMobileBrowser() ? isSupportedMobileBrowser() : JitsiMeetJS.isWebRtcSupported();
}
/**
* Returns whether or not the current environment is a supported
* browser on a mobile device.
*
* @returns {boolean}
*/
export function isSupportedMobileBrowser() {
return (Platform.OS === 'android' && browser.isSupportedAndroidBrowser())
|| (Platform.OS === 'ios' && browser.isSupportedIOSBrowser());
}
/**
* Runs various browser checks to know if the current browser is found within
* the list.
*
* @param {Array<string>} list - Browser names to check. The names should be
* keys in {@link browserNameToCheck}.
* @private
* @returns {boolean}
*/
function _isCurrentBrowserInList(list: string[]) {
return Boolean(list.find(browserName => {
const checkFunction = browserNameToCheck[browserName as keyof typeof browserNameToCheck];
return checkFunction ? checkFunction.call(browser) : false;
}));
}

View File

@@ -0,0 +1,32 @@
import Platform from '../react/Platform';
/**
* Returns whether or not the current environment is a mobile device.
*
* @returns {boolean}
*/
export function isMobileBrowser() {
return Platform.OS === 'android' || Platform.OS === 'ios';
}
/**
* Returns whether or not the current environment is an ios mobile device.
*
* @returns {boolean}
*/
export function isIosMobileBrowser() {
return Platform.OS === 'ios';
}
/**
* Returns whether or not the current environment is an ipad device.
*
* @returns {boolean}
*/
export function isIpadMobileBrowser() {
// @ts-ignore
return isIosMobileBrowser() && Platform.isPad;
}

View File

@@ -0,0 +1,10 @@
/**
* The type of Redux action which updates the feature flags.
*
* {
* type: UPDATE_FLAGS,
* flags: Object
* }
*
*/
export const UPDATE_FLAGS = 'UPDATE_FLAGS';

View File

@@ -0,0 +1,17 @@
import { UPDATE_FLAGS } from './actionTypes';
/**
* Updates the current features flags with the given ones. They will be merged.
*
* @param {Object} flags - The new flags object.
* @returns {{
* type: UPDATE_FLAGS,
* flags: Object
* }}
*/
export function updateFlags(flags: Object) {
return {
type: UPDATE_FLAGS,
flags
};
}

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