This commit is contained in:
11
react/.eslintrc-react-native.js
Normal file
11
react/.eslintrc-react-native.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
'react-native'
|
||||
],
|
||||
rules: {
|
||||
'react-native/no-color-literals': 2,
|
||||
'react-native/no-inline-styles': 2,
|
||||
'react-native/no-unused-styles': 2,
|
||||
'react-native/split-platform-components': 2
|
||||
}
|
||||
};
|
||||
28
react/.eslintrc.js
Normal file
28
react/.eslintrc.js
Normal file
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../.eslintrc.js',
|
||||
'@jitsi/eslint-config/jsdoc',
|
||||
'@jitsi/eslint-config/react',
|
||||
'.eslintrc-react-native.js'
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: [ '*.ts', '*.tsx' ],
|
||||
extends: [ '@jitsi/eslint-config/typescript' ],
|
||||
parserOptions: {
|
||||
project: [ './tsconfig.web.json', './tsconfig.native.json' ]
|
||||
},
|
||||
rules: {
|
||||
// TODO: Remove these and fix the warnings
|
||||
'@typescript-eslint/no-unsafe-function-type': 0,
|
||||
'@typescript-eslint/no-wrapper-object-types': 0,
|
||||
'@typescript-eslint/no-require-imports': 0
|
||||
}
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
'version': 'detect'
|
||||
}
|
||||
}
|
||||
};
|
||||
7
react/bootstrap.native.js
vendored
Normal file
7
react/bootstrap.native.js
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// https://github.com/software-mansion/react-native-gesture-handler/issues/320#issuecomment-443815828
|
||||
import 'react-native-gesture-handler';
|
||||
|
||||
// Apply all necessary polyfills as early as possible to make sure anything imported henceforth
|
||||
// sees them.
|
||||
import 'react-native-get-random-values';
|
||||
import './features/mobile/polyfills';
|
||||
281
react/features/always-on-top/AlwaysOnTop.tsx
Normal file
281
react/features/always-on-top/AlwaysOnTop.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
// We need to reference these files directly to avoid loading things that are not available
|
||||
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
|
||||
import StatelessAvatar from '../base/avatar/components/web/StatelessAvatar';
|
||||
import { getAvatarColor, getInitials } from '../base/avatar/functions';
|
||||
import { DEFAULT_ICON } from '../base/icons/svg/constants';
|
||||
|
||||
import Toolbar from './Toolbar';
|
||||
|
||||
const { api } = window.alwaysOnTop;
|
||||
|
||||
/**
|
||||
* The timeout in ms for hiding the toolbar.
|
||||
*/
|
||||
const TOOLBAR_TIMEOUT = 4000;
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link AlwaysOnTop}.
|
||||
*/
|
||||
interface IState {
|
||||
avatarURL: string;
|
||||
customAvatarBackgrounds: Array<string>;
|
||||
displayName: string;
|
||||
formattedDisplayName: string;
|
||||
isVideoDisplayed: boolean;
|
||||
userID: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the always on top page.
|
||||
*
|
||||
* @class AlwaysOnTop
|
||||
* @augments Component
|
||||
*/
|
||||
export default class AlwaysOnTop extends Component<any, IState> {
|
||||
_hovered: boolean;
|
||||
|
||||
/**
|
||||
* Initializes a new {@code AlwaysOnTop} instance.
|
||||
*
|
||||
* @param {*} props - The read-only properties with which the new instance
|
||||
* is to be initialized.
|
||||
*/
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
avatarURL: '',
|
||||
customAvatarBackgrounds: [],
|
||||
displayName: '',
|
||||
formattedDisplayName: '',
|
||||
isVideoDisplayed: true,
|
||||
userID: '',
|
||||
visible: true
|
||||
};
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._avatarChangedListener = this._avatarChangedListener.bind(this);
|
||||
this._displayNameChangedListener
|
||||
= this._displayNameChangedListener.bind(this);
|
||||
this._videoChangedListener
|
||||
= this._videoChangedListener.bind(this);
|
||||
this._mouseMove = this._mouseMove.bind(this);
|
||||
this._onMouseOut = this._onMouseOut.bind(this);
|
||||
this._onMouseOver = this._onMouseOver.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles avatar changed api events.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_avatarChangedListener({ avatarURL, id }: { avatarURL: string; id: string; }) {
|
||||
if (api._getOnStageParticipant() === id
|
||||
&& avatarURL !== this.state.avatarURL) {
|
||||
this.setState({ avatarURL });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles display name changed api events.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_displayNameChangedListener({ displayname, formattedDisplayName, id }: { displayname: string;
|
||||
formattedDisplayName: string; id: string; }) {
|
||||
if (api._getOnStageParticipant() === id
|
||||
&& (formattedDisplayName !== this.state.formattedDisplayName
|
||||
|| displayname !== this.state.displayName)) {
|
||||
// I think the API has a typo using lowercase n for the displayname
|
||||
this.setState({
|
||||
displayName: displayname,
|
||||
formattedDisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the toolbar after a timeout.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_hideToolbarAfterTimeout() {
|
||||
setTimeout(
|
||||
() => {
|
||||
if (this._hovered) {
|
||||
this._hideToolbarAfterTimeout();
|
||||
} else {
|
||||
this.setState({ visible: false });
|
||||
}
|
||||
},
|
||||
TOOLBAR_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles large video changed api events.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_videoChangedListener() {
|
||||
const userID = api._getOnStageParticipant();
|
||||
const avatarURL = api.getAvatarURL(userID);
|
||||
const displayName = api.getDisplayName(userID);
|
||||
const formattedDisplayName = api._getFormattedDisplayName(userID);
|
||||
const isVideoDisplayed = Boolean(api._getPrejoinVideo?.() || api._getLargeVideo());
|
||||
|
||||
this.setState({
|
||||
avatarURL,
|
||||
displayName,
|
||||
formattedDisplayName,
|
||||
isVideoDisplayed,
|
||||
userID
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mouse move events.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_mouseMove() {
|
||||
this.state.visible || this.setState({ visible: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar mouse out handler.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onMouseOut() {
|
||||
this._hovered = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar mouse over handler.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onMouseOver() {
|
||||
this._hovered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders display name and avatar for the on stage participant.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderVideoNotAvailableScreen() {
|
||||
const {
|
||||
avatarURL,
|
||||
customAvatarBackgrounds,
|
||||
displayName,
|
||||
formattedDisplayName,
|
||||
isVideoDisplayed
|
||||
} = this.state;
|
||||
|
||||
if (isVideoDisplayed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id = 'videoNotAvailableScreen'>
|
||||
<div id = 'avatarContainer'>
|
||||
<StatelessAvatar
|
||||
color = { getAvatarColor(displayName, customAvatarBackgrounds) }
|
||||
iconUser = { DEFAULT_ICON.IconUser }
|
||||
id = 'avatar'
|
||||
initials = { getInitials(displayName) }
|
||||
url = { avatarURL } />)
|
||||
</div>
|
||||
<div
|
||||
className = 'displayname'
|
||||
id = 'displayname'>
|
||||
{ formattedDisplayName }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets mouse move listener and initial toolbar timeout.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
api.on('avatarChanged', this._avatarChangedListener);
|
||||
api.on('displayNameChange', this._displayNameChangedListener);
|
||||
api.on('largeVideoChanged', this._videoChangedListener);
|
||||
api.on('prejoinVideoChanged', this._videoChangedListener);
|
||||
api.on('videoConferenceJoined', this._videoChangedListener);
|
||||
|
||||
this._videoChangedListener();
|
||||
|
||||
window.addEventListener('mousemove', this._mouseMove);
|
||||
|
||||
this._hideToolbarAfterTimeout();
|
||||
api.getCustomAvatarBackgrounds()
|
||||
.then((res: { avatarBackgrounds?: string[]; }) =>
|
||||
this.setState({
|
||||
customAvatarBackgrounds: res.avatarBackgrounds || []
|
||||
}))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a timeout to hide the toolbar when the toolbar is shown.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidUpdate(_prevProps: any, prevState: IState) {
|
||||
if (!prevState.visible && this.state.visible) {
|
||||
this._hideToolbarAfterTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all listeners.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentWillUnmount() {
|
||||
api.removeListener('avatarChanged', this._avatarChangedListener);
|
||||
api.removeListener(
|
||||
'displayNameChange',
|
||||
this._displayNameChangedListener);
|
||||
api.removeListener(
|
||||
'largeVideoChanged',
|
||||
this._videoChangedListener);
|
||||
api.removeListener(
|
||||
'prejoinVideoChanged',
|
||||
this._videoChangedListener);
|
||||
api.removeListener(
|
||||
'videoConferenceJoined',
|
||||
this._videoChangedListener);
|
||||
|
||||
window.removeEventListener('mousemove', this._mouseMove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<div id = 'alwaysOnTop'>
|
||||
<Toolbar
|
||||
className = { this.state.visible ? 'fadeIn' : 'fadeOut' }
|
||||
onMouseOut = { this._onMouseOut }
|
||||
onMouseOver = { this._onMouseOver } />
|
||||
{ this._renderVideoNotAvailableScreen() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
180
react/features/always-on-top/AudioMuteButton.tsx
Normal file
180
react/features/always-on-top/AudioMuteButton.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
// We need to reference these files directly to avoid loading things that are not available
|
||||
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
|
||||
|
||||
import { DEFAULT_ICON } from '../base/icons/svg/constants';
|
||||
import { IProps } from '../base/toolbox/components/AbstractButton';
|
||||
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
|
||||
const { api } = window.alwaysOnTop;
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link AudioMuteButton}.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* Whether audio is available is not.
|
||||
*/
|
||||
audioAvailable: boolean;
|
||||
|
||||
/**
|
||||
* Whether audio is muted or not.
|
||||
*/
|
||||
audioMuted: boolean;
|
||||
}
|
||||
|
||||
type Props = Partial<IProps>;
|
||||
|
||||
/**
|
||||
* Stateless "mute/unmute audio" button for the Always-on-Top windows.
|
||||
*/
|
||||
export default class AudioMuteButton extends Component<Props, IState> {
|
||||
icon = DEFAULT_ICON.IconMic;
|
||||
toggledIcon = DEFAULT_ICON.IconMicSlash;
|
||||
accessibilityLabel = 'Audio mute';
|
||||
|
||||
/**
|
||||
* Initializes a new {@code AudioMuteButton} instance.
|
||||
*
|
||||
* @param {IProps} props - The React {@code Component} props to initialize
|
||||
* the new {@code AudioMuteButton} instance with.
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
audioAvailable: false,
|
||||
audioMuted: true
|
||||
};
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._audioAvailabilityListener
|
||||
= this._audioAvailabilityListener.bind(this);
|
||||
this._audioMutedListener = this._audioMutedListener.bind(this);
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets mouse move listener and initial toolbar timeout.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
api.on('audioAvailabilityChanged', this._audioAvailabilityListener);
|
||||
api.on('audioMuteStatusChanged', this._audioMutedListener);
|
||||
|
||||
Promise.all([
|
||||
api.isAudioAvailable(),
|
||||
api.isAudioMuted(),
|
||||
api.isAudioDisabled?.() || Promise.resolve(false)
|
||||
])
|
||||
.then(([ audioAvailable, audioMuted, audioDisabled ]) =>
|
||||
this.setState({
|
||||
audioAvailable: audioAvailable && !audioDisabled,
|
||||
audioMuted
|
||||
}))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all listeners.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentWillUnmount() {
|
||||
api.removeListener(
|
||||
'audioAvailabilityChanged',
|
||||
this._audioAvailabilityListener);
|
||||
api.removeListener(
|
||||
'audioMuteStatusChanged',
|
||||
this._audioMutedListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles audio available api events.
|
||||
*
|
||||
* @param {{ available: boolean }} status - The new available status.
|
||||
* @returns {void}
|
||||
*/
|
||||
_audioAvailabilityListener({ available }: { available: boolean; }) {
|
||||
this.setState({ audioAvailable: available });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles audio muted api events.
|
||||
*
|
||||
* @param {{ muted: boolean }} status - The new muted status.
|
||||
* @returns {void}
|
||||
*/
|
||||
_audioMutedListener({ muted }: { muted: boolean; }) {
|
||||
this.setState({ audioMuted: muted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if audio is currently muted or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isAudioMuted() {
|
||||
return this.state.audioMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is disabled or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isDisabled() {
|
||||
return !this.state.audioAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the muted state.
|
||||
*
|
||||
* @override
|
||||
* @param {boolean} _audioMuted - Whether audio should be muted or not.
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_setAudioMuted(_audioMuted: boolean) {
|
||||
this.state.audioAvailable && api.executeCommand('toggleAudio');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and toggles the audio mute state
|
||||
* accordingly.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
this._setAudioMuted(!this._isAudioMuted());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const toggled = this._isAudioMuted();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
accessibilityLabel = { this.accessibilityLabel }
|
||||
disabled = { this._isDisabled() }
|
||||
icon = { toggled ? this.toggledIcon : this.icon }
|
||||
onClick = { this._onClick }
|
||||
toggled = { toggled } />
|
||||
);
|
||||
}
|
||||
}
|
||||
60
react/features/always-on-top/HangupButton.tsx
Normal file
60
react/features/always-on-top/HangupButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
// We need to reference these files directly to avoid loading things that are not available
|
||||
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
|
||||
import { DEFAULT_ICON } from '../base/icons/svg/constants';
|
||||
import { IProps } from '../base/toolbox/components/AbstractButton';
|
||||
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
|
||||
const { api } = window.alwaysOnTop;
|
||||
|
||||
type Props = Partial<IProps>;
|
||||
|
||||
/**
|
||||
* Stateless hangup button for the Always-on-Top windows.
|
||||
*/
|
||||
export default class HangupButton extends Component<Props> {
|
||||
|
||||
accessibilityLabel = 'Hangup';
|
||||
icon = DEFAULT_ICON.IconHangup;
|
||||
|
||||
/**
|
||||
* Initializes a new {@code HangupButton} instance.
|
||||
*
|
||||
* @param {IProps} props - The React {@code Component} props to initialize
|
||||
* the new {@code HangupButton} instance with.
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and disconnects the conference.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
api.executeCommand('hangup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<ToolbarButton
|
||||
accessibilityLabel = { this.accessibilityLabel }
|
||||
customClass = 'hangup-button'
|
||||
icon = { this.icon }
|
||||
onClick = { this._onClick } />
|
||||
);
|
||||
}
|
||||
}
|
||||
137
react/features/always-on-top/Toolbar.tsx
Normal file
137
react/features/always-on-top/Toolbar.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import AudioMuteButton from './AudioMuteButton';
|
||||
import HangupButton from './HangupButton';
|
||||
import VideoMuteButton from './VideoMuteButton';
|
||||
|
||||
const { api } = window.alwaysOnTop;
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link Toolbar}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Additional CSS class names to add to the root of the toolbar.
|
||||
*/
|
||||
className: string;
|
||||
|
||||
/**
|
||||
* Callback invoked when no longer moused over the toolbar.
|
||||
*/
|
||||
onMouseOut: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback invoked when the mouse has moved over the toolbar.
|
||||
*/
|
||||
onMouseOver: (e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link Toolbar}.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* Whether audio button to be shown or not.
|
||||
*/
|
||||
showAudioButton: boolean;
|
||||
|
||||
/**
|
||||
* Whether video button to be shown or not.
|
||||
*/
|
||||
showVideoButton: boolean;
|
||||
}
|
||||
|
||||
type Props = Partial<IProps>;
|
||||
|
||||
/**
|
||||
* Represents the toolbar in the Always On Top window.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
export default class Toolbar extends Component<Props, IState> {
|
||||
/**
|
||||
* Initializes a new {@code Toolbar} instance.
|
||||
*
|
||||
* @param {IProps} props - The React {@code Component} props to initialize the new {@code Toolbar} instance with.
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showAudioButton: true,
|
||||
showVideoButton: true
|
||||
};
|
||||
|
||||
this._videoConferenceJoinedListener = this._videoConferenceJoinedListener.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets listens for changing meetings while showing the toolbar.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
api.on('videoConferenceJoined', this._videoConferenceJoinedListener);
|
||||
|
||||
this._videoConferenceJoinedListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles is visitor changes.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_videoConferenceJoinedListener() {
|
||||
// for electron clients that embed the api and are not updated
|
||||
if (!api.isVisitor) {
|
||||
console.warn('external API not updated');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isNotVisitor = !api.isVisitor();
|
||||
|
||||
this.setState({
|
||||
showAudioButton: isNotVisitor,
|
||||
showVideoButton: isNotVisitor
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all listeners.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentWillUnmount() {
|
||||
api.removeListener('videoConferenceJoined', this._videoConferenceJoinedListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
className = '',
|
||||
onMouseOut,
|
||||
onMouseOver
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { `toolbox-content-items always-on-top-toolbox ${className}` }
|
||||
onMouseOut = { onMouseOut }
|
||||
onMouseOver = { onMouseOver }>
|
||||
{ this.state.showAudioButton && <AudioMuteButton /> }
|
||||
{ this.state.showVideoButton && <VideoMuteButton /> }
|
||||
<HangupButton customClass = 'hangup-button' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
69
react/features/always-on-top/ToolbarButton.tsx
Normal file
69
react/features/always-on-top/ToolbarButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import Icon from '../base/icons/components/Icon';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Accessibility label for button.
|
||||
*/
|
||||
accessibilityLabel: string;
|
||||
|
||||
/**
|
||||
* An extra class name to be added at the end of the element's class name
|
||||
* in order to enable custom styling.
|
||||
*/
|
||||
customClass?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the button is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Button icon.
|
||||
*/
|
||||
icon: Function;
|
||||
|
||||
/**
|
||||
* Click handler.
|
||||
*/
|
||||
onClick: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Whether or not the button is toggled.
|
||||
*/
|
||||
toggled?: boolean;
|
||||
}
|
||||
|
||||
const ToolbarButton = ({
|
||||
accessibilityLabel,
|
||||
customClass,
|
||||
disabled = false,
|
||||
onClick,
|
||||
icon,
|
||||
toggled = false
|
||||
}: IProps) => {
|
||||
const onKeyPress = useCallback(event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}, [ onClick ]);
|
||||
|
||||
return (<div
|
||||
aria-disabled = { disabled }
|
||||
aria-label = { accessibilityLabel }
|
||||
aria-pressed = { toggled }
|
||||
className = { `toolbox-button ${disabled ? ' disabled' : ''}` }
|
||||
onClick = { disabled ? undefined : onClick }
|
||||
onKeyPress = { disabled ? undefined : onKeyPress }
|
||||
role = 'button'
|
||||
tabIndex = { 0 }>
|
||||
<div className = { `toolbox-icon ${disabled ? 'disabled' : ''} ${customClass ?? ''}` }>
|
||||
<Icon src = { icon } />
|
||||
</div>
|
||||
</div>);
|
||||
};
|
||||
|
||||
export default ToolbarButton;
|
||||
180
react/features/always-on-top/VideoMuteButton.tsx
Normal file
180
react/features/always-on-top/VideoMuteButton.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
// We need to reference these files directly to avoid loading things that are not available
|
||||
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
|
||||
import { DEFAULT_ICON } from '../base/icons/svg/constants';
|
||||
import { IProps } from '../base/toolbox/components/AbstractButton';
|
||||
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
|
||||
const { api } = window.alwaysOnTop;
|
||||
|
||||
type Props = Partial<IProps>;
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link VideoMuteButton}.
|
||||
*/
|
||||
type State = {
|
||||
|
||||
/**
|
||||
* Whether video is available is not.
|
||||
*/
|
||||
videoAvailable: boolean;
|
||||
|
||||
/**
|
||||
* Whether video is muted or not.
|
||||
*/
|
||||
videoMuted: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stateless "mute/unmute video" button for the Always-on-Top windows.
|
||||
*/
|
||||
export default class VideoMuteButton extends Component<Props, State> {
|
||||
|
||||
icon = DEFAULT_ICON.IconVideo;
|
||||
toggledIcon = DEFAULT_ICON.IconVideoOff;
|
||||
accessibilityLabel = 'Video mute';
|
||||
|
||||
/**
|
||||
* Initializes a new {@code VideoMuteButton} instance.
|
||||
*
|
||||
* @param {Props} props - The React {@code Component} props to initialize
|
||||
* the new {@code VideoMuteButton} instance with.
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
videoAvailable: false,
|
||||
videoMuted: true
|
||||
};
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._videoAvailabilityListener
|
||||
= this._videoAvailabilityListener.bind(this);
|
||||
this._videoMutedListener = this._videoMutedListener.bind(this);
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets mouse move listener and initial toolbar timeout.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
api.on('videoAvailabilityChanged', this._videoAvailabilityListener);
|
||||
api.on('videoMuteStatusChanged', this._videoMutedListener);
|
||||
|
||||
Promise.all([
|
||||
api.isVideoAvailable(),
|
||||
api.isVideoMuted()
|
||||
])
|
||||
.then(([ videoAvailable, videoMuted ]) =>
|
||||
this.setState({
|
||||
videoAvailable,
|
||||
videoMuted
|
||||
}))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all listeners.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentWillUnmount() {
|
||||
api.removeListener(
|
||||
'videoAvailabilityChanged',
|
||||
this._videoAvailabilityListener);
|
||||
api.removeListener(
|
||||
'videoMuteStatusChanged',
|
||||
this._videoMutedListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is disabled or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isDisabled() {
|
||||
return !this.state.videoAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if video is currently muted or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isVideoMuted() {
|
||||
return this.state.videoMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the muted state.
|
||||
*
|
||||
* @override
|
||||
* @param {boolean} _videoMuted - Whether video should be muted or not.
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_setVideoMuted(_videoMuted: boolean) {
|
||||
this.state.videoAvailable && api.executeCommand('toggleVideo', false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles video available api events.
|
||||
*
|
||||
* @param {{ available: boolean }} status - The new available status.
|
||||
* @returns {void}
|
||||
*/
|
||||
_videoAvailabilityListener({ available }: { available: boolean; }) {
|
||||
this.setState({ videoAvailable: available });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles video muted api events.
|
||||
*
|
||||
* @param {{ muted: boolean }} status - The new muted status.
|
||||
* @returns {void}
|
||||
*/
|
||||
_videoMutedListener({ muted }: { muted: boolean; }) {
|
||||
this.setState({ videoMuted: muted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and toggles the video mute state
|
||||
* accordingly.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
this._setVideoMuted(!this._isVideoMuted());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const toggled = this._isVideoMuted();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
accessibilityLabel = { this.accessibilityLabel }
|
||||
disabled = { this._isDisabled() }
|
||||
icon = { toggled ? this.toggledIcon : this.icon }
|
||||
onClick = { this._onClick }
|
||||
toggled = { toggled } />
|
||||
);
|
||||
}
|
||||
}
|
||||
13
react/features/always-on-top/index.tsx
Normal file
13
react/features/always-on-top/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import AlwaysOnTop from './AlwaysOnTop';
|
||||
|
||||
// Render the main/root Component.
|
||||
/* eslint-disable-next-line react/no-deprecated */
|
||||
ReactDOM.render(<AlwaysOnTop />, document.getElementById('react'));
|
||||
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
/* eslint-disable-next-line react/no-deprecated */
|
||||
() => ReactDOM.unmountComponentAtNode(document.getElementById('react') ?? document.body));
|
||||
983
react/features/analytics/AnalyticsEvents.ts
Normal file
983
react/features/analytics/AnalyticsEvents.ts
Normal file
@@ -0,0 +1,983 @@
|
||||
/**
|
||||
* The constant for the event type 'track'.
|
||||
* TODO: keep these constants in a single place. Can we import them from
|
||||
* lib-jitsi-meet's AnalyticsEvents somehow?
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const TYPE_TRACK = 'track';
|
||||
|
||||
/**
|
||||
* The constant for the event type 'UI' (User Interaction).
|
||||
* TODO: keep these constants in a single place. Can we import them from
|
||||
* lib-jitsi-meet's AnalyticsEvents somehow?
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const TYPE_UI = 'ui';
|
||||
|
||||
/**
|
||||
* The identifier for the "pinned" action. The local participant has pinned a
|
||||
* participant to remain on large video.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
export const ACTION_PINNED = 'pinned';
|
||||
|
||||
/**
|
||||
* The identifier for the "unpinned" action. The local participant has unpinned
|
||||
* a participant so the participant doesn't remain permanently on local large
|
||||
* video.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
export const ACTION_UNPINNED = 'unpinned';
|
||||
|
||||
/**
|
||||
* The identifier for the "pressed" action for shortcut events. This action
|
||||
* means that a button was pressed (and not yet released).
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
export const ACTION_SHORTCUT_PRESSED = 'pressed';
|
||||
|
||||
/**
|
||||
* The identifier for the "released" action for shortcut events. This action
|
||||
* means that a button which was previously pressed was released.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
export const ACTION_SHORTCUT_RELEASED = 'released';
|
||||
|
||||
/**
|
||||
* The identifier for the "triggered" action for shortcut events. This action
|
||||
* means that a button was pressed, and we don't care about whether it was
|
||||
* released or will be released in the future.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
export const ACTION_SHORTCUT_TRIGGERED = 'triggered';
|
||||
|
||||
/**
|
||||
* The name of the keyboard shortcut or toolbar button for muting audio.
|
||||
*/
|
||||
export const AUDIO_MUTE = 'audio.mute';
|
||||
|
||||
/**
|
||||
* The name of the keyboard shortcut or toolbar button for muting desktop sharing.
|
||||
*/
|
||||
export const DESKTOP_MUTE = 'desktop.mute';
|
||||
|
||||
/**
|
||||
* The name of the keyboard shortcut or toolbar button for muting video.
|
||||
*/
|
||||
export const VIDEO_MUTE = 'video.mute';
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that a certain action was requested through
|
||||
* the jitsi-meet API.
|
||||
*
|
||||
* @param {string} action - The action which was requested through the
|
||||
* jitsi-meet API.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createApiEvent(action: string, attributes = {}) {
|
||||
return {
|
||||
action,
|
||||
attributes,
|
||||
source: 'jitsi-meet-api'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that the audio-only mode has been changed.
|
||||
*
|
||||
* @param {boolean} enabled - True if audio-only is enabled, false otherwise.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createAudioOnlyChangedEvent(enabled: boolean) {
|
||||
return {
|
||||
action: `audio.only.${enabled ? 'enabled' : 'disabled'}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event for about the JitsiConnection.
|
||||
*
|
||||
* @param {string} action - The action that the event represents.
|
||||
* @param {boolean} attributes - Additional attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createConnectionEvent(action: string, attributes = {}) {
|
||||
return {
|
||||
action,
|
||||
actionSubject: 'connection',
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates an action occurred in the calendar
|
||||
* integration UI.
|
||||
*
|
||||
* @param {string} eventName - The name of the calendar UI event.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createCalendarClickedEvent(eventName: string, attributes = {}) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: eventName,
|
||||
attributes,
|
||||
source: 'calendar',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that the calendar container is shown and
|
||||
* selected.
|
||||
*
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createCalendarSelectedEvent(attributes = {}) {
|
||||
return {
|
||||
action: 'selected',
|
||||
attributes,
|
||||
source: 'calendar',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event indicating that a calendar has been connected.
|
||||
*
|
||||
* @param {boolean} attributes - Additional attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createCalendarConnectedEvent(attributes = {}) {
|
||||
return {
|
||||
action: 'connected',
|
||||
actionSubject: 'calendar',
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates an action occurred in the recent list
|
||||
* integration UI.
|
||||
*
|
||||
* @param {string} eventName - The name of the recent list UI event.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createRecentClickedEvent(eventName: string, attributes = {}) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: eventName,
|
||||
attributes,
|
||||
source: 'recent.list',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicate an action occurred in the chrome extension banner.
|
||||
*
|
||||
* @param {boolean} installPressed - Whether the user pressed install or `x` - cancel.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createChromeExtensionBannerEvent(installPressed: boolean, attributes = {}) {
|
||||
return {
|
||||
action: installPressed ? 'install' : 'cancel',
|
||||
attributes,
|
||||
source: 'chrome.extension.banner',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that the recent list container is shown and
|
||||
* selected.
|
||||
*
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createRecentSelectedEvent(attributes = {}) {
|
||||
return {
|
||||
action: 'selected',
|
||||
attributes,
|
||||
source: 'recent.list',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event for an action on the deep linking page.
|
||||
*
|
||||
* @param {string} action - The action that the event represents.
|
||||
* @param {string} actionSubject - The subject that was acted upon.
|
||||
* @param {boolean} attributes - Additional attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createDeepLinkingPageEvent(
|
||||
action: string, actionSubject: string, attributes = {}) {
|
||||
return {
|
||||
action,
|
||||
actionSubject,
|
||||
source: 'deepLinkingPage',
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that a device was changed.
|
||||
*
|
||||
* @param {string} mediaType - The media type of the device ('audio' or
|
||||
* 'video').
|
||||
* @param {string} deviceType - The type of the device ('input' or 'output').
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createDeviceChangedEvent(mediaType: string, deviceType: string) {
|
||||
return {
|
||||
action: 'device.changed',
|
||||
attributes: {
|
||||
'device_type': deviceType,
|
||||
'media_type': mediaType
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event indicating that an action related to E2EE occurred.
|
||||
*
|
||||
* @param {string} action - The action which occurred.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createE2EEEvent(action: string) {
|
||||
return {
|
||||
action,
|
||||
actionSubject: 'e2ee'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which specifies that the feedback dialog has been opened.
|
||||
*
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createFeedbackOpenEvent() {
|
||||
return {
|
||||
action: 'feedback.opened'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event for an action regarding the AddPeopleDialog (invites).
|
||||
*
|
||||
* @param {string} action - The action that the event represents.
|
||||
* @param {string} actionSubject - The subject that was acted upon.
|
||||
* @param {boolean} attributes - Additional attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createInviteDialogEvent(
|
||||
action: string, actionSubject: string, attributes = {}) {
|
||||
return {
|
||||
action,
|
||||
actionSubject,
|
||||
attributes,
|
||||
source: 'inviteDialog'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which reports about the current network information reported by the operating system.
|
||||
*
|
||||
* @param {boolean} isOnline - Tells whether or not the internet is reachable.
|
||||
* @param {string} [networkType] - Network type, see {@code NetworkInfo} type defined by the 'base/net-info' feature.
|
||||
* @param {Object} [details] - Extra info, see {@code NetworkInfo} type defined by the 'base/net-info' feature.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createNetworkInfoEvent({ isOnline, networkType, details }:
|
||||
{ details?: Object; isOnline: boolean; networkType?: string; }) {
|
||||
const attributes: {
|
||||
details?: Object;
|
||||
isOnline: boolean;
|
||||
networkType?: string;
|
||||
} = { isOnline };
|
||||
|
||||
// Do no include optional stuff or Amplitude handler will log warnings.
|
||||
networkType && (attributes.networkType = networkType);
|
||||
details && (attributes.details = details);
|
||||
|
||||
return {
|
||||
action: 'network.info',
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a "not allowed error" event.
|
||||
*
|
||||
* @param {string} type - The type of the error.
|
||||
* @param {string} reason - The reason for the error.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createNotAllowedErrorEvent(type: string, reason: string) {
|
||||
return {
|
||||
action: 'not.allowed.error',
|
||||
attributes: {
|
||||
reason,
|
||||
type
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an "offer/answer failure" event.
|
||||
*
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createOfferAnswerFailedEvent() {
|
||||
return {
|
||||
action: 'offer.answer.failure'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a "page reload" event.
|
||||
*
|
||||
* @param {string} reason - The reason for the reload.
|
||||
* @param {number} timeout - The timeout in seconds after which the page is
|
||||
* scheduled to reload.
|
||||
* @param {Object} details - The details for the error.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createPageReloadScheduledEvent(reason: string, timeout: number, details: Object = {}) {
|
||||
return {
|
||||
action: 'page.reload.scheduled',
|
||||
attributes: {
|
||||
reason,
|
||||
timeout,
|
||||
...details
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a "pinned" or "unpinned" event.
|
||||
*
|
||||
* @param {string} action - The action ("pinned" or "unpinned").
|
||||
* @param {string} participantId - The ID of the participant which was pinned.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createPinnedEvent(action: string, participantId: string, attributes = {}) {
|
||||
return {
|
||||
type: TYPE_TRACK,
|
||||
action,
|
||||
actionSubject: 'participant',
|
||||
objectType: 'participant',
|
||||
objectId: participantId,
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a poll event.
|
||||
* The following events will be created:
|
||||
* - poll.created
|
||||
* - poll.vote.checked
|
||||
* - poll.vote.sent
|
||||
* - poll.vote.skipped
|
||||
* - poll.vote.detailsViewed
|
||||
* - poll.vote.changed
|
||||
* - poll.option.added
|
||||
* - poll.option.moved
|
||||
* - poll.option.removed.
|
||||
*
|
||||
* @param {string} action - The action.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createPollEvent(action: string) {
|
||||
return {
|
||||
action: `poll.${action}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that a button in the profile panel was
|
||||
* clicked.
|
||||
*
|
||||
* @param {string} buttonName - The name of the button.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createProfilePanelButtonEvent(buttonName: string, attributes = {}) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: buttonName,
|
||||
attributes,
|
||||
source: 'profile.panel',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that a specific button on one of the
|
||||
* recording-related dialogs was clicked.
|
||||
*
|
||||
* @param {string} dialogName - The name of the dialog (e.g. 'start' or 'stop').
|
||||
* @param {string} buttonName - The name of the button (e.g. 'confirm' or
|
||||
* 'cancel').
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createRecordingDialogEvent(
|
||||
dialogName: string, buttonName: string, attributes = {}) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: buttonName,
|
||||
attributes,
|
||||
source: `${dialogName}.recording.dialog`,
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that a specific button on one of the
|
||||
* liveStreaming-related dialogs was clicked.
|
||||
*
|
||||
* @param {string} dialogName - The name of the dialog (e.g. 'start' or 'stop').
|
||||
* @param {string} buttonName - The name of the button (e.g. 'confirm' or
|
||||
* 'cancel').
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createLiveStreamingDialogEvent(dialogName: string, buttonName: string) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: buttonName,
|
||||
source: `${dialogName}.liveStreaming.dialog`,
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event with the local tracks duration.
|
||||
*
|
||||
* @param {Object} duration - The object with the duration of the local tracks.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createLocalTracksDurationEvent(duration: {
|
||||
audio: { value: number; };
|
||||
conference: { value: number; };
|
||||
video: {
|
||||
camera: { value: number; };
|
||||
desktop: { value: number; };
|
||||
};
|
||||
}) {
|
||||
const { audio, video, conference } = duration;
|
||||
const { camera, desktop } = video;
|
||||
|
||||
return {
|
||||
action: 'local.tracks.durations',
|
||||
attributes: {
|
||||
audio: audio.value,
|
||||
camera: camera.value,
|
||||
conference: conference.value,
|
||||
desktop: desktop.value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that an action related to recording has
|
||||
* occurred.
|
||||
*
|
||||
* @param {string} action - The action (e.g. 'start' or 'stop').
|
||||
* @param {string} type - The recording type (e.g. 'file' or 'live').
|
||||
* @param {number} value - The duration of the recording in seconds (for stop
|
||||
* action).
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createRecordingEvent(action: string, type: string, value?: number) {
|
||||
return {
|
||||
action,
|
||||
actionSubject: `recording.${type}`,
|
||||
attributes: {
|
||||
value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that the same conference has been rejoined.
|
||||
*
|
||||
* @param {string} url - The full conference URL.
|
||||
* @param {number} lastConferenceDuration - How many seconds user stayed in the previous conference.
|
||||
* @param {number} timeSinceLeft - How many seconds since the last conference was left.
|
||||
* @returns {Object} The event in a format suitable for sending via sendAnalytics.
|
||||
*/
|
||||
export function createRejoinedEvent({ url, lastConferenceDuration, timeSinceLeft }: {
|
||||
lastConferenceDuration: number;
|
||||
timeSinceLeft: number;
|
||||
url: string;
|
||||
}) {
|
||||
return {
|
||||
action: 'rejoined',
|
||||
attributes: {
|
||||
lastConferenceDuration,
|
||||
timeSinceLeft,
|
||||
url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which specifies that the "confirm" button on the remote
|
||||
* mute dialog has been clicked.
|
||||
*
|
||||
* @param {string} participantId - The ID of the participant that was remotely
|
||||
* muted.
|
||||
* @param {string} mediaType - The media type of the channel to mute.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createRemoteMuteConfirmedEvent(participantId: string, mediaType: string) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
attributes: {
|
||||
'participant_id': participantId,
|
||||
'media_type': mediaType
|
||||
},
|
||||
source: 'remote.mute.button',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that one of the buttons in the "remote
|
||||
* video menu" was clicked.
|
||||
*
|
||||
* @param {string} buttonName - The name of the button.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createRemoteVideoMenuButtonEvent(buttonName: string, attributes = {}) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: buttonName,
|
||||
attributes,
|
||||
source: 'remote.video.menu',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rtcstats websocket onclose event. We send this to amplitude in order
|
||||
* to detect trace ws prematurely closing.
|
||||
*
|
||||
* @param {Object} closeEvent - The event with which the websocket closed.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createRTCStatsTraceCloseEvent(closeEvent: { code: string; reason: string; }) {
|
||||
const event: {
|
||||
action: string;
|
||||
code?: string;
|
||||
reason?: string;
|
||||
source: string;
|
||||
} = {
|
||||
action: 'trace.onclose',
|
||||
source: 'rtcstats'
|
||||
};
|
||||
|
||||
event.code = closeEvent.code;
|
||||
event.reason = closeEvent.reason;
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event indicating that an action related to screen sharing
|
||||
* occurred (e.g. It was started or stopped).
|
||||
*
|
||||
* @param {string} action - The action which occurred.
|
||||
* @param {number?} value - The screenshare duration in seconds.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createScreenSharingEvent(action: string, value = null) {
|
||||
return {
|
||||
action,
|
||||
actionSubject: 'screen.sharing',
|
||||
attributes: {
|
||||
value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates the screen sharing video is not displayed when it needs to be displayed.
|
||||
*
|
||||
* @param {Object} attributes - Additional information that describes the issue.
|
||||
* @returns {Object} The event in a format suitable for sending via sendAnalytics.
|
||||
*/
|
||||
export function createScreenSharingIssueEvent(attributes = {}) {
|
||||
return {
|
||||
action: 'screen.sharing.issue',
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event associated with the "shared video" feature.
|
||||
*
|
||||
* @param {string} action - The action that the event represents.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createSharedVideoEvent(action: string, attributes = {}) {
|
||||
return {
|
||||
action,
|
||||
attributes,
|
||||
actionSubject: 'shared.video'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event associated with a shortcut being pressed, released or
|
||||
* triggered. By convention, where appropriate an attribute named 'enable'
|
||||
* should be used to indicate the action which resulted by the shortcut being
|
||||
* pressed (e.g. Whether screen sharing was enabled or disabled).
|
||||
*
|
||||
* @param {string} shortcut - The identifier of the shortcut which produced
|
||||
* an action.
|
||||
* @param {string} action - The action that the event represents (one
|
||||
* of ACTION_SHORTCUT_PRESSED, ACTION_SHORTCUT_RELEASED
|
||||
* or ACTION_SHORTCUT_TRIGGERED).
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @param {string} source - The event's source.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createShortcutEvent(
|
||||
shortcut: string,
|
||||
action = ACTION_SHORTCUT_TRIGGERED,
|
||||
attributes = {},
|
||||
source = 'keyboard.shortcut') {
|
||||
return {
|
||||
action,
|
||||
actionSubjectId: shortcut,
|
||||
attributes,
|
||||
source,
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates the "start audio only" configuration.
|
||||
*
|
||||
* @param {boolean} audioOnly - Whether "start audio only" is enabled or not.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createStartAudioOnlyEvent(audioOnly: boolean) {
|
||||
return {
|
||||
action: 'start.audio.only',
|
||||
attributes: {
|
||||
enabled: audioOnly
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates the "start silent" configuration.
|
||||
*
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createStartSilentEvent() {
|
||||
return {
|
||||
action: 'start.silent'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that HTMLAudioElement.play has failed.
|
||||
*
|
||||
* @param {string} elementID - The ID of the HTMLAudioElement.
|
||||
* @returns {Object} The event in a format suitable for sending via sendAnalytics.
|
||||
*/
|
||||
export function createAudioPlayErrorEvent(elementID: string) {
|
||||
return {
|
||||
action: 'audio.play.error',
|
||||
attributes: {
|
||||
elementID
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that HTMLAudioElement.play has succeeded after a prior failure.
|
||||
*
|
||||
* @param {string} elementID - The ID of the HTMLAudioElement.
|
||||
* @returns {Object} The event in a format suitable for sending via sendAnalytics.
|
||||
*/
|
||||
export function createAudioPlaySuccessEvent(elementID: string) {
|
||||
return {
|
||||
action: 'audio.play.success',
|
||||
attributes: {
|
||||
elementID
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates the "start muted" configuration.
|
||||
*
|
||||
* @param {string} source - The source of the configuration, 'local' or
|
||||
* 'remote' depending on whether it comes from the static configuration (i.e.
|
||||
* {@code config.js}) or comes dynamically from Jicofo.
|
||||
* @param {boolean} audioMute - Whether the configuration requests that audio
|
||||
* is muted.
|
||||
* @param {boolean} videoMute - Whether the configuration requests that video
|
||||
* is muted.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createStartMutedConfigurationEvent(
|
||||
source: string,
|
||||
audioMute: boolean,
|
||||
videoMute: boolean) {
|
||||
return {
|
||||
action: 'start.muted.configuration',
|
||||
attributes: {
|
||||
source,
|
||||
'audio_mute': audioMute,
|
||||
'video_mute': videoMute
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically changing the mute state of a media track in order to match
|
||||
* the current stored state in redux.
|
||||
*
|
||||
* @param {string} mediaType - The track's media type ('audio' or 'video').
|
||||
* @param {boolean} muted - Whether the track is being muted or unmuted as
|
||||
* as result of the sync operation.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createSyncTrackStateEvent(mediaType: string, muted: boolean) {
|
||||
return {
|
||||
action: 'sync.track.state',
|
||||
attributes: {
|
||||
'media_type': mediaType,
|
||||
muted
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event associated with a toolbar button being clicked/pressed. By
|
||||
* convention, where appropriate an attribute named 'enable' should be used to
|
||||
* indicate the action which resulted by the shortcut being pressed (e.g.
|
||||
* Whether screen sharing was enabled or disabled).
|
||||
*
|
||||
* @param {string} buttonName - The identifier of the toolbar button which was
|
||||
* clicked/pressed.
|
||||
* @param {Object} attributes - Attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createToolbarEvent(buttonName: string, attributes = {}) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: buttonName,
|
||||
attributes,
|
||||
source: 'toolbar.button',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event associated with a reaction button being clicked/pressed.
|
||||
*
|
||||
* @param {string} buttonName - The identifier of the reaction button which was
|
||||
* clicked/pressed.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createReactionMenuEvent(buttonName: string) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: 'button',
|
||||
source: 'reaction',
|
||||
buttonName,
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event associated with disabling of reaction sounds.
|
||||
*
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createReactionSoundsDisabledEvent() {
|
||||
return {
|
||||
action: 'disabled',
|
||||
actionSubject: 'sounds',
|
||||
source: 'reaction.settings',
|
||||
type: TYPE_UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates that a local track was muted.
|
||||
*
|
||||
* @param {string} mediaType - The track's media type ('audio' or 'video').
|
||||
* @param {string} reason - The reason the track was muted (e.g. It was
|
||||
* triggered by the "initial mute" option, or a previously muted track was
|
||||
* replaced (e.g. When a new device was used)).
|
||||
* @param {boolean} muted - Whether the track was muted or unmuted.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createTrackMutedEvent(mediaType: string, reason: string, muted = true) {
|
||||
return {
|
||||
action: 'track.muted',
|
||||
attributes: {
|
||||
'media_type': mediaType,
|
||||
muted,
|
||||
reason
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event for joining a vpaas conference.
|
||||
*
|
||||
* @param {string} tenant - The conference tenant.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createVpaasConferenceJoinedEvent(tenant: string) {
|
||||
return {
|
||||
action: 'vpaas.conference.joined',
|
||||
attributes: {
|
||||
tenant
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event for an action on the welcome page.
|
||||
*
|
||||
* @param {string} action - The action that the event represents.
|
||||
* @param {string} actionSubject - The subject that was acted upon.
|
||||
* @param {boolean} attributes - Additional attributes to attach to the event.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createWelcomePageEvent(action: string, actionSubject?: string, attributes = {}) {
|
||||
return {
|
||||
action,
|
||||
actionSubject,
|
||||
attributes,
|
||||
source: 'welcomePage'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates a screenshot of the screensharing has been taken.
|
||||
*
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createScreensharingCaptureTakenEvent() {
|
||||
return {
|
||||
action: 'screen.sharing.capture.taken'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event for an action on breakout rooms.
|
||||
*
|
||||
* @param {string} actionSubject - The subject that was acted upon.
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createBreakoutRoomsEvent(actionSubject: string) {
|
||||
return {
|
||||
action: 'clicked',
|
||||
actionSubject: `${actionSubject}.button`,
|
||||
source: 'breakout.rooms'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates a GIF was sent.
|
||||
*
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createGifSentEvent() {
|
||||
return {
|
||||
action: 'gif.sent'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates the whiteboard was opened.
|
||||
*
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createOpenWhiteboardEvent() {
|
||||
return {
|
||||
action: 'whiteboard.open'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event which indicates the whiteboard limit was enforced.
|
||||
*
|
||||
* @returns {Object} The event in a format suitable for sending via
|
||||
* sendAnalytics.
|
||||
*/
|
||||
export function createRestrictWhiteboardEvent() {
|
||||
return {
|
||||
action: 'whiteboard.restrict'
|
||||
};
|
||||
}
|
||||
29
react/features/analytics/actionTypes.ts
Normal file
29
react/features/analytics/actionTypes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The type of (redux) action which signals that local media duration has changed.
|
||||
*
|
||||
* {
|
||||
* type: UPDATE_LOCAL_TRACKS_DURATION,
|
||||
* localTracksDuration: Object
|
||||
* }
|
||||
*/
|
||||
export const UPDATE_LOCAL_TRACKS_DURATION = 'UPDATE_LOCAL_TRACKS_DURATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the isInitialized redux prop.
|
||||
*
|
||||
* {
|
||||
* type: SET_INITIALIZED,
|
||||
* value: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_INITIALIZED = 'SET_INITIALIZED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which updates the initial permanent properties.
|
||||
*
|
||||
* {
|
||||
* type: SET_INITIAL_PERMANENT_PROPERTIES,
|
||||
* properties: Object
|
||||
* }
|
||||
*/
|
||||
export const SET_INITIAL_PERMANENT_PROPERTIES = 'SET_INITIAL_PERMANENT_PROPERTIES';
|
||||
25
react/features/analytics/actions.ts
Normal file
25
react/features/analytics/actions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { analytics } from '../base/lib-jitsi-meet';
|
||||
|
||||
import { SET_INITIAL_PERMANENT_PROPERTIES } from './actionTypes';
|
||||
|
||||
/**
|
||||
* Updates a permanentProperty.
|
||||
*
|
||||
* @param {Object} properties - An object with properties to be updated.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function setPermanentProperty(properties: Object) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const { isInitialized = false } = getState()['features/analytics'];
|
||||
|
||||
if (isInitialized) {
|
||||
analytics.addPermanentProperties(properties);
|
||||
} else {
|
||||
dispatch({
|
||||
type: SET_INITIAL_PERMANENT_PROPERTIES,
|
||||
properties
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
339
react/features/analytics/functions.ts
Normal file
339
react/features/analytics/functions.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// @ts-expect-error
|
||||
import { API_ID } from '../../../modules/API/constants';
|
||||
import { getName as getAppName } from '../app/functions';
|
||||
import { IStore } from '../app/types';
|
||||
import { getAnalyticsRoomName } from '../base/conference/functions';
|
||||
import checkChromeExtensionsInstalled from '../base/environment/checkChromeExtensionsInstalled';
|
||||
import {
|
||||
isMobileBrowser
|
||||
} from '../base/environment/utils';
|
||||
import JitsiMeetJS, {
|
||||
analytics,
|
||||
browser
|
||||
} from '../base/lib-jitsi-meet';
|
||||
import { isAnalyticsEnabled } from '../base/lib-jitsi-meet/functions.any';
|
||||
import { isEmbedded } from '../base/util/embedUtils';
|
||||
import { getJitsiMeetGlobalNS } from '../base/util/helpers';
|
||||
import { loadScript } from '../base/util/loadScript';
|
||||
import { parseURLParams } from '../base/util/parseURLParams';
|
||||
import { parseURIString } from '../base/util/uri';
|
||||
import { isPrejoinPageVisible } from '../prejoin/functions';
|
||||
|
||||
import AmplitudeHandler from './handlers/AmplitudeHandler';
|
||||
import MatomoHandler from './handlers/MatomoHandler';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Sends an event through the lib-jitsi-meet AnalyticsAdapter interface.
|
||||
*
|
||||
* @param {Object} event - The event to send. It should be formatted as
|
||||
* described in AnalyticsAdapter.js in lib-jitsi-meet.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function sendAnalytics(event: Object) {
|
||||
try {
|
||||
analytics.sendEvent(event);
|
||||
} catch (e) {
|
||||
logger.warn(`Error sending analytics event: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return saved amplitude identity info such as session id, device id and user id. We assume these do not change for
|
||||
* the duration of the conference.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getAmplitudeIdentity() {
|
||||
return analytics.amplitudeIdentityProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the analytics adapter to its initial state - removes handlers, cache,
|
||||
* disabled state, etc.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function resetAnalytics() {
|
||||
analytics.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the analytics handlers.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action} is being dispatched.
|
||||
* @returns {Promise} Resolves with the handlers that have been successfully loaded.
|
||||
*/
|
||||
export async function createHandlers({ getState }: IStore) {
|
||||
getJitsiMeetGlobalNS().analyticsHandlers = [];
|
||||
|
||||
if (!isAnalyticsEnabled(getState)) {
|
||||
// Avoid all analytics processing if there are no handlers, since no event would be sent.
|
||||
analytics.dispose();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const config = state['features/base/config'];
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
const host = locationURL ? locationURL.host : '';
|
||||
const {
|
||||
analytics: analyticsConfig = {},
|
||||
deploymentInfo
|
||||
} = config;
|
||||
const {
|
||||
amplitudeAPPKey,
|
||||
blackListedEvents,
|
||||
scriptURLs,
|
||||
matomoEndpoint,
|
||||
matomoSiteID,
|
||||
whiteListedEvents
|
||||
} = analyticsConfig;
|
||||
const { group, user } = state['features/base/jwt'];
|
||||
const handlerConstructorOptions = {
|
||||
amplitudeAPPKey,
|
||||
blackListedEvents,
|
||||
envType: deploymentInfo?.envType || 'dev',
|
||||
matomoEndpoint,
|
||||
matomoSiteID,
|
||||
group,
|
||||
host,
|
||||
product: deploymentInfo?.product,
|
||||
subproduct: deploymentInfo?.environment,
|
||||
user: user?.id,
|
||||
version: JitsiMeetJS.version,
|
||||
whiteListedEvents
|
||||
};
|
||||
const handlers = [];
|
||||
|
||||
if (amplitudeAPPKey) {
|
||||
try {
|
||||
const amplitude = new AmplitudeHandler(handlerConstructorOptions);
|
||||
|
||||
analytics.amplitudeIdentityProps = amplitude.getIdentityProps();
|
||||
|
||||
handlers.push(amplitude);
|
||||
} catch (e) {
|
||||
logger.error('Failed to initialize Amplitude handler', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (matomoEndpoint && matomoSiteID) {
|
||||
try {
|
||||
const matomo = new MatomoHandler(handlerConstructorOptions);
|
||||
|
||||
handlers.push(matomo);
|
||||
} catch (e) {
|
||||
logger.error('Failed to initialize Matomo handler', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(scriptURLs) && scriptURLs.length > 0) {
|
||||
let externalHandlers;
|
||||
|
||||
try {
|
||||
externalHandlers = await _loadHandlers(scriptURLs, handlerConstructorOptions);
|
||||
handlers.push(...externalHandlers);
|
||||
} catch (e) {
|
||||
logger.error('Failed to initialize external analytics handlers', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid all analytics processing if there are no handlers, since no event would be sent.
|
||||
if (handlers.length === 0) {
|
||||
analytics.dispose();
|
||||
}
|
||||
|
||||
logger.info(`Initialized ${handlers.length} analytics handlers`);
|
||||
|
||||
return handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inits JitsiMeetJS.analytics by setting permanent properties and setting the handlers from the loaded scripts.
|
||||
* NOTE: Has to be used after JitsiMeetJS.init. Otherwise analytics will be null.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action} is being dispatched.
|
||||
* @param {Array<Object>} handlers - The analytics handlers.
|
||||
* @returns {boolean} - True if the analytics were successfully initialized and false otherwise.
|
||||
*/
|
||||
export function initAnalytics(store: IStore, handlers: Array<Object>): boolean {
|
||||
const { getState, dispatch } = store;
|
||||
|
||||
if (!isAnalyticsEnabled(getState) || handlers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const config = state['features/base/config'];
|
||||
const {
|
||||
deploymentInfo
|
||||
} = config;
|
||||
const { group, server } = state['features/base/jwt'];
|
||||
const { locationURL = { href: '' } } = state['features/base/connection'];
|
||||
const { tenant } = parseURIString(locationURL.href) || {};
|
||||
const params = parseURLParams(locationURL.href) ?? {};
|
||||
const permanentProperties: {
|
||||
appName?: string;
|
||||
externalApi?: boolean;
|
||||
group?: string;
|
||||
inIframe?: boolean;
|
||||
isPromotedFromVisitor?: boolean;
|
||||
isVisitor?: boolean;
|
||||
overwritesCustomButtonsWithURL?: boolean;
|
||||
overwritesDefaultLogoUrl?: boolean;
|
||||
overwritesDeploymentUrls?: boolean;
|
||||
overwritesLiveStreamingUrls?: boolean;
|
||||
overwritesSupportUrl?: boolean;
|
||||
server?: string;
|
||||
tenant?: string;
|
||||
wasLobbyVisible?: boolean;
|
||||
wasPrejoinDisplayed?: boolean;
|
||||
websocket?: boolean;
|
||||
} & typeof deploymentInfo = {};
|
||||
|
||||
if (server) {
|
||||
permanentProperties.server = server;
|
||||
}
|
||||
if (group) {
|
||||
permanentProperties.group = group;
|
||||
}
|
||||
|
||||
// Report the application name
|
||||
permanentProperties.appName = getAppName();
|
||||
|
||||
// Report if user is using websocket
|
||||
permanentProperties.websocket = typeof config.websocket === 'string';
|
||||
|
||||
// Report if user is using the external API
|
||||
permanentProperties.externalApi = typeof API_ID === 'number';
|
||||
|
||||
// Report if we are loaded in iframe
|
||||
permanentProperties.inIframe = isEmbedded();
|
||||
|
||||
// Report the tenant from the URL.
|
||||
permanentProperties.tenant = tenant || '/';
|
||||
|
||||
permanentProperties.wasPrejoinDisplayed = isPrejoinPageVisible(state);
|
||||
|
||||
// Currently we don't know if there will be lobby. We will update it to true if we go through lobby.
|
||||
permanentProperties.wasLobbyVisible = false;
|
||||
|
||||
// Setting visitor properties to false by default. We will update them later if it turns out we are visitor.
|
||||
permanentProperties.isVisitor = false;
|
||||
permanentProperties.isPromotedFromVisitor = false;
|
||||
|
||||
// TODO: Temporary metric. To be removed once we don't need it.
|
||||
permanentProperties.overwritesSupportUrl = 'interfaceConfig.SUPPORT_URL' in params;
|
||||
permanentProperties.overwritesDefaultLogoUrl = 'config.defaultLogoUrl' in params;
|
||||
|
||||
const deploymentUrlsConfig = params['config.deploymentUrls'] ?? {};
|
||||
|
||||
permanentProperties.overwritesDeploymentUrls
|
||||
= 'config.deploymentUrls.downloadAppsUrl' in params || 'config.deploymentUrls.userDocumentationURL' in params
|
||||
|| (typeof deploymentUrlsConfig === 'object'
|
||||
&& ('downloadAppsUrl' in deploymentUrlsConfig || 'userDocumentationURL' in deploymentUrlsConfig));
|
||||
const liveStreamingConfig = params['config.liveStreaming'] ?? {};
|
||||
|
||||
permanentProperties.overwritesLiveStreamingUrls
|
||||
= ('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
|
||||
)
|
||||
);
|
||||
|
||||
permanentProperties.overwritesCustomButtonsWithURL = 'config.customToolbarButtons' in params;
|
||||
|
||||
// Optionally, include local deployment information based on the
|
||||
// contents of window.config.deploymentInfo.
|
||||
if (deploymentInfo) {
|
||||
for (const key in deploymentInfo) {
|
||||
if (deploymentInfo.hasOwnProperty(key)) {
|
||||
permanentProperties[key as keyof typeof deploymentInfo] = deploymentInfo[
|
||||
key as keyof typeof deploymentInfo];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analytics.addPermanentProperties({
|
||||
...permanentProperties,
|
||||
...getState()['features/analytics'].initialPermanentProperties
|
||||
});
|
||||
|
||||
analytics.setConferenceName(getAnalyticsRoomName(state, dispatch));
|
||||
|
||||
// Set the handlers last, since this triggers emptying of the cache
|
||||
analytics.setAnalyticsHandlers(handlers);
|
||||
|
||||
if (!isMobileBrowser() && browser.isChromiumBased()) {
|
||||
const bannerCfg = state['features/base/config'].chromeExtensionBanner;
|
||||
|
||||
checkChromeExtensionsInstalled(bannerCfg).then(extensionsInstalled => {
|
||||
if (extensionsInstalled?.length) {
|
||||
analytics.addPermanentProperties({
|
||||
hasChromeExtension: extensionsInstalled.some(ext => ext)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to load the scripts for the external analytics handlers and creates them.
|
||||
*
|
||||
* @param {Array} scriptURLs - The array of script urls to load.
|
||||
* @param {Object} handlerConstructorOptions - The default options to pass when creating handlers.
|
||||
* @private
|
||||
* @returns {Promise} Resolves with the handlers that have been successfully loaded and rejects if there are no handlers
|
||||
* loaded or the analytics is disabled.
|
||||
*/
|
||||
function _loadHandlers(scriptURLs: string[] = [], handlerConstructorOptions: Object) {
|
||||
const promises: Promise<{ error?: Error; type: string; url?: string; }>[] = [];
|
||||
|
||||
for (const url of scriptURLs) {
|
||||
promises.push(
|
||||
loadScript(url).then(
|
||||
() => {
|
||||
return { type: 'success' };
|
||||
},
|
||||
(error: Error) => {
|
||||
return {
|
||||
type: 'error',
|
||||
error,
|
||||
url
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(values => {
|
||||
for (const el of values) {
|
||||
if (el.type === 'error') {
|
||||
logger.warn(`Failed to load ${el.url}: ${el.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const handlers = [];
|
||||
|
||||
for (const Handler of getJitsiMeetGlobalNS().analyticsHandlers) {
|
||||
// Catch any error while loading to avoid skipping analytics in case
|
||||
// of multiple scripts.
|
||||
try {
|
||||
handlers.push(new Handler(handlerConstructorOptions));
|
||||
} catch (error) {
|
||||
logger.warn(`Error creating analytics handler: ${error}`);
|
||||
}
|
||||
}
|
||||
logger.debug(`Loaded ${handlers.length} external analytics handlers`);
|
||||
|
||||
return handlers;
|
||||
});
|
||||
}
|
||||
115
react/features/analytics/handlers/AbstractHandler.ts
Normal file
115
react/features/analytics/handlers/AbstractHandler.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export interface IEvent {
|
||||
action?: string;
|
||||
actionSubject?: string;
|
||||
attributes?: {
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
name?: string;
|
||||
source?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface IOptions {
|
||||
amplitudeAPPKey?: string;
|
||||
blackListedEvents?: string[];
|
||||
envType?: string;
|
||||
group?: string;
|
||||
host?: string;
|
||||
matomoEndpoint?: string;
|
||||
matomoSiteID?: string;
|
||||
product?: string;
|
||||
subproduct?: string;
|
||||
user?: string;
|
||||
version?: string;
|
||||
whiteListedEvents?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract implementation of analytics handler.
|
||||
*/
|
||||
export default class AbstractHandler {
|
||||
_enabled: boolean;
|
||||
_whiteListedEvents: Array<string> | undefined;
|
||||
_blackListedEvents: Array<string> | undefined;
|
||||
|
||||
/**
|
||||
* Creates new instance.
|
||||
*
|
||||
* @param {Object} options - Optional parameters.
|
||||
*/
|
||||
constructor(options: IOptions = {}) {
|
||||
this._enabled = false;
|
||||
this._whiteListedEvents = options.whiteListedEvents;
|
||||
|
||||
// FIXME:
|
||||
// Keeping the list with the very noisy events so that we don't flood with events whoever hasn't configured
|
||||
// white/black lists yet. We need to solve this issue properly by either making these events not so noisy or
|
||||
// by removing them completely from the code.
|
||||
this._blackListedEvents = [
|
||||
...(options.blackListedEvents || []), // eslint-disable-line no-extra-parens
|
||||
'e2e_rtt', 'rtp.stats', 'rtt.by.region', 'available.device', 'stream.switch.delay', 'ice.state.changed',
|
||||
'ice.duration', 'peer.conn.status.duration'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a name for the event from the event properties.
|
||||
*
|
||||
* @param {Object} event - The analytics event.
|
||||
* @returns {string} - The extracted name.
|
||||
*/
|
||||
_extractName(event: IEvent) {
|
||||
// Page events have a single 'name' field.
|
||||
if (event.type === 'page') {
|
||||
return event.name;
|
||||
}
|
||||
|
||||
const {
|
||||
action,
|
||||
actionSubject,
|
||||
source
|
||||
} = event;
|
||||
|
||||
// All events have action, actionSubject, and source fields. All
|
||||
// three fields are required, and often jitsi-meet and
|
||||
// lib-jitsi-meet use the same value when separate values are not
|
||||
// necessary (i.e. event.action == event.actionSubject).
|
||||
// Here we concatenate these three fields, but avoid adding the same
|
||||
// value twice, because it would only make the event's name harder
|
||||
// to read.
|
||||
let name = action;
|
||||
|
||||
if (actionSubject && actionSubject !== action) {
|
||||
name = `${actionSubject}.${action}`;
|
||||
}
|
||||
if (source && source !== action) {
|
||||
name = `${source}.${name}`;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an event should be ignored or not.
|
||||
*
|
||||
* @param {Object} event - The event.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldIgnore(event: IEvent) {
|
||||
if (!event || !this._enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const name = this._extractName(event) ?? '';
|
||||
|
||||
if (Array.isArray(this._whiteListedEvents)) {
|
||||
return this._whiteListedEvents.indexOf(name) === -1;
|
||||
}
|
||||
|
||||
if (Array.isArray(this._blackListedEvents)) {
|
||||
return this._blackListedEvents.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
91
react/features/analytics/handlers/AmplitudeHandler.ts
Normal file
91
react/features/analytics/handlers/AmplitudeHandler.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Identify } from '@amplitude/analytics-core';
|
||||
|
||||
import logger from '../logger';
|
||||
|
||||
import AbstractHandler, { IEvent } from './AbstractHandler';
|
||||
import { fixDeviceID } from './amplitude/fixDeviceID';
|
||||
import amplitude, { initAmplitude } from './amplitude/lib';
|
||||
|
||||
/**
|
||||
* Analytics handler for Amplitude.
|
||||
*/
|
||||
export default class AmplitudeHandler extends AbstractHandler {
|
||||
|
||||
/**
|
||||
* Creates new instance of the Amplitude analytics handler.
|
||||
*
|
||||
* @param {Object} options - The amplitude options.
|
||||
* @param {string} options.amplitudeAPPKey - The Amplitude app key required by the Amplitude API
|
||||
* in the Amplitude events.
|
||||
*/
|
||||
constructor(options: any) {
|
||||
super(options);
|
||||
|
||||
const {
|
||||
amplitudeAPPKey,
|
||||
user
|
||||
} = options;
|
||||
|
||||
this._enabled = true;
|
||||
|
||||
initAmplitude(amplitudeAPPKey, user)
|
||||
.then(() => {
|
||||
logger.info('Amplitude initialized');
|
||||
fixDeviceID(amplitude);
|
||||
})
|
||||
.catch(e => {
|
||||
logger.error('Error initializing Amplitude', e);
|
||||
this._enabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Amplitude user properties.
|
||||
*
|
||||
* @param {Object} userProps - The user properties.
|
||||
* @returns {void}
|
||||
*/
|
||||
setUserProperties(userProps: any) {
|
||||
if (this._enabled) {
|
||||
const identify = new Identify();
|
||||
|
||||
// Set all properties
|
||||
Object.entries(userProps).forEach(([ key, value ]) => {
|
||||
identify.set(key, value as any);
|
||||
});
|
||||
|
||||
amplitude.identify(identify);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to Amplitude. The format of the event is described
|
||||
* in AnalyticsAdapter in lib-jitsi-meet.
|
||||
*
|
||||
* @param {Object} event - The event in the format specified by
|
||||
* lib-jitsi-meet.
|
||||
* @returns {void}
|
||||
*/
|
||||
sendEvent(event: IEvent) {
|
||||
if (this._shouldIgnore(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventName = this._extractName(event) ?? '';
|
||||
|
||||
amplitude.track(eventName, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return amplitude identity information.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
getIdentityProps() {
|
||||
return {
|
||||
sessionId: amplitude.getSessionId(),
|
||||
deviceId: amplitude.getDeviceId(),
|
||||
userId: amplitude.getUserId()
|
||||
};
|
||||
}
|
||||
}
|
||||
170
react/features/analytics/handlers/MatomoHandler.ts
Normal file
170
react/features/analytics/handlers/MatomoHandler.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/* global _paq */
|
||||
|
||||
import { getJitsiMeetGlobalNS } from '../../base/util/helpers';
|
||||
|
||||
import AbstractHandler, { IEvent } from './AbstractHandler';
|
||||
|
||||
/**
|
||||
* Analytics handler for Matomo.
|
||||
*/
|
||||
export default class MatomoHandler extends AbstractHandler {
|
||||
_userProperties: Object;
|
||||
|
||||
/**
|
||||
* Creates new instance of the Matomo handler.
|
||||
*
|
||||
* @param {Object} options - The matomo options.
|
||||
* @param {string} options.matomoEndpoint - The Matomo endpoint.
|
||||
* @param {string} options.matomoSiteID - The site ID.
|
||||
*/
|
||||
constructor(options: any) {
|
||||
super(options);
|
||||
this._userProperties = {};
|
||||
|
||||
if (!options.matomoEndpoint) {
|
||||
throw new Error(
|
||||
'Failed to initialize Matomo handler: no endpoint defined.'
|
||||
);
|
||||
}
|
||||
if (!options.matomoSiteID) {
|
||||
throw new Error(
|
||||
'Failed to initialize Matomo handler: no site ID defined.'
|
||||
);
|
||||
}
|
||||
|
||||
this._enabled = true;
|
||||
this._initMatomo(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the _paq object.
|
||||
*
|
||||
* @param {Object} options - The matomo options.
|
||||
* @param {string} options.matomoEndpoint - The Matomo endpoint.
|
||||
* @param {string} options.matomoSiteID - The site ID.
|
||||
* @returns {void}
|
||||
*/
|
||||
_initMatomo(options: any) {
|
||||
// @ts-ignore
|
||||
const _paq = window._paq || [];
|
||||
|
||||
// @ts-ignore
|
||||
window._paq = _paq;
|
||||
|
||||
_paq.push([ 'trackPageView' ]);
|
||||
_paq.push([ 'enableLinkTracking' ]);
|
||||
|
||||
(function() {
|
||||
// add trailing slash if needed
|
||||
const u = options.matomoEndpoint.endsWith('/')
|
||||
? options.matomoEndpoint
|
||||
: `${options.matomoEndpoint}/`;
|
||||
|
||||
// configure the tracker
|
||||
_paq.push([ 'setTrackerUrl', `${u}matomo.php` ]);
|
||||
_paq.push([ 'setSiteId', options.matomoSiteID ]);
|
||||
|
||||
// insert the matomo script
|
||||
const d = document,
|
||||
g = d.createElement('script'),
|
||||
s = d.getElementsByTagName('script')[0];
|
||||
|
||||
g.type = 'text/javascript';
|
||||
g.async = true;
|
||||
g.defer = true;
|
||||
g.src = `${u}matomo.js`;
|
||||
s.parentNode?.insertBefore(g, s);
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the integer to use for a Matomo event's value field
|
||||
* from a lib-jitsi-meet analytics event.
|
||||
*
|
||||
* @param {Object} event - The lib-jitsi-meet analytics event.
|
||||
* @returns {number} - The integer to use for the 'value' of a Matomo
|
||||
* event, or NaN if the lib-jitsi-meet event doesn't contain a
|
||||
* suitable value.
|
||||
* @private
|
||||
*/
|
||||
_extractValue(event: IEvent) {
|
||||
const value = event?.attributes?.value;
|
||||
|
||||
// Try to extract an integer from the 'value' attribute.
|
||||
return Math.round(parseFloat(value ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the permanent properties for the current session.
|
||||
*
|
||||
* @param {Object} userProps - The permanent properties.
|
||||
* @returns {void}
|
||||
*/
|
||||
setUserProperties(userProps: any = {}) {
|
||||
if (!this._enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visitScope = [ 'user_agent', 'callstats_name', 'browser_name' ];
|
||||
|
||||
// add variables in the 'page' scope
|
||||
Object.keys(userProps)
|
||||
.filter(key => visitScope.indexOf(key) === -1)
|
||||
.forEach((key, index) => {
|
||||
// @ts-ignore
|
||||
_paq.push([
|
||||
'setCustomVariable',
|
||||
1 + index,
|
||||
key,
|
||||
userProps[key],
|
||||
'page'
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
// add variables in the 'visit' scope
|
||||
Object.keys(userProps)
|
||||
.filter(key => visitScope.indexOf(key) !== -1)
|
||||
.forEach((key, index) => {
|
||||
// @ts-ignore
|
||||
_paq.push([
|
||||
'setCustomVariable',
|
||||
1 + index,
|
||||
key,
|
||||
userProps[key],
|
||||
'visit'
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the entry point of the API. The function sends an event to
|
||||
* the Matomo endpoint. The format of the event is described in
|
||||
* analyticsAdapter in lib-jitsi-meet.
|
||||
*
|
||||
* @param {Object} event - The event in the format specified by
|
||||
* lib-jitsi-meet.
|
||||
* @returns {void}
|
||||
*/
|
||||
sendEvent(event: IEvent) {
|
||||
if (this._shouldIgnore(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = this._extractValue(event);
|
||||
const matomoEvent: Array<string | number | undefined> = [
|
||||
'trackEvent', 'jitsi-meet', this._extractName(event) ];
|
||||
|
||||
if (!isNaN(value)) {
|
||||
matomoEvent.push(value);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
_paq.push(matomoEvent);
|
||||
}
|
||||
}
|
||||
|
||||
const globalNS = getJitsiMeetGlobalNS();
|
||||
|
||||
globalNS.analyticsHandlers = globalNS.analyticsHandlers || [];
|
||||
globalNS.analyticsHandlers.push(MatomoHandler);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Types } from '@amplitude/analytics-react-native';
|
||||
import DefaultPreference from 'react-native-default-preference';
|
||||
import { getUniqueId } from 'react-native-device-info';
|
||||
|
||||
import logger from '../../logger';
|
||||
|
||||
|
||||
/**
|
||||
* Custom logic for setting the correct device id.
|
||||
*
|
||||
* @param {Types.ReactNativeClient} amplitude - The amplitude instance.
|
||||
* @returns {void}
|
||||
*/
|
||||
export async function fixDeviceID(amplitude: Types.ReactNativeClient) {
|
||||
await DefaultPreference.setName('jitsi-preferences');
|
||||
|
||||
const current = await DefaultPreference.get('amplitudeDeviceId');
|
||||
|
||||
if (current) {
|
||||
amplitude.setDeviceId(current);
|
||||
} else {
|
||||
const uid = await getUniqueId();
|
||||
|
||||
if (!uid) {
|
||||
logger.warn('Device ID is not set!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
amplitude.setDeviceId(uid as string);
|
||||
await DefaultPreference.set('amplitudeDeviceId', uid as string);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Types } from '@amplitude/analytics-browser';
|
||||
// @ts-ignore
|
||||
import { jitsiLocalStorage } from '@jitsi/js-utils';
|
||||
|
||||
import logger from '../../logger';
|
||||
|
||||
/**
|
||||
* Key used to store the device id in local storage.
|
||||
*/
|
||||
const DEVICE_ID_KEY = '__AMDID';
|
||||
|
||||
/**
|
||||
* Custom logic for setting the correct device id.
|
||||
*
|
||||
* @param {Types.BrowserClient} amplitude - The amplitude instance.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function fixDeviceID(amplitude: Types.BrowserClient) {
|
||||
const deviceId = jitsiLocalStorage.getItem(DEVICE_ID_KEY);
|
||||
|
||||
if (deviceId) {
|
||||
// Set the device id in Amplitude.
|
||||
try {
|
||||
amplitude.setDeviceId(JSON.parse(deviceId));
|
||||
} catch (error) {
|
||||
logger.error('Failed to set device ID in Amplitude', error);
|
||||
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
} else {
|
||||
const newDeviceId = amplitude.getDeviceId();
|
||||
|
||||
if (newDeviceId) {
|
||||
jitsiLocalStorage.setItem(DEVICE_ID_KEY, JSON.stringify(newDeviceId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amplitude shared deviceId.
|
||||
*
|
||||
* @returns {string} - The amplitude deviceId.
|
||||
*/
|
||||
export function getDeviceID() {
|
||||
return jitsiLocalStorage.getItem(DEVICE_ID_KEY);
|
||||
}
|
||||
15
react/features/analytics/handlers/amplitude/lib.native.ts
Normal file
15
react/features/analytics/handlers/amplitude/lib.native.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import amplitude from '@amplitude/analytics-react-native';
|
||||
|
||||
export default amplitude;
|
||||
|
||||
/**
|
||||
* Initializes the Amplitude instance.
|
||||
*
|
||||
* @param {string} amplitudeAPPKey - The Amplitude app key.
|
||||
* @param {string | undefined} user - The user ID.
|
||||
* @returns {Promise} The initialized Amplitude instance.
|
||||
*/
|
||||
export function initAmplitude(
|
||||
amplitudeAPPKey: string, user: string | undefined): Promise<unknown> {
|
||||
return amplitude.init(amplitudeAPPKey, user, {}).promise;
|
||||
}
|
||||
38
react/features/analytics/handlers/amplitude/lib.web.ts
Normal file
38
react/features/analytics/handlers/amplitude/lib.web.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createInstance } from '@amplitude/analytics-browser';
|
||||
|
||||
const amplitude = createInstance();
|
||||
|
||||
export default amplitude;
|
||||
|
||||
/**
|
||||
* Initializes the Amplitude instance.
|
||||
*
|
||||
* @param {string} amplitudeAPPKey - The Amplitude app key.
|
||||
* @param {string | undefined} user - The user ID.
|
||||
* @returns {Promise} The initialized Amplitude instance.
|
||||
*/
|
||||
export function initAmplitude(
|
||||
amplitudeAPPKey: string, user: string | undefined): Promise<unknown> {
|
||||
|
||||
// Forces sending all events on exit (flushing) via sendBeacon.
|
||||
window.addEventListener('pagehide', () => {
|
||||
// Set https transport to use sendBeacon API.
|
||||
amplitude.setTransport('beacon');
|
||||
// Send all pending events to server.
|
||||
amplitude.flush();
|
||||
});
|
||||
|
||||
const options = {
|
||||
autocapture: {
|
||||
attribution: true,
|
||||
pageViews: true,
|
||||
sessions: false,
|
||||
fileDownloads: false,
|
||||
formInteractions: false,
|
||||
elementInteractions: false
|
||||
},
|
||||
defaultTracking: false
|
||||
};
|
||||
|
||||
return amplitude.init(amplitudeAPPKey, user, options).promise;
|
||||
}
|
||||
3
react/features/analytics/logger.ts
Normal file
3
react/features/analytics/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/analytics');
|
||||
216
react/features/analytics/middleware.ts
Normal file
216
react/features/analytics/middleware.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import {
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_WILL_LEAVE,
|
||||
SET_ROOM
|
||||
} from '../base/conference/actionTypes';
|
||||
import { SET_CONFIG } from '../base/config/actionTypes';
|
||||
import { SET_NETWORK_INFO } from '../base/net-info/actionTypes';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import {
|
||||
TRACK_ADDED,
|
||||
TRACK_REMOVED,
|
||||
TRACK_UPDATED
|
||||
} from '../base/tracks/actionTypes';
|
||||
import {
|
||||
getLocalAudioTrack,
|
||||
getLocalVideoTrack
|
||||
} from '../base/tracks/functions';
|
||||
import { SET_LOBBY_VISIBILITY } from '../lobby/actionTypes';
|
||||
import { getIsLobbyVisible } from '../lobby/functions';
|
||||
import { I_AM_VISITOR_MODE } from '../visitors/actionTypes';
|
||||
import { iAmVisitor } from '../visitors/functions';
|
||||
|
||||
import { createLocalTracksDurationEvent, createNetworkInfoEvent } from './AnalyticsEvents';
|
||||
import { SET_INITIALIZED, UPDATE_LOCAL_TRACKS_DURATION } from './actionTypes';
|
||||
import { setPermanentProperty } from './actions';
|
||||
import { createHandlers, initAnalytics, resetAnalytics, sendAnalytics } from './functions';
|
||||
|
||||
/**
|
||||
* Calculates the duration of the local tracks.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {Object} - The local tracks duration.
|
||||
*/
|
||||
function calculateLocalTrackDuration(state: IReduxState) {
|
||||
const now = Date.now();
|
||||
const { localTracksDuration } = state['features/analytics'];
|
||||
const { conference } = state['features/base/conference'];
|
||||
const { audio, video } = localTracksDuration;
|
||||
const { camera, desktop } = video;
|
||||
const tracks = state['features/base/tracks'];
|
||||
const audioTrack = getLocalAudioTrack(tracks);
|
||||
const videoTrack = getLocalVideoTrack(tracks);
|
||||
const newDuration = { ...localTracksDuration };
|
||||
|
||||
if (!audioTrack || audioTrack.muted || !conference) {
|
||||
newDuration.audio = {
|
||||
startedTime: -1,
|
||||
value: audio.value + (audio.startedTime === -1 ? 0 : now - audio.startedTime)
|
||||
};
|
||||
} else if (audio.startedTime === -1) {
|
||||
newDuration.audio.startedTime = now;
|
||||
}
|
||||
|
||||
if (!videoTrack || videoTrack.muted || !conference) {
|
||||
newDuration.video = {
|
||||
camera: {
|
||||
startedTime: -1,
|
||||
value: camera.value + (camera.startedTime === -1 ? 0 : now - camera.startedTime)
|
||||
},
|
||||
desktop: {
|
||||
startedTime: -1,
|
||||
value: desktop.value + (desktop.startedTime === -1 ? 0 : now - desktop.startedTime)
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const { videoType } = videoTrack;
|
||||
|
||||
if (video[videoType as keyof typeof video].startedTime === -1) {
|
||||
newDuration.video[videoType as keyof typeof video].startedTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...localTracksDuration,
|
||||
...newDuration
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware which intercepts config actions to handle evaluating analytics
|
||||
* config based on the config stored in the store.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case I_AM_VISITOR_MODE: {
|
||||
const oldIAmVisitor = iAmVisitor(store.getState());
|
||||
const result = next(action);
|
||||
const newIAmVisitor = iAmVisitor(store.getState());
|
||||
|
||||
store.dispatch(setPermanentProperty({
|
||||
isVisitor: newIAmVisitor,
|
||||
isPromotedFromVisitor: oldIAmVisitor && !newIAmVisitor
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
case SET_CONFIG:
|
||||
if (navigator.product === 'ReactNative') {
|
||||
// Resetting the analytics is currently not needed for web because
|
||||
// the user will be redirected to another page and new instance of
|
||||
// Analytics will be created and initialized.
|
||||
resetAnalytics();
|
||||
|
||||
const { dispatch } = store;
|
||||
|
||||
dispatch({
|
||||
type: SET_INITIALIZED,
|
||||
value: false
|
||||
});
|
||||
}
|
||||
break;
|
||||
case SET_ROOM: {
|
||||
// createHandlers is called before the SET_ROOM action is executed in order for Amplitude to initialize before
|
||||
// the deeplinking logic is executed (after the SET_ROOM action) so that the Amplitude device id is available
|
||||
// if needed.
|
||||
const createHandlersPromise = createHandlers(store);
|
||||
const result = next(action);
|
||||
|
||||
createHandlersPromise.then(handlers => {
|
||||
if (initAnalytics(store, handlers)) {
|
||||
store.dispatch({
|
||||
type: SET_INITIALIZED,
|
||||
value: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case CONFERENCE_JOINED: {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
|
||||
dispatch({
|
||||
type: UPDATE_LOCAL_TRACKS_DURATION,
|
||||
localTracksDuration: {
|
||||
...calculateLocalTrackDuration(state),
|
||||
conference: {
|
||||
startedTime: Date.now(),
|
||||
value: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_WILL_LEAVE: {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const { localTracksDuration } = state['features/analytics'];
|
||||
const newLocalTracksDuration = {
|
||||
...calculateLocalTrackDuration(state),
|
||||
conference: {
|
||||
startedTime: -1,
|
||||
value: Date.now() - localTracksDuration.conference.startedTime
|
||||
}
|
||||
};
|
||||
|
||||
sendAnalytics(createLocalTracksDurationEvent(newLocalTracksDuration));
|
||||
|
||||
dispatch({
|
||||
type: UPDATE_LOCAL_TRACKS_DURATION,
|
||||
localTracksDuration: newLocalTracksDuration
|
||||
});
|
||||
break;
|
||||
}
|
||||
case SET_LOBBY_VISIBILITY:
|
||||
if (getIsLobbyVisible(store.getState())) {
|
||||
store.dispatch(setPermanentProperty({
|
||||
wasLobbyVisible: true
|
||||
}));
|
||||
}
|
||||
|
||||
break;
|
||||
case SET_NETWORK_INFO:
|
||||
sendAnalytics(
|
||||
createNetworkInfoEvent({
|
||||
isOnline: action.isOnline,
|
||||
details: action.details,
|
||||
networkType: action.networkType
|
||||
}));
|
||||
break;
|
||||
case TRACK_ADDED:
|
||||
case TRACK_REMOVED:
|
||||
case TRACK_UPDATED: {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const { localTracksDuration } = state['features/analytics'];
|
||||
|
||||
if (localTracksDuration.conference.startedTime === -1) {
|
||||
// We don't want to track the media duration if the conference is not joined yet because otherwise we won't
|
||||
// be able to compare them with the conference duration (from conference join to conference will leave).
|
||||
break;
|
||||
}
|
||||
dispatch({
|
||||
type: UPDATE_LOCAL_TRACKS_DURATION,
|
||||
localTracksDuration: {
|
||||
...localTracksDuration,
|
||||
...calculateLocalTrackDuration(state)
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
88
react/features/analytics/reducer.ts
Normal file
88
react/features/analytics/reducer.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import {
|
||||
SET_INITIALIZED,
|
||||
SET_INITIAL_PERMANENT_PROPERTIES,
|
||||
UPDATE_LOCAL_TRACKS_DURATION
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Initial state.
|
||||
*/
|
||||
const DEFAULT_STATE = {
|
||||
isInitialized: false,
|
||||
initialPermanentProperties: {},
|
||||
localTracksDuration: {
|
||||
audio: {
|
||||
startedTime: -1,
|
||||
value: 0
|
||||
},
|
||||
video: {
|
||||
camera: {
|
||||
startedTime: -1,
|
||||
value: 0
|
||||
},
|
||||
desktop: {
|
||||
startedTime: -1,
|
||||
value: 0
|
||||
}
|
||||
},
|
||||
conference: {
|
||||
startedTime: -1,
|
||||
value: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface IValue {
|
||||
startedTime: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface IAnalyticsState {
|
||||
initialPermanentProperties: Object;
|
||||
isInitialized: boolean;
|
||||
localTracksDuration: {
|
||||
audio: IValue;
|
||||
conference: IValue;
|
||||
video: {
|
||||
camera: IValue;
|
||||
desktop: IValue;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for actions which changes the state of the analytics feature.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the feature features/analytics.
|
||||
* @param {Object} action - Action object.
|
||||
* @param {string} action.type - Type of action.
|
||||
* @returns {Object}
|
||||
*/
|
||||
ReducerRegistry.register<IAnalyticsState>('features/analytics',
|
||||
(state = DEFAULT_STATE, action): IAnalyticsState => {
|
||||
switch (action.type) {
|
||||
case SET_INITIALIZED:
|
||||
return {
|
||||
...state,
|
||||
initialPermanentProperties: action.value ? state.initialPermanentProperties : {},
|
||||
isInitialized: action.value
|
||||
};
|
||||
case SET_INITIAL_PERMANENT_PROPERTIES:
|
||||
return {
|
||||
...state,
|
||||
initialPermanentProperties: {
|
||||
...state.initialPermanentProperties,
|
||||
...action.properties
|
||||
}
|
||||
};
|
||||
case UPDATE_LOCAL_TRACKS_DURATION:
|
||||
return {
|
||||
...state,
|
||||
localTracksDuration: action.localTracksDuration
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
160
react/features/app/actions.any.ts
Normal file
160
react/features/app/actions.any.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
import { openTokenAuthUrl } from '../authentication/actions';
|
||||
|
||||
// @ts-ignore
|
||||
import { getTokenAuthUrl, isTokenAuthEnabled } from '../authentication/functions';
|
||||
import { getJwtExpirationDate } from '../base/jwt/functions';
|
||||
import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import { isLocalTrackMuted } from '../base/tracks/functions.any';
|
||||
import { getLocationContextRoot, parseURIString } from '../base/util/uri';
|
||||
|
||||
import { addTrackStateToURL } from './functions.any';
|
||||
import logger from './logger';
|
||||
import { IStore } from './types';
|
||||
|
||||
|
||||
/**
|
||||
* Redirects to another page generated by replacing the path in the original URL
|
||||
* with the given path.
|
||||
*
|
||||
* @param {(string)} pathname - The path to navigate to.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function redirectWithStoredParams(pathname: string) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const { locationURL } = getState()['features/base/connection'];
|
||||
const newLocationURL = new URL(locationURL?.href ?? '');
|
||||
|
||||
newLocationURL.pathname = pathname;
|
||||
window.location.assign(newLocationURL.toString());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a specific pathname to window.location.pathname taking into account
|
||||
* the context root of the Web app.
|
||||
*
|
||||
* @param {string} pathname - The pathname to assign to
|
||||
* window.location.pathname. If the specified pathname is relative, the context
|
||||
* root of the Web app will be prepended to the specified pathname before
|
||||
* assigning it to window.location.pathname.
|
||||
* @param {string} hashParam - Optional hash param to assign to
|
||||
* window.location.hash.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function redirectToStaticPage(pathname: string, hashParam?: string) {
|
||||
return () => {
|
||||
const windowLocation = window.location;
|
||||
let newPathname = pathname;
|
||||
|
||||
if (!newPathname.startsWith('/')) {
|
||||
// A pathname equal to ./ specifies the current directory. It will be
|
||||
// fine but pointless to include it because contextRoot is the current
|
||||
// directory.
|
||||
newPathname.startsWith('./')
|
||||
&& (newPathname = newPathname.substring(2));
|
||||
newPathname = getLocationContextRoot(windowLocation) + newPathname;
|
||||
}
|
||||
|
||||
if (hashParam) {
|
||||
windowLocation.hash = hashParam;
|
||||
}
|
||||
|
||||
windowLocation.pathname = newPathname;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the page by restoring the original URL.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function reloadWithStoredParams() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
|
||||
// Preserve the local tracks muted states.
|
||||
// @ts-ignore
|
||||
const newURL = addTrackStateToURL(locationURL, state);
|
||||
const windowLocation = window.location;
|
||||
const oldSearchString = windowLocation.search;
|
||||
|
||||
windowLocation.replace(newURL.toString());
|
||||
|
||||
if (newURL.search === oldSearchString) {
|
||||
// NOTE: Assuming that only the hash or search part of the URL will
|
||||
// be changed!
|
||||
// location.replace will not trigger redirect/reload when
|
||||
// only the hash params are changed. That's why we need to call
|
||||
// reload in addition to replace.
|
||||
windowLocation.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether tokenAuthUrl is set, we have a jwt token that will expire soon
|
||||
* and redirect to the auth url to obtain new token if this is the case.
|
||||
*
|
||||
* @param {Dispatch} dispatch - The Redux dispatch function.
|
||||
* @param {Function} getState - The Redux state.
|
||||
* @param {Function} failureCallback - The callback on failure to obtain auth url.
|
||||
* @returns {boolean} Whether we will redirect or not.
|
||||
*/
|
||||
export function maybeRedirectToTokenAuthUrl(
|
||||
dispatch: IStore['dispatch'], getState: IStore['getState'], failureCallback: Function) {
|
||||
const state = getState();
|
||||
const config = state['features/base/config'];
|
||||
const { enabled: audioOnlyEnabled } = state['features/base/audio-only'];
|
||||
const { startAudioOnly } = config;
|
||||
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
|
||||
const audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
|
||||
const videoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
|
||||
|
||||
if (!isTokenAuthEnabled(config)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if tokenAuthUrl check jwt if is about to expire go through the url to get new token
|
||||
const jwt = state['features/base/jwt'].jwt;
|
||||
const expirationDate = getJwtExpirationDate(jwt);
|
||||
|
||||
// if there is jwt and its expiration time is less than 3 minutes away
|
||||
// let's obtain new token
|
||||
if (expirationDate && expirationDate.getTime() - Date.now() < 3 * 60 * 1000) {
|
||||
const room = state['features/base/conference'].room;
|
||||
const { tenant } = parseURIString(locationURL.href) || {};
|
||||
|
||||
getTokenAuthUrl(
|
||||
config,
|
||||
locationURL,
|
||||
{
|
||||
audioMuted,
|
||||
audioOnlyEnabled: audioOnlyEnabled || startAudioOnly,
|
||||
skipPrejoin: true,
|
||||
videoMuted
|
||||
},
|
||||
room,
|
||||
tenant
|
||||
)
|
||||
.then((tokenAuthServiceUrl: string | undefined) => {
|
||||
if (!tokenAuthServiceUrl) {
|
||||
logger.warn('Cannot handle login, token service URL is not set');
|
||||
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
return dispatch(openTokenAuthUrl(tokenAuthServiceUrl));
|
||||
})
|
||||
.catch(() => {
|
||||
failureCallback();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
222
react/features/app/actions.native.ts
Normal file
222
react/features/app/actions.native.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { setRoom } from '../base/conference/actions';
|
||||
import { getConferenceState } from '../base/conference/functions';
|
||||
import {
|
||||
configWillLoad,
|
||||
loadConfigError,
|
||||
setConfig,
|
||||
storeConfig
|
||||
} from '../base/config/actions';
|
||||
import {
|
||||
createFakeConfig,
|
||||
restoreConfig
|
||||
} from '../base/config/functions.native';
|
||||
import { connect, disconnect, setLocationURL } from '../base/connection/actions.native';
|
||||
import { JITSI_CONNECTION_URL_KEY } from '../base/connection/constants';
|
||||
import { loadConfig } from '../base/lib-jitsi-meet/functions.native';
|
||||
import { createDesiredLocalTracks } from '../base/tracks/actions.native';
|
||||
import isInsecureRoomName from '../base/util/isInsecureRoomName';
|
||||
import { parseURLParams } from '../base/util/parseURLParams';
|
||||
import {
|
||||
appendURLParam,
|
||||
getBackendSafeRoomName,
|
||||
parseURIString,
|
||||
toURLString
|
||||
} from '../base/util/uri';
|
||||
import { isPrejoinPageEnabled } from '../mobile/navigation/functions';
|
||||
import {
|
||||
goBackToRoot,
|
||||
navigateRoot
|
||||
} from '../mobile/navigation/rootNavigationContainerRef';
|
||||
import { screen } from '../mobile/navigation/routes';
|
||||
import { clearNotifications } from '../notifications/actions';
|
||||
import { isUnsafeRoomWarningEnabled } from '../prejoin/functions';
|
||||
|
||||
import { maybeRedirectToTokenAuthUrl } from './actions.any';
|
||||
import { addTrackStateToURL, getDefaultURL } from './functions.native';
|
||||
import logger from './logger';
|
||||
import { IReloadNowOptions, IStore } from './types';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Triggers an in-app navigation to a specific route. Allows navigation to be
|
||||
* abstracted between the mobile/React Native and Web/React applications.
|
||||
*
|
||||
* @param {string|undefined} uri - The URI to which to navigate. It may be a
|
||||
* full URL with an HTTP(S) scheme, a full or partial URI with the app-specific
|
||||
* scheme, or a mere room name.
|
||||
* @param {Object} [options] - Options.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function appNavigate(uri?: string, options: IReloadNowOptions = {}) {
|
||||
logger.info(`appNavigate to ${uri}`);
|
||||
|
||||
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
let location = parseURIString(uri);
|
||||
|
||||
// If the specified location (URI) does not identify a host, use the app's
|
||||
// default.
|
||||
if (!location?.host) {
|
||||
const defaultLocation = parseURIString(getDefaultURL(getState));
|
||||
|
||||
if (location) {
|
||||
location.host = defaultLocation.host;
|
||||
|
||||
// FIXME Turn location's host, hostname, and port properties into
|
||||
// setters in order to reduce the risks of inconsistent state.
|
||||
location.hostname = defaultLocation.hostname;
|
||||
location.pathname
|
||||
= defaultLocation.pathname + location.pathname.substr(1);
|
||||
location.port = defaultLocation.port;
|
||||
location.protocol = defaultLocation.protocol;
|
||||
} else {
|
||||
location = defaultLocation;
|
||||
}
|
||||
}
|
||||
|
||||
location.protocol || (location.protocol = 'https:');
|
||||
const { contextRoot, host, hostname, pathname, room } = location;
|
||||
const locationURL = new URL(location.toString());
|
||||
const { conference } = getConferenceState(getState());
|
||||
|
||||
if (room) {
|
||||
if (conference) {
|
||||
|
||||
// We need to check if the location is the same with the previous one.
|
||||
const currentLocationURL = conference?.getConnection()[JITSI_CONNECTION_URL_KEY];
|
||||
const { hostname: currentHostName, pathname: currentPathName } = currentLocationURL;
|
||||
|
||||
if (currentHostName === hostname && currentPathName === pathname) {
|
||||
logger.warn(`Joining same conference using URL: ${currentLocationURL}`);
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
navigateRoot(screen.connecting);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(disconnect());
|
||||
|
||||
dispatch(configWillLoad(locationURL, room));
|
||||
|
||||
let protocol = location.protocol.toLowerCase();
|
||||
|
||||
// The React Native app supports an app-specific scheme which is sure to not
|
||||
// be supported by fetch.
|
||||
protocol !== 'http:' && protocol !== 'https:' && (protocol = 'https:');
|
||||
|
||||
const baseURL = `${protocol}//${host}${contextRoot || '/'}`;
|
||||
let url = `${baseURL}config.js`;
|
||||
|
||||
// XXX In order to support multiple shards, tell the room to the deployment.
|
||||
room && (url = appendURLParam(url, 'room', getBackendSafeRoomName(room) ?? ''));
|
||||
|
||||
const { release } = parseURLParams(location, true, 'search');
|
||||
|
||||
release && (url = appendURLParam(url, 'release', release));
|
||||
|
||||
let config;
|
||||
|
||||
// Avoid (re)loading the config when there is no room.
|
||||
if (!room) {
|
||||
config = restoreConfig(baseURL);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
try {
|
||||
config = await loadConfig(url);
|
||||
dispatch(storeConfig(baseURL, config));
|
||||
} catch (error: any) {
|
||||
config = restoreConfig(baseURL);
|
||||
|
||||
if (!config) {
|
||||
if (room) {
|
||||
dispatch(loadConfigError(error, locationURL));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is no room (we are on the welcome page), don't fail, just create a fake one.
|
||||
logger.warn('Failed to load config but there is no room, applying a fake one');
|
||||
config = createFakeConfig(baseURL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (getState()['features/base/config'].locationURL !== locationURL) {
|
||||
dispatch(loadConfigError(new Error('Config no longer needed!'), locationURL));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setLocationURL(locationURL));
|
||||
dispatch(setConfig(config));
|
||||
dispatch(setRoom(room));
|
||||
|
||||
if (!room) {
|
||||
goBackToRoot(getState(), dispatch);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(createDesiredLocalTracks());
|
||||
dispatch(clearNotifications());
|
||||
|
||||
if (!options.hidePrejoin && isPrejoinPageEnabled(getState())) {
|
||||
if (isUnsafeRoomWarningEnabled(getState()) && isInsecureRoomName(room)) {
|
||||
navigateRoot(screen.unsafeRoomWarning);
|
||||
} else {
|
||||
navigateRoot(screen.preJoin);
|
||||
}
|
||||
} else {
|
||||
dispatch(connect());
|
||||
navigateRoot(screen.conference.root);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the welcome page is enabled and redirects to it.
|
||||
* If requested show a thank you dialog before that.
|
||||
* If we have a close page enabled, redirect to it without
|
||||
* showing any other dialog.
|
||||
*
|
||||
* @param {Object} _options - Ignored.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function maybeRedirectToWelcomePage(_options?: any): any {
|
||||
// Dummy.
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the page.
|
||||
*
|
||||
* @protected
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function reloadNow() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
|
||||
const state = getState();
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
|
||||
// Preserve the local tracks muted state after the reload.
|
||||
// @ts-ignore
|
||||
const newURL = addTrackStateToURL(locationURL, state);
|
||||
|
||||
const reloadAction = () => {
|
||||
logger.info(`Reloading the conference using URL: ${locationURL}`);
|
||||
|
||||
dispatch(appNavigate(toURLString(newURL), {
|
||||
hidePrejoin: true
|
||||
}));
|
||||
};
|
||||
|
||||
if (maybeRedirectToTokenAuthUrl(dispatch, getState, reloadAction)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reloadAction();
|
||||
};
|
||||
}
|
||||
186
react/features/app/actions.web.ts
Normal file
186
react/features/app/actions.web.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// @ts-expect-error
|
||||
import { API_ID } from '../../../modules/API';
|
||||
import { setRoom } from '../base/conference/actions';
|
||||
import {
|
||||
configWillLoad,
|
||||
setConfig
|
||||
} from '../base/config/actions';
|
||||
import { setLocationURL } from '../base/connection/actions.web';
|
||||
import { loadConfig } from '../base/lib-jitsi-meet/functions.web';
|
||||
import { isEmbedded } from '../base/util/embedUtils';
|
||||
import { parseURIString } from '../base/util/uri';
|
||||
import { isVpaasMeeting } from '../jaas/functions';
|
||||
import { clearNotifications, showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
|
||||
import { isWelcomePageEnabled } from '../welcome/functions';
|
||||
|
||||
import {
|
||||
maybeRedirectToTokenAuthUrl,
|
||||
redirectToStaticPage,
|
||||
redirectWithStoredParams,
|
||||
reloadWithStoredParams
|
||||
} from './actions.any';
|
||||
import { getDefaultURL, getName } from './functions.web';
|
||||
import logger from './logger';
|
||||
import { IStore } from './types';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
|
||||
/**
|
||||
* Triggers an in-app navigation to a specific route. Allows navigation to be
|
||||
* abstracted between the mobile/React Native and Web/React applications.
|
||||
*
|
||||
* @param {string|undefined} uri - The URI to which to navigate. It may be a
|
||||
* full URL with an HTTP(S) scheme, a full or partial URI with the app-specific
|
||||
* scheme, or a mere room name.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function appNavigate(uri?: string) {
|
||||
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
let location = parseURIString(uri);
|
||||
|
||||
// If the specified location (URI) does not identify a host, use the app's
|
||||
// default.
|
||||
if (!location?.host) {
|
||||
const defaultLocation = parseURIString(getDefaultURL(getState));
|
||||
|
||||
if (location) {
|
||||
location.host = defaultLocation.host;
|
||||
|
||||
// FIXME Turn location's host, hostname, and port properties into
|
||||
// setters in order to reduce the risks of inconsistent state.
|
||||
location.hostname = defaultLocation.hostname;
|
||||
location.pathname
|
||||
= defaultLocation.pathname + location.pathname.substr(1);
|
||||
location.port = defaultLocation.port;
|
||||
location.protocol = defaultLocation.protocol;
|
||||
} else {
|
||||
location = defaultLocation;
|
||||
}
|
||||
}
|
||||
|
||||
location.protocol || (location.protocol = 'https:');
|
||||
|
||||
const { room } = location;
|
||||
const locationURL = new URL(location.toString());
|
||||
|
||||
// There are notifications now that gets displayed after we technically left
|
||||
// the conference, but we're still on the conference screen.
|
||||
dispatch(clearNotifications());
|
||||
|
||||
dispatch(configWillLoad(locationURL, room));
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
dispatch(setLocationURL(locationURL));
|
||||
dispatch(setConfig(config));
|
||||
dispatch(setRoom(room));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the welcome page is enabled and redirects to it.
|
||||
* If requested show a thank you dialog before that.
|
||||
* If we have a close page enabled, redirect to it without
|
||||
* showing any other dialog.
|
||||
*
|
||||
* @param {Object} options - Used to decide which particular close page to show
|
||||
* or if close page is disabled, whether we should show the thankyou dialog.
|
||||
* @param {boolean} options.showThankYou - Whether we should
|
||||
* show thank you dialog.
|
||||
* @param {boolean} options.feedbackSubmitted - Whether feedback was submitted.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function maybeRedirectToWelcomePage(options: { feedbackSubmitted?: boolean; showThankYou?: boolean; } = {}) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
|
||||
const {
|
||||
enableClosePage
|
||||
} = getState()['features/base/config'];
|
||||
|
||||
// if close page is enabled redirect to it, without further action
|
||||
if (enableClosePage) {
|
||||
if (isVpaasMeeting(getState())) {
|
||||
const isOpenedInIframe = isEmbedded();
|
||||
|
||||
if (isOpenedInIframe) {
|
||||
// @ts-ignore
|
||||
window.location = 'about:blank';
|
||||
} else {
|
||||
dispatch(redirectToStaticPage('/'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { jwt } = getState()['features/base/jwt'];
|
||||
|
||||
let hashParam;
|
||||
|
||||
// save whether current user is guest or not, and pass auth token,
|
||||
// before navigating to close page
|
||||
window.sessionStorage.setItem('guest', (!jwt).toString());
|
||||
window.sessionStorage.setItem('jwt', jwt ?? '');
|
||||
|
||||
let path = 'close.html';
|
||||
|
||||
if (interfaceConfig.SHOW_PROMOTIONAL_CLOSE_PAGE) {
|
||||
if (Number(API_ID) === API_ID) {
|
||||
hashParam = `#jitsi_meet_external_api_id=${API_ID}`;
|
||||
}
|
||||
path = 'close3.html';
|
||||
} else if (!options.feedbackSubmitted) {
|
||||
path = 'close2.html';
|
||||
}
|
||||
|
||||
dispatch(redirectToStaticPage(`static/${path}`, hashParam));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// else: show thankYou dialog only if there is no feedback
|
||||
if (options.showThankYou) {
|
||||
dispatch(showNotification({
|
||||
titleArguments: { appName: getName() },
|
||||
titleKey: 'dialog.thankYou'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
||||
}
|
||||
|
||||
// if Welcome page is enabled redirect to welcome page after 3 sec, if
|
||||
// there is a thank you message to be shown, 0.5s otherwise.
|
||||
if (isWelcomePageEnabled(getState())) {
|
||||
setTimeout(
|
||||
() => {
|
||||
dispatch(redirectWithStoredParams('/'));
|
||||
},
|
||||
options.showThankYou ? 3000 : 500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the page.
|
||||
*
|
||||
* @protected
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function reloadNow() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
|
||||
const state = getState();
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
|
||||
const reloadAction = () => {
|
||||
logger.info(`Reloading the conference using URL: ${locationURL}`);
|
||||
|
||||
dispatch(reloadWithStoredParams());
|
||||
};
|
||||
|
||||
if (maybeRedirectToTokenAuthUrl(dispatch, getState, reloadAction)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reloadAction();
|
||||
};
|
||||
}
|
||||
89
react/features/app/components/AbstractApp.ts
Normal file
89
react/features/app/components/AbstractApp.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import BaseApp from '../../base/app/components/BaseApp';
|
||||
import { toURLString } from '../../base/util/uri';
|
||||
import { appNavigate } from '../actions';
|
||||
import { getDefaultURL } from '../functions';
|
||||
|
||||
/**
|
||||
* The type of React {@code Component} props of {@link AbstractApp}.
|
||||
*/
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* XXX Refer to the implementation of loadURLObject: in
|
||||
* ios/sdk/src/JitsiMeetView.m for further information.
|
||||
*/
|
||||
timestamp: number;
|
||||
|
||||
/**
|
||||
* The URL, if any, with which the app was launched.
|
||||
*/
|
||||
url: Object | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base (abstract) class for main App component.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export class AbstractApp<P extends IProps = IProps> extends BaseApp<P> {
|
||||
/**
|
||||
* Initializes the app.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override async componentDidMount() {
|
||||
await super.componentDidMount();
|
||||
|
||||
// If a URL was explicitly specified to this React Component, then
|
||||
// open it; otherwise, use a default.
|
||||
this._openURL(toURLString(this.props.url) || this._getDefaultURL());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React Component's componentDidUpdate.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override async componentDidUpdate(prevProps: IProps) {
|
||||
const previousUrl = toURLString(prevProps.url);
|
||||
const currentUrl = toURLString(this.props.url);
|
||||
const previousTimestamp = prevProps.timestamp;
|
||||
const currentTimestamp = this.props.timestamp;
|
||||
|
||||
await this._init.promise;
|
||||
|
||||
// Deal with URL changes.
|
||||
|
||||
if (previousUrl !== currentUrl
|
||||
|
||||
// XXX Refer to the implementation of loadURLObject: in
|
||||
// ios/sdk/src/JitsiMeetView.m for further information.
|
||||
|| previousTimestamp !== currentTimestamp) {
|
||||
this._openURL(currentUrl || this._getDefaultURL());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default URL to be opened when this {@code App} mounts.
|
||||
*
|
||||
* @protected
|
||||
* @returns {string} The default URL to be opened when this {@code App}
|
||||
* mounts.
|
||||
*/
|
||||
_getDefaultURL() {
|
||||
// @ts-ignore
|
||||
return getDefaultURL(this.state.store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates this {@code AbstractApp} to (i.e. Opens) a specific URL.
|
||||
*
|
||||
* @param {Object|string} url - The URL to navigate this {@code AbstractApp}
|
||||
* to (i.e. The URL to open).
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_openURL(url: string | Object) {
|
||||
this.state.store?.dispatch(appNavigate(toURLString(url)));
|
||||
}
|
||||
}
|
||||
310
react/features/app/components/App.native.tsx
Normal file
310
react/features/app/components/App.native.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { ComponentType } from 'react';
|
||||
import { NativeModules, Platform, StyleSheet, View } from 'react-native';
|
||||
import DeviceInfo from 'react-native-device-info';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
// @ts-ignore
|
||||
import { hideSplash } from 'react-native-splash-view';
|
||||
|
||||
import BottomSheetContainer from '../../base/dialog/components/native/BottomSheetContainer';
|
||||
import DialogContainer from '../../base/dialog/components/native/DialogContainer';
|
||||
import { updateFlags } from '../../base/flags/actions';
|
||||
import { CALL_INTEGRATION_ENABLED } from '../../base/flags/constants';
|
||||
import { clientResized, setSafeAreaInsets } from '../../base/responsive-ui/actions';
|
||||
import DimensionsDetector from '../../base/responsive-ui/components/DimensionsDetector.native';
|
||||
import { updateSettings } from '../../base/settings/actions';
|
||||
import JitsiThemePaperProvider from '../../base/ui/components/JitsiThemeProvider.native';
|
||||
import { isEmbedded } from '../../base/util/embedUtils.native';
|
||||
import { _getRouteToRender } from '../getRouteToRender.native';
|
||||
import logger from '../logger';
|
||||
|
||||
import { AbstractApp, IProps as AbstractAppProps } from './AbstractApp';
|
||||
|
||||
// Register middlewares and reducers.
|
||||
import '../middlewares.native';
|
||||
import '../reducers.native';
|
||||
|
||||
|
||||
declare let __DEV__: any;
|
||||
|
||||
const { AppInfo } = NativeModules;
|
||||
|
||||
const DialogContainerWrapper = Platform.select({
|
||||
default: View
|
||||
});
|
||||
|
||||
/**
|
||||
* The type of React {@code Component} props of {@link App}.
|
||||
*/
|
||||
interface IProps extends AbstractAppProps {
|
||||
|
||||
/**
|
||||
* An object with the feature flags.
|
||||
*/
|
||||
flags: any;
|
||||
|
||||
/**
|
||||
* An object with user information (display name, email, avatar URL).
|
||||
*/
|
||||
userInfo?: Object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root app {@code Component} on mobile/React Native.
|
||||
*
|
||||
* @augments AbstractApp
|
||||
*/
|
||||
export class App extends AbstractApp<IProps> {
|
||||
|
||||
/**
|
||||
* Initializes a new {@code App} instance.
|
||||
*
|
||||
* @param {IProps} props - The read-only React {@code Component} props with
|
||||
* which the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// In the Release configuration, React Native will (intentionally) throw
|
||||
// an unhandled JavascriptException for an unhandled JavaScript error.
|
||||
// This will effectively kill the app. In accord with the Web, do not
|
||||
// kill the app.
|
||||
this._maybeDisableExceptionsManager();
|
||||
|
||||
// Bind event handler so it is only bound once per instance.
|
||||
this._onDimensionsChanged = this._onDimensionsChanged.bind(this);
|
||||
this._onSafeAreaInsetsChanged = this._onSafeAreaInsetsChanged.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the color scheme.
|
||||
*
|
||||
* @inheritdoc
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override async componentDidMount() {
|
||||
await super.componentDidMount();
|
||||
|
||||
hideSplash();
|
||||
|
||||
const liteTxt = AppInfo.isLiteSDK ? ' (lite)' : '';
|
||||
|
||||
logger.info(`Loaded SDK ${AppInfo.sdkVersion}${liteTxt} isEmbedded=${isEmbedded()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<JitsiThemePaperProvider>
|
||||
{ super.render() }
|
||||
</JitsiThemePaperProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes feature flags and updates settings.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
async _extraInit() {
|
||||
const { dispatch, getState } = this.state.store ?? {};
|
||||
const { flags = {}, url, userInfo } = this.props;
|
||||
let callIntegrationEnabled = flags[CALL_INTEGRATION_ENABLED as keyof typeof flags];
|
||||
|
||||
// CallKit does not work on the simulator, make sure we disable it.
|
||||
if (Platform.OS === 'ios' && DeviceInfo.isEmulatorSync()) {
|
||||
flags[CALL_INTEGRATION_ENABLED] = false;
|
||||
callIntegrationEnabled = false;
|
||||
logger.info('Disabling CallKit because this is a simulator');
|
||||
}
|
||||
|
||||
// Disable Android ConnectionService by default.
|
||||
if (Platform.OS === 'android' && typeof callIntegrationEnabled === 'undefined') {
|
||||
flags[CALL_INTEGRATION_ENABLED] = false;
|
||||
callIntegrationEnabled = false;
|
||||
}
|
||||
|
||||
// We set these early enough so then we avoid any unnecessary re-renders.
|
||||
dispatch?.(updateFlags(flags));
|
||||
|
||||
const route = await _getRouteToRender();
|
||||
|
||||
// We need the root navigator to be set early.
|
||||
await this._navigate(route);
|
||||
|
||||
// HACK ALERT!
|
||||
// Wait until the root navigator is ready.
|
||||
// We really need to break the inheritance relationship between App,
|
||||
// AbstractApp and BaseApp, it's very inflexible and cumbersome right now.
|
||||
const rootNavigationReady = new Promise<void>(resolve => {
|
||||
const i = setInterval(() => {
|
||||
// @ts-ignore
|
||||
const { ready } = getState()['features/app'] || {};
|
||||
|
||||
if (ready) {
|
||||
clearInterval(i);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
await rootNavigationReady;
|
||||
|
||||
// Update specified server URL.
|
||||
if (typeof url !== 'undefined') {
|
||||
|
||||
// @ts-ignore
|
||||
const { serverURL } = url;
|
||||
|
||||
if (typeof serverURL !== 'undefined') {
|
||||
dispatch?.(updateSettings({ serverURL }));
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
dispatch?.(updateSettings(userInfo || {}));
|
||||
|
||||
// Update settings with feature-flag.
|
||||
if (typeof callIntegrationEnabled !== 'undefined') {
|
||||
dispatch?.(updateSettings({ disableCallIntegration: !callIntegrationEnabled }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the parent method to inject {@link DimensionsDetector} as
|
||||
* the top most component.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_createMainElement(component: ComponentType<any>, props: Object) {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<DimensionsDetector
|
||||
onDimensionsChanged = { this._onDimensionsChanged }
|
||||
onSafeAreaInsetsChanged = { this._onSafeAreaInsetsChanged }>
|
||||
{ super._createMainElement(component, props) }
|
||||
</DimensionsDetector>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to disable the use of React Native
|
||||
* {@link ExceptionsManager#handleException} on platforms and in
|
||||
* configurations on/in which the use of the method in questions has been
|
||||
* determined to be undesirable. For example, React Native will
|
||||
* (intentionally) throw an unhandled {@code JavascriptException} for an
|
||||
* unhandled JavaScript error in the Release configuration. This will
|
||||
* effectively kill the app. In accord with the Web, do not kill the app.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_maybeDisableExceptionsManager() {
|
||||
if (__DEV__) {
|
||||
// As mentioned above, only the Release configuration was observed
|
||||
// to suffer.
|
||||
return;
|
||||
}
|
||||
if (Platform.OS !== 'android') {
|
||||
// A solution based on RTCSetFatalHandler was implemented on iOS and
|
||||
// it is preferred because it is at a later step of the
|
||||
// error/exception handling and it is specific to fatal
|
||||
// errors/exceptions which were observed to kill the app. The
|
||||
// solution implemented below was tested on Android only so it is
|
||||
// considered safest to use it there only.
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const oldHandler = global.ErrorUtils.getGlobalHandler();
|
||||
const newHandler = _handleException;
|
||||
|
||||
if (!oldHandler || oldHandler !== newHandler) {
|
||||
// @ts-ignore
|
||||
newHandler.next = oldHandler;
|
||||
|
||||
// @ts-ignore
|
||||
global.ErrorUtils.setGlobalHandler(newHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the known available size for the app to occupy.
|
||||
*
|
||||
* @param {number} width - The component's current width.
|
||||
* @param {number} height - The component's current height.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onDimensionsChanged(width: number, height: number) {
|
||||
const { dispatch } = this.state.store ?? {};
|
||||
|
||||
dispatch?.(clientResized(width, height));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the safe are insets values.
|
||||
*
|
||||
* @param {Object} insets - The insets.
|
||||
* @param {number} insets.top - The top inset.
|
||||
* @param {number} insets.right - The right inset.
|
||||
* @param {number} insets.bottom - The bottom inset.
|
||||
* @param {number} insets.left - The left inset.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSafeAreaInsetsChanged(insets: Object) {
|
||||
const { dispatch } = this.state.store ?? {};
|
||||
|
||||
dispatch?.(setSafeAreaInsets(insets));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the platform specific dialog container.
|
||||
*
|
||||
* @returns {React$Element}
|
||||
*/
|
||||
_renderDialogContainer() {
|
||||
return (
|
||||
<DialogContainerWrapper
|
||||
pointerEvents = 'box-none'
|
||||
style = { StyleSheet.absoluteFill }>
|
||||
<BottomSheetContainer />
|
||||
<DialogContainer />
|
||||
</DialogContainerWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a (possibly unhandled) JavaScript error by preventing React Native
|
||||
* from converting a fatal error into an unhandled native exception which will
|
||||
* kill the app.
|
||||
*
|
||||
* @param {Error} error - The (possibly unhandled) JavaScript error to handle.
|
||||
* @param {boolean} fatal - If the specified error is fatal, {@code true};
|
||||
* otherwise, {@code false}.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleException(error: Error, fatal: boolean) {
|
||||
if (fatal) {
|
||||
// In the Release configuration, React Native will (intentionally) throw
|
||||
// an unhandled JavascriptException for an unhandled JavaScript error.
|
||||
// This will effectively kill the app. In accord with the Web, do not
|
||||
// kill the app.
|
||||
logger.error(error);
|
||||
} else {
|
||||
// Forward to the next globalHandler of ErrorUtils.
|
||||
// @ts-ignore
|
||||
const { next } = _handleException;
|
||||
|
||||
typeof next === 'function' && next(error, fatal);
|
||||
}
|
||||
}
|
||||
67
react/features/app/components/App.web.tsx
Normal file
67
react/features/app/components/App.web.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
|
||||
import GlobalStyles from '../../base/ui/components/GlobalStyles.web';
|
||||
import JitsiThemeProvider from '../../base/ui/components/JitsiThemeProvider.web';
|
||||
import DialogContainer from '../../base/ui/components/web/DialogContainer';
|
||||
import ChromeExtensionBanner from '../../chrome-extension-banner/components/ChromeExtensionBanner.web';
|
||||
import OverlayContainer from '../../overlay/components/web/OverlayContainer';
|
||||
|
||||
import { AbstractApp } from './AbstractApp';
|
||||
|
||||
// Register middlewares and reducers.
|
||||
import '../middlewares';
|
||||
import '../reducers';
|
||||
|
||||
|
||||
/**
|
||||
* Root app {@code Component} on Web/React.
|
||||
*
|
||||
* @augments AbstractApp
|
||||
*/
|
||||
export class App extends AbstractApp {
|
||||
|
||||
/**
|
||||
* Creates an extra {@link ReactElement}s to be added (unconditionally)
|
||||
* alongside the main element.
|
||||
*
|
||||
* @abstract
|
||||
* @protected
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override _createExtraElement() {
|
||||
return (
|
||||
<JitsiThemeProvider>
|
||||
<OverlayContainer />
|
||||
</JitsiThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the parent method to inject {@link AtlasKitThemeProvider} as
|
||||
* the top most component.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
override _createMainElement(component: React.ComponentType, props?: Object) {
|
||||
return (
|
||||
<JitsiThemeProvider>
|
||||
<GlobalStyles />
|
||||
<ChromeExtensionBanner />
|
||||
{ super._createMainElement(component, props) }
|
||||
</JitsiThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the platform specific dialog container.
|
||||
*
|
||||
* @returns {React$Element}
|
||||
*/
|
||||
override _renderDialogContainer() {
|
||||
return (
|
||||
<JitsiThemeProvider>
|
||||
<DialogContainer />
|
||||
</JitsiThemeProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
25
react/features/app/functions.any.ts
Normal file
25
react/features/app/functions.any.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { isLocalTrackMuted } from '../base/tracks/functions';
|
||||
import { addHashParamsToURL } from '../base/util/uri';
|
||||
|
||||
/**
|
||||
* Adds the current track state to the passed URL.
|
||||
*
|
||||
* @param {URL} url - The URL that will be modified.
|
||||
* @param {Function|Object} stateful - The redux store or {@code getState} function.
|
||||
* @returns {URL} - Returns the modified URL.
|
||||
*/
|
||||
export function addTrackStateToURL(url: string, stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
const tracks = state['features/base/tracks'];
|
||||
const isVideoMuted = isLocalTrackMuted(tracks, MEDIA_TYPE.VIDEO);
|
||||
const isAudioMuted = isLocalTrackMuted(tracks, MEDIA_TYPE.AUDIO);
|
||||
|
||||
return addHashParamsToURL(new URL(url), { // use new URL object in order to not pollute the passed parameter.
|
||||
'config.startWithAudioMuted': isAudioMuted,
|
||||
'config.startWithVideoMuted': isVideoMuted
|
||||
});
|
||||
|
||||
}
|
||||
40
react/features/app/functions.native.ts
Normal file
40
react/features/app/functions.native.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { getServerURL } from '../base/settings/functions.native';
|
||||
|
||||
export * from './functions.any';
|
||||
|
||||
/**
|
||||
* Retrieves the default URL for the app. This can either come from a prop to
|
||||
* the root App component or be configured in the settings.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store or {@code getState}
|
||||
* function.
|
||||
* @returns {string} - Default URL for the app.
|
||||
*/
|
||||
export function getDefaultURL(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
|
||||
return getServerURL(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns application name.
|
||||
*
|
||||
* @returns {string} The application name.
|
||||
*/
|
||||
export function getName() {
|
||||
return NativeModules.AppInfo.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the Jitsi Meet SDK bundle on iOS. On Android it will be
|
||||
* undefined.
|
||||
*
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function getSdkBundlePath() {
|
||||
return NativeModules.AppInfo.sdkBundlePath;
|
||||
}
|
||||
59
react/features/app/functions.web.ts
Normal file
59
react/features/app/functions.web.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { getServerURL } from '../base/settings/functions.web';
|
||||
import { getJitsiMeetGlobalNS } from '../base/util/helpers';
|
||||
|
||||
export * from './functions.any';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Retrieves the default URL for the app. This can either come from a prop to
|
||||
* the root App component or be configured in the settings.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store or {@code getState}
|
||||
* function.
|
||||
* @returns {string} - Default URL for the app.
|
||||
*/
|
||||
export function getDefaultURL(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
const { href } = window.location;
|
||||
|
||||
if (href) {
|
||||
return href;
|
||||
}
|
||||
|
||||
return getServerURL(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns application name.
|
||||
*
|
||||
* @returns {string} The application name.
|
||||
*/
|
||||
export function getName() {
|
||||
return interfaceConfig.APP_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a handler function after the window load event has been received.
|
||||
* If the app has already loaded, the handler is executed immediately.
|
||||
* Otherwise, the handler is registered as a 'load' event listener.
|
||||
*
|
||||
* @param {Function} handler - The callback function to execute.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function executeAfterLoad(handler: () => void) {
|
||||
const safeHandler = () => {
|
||||
try {
|
||||
handler();
|
||||
} catch (error) {
|
||||
logger.error('Error executing handler after load:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (getJitsiMeetGlobalNS()?.hasLoaded) {
|
||||
safeHandler();
|
||||
} else {
|
||||
window.addEventListener('load', safeHandler);
|
||||
}
|
||||
}
|
||||
18
react/features/app/getRouteToRender.native.ts
Normal file
18
react/features/app/getRouteToRender.native.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
import RootNavigationContainer from '../mobile/navigation/components/RootNavigationContainer';
|
||||
|
||||
const route = {
|
||||
component: RootNavigationContainer,
|
||||
href: undefined
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines which route is to be rendered in order to depict a specific Redux
|
||||
* store.
|
||||
*
|
||||
* @param {any} _stateful - Used on web.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function _getRouteToRender(_stateful?: any) {
|
||||
return Promise.resolve(route);
|
||||
}
|
||||
148
react/features/app/getRouteToRender.web.ts
Normal file
148
react/features/app/getRouteToRender.web.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// @ts-expect-error
|
||||
import { generateRoomWithoutSeparator } from '@jitsi/js-utils/random';
|
||||
|
||||
import { getTokenAuthUrl } from '../authentication/functions.web';
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { isRoomValid } from '../base/conference/functions';
|
||||
import { isSupportedBrowser } from '../base/environment/environment';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { parseURIString } from '../base/util/uri';
|
||||
import Conference from '../conference/components/web/Conference';
|
||||
import { getDeepLinkingPage } from '../deep-linking/functions';
|
||||
import UnsupportedDesktopBrowser from '../unsupported-browser/components/UnsupportedDesktopBrowser';
|
||||
import BlankPage from '../welcome/components/BlankPage.web';
|
||||
import WelcomePage from '../welcome/components/WelcomePage.web';
|
||||
import { getCustomLandingPageURL, isWelcomePageEnabled } from '../welcome/functions';
|
||||
|
||||
import { IReduxState } from './types';
|
||||
|
||||
/**
|
||||
* Determines which route is to be rendered in order to depict a specific Redux
|
||||
* store.
|
||||
*
|
||||
* @param {(Function|Object)} stateful - THe redux store, state, or
|
||||
* {@code getState} function.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function _getRouteToRender(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
|
||||
return _getWebConferenceRoute(state) || _getWebWelcomePageRoute(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@code Route} to display when trying to access a conference if
|
||||
* a valid conference is being joined.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {Promise|undefined}
|
||||
*/
|
||||
function _getWebConferenceRoute(state: IReduxState) {
|
||||
const room = state['features/base/conference'].room;
|
||||
|
||||
if (!isRoomValid(room)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = _getEmptyRoute();
|
||||
const config = state['features/base/config'];
|
||||
|
||||
// if we have auto redirect enabled, and we have previously logged in successfully
|
||||
// let's redirect to the auth url to get the token and login again
|
||||
if (!browser.isElectron() && config.tokenAuthUrl && config.tokenAuthUrlAutoRedirect
|
||||
&& state['features/authentication'].tokenAuthUrlSuccessful
|
||||
&& !state['features/base/jwt'].jwt && room) {
|
||||
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
|
||||
const { tenant } = parseURIString(locationURL.href) || {};
|
||||
const { startAudioOnly } = config;
|
||||
|
||||
return getTokenAuthUrl(
|
||||
config,
|
||||
locationURL,
|
||||
{
|
||||
audioMuted: false,
|
||||
audioOnlyEnabled: startAudioOnly,
|
||||
skipPrejoin: false,
|
||||
videoMuted: false
|
||||
},
|
||||
room,
|
||||
tenant
|
||||
)
|
||||
.then((url: string | undefined) => {
|
||||
route.href = url;
|
||||
|
||||
return route;
|
||||
})
|
||||
.catch(() => Promise.resolve(route));
|
||||
}
|
||||
|
||||
// Update the location if it doesn't match. This happens when a room is
|
||||
// joined from the welcome page. The reason for doing this instead of using
|
||||
// the history API is that we want to load the config.js which takes the
|
||||
// room into account.
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
|
||||
if (window.location.href !== locationURL?.href) {
|
||||
route.href = locationURL?.href;
|
||||
|
||||
return Promise.resolve(route);
|
||||
}
|
||||
|
||||
return getDeepLinkingPage(state)
|
||||
.then(deepLinkComponent => {
|
||||
if (deepLinkComponent) {
|
||||
route.component = deepLinkComponent;
|
||||
} else if (isSupportedBrowser()) {
|
||||
route.component = Conference;
|
||||
} else {
|
||||
route.component = UnsupportedDesktopBrowser;
|
||||
}
|
||||
|
||||
return route;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@code Route} to display when trying to access the welcome page.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
function _getWebWelcomePageRoute(state: IReduxState) {
|
||||
const route = _getEmptyRoute();
|
||||
|
||||
if (isWelcomePageEnabled(state)) {
|
||||
if (isSupportedBrowser()) {
|
||||
const customLandingPage = getCustomLandingPageURL(state);
|
||||
|
||||
if (customLandingPage) {
|
||||
route.href = customLandingPage;
|
||||
} else {
|
||||
route.component = WelcomePage;
|
||||
}
|
||||
} else {
|
||||
route.component = UnsupportedDesktopBrowser;
|
||||
}
|
||||
} else {
|
||||
// Web: if the welcome page is disabled, go directly to a random room.
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.pathname += generateRoomWithoutSeparator();
|
||||
route.href = url.href;
|
||||
}
|
||||
|
||||
return Promise.resolve(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default {@code Route}.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _getEmptyRoute(): { component: React.ReactNode; href?: string; } {
|
||||
return {
|
||||
component: BlankPage,
|
||||
href: undefined
|
||||
};
|
||||
}
|
||||
3
react/features/app/logger.ts
Normal file
3
react/features/app/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/app');
|
||||
180
react/features/app/middleware.ts
Normal file
180
react/features/app/middleware.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { createConnectionEvent } from '../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../analytics/functions';
|
||||
import { appWillNavigate } from '../base/app/actions';
|
||||
import { SET_ROOM } from '../base/conference/actionTypes';
|
||||
import { CONNECTION_ESTABLISHED, CONNECTION_FAILED } from '../base/connection/actionTypes';
|
||||
import { getURLWithoutParams } from '../base/connection/utils';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { isEmbedded } from '../base/util/embedUtils';
|
||||
|
||||
import { reloadNow } from './actions';
|
||||
import { _getRouteToRender } from './getRouteToRender';
|
||||
import { IStore } from './types';
|
||||
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case CONNECTION_ESTABLISHED:
|
||||
return _connectionEstablished(store, next, action);
|
||||
case CONNECTION_FAILED:
|
||||
return _connectionFailed(store, next, action);
|
||||
|
||||
case SET_ROOM:
|
||||
return _setRoom(store, next, action);
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Notifies the feature app that the action {@link CONNECTION_ESTABLISHED} is
|
||||
* being dispatched within a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} 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 new state that is the result of the reduction of the
|
||||
* specified {@code action}.
|
||||
*/
|
||||
function _connectionEstablished(store: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
|
||||
// In the Web app we explicitly do not want to display the hash and
|
||||
// query/search URL params. Unfortunately, window.location and, more
|
||||
// importantly, its params are used not only in jitsi-meet but also in
|
||||
// lib-jitsi-meet. Consequently, the time to remove the params is
|
||||
// determined by when no one needs them anymore.
|
||||
// @ts-ignore
|
||||
const { history, location } = window;
|
||||
|
||||
if (isEmbedded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (history
|
||||
&& location
|
||||
&& history.length
|
||||
&& typeof history.replaceState === 'function') {
|
||||
// @ts-ignore
|
||||
const replacement = getURLWithoutParams(location);
|
||||
|
||||
// @ts-ignore
|
||||
if (location !== replacement) {
|
||||
history.replaceState(
|
||||
history.state,
|
||||
document?.title || '',
|
||||
replacement);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* CONNECTION_FAILED action side effects.
|
||||
*
|
||||
* @param {Object} store - The 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 CONNECTION_FAILED} which is being dispatched in the specified
|
||||
* {@code store}.
|
||||
* @returns {Object}
|
||||
* @private
|
||||
*/
|
||||
function _connectionFailed({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
|
||||
// In the case of a split-brain error, reload early and prevent further
|
||||
// handling of the action.
|
||||
if (_isMaybeSplitBrainError(getState, action)) {
|
||||
dispatch(reloadNow());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not a CONNECTION_FAILED action is for a possible split brain error. A split brain error occurs
|
||||
* when at least two users join a conference on different bridges. It is assumed the split brain scenario occurs very
|
||||
* early on in the call.
|
||||
*
|
||||
* @param {Function} getState - The redux function for fetching the current state.
|
||||
* @param {Action} action - The redux action {@code CONNECTION_FAILED} which is being dispatched in the specified
|
||||
* {@code store}.
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isMaybeSplitBrainError(getState: IStore['getState'], action: AnyAction) {
|
||||
const { error } = action;
|
||||
const isShardChangedError = error
|
||||
&& error.message === 'item-not-found'
|
||||
&& error.details?.shard_changed;
|
||||
|
||||
if (isShardChangedError) {
|
||||
const state = getState();
|
||||
const { timeEstablished } = state['features/base/connection'];
|
||||
const { _immediateReloadThreshold } = state['features/base/config'];
|
||||
|
||||
const timeSinceConnectionEstablished = Number(timeEstablished && Date.now() - timeEstablished);
|
||||
const reloadThreshold = typeof _immediateReloadThreshold === 'number' ? _immediateReloadThreshold : 1500;
|
||||
|
||||
const isWithinSplitBrainThreshold = !timeEstablished || timeSinceConnectionEstablished <= reloadThreshold;
|
||||
|
||||
sendAnalytics(createConnectionEvent('failed', {
|
||||
...error,
|
||||
connectionEstablished: timeEstablished,
|
||||
splitBrain: isWithinSplitBrainThreshold,
|
||||
timeSinceConnectionEstablished
|
||||
}));
|
||||
|
||||
return isWithinSplitBrainThreshold;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to a route in accord with a specific redux state.
|
||||
*
|
||||
* @param {Store} store - The redux store which determines/identifies the route
|
||||
* to navigate to.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _navigate({ dispatch, getState }: IStore) {
|
||||
const state = getState();
|
||||
const { app } = state['features/base/app'];
|
||||
|
||||
_getRouteToRender(state).then((route: Object) => {
|
||||
dispatch(appWillNavigate(app, route));
|
||||
|
||||
return app._navigate(route);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the feature app that the action {@link SET_ROOM} is being dispatched
|
||||
* within a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} 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 new state that is the result of the reduction of the
|
||||
* specified {@code action}.
|
||||
*/
|
||||
function _setRoom(store: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
|
||||
_navigate(store);
|
||||
|
||||
return result;
|
||||
}
|
||||
57
react/features/app/middlewares.any.ts
Normal file
57
react/features/app/middlewares.any.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import '../analytics/middleware';
|
||||
import '../authentication/middleware';
|
||||
import '../av-moderation/middleware';
|
||||
import '../base/conference/middleware';
|
||||
import '../base/config/middleware';
|
||||
import '../base/i18n/middleware';
|
||||
import '../base/jwt/middleware';
|
||||
import '../base/known-domains/middleware';
|
||||
import '../base/lastn/middleware';
|
||||
import '../base/lib-jitsi-meet/middleware';
|
||||
import '../base/logging/middleware';
|
||||
import '../base/media/middleware';
|
||||
import '../base/net-info/middleware';
|
||||
import '../base/participants/middleware';
|
||||
import '../base/responsive-ui/middleware';
|
||||
import '../base/redux/middleware';
|
||||
import '../base/settings/middleware';
|
||||
import '../base/sounds/middleware';
|
||||
import '../base/testing/middleware';
|
||||
import '../base/tracks/middleware';
|
||||
import '../base/user-interaction/middleware';
|
||||
import '../breakout-rooms/middleware';
|
||||
import '../calendar-sync/middleware';
|
||||
import '../chat/middleware';
|
||||
import '../conference/middleware';
|
||||
import '../connection-indicator/middleware';
|
||||
import '../device-selection/middleware';
|
||||
import '../display-name/middleware';
|
||||
import '../dynamic-branding/middleware';
|
||||
import '../etherpad/middleware';
|
||||
import '../filmstrip/middleware';
|
||||
import '../follow-me/middleware';
|
||||
import '../invite/middleware';
|
||||
import '../jaas/middleware';
|
||||
import '../large-video/middleware';
|
||||
import '../lobby/middleware';
|
||||
import '../notifications/middleware';
|
||||
import '../overlay/middleware';
|
||||
import '../participants-pane/middleware';
|
||||
import '../polls/middleware';
|
||||
import '../polls-history/middleware';
|
||||
import '../reactions/middleware';
|
||||
import '../recent-list/middleware';
|
||||
import '../recording/middleware';
|
||||
import '../rejoin/middleware';
|
||||
import '../room-lock/middleware';
|
||||
import '../rtcstats/middleware';
|
||||
import '../speaker-stats/middleware';
|
||||
import '../subtitles/middleware';
|
||||
import '../transcribing/middleware';
|
||||
import '../video-layout/middleware';
|
||||
import '../video-quality/middleware';
|
||||
import '../videosipgw/middleware';
|
||||
import '../visitors/middleware';
|
||||
import '../whiteboard/middleware.any';
|
||||
|
||||
import './middleware';
|
||||
19
react/features/app/middlewares.native.ts
Normal file
19
react/features/app/middlewares.native.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import '../dynamic-branding/middleware';
|
||||
import '../gifs/middleware';
|
||||
import '../mobile/audio-mode/middleware';
|
||||
import '../mobile/background/middleware';
|
||||
import '../mobile/call-integration/middleware';
|
||||
import '../mobile/external-api/middleware';
|
||||
import '../mobile/full-screen/middleware';
|
||||
import '../mobile/navigation/middleware';
|
||||
import '../mobile/permissions/middleware';
|
||||
import '../mobile/proximity/middleware';
|
||||
import '../mobile/wake-lock/middleware';
|
||||
import '../mobile/react-native-sdk/middleware';
|
||||
import '../mobile/watchos/middleware';
|
||||
import '../share-room/middleware';
|
||||
import '../shared-video/middleware';
|
||||
import '../toolbox/middleware.native';
|
||||
import '../whiteboard/middleware.native';
|
||||
|
||||
import './middlewares.any';
|
||||
28
react/features/app/middlewares.web.ts
Normal file
28
react/features/app/middlewares.web.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import '../base/app/middleware';
|
||||
import '../base/connection/middleware';
|
||||
import '../base/devices/middleware';
|
||||
import '../base/media/middleware';
|
||||
import '../deep-linking/middleware.web';
|
||||
import '../dynamic-branding/middleware';
|
||||
import '../e2ee/middleware';
|
||||
import '../external-api/middleware';
|
||||
import '../keyboard-shortcuts/middleware';
|
||||
import '../no-audio-signal/middleware';
|
||||
import '../notifications/middleware';
|
||||
import '../noise-detection/middleware';
|
||||
import '../old-client-notification/middleware';
|
||||
import '../power-monitor/middleware';
|
||||
import '../prejoin/middleware';
|
||||
import '../remote-control/middleware';
|
||||
import '../screen-share/middleware';
|
||||
import '../shared-video/middleware';
|
||||
import '../web-hid/middleware';
|
||||
import '../settings/middleware';
|
||||
import '../talk-while-muted/middleware';
|
||||
import '../toolbox/middleware';
|
||||
import '../face-landmarks/middleware';
|
||||
import '../gifs/middleware';
|
||||
import '../whiteboard/middleware.web';
|
||||
import '../file-sharing/middleware.web';
|
||||
|
||||
import './middlewares.any';
|
||||
22
react/features/app/reducer.native.ts
Normal file
22
react/features/app/reducer.native.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { _ROOT_NAVIGATION_READY } from '../mobile/navigation/actionTypes';
|
||||
|
||||
/**
|
||||
* Listen for actions which changes the state of the app feature.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the feature features/app.
|
||||
* @param {Object} action - Action object.
|
||||
* @param {string} action.type - Type of action.
|
||||
* @returns {Object}
|
||||
*/
|
||||
ReducerRegistry.register('features/app', (state: Object = {}, action) => {
|
||||
switch (action.type) {
|
||||
case _ROOT_NAVIGATION_READY:
|
||||
return {
|
||||
...state,
|
||||
ready: action.ready
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
58
react/features/app/reducers.any.ts
Normal file
58
react/features/app/reducers.any.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import '../analytics/reducer';
|
||||
import '../authentication/reducer';
|
||||
import '../av-moderation/reducer';
|
||||
import '../base/app/reducer';
|
||||
import '../base/audio-only/reducer';
|
||||
import '../base/conference/reducer';
|
||||
import '../base/config/reducer';
|
||||
import '../base/connection/reducer';
|
||||
import '../base/dialog/reducer';
|
||||
import '../base/flags/reducer';
|
||||
import '../base/jwt/reducer';
|
||||
import '../base/known-domains/reducer';
|
||||
import '../base/lastn/reducer';
|
||||
import '../base/lib-jitsi-meet/reducer';
|
||||
import '../base/logging/reducer';
|
||||
import '../base/media/reducer';
|
||||
import '../base/net-info/reducer';
|
||||
import '../base/participants/reducer';
|
||||
import '../base/responsive-ui/reducer';
|
||||
import '../base/settings/reducer';
|
||||
import '../base/sounds/reducer';
|
||||
import '../base/testing/reducer';
|
||||
import '../base/tracks/reducer';
|
||||
import '../base/user-interaction/reducer';
|
||||
import '../breakout-rooms/reducer';
|
||||
import '../calendar-sync/reducer';
|
||||
import '../chat/reducer';
|
||||
import '../deep-linking/reducer';
|
||||
import '../dropbox/reducer';
|
||||
import '../dynamic-branding/reducer';
|
||||
import '../etherpad/reducer';
|
||||
import '../filmstrip/reducer';
|
||||
import '../follow-me/reducer';
|
||||
import '../gifs/reducer';
|
||||
import '../google-api/reducer';
|
||||
import '../invite/reducer';
|
||||
import '../jaas/reducer';
|
||||
import '../large-video/reducer';
|
||||
import '../lobby/reducer';
|
||||
import '../notifications/reducer';
|
||||
import '../participants-pane/reducer';
|
||||
import '../polls/reducer';
|
||||
import '../polls-history/reducer';
|
||||
import '../reactions/reducer';
|
||||
import '../recent-list/reducer';
|
||||
import '../recording/reducer';
|
||||
import '../settings/reducer';
|
||||
import '../speaker-stats/reducer';
|
||||
import '../shared-video/reducer';
|
||||
import '../subtitles/reducer';
|
||||
import '../screen-share/reducer';
|
||||
import '../toolbox/reducer';
|
||||
import '../transcribing/reducer';
|
||||
import '../video-layout/reducer';
|
||||
import '../video-quality/reducer';
|
||||
import '../videosipgw/reducer';
|
||||
import '../visitors/reducer';
|
||||
import '../whiteboard/reducer';
|
||||
11
react/features/app/reducers.native.ts
Normal file
11
react/features/app/reducers.native.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import '../mobile/audio-mode/reducer';
|
||||
import '../mobile/background/reducer';
|
||||
import '../mobile/call-integration/reducer';
|
||||
import '../mobile/external-api/reducer';
|
||||
import '../mobile/full-screen/reducer';
|
||||
import '../mobile/watchos/reducer';
|
||||
import '../share-room/reducer';
|
||||
|
||||
import './reducer.native';
|
||||
|
||||
import './reducers.any';
|
||||
22
react/features/app/reducers.web.ts
Normal file
22
react/features/app/reducers.web.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import '../base/devices/reducer';
|
||||
import '../base/premeeting/reducer';
|
||||
import '../base/tooltip/reducer';
|
||||
import '../e2ee/reducer';
|
||||
import '../face-landmarks/reducer';
|
||||
import '../feedback/reducer';
|
||||
import '../keyboard-shortcuts/reducer';
|
||||
import '../no-audio-signal/reducer';
|
||||
import '../noise-detection/reducer';
|
||||
import '../participants-pane/reducer';
|
||||
import '../power-monitor/reducer';
|
||||
import '../prejoin/reducer';
|
||||
import '../remote-control/reducer';
|
||||
import '../screen-share/reducer';
|
||||
import '../noise-suppression/reducer';
|
||||
import '../screenshot-capture/reducer';
|
||||
import '../talk-while-muted/reducer';
|
||||
import '../virtual-background/reducer';
|
||||
import '../web-hid/reducer';
|
||||
import '../file-sharing/reducer';
|
||||
|
||||
import './reducers.any';
|
||||
183
react/features/app/types.ts
Normal file
183
react/features/app/types.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { AnyAction } from 'redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
|
||||
import { IAnalyticsState } from '../analytics/reducer';
|
||||
import { IAuthenticationState } from '../authentication/reducer';
|
||||
import { IAVModerationState } from '../av-moderation/reducer';
|
||||
import { IAppState } from '../base/app/reducer';
|
||||
import { IAudioOnlyState } from '../base/audio-only/reducer';
|
||||
import { IConferenceState } from '../base/conference/reducer';
|
||||
import { IConfigState } from '../base/config/reducer';
|
||||
import { IConnectionState } from '../base/connection/reducer';
|
||||
import { IDevicesState } from '../base/devices/types';
|
||||
import { IDialogState } from '../base/dialog/reducer';
|
||||
import { IFlagsState } from '../base/flags/reducer';
|
||||
import { IJwtState } from '../base/jwt/reducer';
|
||||
import { IKnownDomainsState } from '../base/known-domains/reducer';
|
||||
import { ILastNState } from '../base/lastn/reducer';
|
||||
import { ILibJitsiMeetState } from '../base/lib-jitsi-meet/reducer';
|
||||
import { ILoggingState } from '../base/logging/reducer';
|
||||
import { IMediaState } from '../base/media/reducer';
|
||||
import { INetInfoState } from '../base/net-info/reducer';
|
||||
import { IParticipantsState } from '../base/participants/reducer';
|
||||
import { IPreMeetingState } from '../base/premeeting/types';
|
||||
import { IResponsiveUIState } from '../base/responsive-ui/reducer';
|
||||
import { ISettingsState } from '../base/settings/reducer';
|
||||
import { ISoundsState } from '../base/sounds/reducer';
|
||||
import { ITestingState } from '../base/testing/reducer';
|
||||
import { ITooltipState } from '../base/tooltip/reducer';
|
||||
import { INoSrcDataState, ITracksState } from '../base/tracks/reducer';
|
||||
import { IUserInteractionState } from '../base/user-interaction/reducer';
|
||||
import { IBreakoutRoomsState } from '../breakout-rooms/reducer';
|
||||
import { ICalendarSyncState } from '../calendar-sync/reducer';
|
||||
import { IChatState } from '../chat/reducer';
|
||||
import { IDeepLinkingState } from '../deep-linking/reducer';
|
||||
import { IDropboxState } from '../dropbox/reducer';
|
||||
import { IDynamicBrandingState } from '../dynamic-branding/reducer';
|
||||
import { IE2EEState } from '../e2ee/reducer';
|
||||
import { IEtherpadState } from '../etherpad/reducer';
|
||||
import { IFaceLandmarksState } from '../face-landmarks/reducer';
|
||||
import { IFeedbackState } from '../feedback/reducer';
|
||||
import { IFileSharingState } from '../file-sharing/reducer';
|
||||
import { IFilmstripState } from '../filmstrip/reducer';
|
||||
import { IFollowMeState } from '../follow-me/reducer';
|
||||
import { IGifsState } from '../gifs/reducer';
|
||||
import { IGoogleApiState } from '../google-api/reducer';
|
||||
import { IInviteState } from '../invite/reducer';
|
||||
import { IJaaSState } from '../jaas/reducer';
|
||||
import { IKeyboardShortcutsState } from '../keyboard-shortcuts/types';
|
||||
import { ILargeVideoState } from '../large-video/reducer';
|
||||
import { ILobbyState } from '../lobby/reducer';
|
||||
import { IMobileAudioModeState } from '../mobile/audio-mode/reducer';
|
||||
import { IMobileBackgroundState } from '../mobile/background/reducer';
|
||||
import { ICallIntegrationState } from '../mobile/call-integration/reducer';
|
||||
import { IMobileExternalApiState } from '../mobile/external-api/reducer';
|
||||
import { IFullScreenState } from '../mobile/full-screen/reducer';
|
||||
import { IMobileWatchOSState } from '../mobile/watchos/reducer';
|
||||
import { INoAudioSignalState } from '../no-audio-signal/reducer';
|
||||
import { INoiseDetectionState } from '../noise-detection/reducer';
|
||||
import { INoiseSuppressionState } from '../noise-suppression/reducer';
|
||||
import { INotificationsState } from '../notifications/reducer';
|
||||
import { IParticipantsPaneState } from '../participants-pane/reducer';
|
||||
import { IPollsState } from '../polls/reducer';
|
||||
import { IPollsHistoryState } from '../polls-history/reducer';
|
||||
import { IPowerMonitorState } from '../power-monitor/reducer';
|
||||
import { IPrejoinState } from '../prejoin/reducer';
|
||||
import { IReactionsState } from '../reactions/reducer';
|
||||
import { IRecentListState } from '../recent-list/reducer';
|
||||
import { IRecordingState } from '../recording/reducer';
|
||||
import { IRemoteControlState } from '../remote-control/reducer';
|
||||
import { IScreenShareState } from '../screen-share/reducer';
|
||||
import { IScreenshotCaptureState } from '../screenshot-capture/reducer';
|
||||
import { IShareRoomState } from '../share-room/reducer';
|
||||
import { ISharedVideoState } from '../shared-video/reducer';
|
||||
import { ISpeakerStatsState } from '../speaker-stats/reducer';
|
||||
import { ISubtitlesState } from '../subtitles/reducer';
|
||||
import { ITalkWhileMutedState } from '../talk-while-muted/reducer';
|
||||
import { IToolboxState } from '../toolbox/reducer';
|
||||
import { ITranscribingState } from '../transcribing/reducer';
|
||||
import { IVideoLayoutState } from '../video-layout/reducer';
|
||||
import { IVideoQualityPersistedState, IVideoQualityState } from '../video-quality/reducer';
|
||||
import { IVideoSipGW } from '../videosipgw/reducer';
|
||||
import { IVirtualBackground } from '../virtual-background/reducer';
|
||||
import { IVisitorsState } from '../visitors/reducer';
|
||||
import { IWebHid } from '../web-hid/reducer';
|
||||
import { IWhiteboardState } from '../whiteboard/reducer';
|
||||
|
||||
export interface IStore {
|
||||
dispatch: ThunkDispatch<IReduxState, void, AnyAction>;
|
||||
getState: () => IReduxState;
|
||||
}
|
||||
|
||||
export interface IReduxState {
|
||||
'features/analytics': IAnalyticsState;
|
||||
'features/authentication': IAuthenticationState;
|
||||
'features/av-moderation': IAVModerationState;
|
||||
'features/base/app': IAppState;
|
||||
'features/base/audio-only': IAudioOnlyState;
|
||||
'features/base/color-scheme': any;
|
||||
'features/base/conference': IConferenceState;
|
||||
'features/base/config': IConfigState;
|
||||
'features/base/connection': IConnectionState;
|
||||
'features/base/devices': IDevicesState;
|
||||
'features/base/dialog': IDialogState;
|
||||
'features/base/flags': IFlagsState;
|
||||
'features/base/jwt': IJwtState;
|
||||
'features/base/known-domains': IKnownDomainsState;
|
||||
'features/base/lastn': ILastNState;
|
||||
'features/base/lib-jitsi-meet': ILibJitsiMeetState;
|
||||
'features/base/logging': ILoggingState;
|
||||
'features/base/media': IMediaState;
|
||||
'features/base/net-info': INetInfoState;
|
||||
'features/base/no-src-data': INoSrcDataState;
|
||||
'features/base/participants': IParticipantsState;
|
||||
'features/base/premeeting': IPreMeetingState;
|
||||
'features/base/responsive-ui': IResponsiveUIState;
|
||||
'features/base/settings': ISettingsState;
|
||||
'features/base/sounds': ISoundsState;
|
||||
'features/base/tooltip': ITooltipState;
|
||||
'features/base/tracks': ITracksState;
|
||||
'features/base/user-interaction': IUserInteractionState;
|
||||
'features/breakout-rooms': IBreakoutRoomsState;
|
||||
'features/calendar-sync': ICalendarSyncState;
|
||||
'features/call-integration': ICallIntegrationState;
|
||||
'features/chat': IChatState;
|
||||
'features/deep-linking': IDeepLinkingState;
|
||||
'features/dropbox': IDropboxState;
|
||||
'features/dynamic-branding': IDynamicBrandingState;
|
||||
'features/e2ee': IE2EEState;
|
||||
'features/etherpad': IEtherpadState;
|
||||
'features/face-landmarks': IFaceLandmarksState;
|
||||
'features/feedback': IFeedbackState;
|
||||
'features/file-sharing': IFileSharingState;
|
||||
'features/filmstrip': IFilmstripState;
|
||||
'features/follow-me': IFollowMeState;
|
||||
'features/full-screen': IFullScreenState;
|
||||
'features/gifs': IGifsState;
|
||||
'features/google-api': IGoogleApiState;
|
||||
'features/invite': IInviteState;
|
||||
'features/jaas': IJaaSState;
|
||||
'features/keyboard-shortcuts': IKeyboardShortcutsState;
|
||||
'features/large-video': ILargeVideoState;
|
||||
'features/lobby': ILobbyState;
|
||||
'features/mobile/audio-mode': IMobileAudioModeState;
|
||||
'features/mobile/background': IMobileBackgroundState;
|
||||
'features/mobile/external-api': IMobileExternalApiState;
|
||||
'features/mobile/watchos': IMobileWatchOSState;
|
||||
'features/no-audio-signal': INoAudioSignalState;
|
||||
'features/noise-detection': INoiseDetectionState;
|
||||
'features/noise-suppression': INoiseSuppressionState;
|
||||
'features/notifications': INotificationsState;
|
||||
'features/participants-pane': IParticipantsPaneState;
|
||||
'features/polls': IPollsState;
|
||||
'features/polls-history': IPollsHistoryState;
|
||||
'features/power-monitor': IPowerMonitorState;
|
||||
'features/prejoin': IPrejoinState;
|
||||
'features/reactions': IReactionsState;
|
||||
'features/recent-list': IRecentListState;
|
||||
'features/recording': IRecordingState;
|
||||
'features/remote-control': IRemoteControlState;
|
||||
'features/screen-share': IScreenShareState;
|
||||
'features/screenshot-capture': IScreenshotCaptureState;
|
||||
'features/settings': ISettingsState;
|
||||
'features/share-room': IShareRoomState;
|
||||
'features/shared-video': ISharedVideoState;
|
||||
'features/speaker-stats': ISpeakerStatsState;
|
||||
'features/subtitles': ISubtitlesState;
|
||||
'features/talk-while-muted': ITalkWhileMutedState;
|
||||
'features/testing': ITestingState;
|
||||
'features/toolbox': IToolboxState;
|
||||
'features/transcribing': ITranscribingState;
|
||||
'features/video-layout': IVideoLayoutState;
|
||||
'features/video-quality': IVideoQualityState;
|
||||
'features/video-quality-persistent-storage': IVideoQualityPersistedState;
|
||||
'features/videosipgw': IVideoSipGW;
|
||||
'features/virtual-background': IVirtualBackground;
|
||||
'features/visitors': IVisitorsState;
|
||||
'features/web-hid': IWebHid;
|
||||
'features/whiteboard': IWhiteboardState;
|
||||
}
|
||||
|
||||
export interface IReloadNowOptions {
|
||||
hidePrejoin?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
/**
|
||||
* The number of dots to display in AudioLevelIndicator.
|
||||
*
|
||||
* IMPORTANT: AudioLevelIndicator assumes that this is an odd number.
|
||||
*/
|
||||
const AUDIO_LEVEL_DOTS = 5;
|
||||
|
||||
/**
|
||||
* The index of the dot that is at the direct middle of all other dots.
|
||||
*/
|
||||
const CENTER_DOT_INDEX = Math.floor(AUDIO_LEVEL_DOTS / 2);
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link AudioLevelIndicator}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The current audio level to display. The value should be a number between
|
||||
* 0 and 1.
|
||||
*/
|
||||
audioLevel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ReactElement responsible for drawing audio levels.
|
||||
*
|
||||
* @augments {Component}
|
||||
*/
|
||||
class AudioLevelIndicator extends Component<IProps> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { audioLevel: passedAudioLevel } = this.props;
|
||||
|
||||
// First make sure we are sensitive enough.
|
||||
const audioLevel = typeof passedAudioLevel === 'number' && !isNaN(passedAudioLevel)
|
||||
? Math.min(passedAudioLevel * 1.2, 1) : 0;
|
||||
|
||||
// Let's now stretch the audio level over the number of dots we have.
|
||||
const stretchedAudioLevel = AUDIO_LEVEL_DOTS * audioLevel;
|
||||
|
||||
const audioLevelDots = [];
|
||||
|
||||
for (let i = 0; i < AUDIO_LEVEL_DOTS; i++) {
|
||||
const distanceFromCenter = CENTER_DOT_INDEX - i;
|
||||
const audioLevelFromCenter
|
||||
= stretchedAudioLevel - Math.abs(distanceFromCenter);
|
||||
const cappedOpacity = Math.min(
|
||||
1, Math.max(0, audioLevelFromCenter));
|
||||
let className;
|
||||
|
||||
if (distanceFromCenter === 0) {
|
||||
className = 'audiodot-middle';
|
||||
} else if (distanceFromCenter < 0) {
|
||||
className = 'audiodot-top';
|
||||
} else {
|
||||
className = 'audiodot-bottom';
|
||||
}
|
||||
|
||||
audioLevelDots.push(
|
||||
<span
|
||||
className = { className }
|
||||
key = { i }
|
||||
style = {{ opacity: cappedOpacity }} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className = 'audioindicator in-react'>
|
||||
{ audioLevelDots }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioLevelIndicator;
|
||||
88
react/features/authentication/actionTypes.ts
Normal file
88
react/features/authentication/actionTypes.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The type of (redux) action which signals that {@link LoginDialog} has been
|
||||
* canceled.
|
||||
*
|
||||
* {
|
||||
* type: CANCEL_LOGIN
|
||||
* }
|
||||
*/
|
||||
export const CANCEL_LOGIN = 'CANCEL_LOGIN';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals to login.
|
||||
*
|
||||
* {
|
||||
* type: LOGOUT
|
||||
* }
|
||||
*/
|
||||
export const LOGIN = 'LOGIN';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals to logout.
|
||||
*
|
||||
* {
|
||||
* type: LOGOUT
|
||||
* }
|
||||
*/
|
||||
export const LOGOUT = 'LOGOUT';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that we have authenticated successful when
|
||||
* tokenAuthUrl is set.
|
||||
*
|
||||
* {
|
||||
* type: SET_TOKEN_AUTH_URL_SUCCESS
|
||||
* }
|
||||
*/
|
||||
export const SET_TOKEN_AUTH_URL_SUCCESS = 'SET_TOKEN_AUTH_URL_SUCCESS';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that the cyclic operation of waiting
|
||||
* for conference owner has been aborted.
|
||||
*
|
||||
* {
|
||||
* type: STOP_WAIT_FOR_OWNER
|
||||
* }
|
||||
*/
|
||||
export const STOP_WAIT_FOR_OWNER = 'STOP_WAIT_FOR_OWNER';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which informs that the authentication and role
|
||||
* upgrade process has finished either with success or with a specific error.
|
||||
* If {@code error} is {@code undefined}, then the process succeeded;
|
||||
* otherwise, it failed. Refer to
|
||||
* {@link JitsiConference#authenticateAndUpgradeRole} in lib-jitsi-meet for the
|
||||
* error details.
|
||||
*
|
||||
* {
|
||||
* type: UPGRADE_ROLE_FINISHED,
|
||||
* error: Object,
|
||||
* progress: number,
|
||||
* thenableWithCancel: Object
|
||||
* }
|
||||
*/
|
||||
export const UPGRADE_ROLE_FINISHED = 'UPGRADE_ROLE_FINISHED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that the process of authenticating
|
||||
* and upgrading the local participant's role has been started.
|
||||
*
|
||||
* {
|
||||
* type: UPGRADE_ROLE_STARTED,
|
||||
* thenableWithCancel: Object
|
||||
* }
|
||||
*/
|
||||
export const UPGRADE_ROLE_STARTED = 'UPGRADE_ROLE_STARTED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action that sets delayed handler which will check if
|
||||
* the conference has been created and it's now possible to join from anonymous
|
||||
* connection.
|
||||
*
|
||||
* {
|
||||
* type: WAIT_FOR_OWNER,
|
||||
* handler: Function,
|
||||
* timeoutMs: number
|
||||
* }
|
||||
*/
|
||||
export const WAIT_FOR_OWNER = 'WAIT_FOR_OWNER';
|
||||
229
react/features/authentication/actions.any.ts
Normal file
229
react/features/authentication/actions.any.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { checkIfCanJoin } from '../base/conference/actions';
|
||||
import { IJitsiConference } from '../base/conference/reducer';
|
||||
import { hideDialog, openDialog } from '../base/dialog/actions';
|
||||
|
||||
import {
|
||||
LOGIN,
|
||||
LOGOUT,
|
||||
SET_TOKEN_AUTH_URL_SUCCESS,
|
||||
STOP_WAIT_FOR_OWNER,
|
||||
UPGRADE_ROLE_FINISHED,
|
||||
UPGRADE_ROLE_STARTED, WAIT_FOR_OWNER
|
||||
} from './actionTypes';
|
||||
import { LoginDialog, WaitForOwnerDialog } from './components';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Initiates authenticating and upgrading the role of the local participant to
|
||||
* moderator which will allow to create and join a new conference on an XMPP
|
||||
* password + guest access configuration. Refer to {@link LoginDialog} for more
|
||||
* info.
|
||||
*
|
||||
* @param {string} id - The XMPP user's ID (e.g. {@code user@domain.com}).
|
||||
* @param {string} password - The XMPP user's password.
|
||||
* @param {JitsiConference} conference - The conference for which the local
|
||||
* participant's role will be upgraded.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function authenticateAndUpgradeRole(
|
||||
id: string,
|
||||
password: string,
|
||||
conference: IJitsiConference) {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
const process
|
||||
= conference.authenticateAndUpgradeRole({
|
||||
id,
|
||||
password,
|
||||
|
||||
onLoginSuccessful() {
|
||||
// When the login succeeds, the process has completed half
|
||||
// of its job (i.e. 0.5).
|
||||
return dispatch(_upgradeRoleFinished(process, 0.5));
|
||||
}
|
||||
});
|
||||
|
||||
dispatch(_upgradeRoleStarted(process));
|
||||
process.then(
|
||||
/* onFulfilled */ () => dispatch(_upgradeRoleFinished(process, 1)),
|
||||
/* onRejected */ (error: any) => {
|
||||
// The lack of an error signals a cancellation.
|
||||
if (error.authenticationError || error.connectionError) {
|
||||
logger.error('authenticateAndUpgradeRole failed', error);
|
||||
}
|
||||
|
||||
dispatch(_upgradeRoleFinished(process, error));
|
||||
});
|
||||
|
||||
return process;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Signals that the process of authenticating and upgrading the local
|
||||
* participant's role has finished either with success or with a specific error.
|
||||
*
|
||||
* @param {Object} thenableWithCancel - The process of authenticating and
|
||||
* upgrading the local participant's role.
|
||||
* @param {Object} progressOrError - If the value is a {@code number}, then the
|
||||
* process of authenticating and upgrading the local participant's role has
|
||||
* succeeded in one of its two/multiple steps; otherwise, it has failed with the
|
||||
* specified error. Refer to {@link JitsiConference#authenticateAndUpgradeRole}
|
||||
* in lib-jitsi-meet for the error details.
|
||||
* @private
|
||||
* @returns {{
|
||||
* type: UPGRADE_ROLE_FINISHED,
|
||||
* error: ?Object,
|
||||
* progress: number
|
||||
* }}
|
||||
*/
|
||||
function _upgradeRoleFinished(
|
||||
thenableWithCancel: Object,
|
||||
progressOrError: number | any) {
|
||||
let error;
|
||||
let progress;
|
||||
|
||||
if (typeof progressOrError === 'number') {
|
||||
progress = progressOrError;
|
||||
} else {
|
||||
// Make the specified error object resemble an Error instance (to the
|
||||
// extent that jitsi-meet needs it).
|
||||
const {
|
||||
authenticationError,
|
||||
connectionError,
|
||||
...other
|
||||
} = progressOrError;
|
||||
|
||||
error = {
|
||||
name: authenticationError || connectionError,
|
||||
...other
|
||||
};
|
||||
progress = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
type: UPGRADE_ROLE_FINISHED,
|
||||
error,
|
||||
progress,
|
||||
thenableWithCancel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that a process of authenticating and upgrading the local
|
||||
* participant's role has started.
|
||||
*
|
||||
* @param {Object} thenableWithCancel - The process of authenticating and
|
||||
* upgrading the local participant's role.
|
||||
* @private
|
||||
* @returns {{
|
||||
* type: UPGRADE_ROLE_STARTED,
|
||||
* thenableWithCancel: Object
|
||||
* }}
|
||||
*/
|
||||
function _upgradeRoleStarted(thenableWithCancel: Object) {
|
||||
return {
|
||||
type: UPGRADE_ROLE_STARTED,
|
||||
thenableWithCancel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides an authentication dialog where the local participant
|
||||
* should authenticate.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function hideLoginDialog() {
|
||||
return hideDialog(LoginDialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login.
|
||||
*
|
||||
* @returns {{
|
||||
* type: LOGIN
|
||||
* }}
|
||||
*/
|
||||
export function login() {
|
||||
return {
|
||||
type: LOGIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout.
|
||||
*
|
||||
* @returns {{
|
||||
* type: LOGOUT
|
||||
* }}
|
||||
*/
|
||||
export function logout() {
|
||||
return {
|
||||
type: LOGOUT
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens {@link WaitForOnwerDialog}.
|
||||
*
|
||||
* @protected
|
||||
* @returns {Action}
|
||||
*/
|
||||
export function openWaitForOwnerDialog() {
|
||||
return openDialog(WaitForOwnerDialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops waiting for the conference owner.
|
||||
*
|
||||
* @returns {{
|
||||
* type: STOP_WAIT_FOR_OWNER
|
||||
* }}
|
||||
*/
|
||||
export function stopWaitForOwner() {
|
||||
return {
|
||||
type: STOP_WAIT_FOR_OWNER
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when Jicofo rejects to create the room for anonymous user. Will
|
||||
* start the process of "waiting for the owner" by periodically trying to join
|
||||
* the room every five seconds.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function waitForOwner() {
|
||||
return (dispatch: IStore['dispatch']) =>
|
||||
dispatch({
|
||||
type: WAIT_FOR_OWNER,
|
||||
handler: () => dispatch(checkIfCanJoin()),
|
||||
timeoutMs: 5000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens {@link LoginDialog} which will ask to enter username and password
|
||||
* for the current conference.
|
||||
*
|
||||
* @protected
|
||||
* @returns {Action}
|
||||
*/
|
||||
export function openLoginDialog() {
|
||||
return openDialog(LoginDialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the config with new options.
|
||||
*
|
||||
* @param {boolean} value - The new value.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function setTokenAuthUrlSuccess(value: boolean) {
|
||||
return {
|
||||
type: SET_TOKEN_AUTH_URL_SUCCESS,
|
||||
value
|
||||
};
|
||||
}
|
||||
90
react/features/authentication/actions.native.ts
Normal file
90
react/features/authentication/actions.native.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Linking } from 'react-native';
|
||||
|
||||
import { appNavigate } from '../app/actions.native';
|
||||
import { IStore } from '../app/types';
|
||||
import { conferenceLeft } from '../base/conference/actions';
|
||||
import { connectionFailed } from '../base/connection/actions.native';
|
||||
import { set } from '../base/redux/functions';
|
||||
|
||||
import { CANCEL_LOGIN } from './actionTypes';
|
||||
import { stopWaitForOwner } from './actions.any';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Cancels {@ink LoginDialog}.
|
||||
*
|
||||
* @returns {{
|
||||
* type: CANCEL_LOGIN
|
||||
* }}
|
||||
*/
|
||||
export function cancelLogin() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
dispatch({ type: CANCEL_LOGIN });
|
||||
|
||||
// XXX The error associated with CONNECTION_FAILED was marked as
|
||||
// recoverable by the authentication feature and, consequently,
|
||||
// recoverable-aware features such as mobile's external-api did not
|
||||
// deliver the CONFERENCE_FAILED to the SDK clients/consumers (as
|
||||
// a reaction to CONNECTION_FAILED). Since the
|
||||
// app/user is going to navigate to WelcomePage, the SDK
|
||||
// clients/consumers need an event.
|
||||
const { error = { recoverable: undefined }, passwordRequired }
|
||||
= getState()['features/base/connection'];
|
||||
|
||||
passwordRequired
|
||||
&& dispatch(
|
||||
connectionFailed(
|
||||
passwordRequired,
|
||||
set(error, 'recoverable', false) as any));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels {@link WaitForOwnerDialog}. Will navigate back to the welcome page.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function cancelWaitForOwner() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
dispatch(stopWaitForOwner());
|
||||
|
||||
// XXX The error associated with CONFERENCE_FAILED was marked as
|
||||
// recoverable by the feature room-lock and, consequently,
|
||||
// recoverable-aware features such as mobile's external-api did not
|
||||
// deliver the CONFERENCE_FAILED to the SDK clients/consumers. Since the
|
||||
// app/user is going to navigate to WelcomePage, the SDK
|
||||
// clients/consumers need an event.
|
||||
const { authRequired } = getState()['features/base/conference'];
|
||||
|
||||
if (authRequired) {
|
||||
dispatch(conferenceLeft(authRequired));
|
||||
|
||||
// in case we are showing lobby and on top of it wait for owner
|
||||
// we do not want to navigate away from the conference
|
||||
dispatch(appNavigate(undefined));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the default location (e.g. Welcome page).
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function redirectToDefaultLocation() {
|
||||
return (dispatch: IStore['dispatch']) => dispatch(appNavigate(undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens token auth URL page.
|
||||
*
|
||||
* @param {string} tokenAuthServiceUrl - Authentication service URL.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function openTokenAuthUrl(tokenAuthServiceUrl: string) {
|
||||
return () => {
|
||||
Linking.openURL(tokenAuthServiceUrl);
|
||||
};
|
||||
}
|
||||
78
react/features/authentication/actions.web.ts
Normal file
78
react/features/authentication/actions.web.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { maybeRedirectToWelcomePage } from '../app/actions.web';
|
||||
import { IStore } from '../app/types';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
|
||||
import { CANCEL_LOGIN } from './actionTypes';
|
||||
import LoginQuestionDialog from './components/web/LoginQuestionDialog';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Cancels {@ink LoginDialog}.
|
||||
*
|
||||
* @returns {{
|
||||
* type: CANCEL_LOGIN
|
||||
* }}
|
||||
*/
|
||||
export function cancelLogin() {
|
||||
return {
|
||||
type: CANCEL_LOGIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels authentication, closes {@link WaitForOwnerDialog}
|
||||
* and navigates back to the welcome page only in the case of authentication required error.
|
||||
* We can be showing the dialog while lobby is enabled and participant is still waiting there and hiding this dialog
|
||||
* should do nothing.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function cancelWaitForOwner() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const { authRequired } = getState()['features/base/conference'];
|
||||
|
||||
authRequired && dispatch(maybeRedirectToWelcomePage());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the default location (e.g. Welcome page).
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function redirectToDefaultLocation() {
|
||||
return (dispatch: IStore['dispatch']) => dispatch(maybeRedirectToWelcomePage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens token auth URL page.
|
||||
*
|
||||
* @param {string} tokenAuthServiceUrl - Authentication service URL.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function openTokenAuthUrl(tokenAuthServiceUrl: string): any {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const redirect = () => {
|
||||
if (browser.isElectron()) {
|
||||
window.open(tokenAuthServiceUrl, '_blank');
|
||||
} else {
|
||||
window.location.href = tokenAuthServiceUrl;
|
||||
}
|
||||
};
|
||||
|
||||
// Show warning for leaving conference only when in a conference.
|
||||
if (!browser.isElectron() && getState()['features/base/conference'].conference) {
|
||||
dispatch(openDialog(LoginQuestionDialog, {
|
||||
handler: () => {
|
||||
// Give time for the dialog to close.
|
||||
setTimeout(() => redirect(), 500);
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
redirect();
|
||||
}
|
||||
};
|
||||
}
|
||||
2
react/features/authentication/components/index.native.ts
Normal file
2
react/features/authentication/components/index.native.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoginDialog } from './native/LoginDialog';
|
||||
export { default as WaitForOwnerDialog } from './native/WaitForOwnerDialog';
|
||||
2
react/features/authentication/components/index.web.ts
Normal file
2
react/features/authentication/components/index.web.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoginDialog } from './web/LoginDialog';
|
||||
export { default as WaitForOwnerDialog } from './web/WaitForOwnerDialog';
|
||||
318
react/features/authentication/components/native/LoginDialog.tsx
Normal file
318
react/features/authentication/components/native/LoginDialog.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { Component } from 'react';
|
||||
import Dialog from 'react-native-dialog';
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { connect } from '../../../base/connection/actions.native';
|
||||
import { toJid } from '../../../base/connection/functions';
|
||||
import { _abstractMapStateToProps } from '../../../base/dialog/functions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { JitsiConnectionErrors } from '../../../base/lib-jitsi-meet';
|
||||
import { authenticateAndUpgradeRole, cancelLogin } from '../../actions.native';
|
||||
|
||||
/**
|
||||
* The type of the React {@link Component} props of {@link LoginDialog}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* {@link JitsiConference} That needs authentication - will hold a valid
|
||||
* value in XMPP login + guest access mode.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* The server hosts specified in the global config.
|
||||
*/
|
||||
_configHosts?: {
|
||||
anonymousdomain?: string;
|
||||
authdomain?: string;
|
||||
domain: string;
|
||||
focus?: string;
|
||||
muc: string;
|
||||
visitorFocus?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Indicates if the dialog should display "connecting" status message.
|
||||
*/
|
||||
_connecting: boolean;
|
||||
|
||||
/**
|
||||
* The error which occurred during login/authentication.
|
||||
*/
|
||||
_error: any;
|
||||
|
||||
/**
|
||||
* The progress in the floating range between 0 and 1 of the authenticating
|
||||
* and upgrading the role of the local participant/user.
|
||||
*/
|
||||
_progress?: number;
|
||||
|
||||
/**
|
||||
* Redux store dispatch method.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the React {@link Component} state of {@link LoginDialog}.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* The user entered password for the conference.
|
||||
*/
|
||||
password: string;
|
||||
|
||||
/**
|
||||
* The user entered local participant name.
|
||||
*/
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog asks user for username and password.
|
||||
*
|
||||
* First authentication configuration that it will deal with is the main XMPP
|
||||
* domain (config.hosts.domain) with password authentication. A LoginDialog
|
||||
* will be opened after 'CONNECTION_FAILED' action with
|
||||
* 'JitsiConnectionErrors.PASSWORD_REQUIRED' error. After username and password
|
||||
* are entered a new 'connect' action from 'features/base/connection' will be
|
||||
* triggered which will result in new XMPP connection. The conference will start
|
||||
* if the credentials are correct.
|
||||
*
|
||||
* The second setup is the main XMPP domain with password plus guest domain with
|
||||
* anonymous access configured under 'config.hosts.anonymousdomain'. In such
|
||||
* case user connects from the anonymous domain, but if the room does not exist
|
||||
* yet, Jicofo will not allow to start new conference. This will trigger
|
||||
* 'CONFERENCE_FAILED' action with JitsiConferenceErrors.AUTHENTICATION_REQUIRED
|
||||
* error and 'authRequired' value of 'features/base/conference' will hold
|
||||
* the {@link JitsiConference} instance. If user decides to authenticate, a
|
||||
* new/separate XMPP connection is established and authentication is performed.
|
||||
* In case it succeeds, Jicofo will assign new session ID which then can be used
|
||||
* from the anonymous domain connection to create and join the room. This part
|
||||
* is done by {@link JitsiConference#authenticateAndUpgradeRole} in
|
||||
* lib-jitsi-meet.
|
||||
*
|
||||
* See {@link https://github.com/jitsi/jicofo#secure-domain} for a description
|
||||
* of the configuration parameters.
|
||||
*/
|
||||
class LoginDialog extends Component<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new LoginDialog instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
username: '',
|
||||
password: ''
|
||||
};
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onCancel = this._onCancel.bind(this);
|
||||
this._onLogin = this._onLogin.bind(this);
|
||||
this._onPasswordChange = this._onPasswordChange.bind(this);
|
||||
this._onUsernameChange = this._onUsernameChange.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_connecting: connecting,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Dialog.Container
|
||||
coverScreen = { false }
|
||||
visible = { true }>
|
||||
<Dialog.Title>
|
||||
{ t('dialog.login') }
|
||||
</Dialog.Title>
|
||||
<Dialog.Input
|
||||
autoCapitalize = { 'none' }
|
||||
autoCorrect = { false }
|
||||
onChangeText = { this._onUsernameChange }
|
||||
placeholder = { 'user@domain.com' }
|
||||
spellCheck = { false }
|
||||
value = { this.state.username } />
|
||||
<Dialog.Input
|
||||
autoCapitalize = { 'none' }
|
||||
onChangeText = { this._onPasswordChange }
|
||||
placeholder = { t('dialog.userPassword') }
|
||||
secureTextEntry = { true }
|
||||
value = { this.state.password } />
|
||||
<Dialog.Description>
|
||||
{ this._renderMessage() }
|
||||
</Dialog.Description>
|
||||
<Dialog.Button
|
||||
label = { t('dialog.Cancel') }
|
||||
onPress = { this._onCancel } />
|
||||
<Dialog.Button
|
||||
disabled = { connecting }
|
||||
label = { t('dialog.Ok') }
|
||||
onPress = { this._onLogin } />
|
||||
</Dialog.Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an optional message, if applicable.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
* @private
|
||||
*/
|
||||
_renderMessage() {
|
||||
const {
|
||||
_connecting: connecting,
|
||||
_error: error,
|
||||
_progress: progress,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
let messageKey;
|
||||
const messageOptions = { msg: '' };
|
||||
|
||||
if (progress && progress < 1) {
|
||||
messageKey = 'connection.FETCH_SESSION_ID';
|
||||
} else if (error) {
|
||||
const { name } = error;
|
||||
|
||||
if (name === JitsiConnectionErrors.PASSWORD_REQUIRED) {
|
||||
// Show a message that the credentials are incorrect only if the
|
||||
// credentials which have caused the connection to fail are the
|
||||
// ones which the user sees.
|
||||
const { credentials } = error;
|
||||
|
||||
if (credentials
|
||||
&& credentials.jid
|
||||
=== toJid(
|
||||
this.state.username,
|
||||
this.props._configHosts ?? {})
|
||||
&& credentials.password === this.state.password) {
|
||||
messageKey = 'dialog.incorrectPassword';
|
||||
}
|
||||
} else if (name) {
|
||||
messageKey = 'dialog.connectErrorWithMsg';
|
||||
messageOptions.msg = `${name} ${error.message}`;
|
||||
}
|
||||
} else if (connecting) {
|
||||
messageKey = 'connection.CONNECTING';
|
||||
}
|
||||
|
||||
if (messageKey) {
|
||||
return t(messageKey, messageOptions);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user edits the username.
|
||||
*
|
||||
* @param {string} text - A new username value entered by user.
|
||||
* @returns {void}
|
||||
* @private
|
||||
*/
|
||||
_onUsernameChange(text: string) {
|
||||
this.setState({
|
||||
username: text.trim()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user edits the password.
|
||||
*
|
||||
* @param {string} text - A new password value entered by user.
|
||||
* @returns {void}
|
||||
* @private
|
||||
*/
|
||||
_onPasswordChange(text: string) {
|
||||
this.setState({
|
||||
password: text
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies this LoginDialog that it has been dismissed by cancel.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onCancel() {
|
||||
this.props.dispatch(cancelLogin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies this LoginDialog that the login button (OK) has been pressed by
|
||||
* the user.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLogin() {
|
||||
const { _conference: conference, dispatch } = this.props;
|
||||
const { password, username } = this.state;
|
||||
const jid = toJid(username, this.props._configHosts ?? {});
|
||||
let r;
|
||||
|
||||
// If there's a conference it means that the connection has succeeded,
|
||||
// but authentication is required in order to join the room.
|
||||
if (conference) {
|
||||
r = dispatch(authenticateAndUpgradeRole(jid, password, conference));
|
||||
} else {
|
||||
r = dispatch(connect(jid, password));
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code LoginDialog} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
error: authenticateAndUpgradeRoleError,
|
||||
progress,
|
||||
thenableWithCancel
|
||||
} = state['features/authentication'];
|
||||
const { authRequired, conference } = state['features/base/conference'];
|
||||
const { hosts: configHosts } = state['features/base/config'];
|
||||
const {
|
||||
connecting,
|
||||
error: connectionError
|
||||
} = state['features/base/connection'];
|
||||
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
_conference: authRequired || conference,
|
||||
_configHosts: configHosts,
|
||||
_connecting: Boolean(connecting) || Boolean(thenableWithCancel),
|
||||
_error: connectionError || authenticateAndUpgradeRoleError,
|
||||
_progress: progress
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(reduxConnect(_mapStateToProps)(LoginDialog));
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { cancelWaitForOwner, login } from '../../actions.native';
|
||||
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link WaitForOwnerDialog}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Whether to show alternative cancel button text.
|
||||
*/
|
||||
_alternativeCancelText?: boolean;
|
||||
|
||||
/**
|
||||
* Is confirm button hidden?
|
||||
*/
|
||||
_isConfirmHidden?: boolean;
|
||||
|
||||
/**
|
||||
* Redux store dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog is display in XMPP password + guest access configuration, after
|
||||
* user connects from anonymous domain and the conference does not exist yet.
|
||||
*
|
||||
* See {@link LoginDialog} description for more details.
|
||||
*/
|
||||
class WaitForOwnerDialog extends Component<IProps> {
|
||||
/**
|
||||
* Initializes a new WaitForWonderDialog instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onCancel = this._onCancel.bind(this);
|
||||
this._onLogin = this._onLogin.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { _isConfirmHidden } = this.props;
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
cancelLabel = { this.props._alternativeCancelText ? 'dialog.WaitingForHostButton' : 'dialog.Cancel' }
|
||||
confirmLabel = 'dialog.IamHost'
|
||||
descriptionKey = 'dialog.WaitForHostMsg'
|
||||
isConfirmHidden = { _isConfirmHidden }
|
||||
onCancel = { this._onCancel }
|
||||
onSubmit = { this._onLogin } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the cancel button is clicked.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onCancel() {
|
||||
this.props.dispatch(cancelWaitForOwner());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the OK button is clicked.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLogin() {
|
||||
this.props.dispatch(login());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated
|
||||
* {@code WaitForOwnerDialog}'s props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const { membersOnly, lobbyWaitingForHost } = state['features/base/conference'];
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
|
||||
return {
|
||||
_alternativeCancelText: membersOnly && lobbyWaitingForHost,
|
||||
_isConfirmHidden: locationURL?.hostname?.includes('8x8.vc')
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(WaitForOwnerDialog));
|
||||
299
react/features/authentication/components/web/LoginDialog.tsx
Normal file
299
react/features/authentication/components/web/LoginDialog.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { IConfig } from '../../../base/config/configType';
|
||||
import { connect } from '../../../base/connection/actions.web';
|
||||
import { toJid } from '../../../base/connection/functions';
|
||||
import { translate, translateToHTML } from '../../../base/i18n/functions';
|
||||
import { JitsiConnectionErrors } from '../../../base/lib-jitsi-meet';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import Input from '../../../base/ui/components/web/Input';
|
||||
import {
|
||||
authenticateAndUpgradeRole,
|
||||
cancelLogin
|
||||
} from '../../actions.web';
|
||||
import logger from '../../logger';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link LoginDialog}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* {@link JitsiConference} That needs authentication - will hold a valid
|
||||
* value in XMPP login + guest access mode.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* The server hosts specified in the global config.
|
||||
*/
|
||||
_configHosts: IConfig['hosts'];
|
||||
|
||||
/**
|
||||
* Indicates if the dialog should display "connecting" status message.
|
||||
*/
|
||||
_connecting: boolean;
|
||||
|
||||
/**
|
||||
* The error which occurred during login/authentication.
|
||||
*/
|
||||
_error: any;
|
||||
|
||||
/**
|
||||
* The progress in the floating range between 0 and 1 of the authenticating
|
||||
* and upgrading the role of the local participant/user.
|
||||
*/
|
||||
_progress?: number;
|
||||
|
||||
/**
|
||||
* Redux store dispatch method.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Conference room name.
|
||||
*/
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link LoginDialog}.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* The user entered password for the conference.
|
||||
*/
|
||||
password: string;
|
||||
|
||||
/**
|
||||
* The user entered local participant name.
|
||||
*/
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders the login in conference dialog.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
class LoginDialog extends Component<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new {@code LoginDialog} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
username: '',
|
||||
password: ''
|
||||
};
|
||||
|
||||
this._onCancelLogin = this._onCancelLogin.bind(this);
|
||||
this._onLogin = this._onLogin.bind(this);
|
||||
this._onUsernameChange = this._onUsernameChange.bind(this);
|
||||
this._onPasswordChange = this._onPasswordChange.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the cancel button is clicked.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onCancelLogin() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(cancelLogin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies this LoginDialog that the login button (OK) has been pressed by
|
||||
* the user.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLogin() {
|
||||
const {
|
||||
_conference: conference,
|
||||
_configHosts: configHosts,
|
||||
dispatch
|
||||
} = this.props;
|
||||
const { password, username } = this.state;
|
||||
const jid = toJid(username, configHosts ?? {
|
||||
authdomain: '',
|
||||
domain: ''
|
||||
});
|
||||
|
||||
if (conference) {
|
||||
dispatch(authenticateAndUpgradeRole(jid, password, conference));
|
||||
} else {
|
||||
logger.info('Dispatching connect from LoginDialog.');
|
||||
dispatch(connect(jid, password));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for the onChange event of the field.
|
||||
*
|
||||
* @param {string} value - The static event.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onPasswordChange(value: string) {
|
||||
this.setState({
|
||||
password: value
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for the onChange event of the username input.
|
||||
*
|
||||
* @param {string} value - The new value.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onUsernameChange(value: string) {
|
||||
this.setState({
|
||||
username: value
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an optional message, if applicable.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
* @private
|
||||
*/
|
||||
renderMessage() {
|
||||
const {
|
||||
_configHosts: configHosts,
|
||||
_connecting: connecting,
|
||||
_error: error,
|
||||
_progress: progress,
|
||||
t
|
||||
} = this.props;
|
||||
const { username, password } = this.state;
|
||||
const messageOptions: { msg?: string; } = {};
|
||||
let messageKey;
|
||||
|
||||
if (progress && progress < 1) {
|
||||
messageKey = 'connection.FETCH_SESSION_ID';
|
||||
} else if (error) {
|
||||
const { name } = error;
|
||||
|
||||
if (name === JitsiConnectionErrors.PASSWORD_REQUIRED) {
|
||||
const { credentials } = error;
|
||||
|
||||
if (credentials
|
||||
&& credentials.jid === toJid(username, configHosts ?? { authdomain: '',
|
||||
domain: '' })
|
||||
&& credentials.password === password) {
|
||||
messageKey = 'dialog.incorrectPassword';
|
||||
}
|
||||
} else if (name) {
|
||||
messageKey = 'dialog.connectErrorWithMsg';
|
||||
messageOptions.msg = `${name} ${error.message}`;
|
||||
}
|
||||
} else if (connecting) {
|
||||
messageKey = 'connection.CONNECTING';
|
||||
}
|
||||
|
||||
if (messageKey) {
|
||||
return (
|
||||
<span>
|
||||
{ translateToHTML(t, messageKey, messageOptions) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_connecting: connecting,
|
||||
t
|
||||
} = this.props;
|
||||
const { password, username } = this.state;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
disableAutoHideOnSubmit = { true }
|
||||
disableBackdropClose = { true }
|
||||
hideCloseButton = { true }
|
||||
ok = {{
|
||||
disabled: connecting
|
||||
|| !password
|
||||
|| !username,
|
||||
translationKey: 'dialog.login'
|
||||
}}
|
||||
onCancel = { this._onCancelLogin }
|
||||
onSubmit = { this._onLogin }
|
||||
titleKey = { t('dialog.authenticationRequired') }>
|
||||
<Input
|
||||
autoFocus = { true }
|
||||
id = 'login-dialog-username'
|
||||
label = { t('dialog.user') }
|
||||
name = 'username'
|
||||
onChange = { this._onUsernameChange }
|
||||
placeholder = { t('dialog.userIdentifier') }
|
||||
type = 'text'
|
||||
value = { username } />
|
||||
<br />
|
||||
<Input
|
||||
className = 'dialog-bottom-margin'
|
||||
id = 'login-dialog-password'
|
||||
label = { t('dialog.userPassword') }
|
||||
name = 'password'
|
||||
onChange = { this._onPasswordChange }
|
||||
placeholder = { t('dialog.password') }
|
||||
type = 'password'
|
||||
value = { password } />
|
||||
{ this.renderMessage() }
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code LoginDialog} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
error: authenticateAndUpgradeRoleError,
|
||||
progress,
|
||||
thenableWithCancel
|
||||
} = state['features/authentication'];
|
||||
const { authRequired, conference } = state['features/base/conference'];
|
||||
const { hosts: configHosts } = state['features/base/config'];
|
||||
const {
|
||||
connecting,
|
||||
error: connectionError
|
||||
} = state['features/base/connection'];
|
||||
|
||||
return {
|
||||
_conference: authRequired || conference,
|
||||
_configHosts: configHosts,
|
||||
_connecting: Boolean(connecting) || Boolean(thenableWithCancel),
|
||||
_error: connectionError || authenticateAndUpgradeRoleError,
|
||||
_progress: progress
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(reduxConnect(mapStateToProps)(LoginDialog));
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
|
||||
/**
|
||||
* The type of {@link LoginQuestionDialog}'s React {@code Component} props.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The handler.
|
||||
*/
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the dialog that warns the user that the login will leave the conference.
|
||||
*
|
||||
* @param {Object} props - The props of the component.
|
||||
* @returns {React$Element}
|
||||
*/
|
||||
const LoginQuestionDialog = ({ handler }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ok = {{ translationKey: 'dialog.Yes' }}
|
||||
onSubmit = { handler }
|
||||
titleKey = { t('dialog.login') }>
|
||||
<div>
|
||||
{ t('dialog.loginQuestion') }
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginQuestionDialog;
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import { cancelWaitForOwner, login } from '../../actions.web';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link WaitForOwnerDialog}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Whether to show alternative cancel button text.
|
||||
*/
|
||||
_alternativeCancelText?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to hide the login button.
|
||||
*/
|
||||
_hideLoginButton?: boolean;
|
||||
|
||||
/**
|
||||
* Redux store dispatch method.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication message dialog for host confirmation.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
class WaitForOwnerDialog extends PureComponent<IProps> {
|
||||
/**
|
||||
* Instantiates a new component.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onCancelWaitForOwner = this._onCancelWaitForOwner.bind(this);
|
||||
this._onIAmHost = this._onIAmHost.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the cancel button is clicked.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onCancelWaitForOwner() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(cancelWaitForOwner());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the OK button is clicked.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onIAmHost() {
|
||||
this.props.dispatch(login());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ translationKey:
|
||||
this.props._alternativeCancelText ? 'dialog.WaitingForHostButton' : 'dialog.Cancel' }}
|
||||
disableBackdropClose = { true }
|
||||
hideCloseButton = { true }
|
||||
ok = { this.props._hideLoginButton ? { hidden: true,
|
||||
disabled: true } : { translationKey: 'dialog.IamHost' } }
|
||||
onCancel = { this._onCancelWaitForOwner }
|
||||
onSubmit = { this._onIAmHost }
|
||||
titleKey = { t('dialog.WaitingForHostTitle') }>
|
||||
<span>
|
||||
{ this.props._hideLoginButton ? t('dialog.WaitForHostNoAuthMsg') : t('dialog.WaitForHostMsg') }
|
||||
</span>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated
|
||||
* {@code WaitForOwnerDialog}'s props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const { membersOnly, lobbyWaitingForHost } = state['features/base/conference'];
|
||||
const { hideLoginButton } = state['features/base/config'];
|
||||
|
||||
return {
|
||||
_alternativeCancelText: membersOnly && lobbyWaitingForHost,
|
||||
_hideLoginButton: hideLoginButton
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(WaitForOwnerDialog));
|
||||
86
react/features/authentication/functions.any.ts
Normal file
86
react/features/authentication/functions.any.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { IConfig } from '../base/config/configType';
|
||||
import { parseURLParams } from '../base/util/parseURLParams';
|
||||
import { getBackendSafeRoomName } from '../base/util/uri';
|
||||
|
||||
/**
|
||||
* Checks if the token for authentication is available.
|
||||
*
|
||||
* @param {Object} config - Configuration state object from store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTokenAuthEnabled = (config: IConfig): boolean =>
|
||||
typeof config.tokenAuthUrl === 'string' && config.tokenAuthUrl.length > 0;
|
||||
|
||||
/**
|
||||
* Returns the state that we can add as a parameter to the tokenAuthUrl.
|
||||
*
|
||||
* @param {URL} locationURL - The location URL.
|
||||
* @param {Object} options: - Config options {
|
||||
* audioMuted: boolean | undefined
|
||||
* audioOnlyEnabled: boolean | undefined,
|
||||
* skipPrejoin: boolean | undefined,
|
||||
* videoMuted: boolean | undefined
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
*
|
||||
* @returns {Object} The state object.
|
||||
*/
|
||||
export const _getTokenAuthState = (
|
||||
locationURL: URL,
|
||||
options: {
|
||||
audioMuted: boolean | undefined;
|
||||
audioOnlyEnabled: boolean | undefined;
|
||||
skipPrejoin: boolean | undefined;
|
||||
videoMuted: boolean | undefined;
|
||||
},
|
||||
roomName: string | undefined,
|
||||
tenant: string | undefined): object => {
|
||||
const state = {
|
||||
room: roomName,
|
||||
roomSafe: getBackendSafeRoomName(roomName),
|
||||
tenant
|
||||
};
|
||||
|
||||
const {
|
||||
audioMuted = false,
|
||||
audioOnlyEnabled = false,
|
||||
skipPrejoin = false,
|
||||
videoMuted = false
|
||||
} = options;
|
||||
|
||||
if (audioMuted) {
|
||||
|
||||
// @ts-ignore
|
||||
state['config.startWithAudioMuted'] = true;
|
||||
}
|
||||
|
||||
if (audioOnlyEnabled) {
|
||||
|
||||
// @ts-ignore
|
||||
state['config.startAudioOnly'] = true;
|
||||
}
|
||||
|
||||
if (skipPrejoin) {
|
||||
// We have already shown the prejoin screen, no need to show it again after obtaining the token.
|
||||
// @ts-ignore
|
||||
state['config.prejoinConfig.enabled'] = false;
|
||||
}
|
||||
|
||||
if (videoMuted) {
|
||||
|
||||
// @ts-ignore
|
||||
state['config.startWithVideoMuted'] = true;
|
||||
}
|
||||
const params = parseURLParams(locationURL);
|
||||
|
||||
for (const key of Object.keys(params)) {
|
||||
// we allow only config, interfaceConfig and iceServers overrides in the state
|
||||
if (key.startsWith('config.') || key.startsWith('interfaceConfig.') || key.startsWith('iceServers.')) {
|
||||
// @ts-ignore
|
||||
state[key] = params[key];
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
78
react/features/authentication/functions.native.ts
Normal file
78
react/features/authentication/functions.native.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { IConfig } from '../base/config/configType';
|
||||
|
||||
import { _getTokenAuthState } from './functions.any';
|
||||
|
||||
export * from './functions.any';
|
||||
|
||||
/**
|
||||
* Creates the URL pointing to JWT token authentication service. It is
|
||||
* formatted from the 'urlPattern' argument which can contain the following
|
||||
* constants:
|
||||
* '{room}' - name of the conference room passed as <tt>roomName</tt>
|
||||
* argument to this method.
|
||||
*
|
||||
* @param {Object} config - Configuration state object from store. A URL pattern pointing to the login service.
|
||||
* @param {URL} locationURL - The location URL.
|
||||
* @param {Object} options: - Config options {
|
||||
* audioMuted: boolean | undefined
|
||||
* audioOnlyEnabled: boolean | undefined,
|
||||
* skipPrejoin: boolean | undefined,
|
||||
* videoMuted: boolean | undefined
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
*
|
||||
* @returns {Promise<string|undefined>} - The URL pointing to JWT login service or
|
||||
* <tt>undefined</tt> if the pattern stored in config is not a string and the URL can not be
|
||||
* constructed.
|
||||
*/
|
||||
export const getTokenAuthUrl = (
|
||||
config: IConfig,
|
||||
locationURL: URL,
|
||||
options: {
|
||||
audioMuted: boolean | undefined;
|
||||
audioOnlyEnabled: boolean | undefined;
|
||||
skipPrejoin: boolean | undefined;
|
||||
videoMuted: boolean | undefined;
|
||||
},
|
||||
roomName: string | undefined,
|
||||
// eslint-disable-next-line max-params
|
||||
tenant: string | undefined): Promise<string | undefined> => {
|
||||
|
||||
const {
|
||||
audioMuted = false,
|
||||
audioOnlyEnabled = false,
|
||||
skipPrejoin = false,
|
||||
videoMuted = false
|
||||
} = options;
|
||||
|
||||
let url = config.tokenAuthUrl;
|
||||
|
||||
if (!url || !roomName) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
if (url.indexOf('{state}')) {
|
||||
const state = _getTokenAuthState(
|
||||
locationURL,
|
||||
{
|
||||
audioMuted,
|
||||
audioOnlyEnabled,
|
||||
skipPrejoin,
|
||||
videoMuted
|
||||
},
|
||||
roomName,
|
||||
tenant
|
||||
);
|
||||
|
||||
// Append ios=true or android=true to the token URL.
|
||||
// @ts-ignore
|
||||
state[Platform.OS] = true;
|
||||
|
||||
url = url.replace('{state}', encodeURIComponent(JSON.stringify(state)));
|
||||
}
|
||||
|
||||
return Promise.resolve(url.replace('{room}', roomName));
|
||||
};
|
||||
122
react/features/authentication/functions.web.ts
Normal file
122
react/features/authentication/functions.web.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import base64js from 'base64-js';
|
||||
|
||||
import { IConfig } from '../base/config/configType';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
|
||||
import { _getTokenAuthState } from './functions.any';
|
||||
|
||||
export * from './functions.any';
|
||||
|
||||
/**
|
||||
* Based on rfc7636 we need a random string for a code verifier.
|
||||
*/
|
||||
const POSSIBLE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
/**
|
||||
* Crypto random, alternative of Math.random.
|
||||
*
|
||||
* @returns {float} A random value.
|
||||
*/
|
||||
function _cryptoRandom() {
|
||||
const typedArray = new Uint8Array(1);
|
||||
const randomValue = crypto.getRandomValues(typedArray)[0];
|
||||
|
||||
return randomValue / Math.pow(2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the URL pointing to JWT token authentication service. It is
|
||||
* formatted from the 'urlPattern' argument which can contain the following
|
||||
* constants:
|
||||
* '{room}' - name of the conference room passed as <tt>roomName</tt>
|
||||
* argument to this method.
|
||||
*
|
||||
* @param {Object} config - Configuration state object from store. A URL pattern pointing to the login service.
|
||||
* @param {URL} locationURL - The location URL.
|
||||
* @param {Object} options: - Config options {
|
||||
* audioMuted: boolean | undefined
|
||||
* audioOnlyEnabled: boolean | undefined,
|
||||
* skipPrejoin: boolean | undefined,
|
||||
* videoMuted: boolean | undefined
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
*
|
||||
* @returns {Promise<string|undefined>} - The URL pointing to JWT login service or
|
||||
* <tt>undefined</tt> if the pattern stored in config is not a string and the URL can not be
|
||||
* constructed.
|
||||
*/
|
||||
export const getTokenAuthUrl = (
|
||||
config: IConfig,
|
||||
locationURL: URL,
|
||||
options: {
|
||||
audioMuted: boolean | undefined;
|
||||
audioOnlyEnabled: boolean | undefined;
|
||||
skipPrejoin: boolean | undefined;
|
||||
videoMuted: boolean | undefined;
|
||||
},
|
||||
roomName: string | undefined,
|
||||
// eslint-disable-next-line max-params
|
||||
tenant: string | undefined): Promise<string | undefined> => {
|
||||
|
||||
const {
|
||||
audioMuted = false,
|
||||
audioOnlyEnabled = false,
|
||||
skipPrejoin = false,
|
||||
videoMuted = false
|
||||
} = options;
|
||||
|
||||
let url = config.tokenAuthUrl;
|
||||
|
||||
if (!url || !roomName) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
if (url.indexOf('{state}')) {
|
||||
const state = _getTokenAuthState(
|
||||
locationURL,
|
||||
{
|
||||
audioMuted,
|
||||
audioOnlyEnabled,
|
||||
skipPrejoin,
|
||||
videoMuted
|
||||
},
|
||||
roomName,
|
||||
tenant
|
||||
);
|
||||
|
||||
if (browser.isElectron()) {
|
||||
// @ts-ignore
|
||||
state.electron = true;
|
||||
}
|
||||
|
||||
url = url.replace('{state}', encodeURIComponent(JSON.stringify(state)));
|
||||
}
|
||||
|
||||
url = url.replace('{room}', roomName);
|
||||
|
||||
if (url.indexOf('{code_challenge}')) {
|
||||
let codeVerifier = '';
|
||||
|
||||
// random string
|
||||
for (let i = 0; i < 64; i++) {
|
||||
codeVerifier += POSSIBLE_CHARS.charAt(Math.floor(_cryptoRandom() * POSSIBLE_CHARS.length));
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem('code_verifier', codeVerifier);
|
||||
|
||||
return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
|
||||
.then(digest => {
|
||||
// prepare code challenge - base64 encoding without padding as described in:
|
||||
// https://datatracker.ietf.org/doc/html/rfc7636#appendix-A
|
||||
const codeChallenge = base64js.fromByteArray(new Uint8Array(digest))
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
|
||||
return url ? url.replace('{code_challenge}', codeChallenge) : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(url);
|
||||
};
|
||||
3
react/features/authentication/logger.ts
Normal file
3
react/features/authentication/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/authentication');
|
||||
319
react/features/authentication/middleware.ts
Normal file
319
react/features/authentication/middleware.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { APP_WILL_NAVIGATE } from '../base/app/actionTypes';
|
||||
import {
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_LEFT
|
||||
} from '../base/conference/actionTypes';
|
||||
import { isRoomValid } from '../base/conference/functions';
|
||||
import { CONNECTION_ESTABLISHED, CONNECTION_FAILED } from '../base/connection/actionTypes';
|
||||
import { hideDialog } from '../base/dialog/actions';
|
||||
import { isDialogOpen } from '../base/dialog/functions';
|
||||
import {
|
||||
JitsiConferenceErrors,
|
||||
JitsiConnectionErrors
|
||||
} from '../base/lib-jitsi-meet';
|
||||
import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { isLocalTrackMuted } from '../base/tracks/functions.any';
|
||||
import { parseURIString } from '../base/util/uri';
|
||||
import { openLogoutDialog } from '../settings/actions';
|
||||
|
||||
import {
|
||||
CANCEL_LOGIN,
|
||||
LOGIN,
|
||||
LOGOUT,
|
||||
STOP_WAIT_FOR_OWNER,
|
||||
UPGRADE_ROLE_FINISHED,
|
||||
WAIT_FOR_OWNER
|
||||
} from './actionTypes';
|
||||
import {
|
||||
hideLoginDialog,
|
||||
openLoginDialog,
|
||||
openTokenAuthUrl,
|
||||
openWaitForOwnerDialog,
|
||||
redirectToDefaultLocation,
|
||||
setTokenAuthUrlSuccess,
|
||||
stopWaitForOwner,
|
||||
waitForOwner
|
||||
} from './actions';
|
||||
import { LoginDialog, WaitForOwnerDialog } from './components';
|
||||
import { getTokenAuthUrl, isTokenAuthEnabled } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
|
||||
/**
|
||||
* Middleware that captures connection or conference failed errors and controls
|
||||
* {@link WaitForOwnerDialog} and {@link LoginDialog}.
|
||||
*
|
||||
* FIXME Some of the complexity was introduced by the lack of dialog stacking.
|
||||
*
|
||||
* @param {Store} store - Redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case CANCEL_LOGIN: {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const { thenableWithCancel } = state['features/authentication'];
|
||||
|
||||
thenableWithCancel?.cancel();
|
||||
|
||||
// The LoginDialog can be opened on top of "wait for owner". The app
|
||||
// should navigate only if LoginDialog was open without the
|
||||
// WaitForOwnerDialog.
|
||||
if (!isDialogOpen(store, WaitForOwnerDialog)) {
|
||||
if (_isWaitingForOwner(store)) {
|
||||
// Instead of hiding show the new one.
|
||||
const result = next(action);
|
||||
|
||||
dispatch(openWaitForOwnerDialog());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
dispatch(hideLoginDialog());
|
||||
|
||||
const { authRequired, conference } = state['features/base/conference'];
|
||||
const { passwordRequired } = state['features/base/connection'];
|
||||
|
||||
// Only end the meeting if we are not already inside and trying to upgrade.
|
||||
// NOTE: Despite it's confusing name, `passwordRequired` implies an XMPP
|
||||
// connection auth error.
|
||||
if ((passwordRequired || authRequired) && !conference) {
|
||||
dispatch(redirectToDefaultLocation());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_FAILED: {
|
||||
const { error } = action;
|
||||
|
||||
// XXX The feature authentication affords recovery from
|
||||
// CONFERENCE_FAILED caused by
|
||||
// JitsiConferenceErrors.AUTHENTICATION_REQUIRED.
|
||||
let recoverable;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [ _lobbyJid, lobbyWaitingForHost ] = error.params;
|
||||
|
||||
if (error.name === JitsiConferenceErrors.AUTHENTICATION_REQUIRED
|
||||
|| (error.name === JitsiConferenceErrors.MEMBERS_ONLY_ERROR && lobbyWaitingForHost)) {
|
||||
if (typeof error.recoverable === 'undefined') {
|
||||
error.recoverable = true;
|
||||
}
|
||||
recoverable = error.recoverable;
|
||||
}
|
||||
if (recoverable) {
|
||||
store.dispatch(waitForOwner());
|
||||
} else {
|
||||
store.dispatch(stopWaitForOwner());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_JOINED: {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const config = state['features/base/config'];
|
||||
|
||||
if (isTokenAuthEnabled(config)
|
||||
&& config.tokenAuthUrlAutoRedirect
|
||||
&& state['features/base/jwt'].jwt) {
|
||||
// auto redirect is turned on and we have successfully logged in
|
||||
// let's mark that
|
||||
dispatch(setTokenAuthUrlSuccess(true));
|
||||
}
|
||||
|
||||
if (_isWaitingForOwner(store)) {
|
||||
store.dispatch(stopWaitForOwner());
|
||||
}
|
||||
store.dispatch(hideLoginDialog());
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_LEFT:
|
||||
store.dispatch(stopWaitForOwner());
|
||||
break;
|
||||
|
||||
case CONNECTION_ESTABLISHED:
|
||||
store.dispatch(hideLoginDialog());
|
||||
break;
|
||||
|
||||
case CONNECTION_FAILED: {
|
||||
const { error } = action;
|
||||
const { getState } = store;
|
||||
const state = getState();
|
||||
const { jwt } = state['features/base/jwt'];
|
||||
|
||||
if (error
|
||||
&& error.name === JitsiConnectionErrors.PASSWORD_REQUIRED
|
||||
&& typeof error.recoverable === 'undefined'
|
||||
&& !jwt) {
|
||||
error.recoverable = true;
|
||||
|
||||
_handleLogin(store);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case LOGIN: {
|
||||
_handleLogin(store);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case LOGOUT: {
|
||||
_handleLogout(store);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case APP_WILL_NAVIGATE: {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const config = state['features/base/config'];
|
||||
const room = state['features/base/conference'].room;
|
||||
|
||||
if (isRoomValid(room)
|
||||
&& config.tokenAuthUrl && config.tokenAuthUrlAutoRedirect
|
||||
&& state['features/authentication'].tokenAuthUrlSuccessful
|
||||
&& !state['features/base/jwt'].jwt) {
|
||||
// if we have auto redirect enabled, and we have previously logged in successfully
|
||||
// we will redirect to the auth url to get the token and login again
|
||||
// we want to mark token auth success to false as if login is unsuccessful
|
||||
// the participant can join anonymously and not go in login loop
|
||||
dispatch(setTokenAuthUrlSuccess(false));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case STOP_WAIT_FOR_OWNER:
|
||||
_clearExistingWaitForOwnerTimeout(store);
|
||||
store.dispatch(hideDialog(WaitForOwnerDialog));
|
||||
break;
|
||||
|
||||
case UPGRADE_ROLE_FINISHED: {
|
||||
const { error, progress } = action;
|
||||
|
||||
if (!error && progress === 1) {
|
||||
store.dispatch(hideLoginDialog());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case WAIT_FOR_OWNER: {
|
||||
_clearExistingWaitForOwnerTimeout(store);
|
||||
|
||||
const { handler, timeoutMs }: { handler: () => void; timeoutMs: number; } = action;
|
||||
|
||||
action.waitForOwnerTimeoutID = setTimeout(handler, timeoutMs);
|
||||
|
||||
// The WAIT_FOR_OWNER action is cyclic, and we don't want to hide the
|
||||
// login dialog every few seconds.
|
||||
isDialogOpen(store, LoginDialog)
|
||||
|| store.dispatch(openWaitForOwnerDialog());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Will clear the wait for conference owner timeout handler if any is currently
|
||||
* set.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _clearExistingWaitForOwnerTimeout({ getState }: IStore) {
|
||||
const { waitForOwnerTimeoutID } = getState()['features/authentication'];
|
||||
|
||||
waitForOwnerTimeoutID && clearTimeout(waitForOwnerTimeoutID);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the cyclic "wait for conference owner" task is currently scheduled.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isWaitingForOwner({ getState }: IStore) {
|
||||
return Boolean(getState()['features/authentication'].waitForOwnerTimeoutID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles login challenge. Opens login dialog or redirects to token auth URL.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleLogin({ dispatch, getState }: IStore) {
|
||||
const state = getState();
|
||||
const config = state['features/base/config'];
|
||||
const room = state['features/base/conference'].room;
|
||||
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
|
||||
const { tenant } = parseURIString(locationURL.href) || {};
|
||||
const { enabled: audioOnlyEnabled } = state['features/base/audio-only'];
|
||||
const audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
|
||||
const videoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
|
||||
|
||||
if (!room) {
|
||||
logger.warn('Cannot handle login, room is undefined!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTokenAuthEnabled(config)) {
|
||||
dispatch(openLoginDialog());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
getTokenAuthUrl(
|
||||
config,
|
||||
locationURL,
|
||||
{
|
||||
audioMuted,
|
||||
audioOnlyEnabled,
|
||||
skipPrejoin: true,
|
||||
videoMuted
|
||||
},
|
||||
room,
|
||||
tenant
|
||||
)
|
||||
.then((tokenAuthServiceUrl: string | undefined) => {
|
||||
if (!tokenAuthServiceUrl) {
|
||||
logger.warn('Cannot handle login, token service URL is not set');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return dispatch(openTokenAuthUrl(tokenAuthServiceUrl));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles logout challenge. Opens logout dialog and hangs up the conference.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {string} logoutUrl - The url for logging out.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleLogout({ dispatch, getState }: IStore) {
|
||||
const state = getState();
|
||||
const { conference } = state['features/base/conference'];
|
||||
|
||||
if (!conference) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(openLogoutDialog());
|
||||
}
|
||||
96
react/features/authentication/reducer.ts
Normal file
96
react/features/authentication/reducer.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import PersistenceRegistry from '../base/redux/PersistenceRegistry';
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { assign } from '../base/redux/functions';
|
||||
|
||||
import {
|
||||
CANCEL_LOGIN,
|
||||
SET_TOKEN_AUTH_URL_SUCCESS,
|
||||
STOP_WAIT_FOR_OWNER,
|
||||
UPGRADE_ROLE_FINISHED,
|
||||
UPGRADE_ROLE_STARTED,
|
||||
WAIT_FOR_OWNER
|
||||
} from './actionTypes';
|
||||
|
||||
export interface IAuthenticationState {
|
||||
error?: Object | undefined;
|
||||
progress?: number | undefined;
|
||||
thenableWithCancel?: {
|
||||
cancel: Function;
|
||||
};
|
||||
tokenAuthUrlSuccessful?: boolean;
|
||||
waitForOwnerTimeoutID?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the persistence of the feature {@code authentication}.
|
||||
*/
|
||||
PersistenceRegistry.register('features/authentication', {
|
||||
tokenAuthUrlSuccessful: true
|
||||
});
|
||||
|
||||
/**
|
||||
* Listens for actions which change the state of the authentication feature.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the authentication feature.
|
||||
* @param {Object} action - Action object.
|
||||
* @param {string} action.type - Type of action.
|
||||
* @returns {Object}
|
||||
*/
|
||||
ReducerRegistry.register<IAuthenticationState>('features/authentication',
|
||||
(state = {}, action): IAuthenticationState => {
|
||||
switch (action.type) {
|
||||
case CANCEL_LOGIN:
|
||||
return assign(state, {
|
||||
error: undefined,
|
||||
progress: undefined,
|
||||
thenableWithCancel: undefined
|
||||
});
|
||||
case SET_TOKEN_AUTH_URL_SUCCESS:
|
||||
return assign(state, {
|
||||
tokenAuthUrlSuccessful: action.value
|
||||
});
|
||||
|
||||
case STOP_WAIT_FOR_OWNER:
|
||||
return assign(state, {
|
||||
error: undefined,
|
||||
waitForOwnerTimeoutID: undefined
|
||||
});
|
||||
|
||||
case UPGRADE_ROLE_FINISHED: {
|
||||
let { thenableWithCancel } = action;
|
||||
|
||||
if (state.thenableWithCancel === thenableWithCancel) {
|
||||
const { error, progress } = action;
|
||||
|
||||
// An error interrupts the process of authenticating and upgrading
|
||||
// the role of the local participant/user i.e. the process is no
|
||||
// more. Obviously, the process seizes to exist also when it does
|
||||
// its whole job.
|
||||
if (error || progress === 1) {
|
||||
thenableWithCancel = undefined;
|
||||
}
|
||||
|
||||
return assign(state, {
|
||||
error,
|
||||
progress: progress || undefined,
|
||||
thenableWithCancel
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UPGRADE_ROLE_STARTED:
|
||||
return assign(state, {
|
||||
error: undefined,
|
||||
progress: undefined,
|
||||
thenableWithCancel: action.thenableWithCancel
|
||||
});
|
||||
|
||||
case WAIT_FOR_OWNER:
|
||||
return assign(state, {
|
||||
waitForOwnerTimeoutID: action.waitForOwnerTimeoutID
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
144
react/features/av-moderation/actionTypes.ts
Normal file
144
react/features/av-moderation/actionTypes.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The type of (redux) action which signals that A/V Moderation had been disabled.
|
||||
*
|
||||
* {
|
||||
* type: DISABLE_MODERATION
|
||||
* }
|
||||
*/
|
||||
export const DISABLE_MODERATION = 'DISABLE_MODERATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that the notification for audio/video unmute should
|
||||
* be dismissed.
|
||||
*
|
||||
* {
|
||||
* type: DISMISS_PARTICIPANT_PENDING_AUDIO
|
||||
* }
|
||||
*/
|
||||
export const DISMISS_PENDING_PARTICIPANT = 'DISMISS_PENDING_PARTICIPANT';
|
||||
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that A/V Moderation had been enabled.
|
||||
*
|
||||
* {
|
||||
* type: ENABLE_MODERATION
|
||||
* }
|
||||
*/
|
||||
export const ENABLE_MODERATION = 'ENABLE_MODERATION';
|
||||
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that Audio Moderation disable has been requested.
|
||||
*
|
||||
* {
|
||||
* type: REQUEST_DISABLE_AUDIO_MODERATION
|
||||
* }
|
||||
*/
|
||||
export const REQUEST_DISABLE_AUDIO_MODERATION = 'REQUEST_DISABLE_AUDIO_MODERATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that Desktop Moderation disable has been requested.
|
||||
*
|
||||
* {
|
||||
* type: REQUEST_DISABLE_DESKTOP_MODERATION
|
||||
* }
|
||||
*/
|
||||
export const REQUEST_DISABLE_DESKTOP_MODERATION = 'REQUEST_DISABLE_DESKTOP_MODERATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that Video Moderation disable has been requested.
|
||||
*
|
||||
* {
|
||||
* type: REQUEST_DISABLE_VIDEO_MODERATION
|
||||
* }
|
||||
*/
|
||||
export const REQUEST_DISABLE_VIDEO_MODERATION = 'REQUEST_DISABLE_VIDEO_MODERATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that Audio Moderation enable has been requested.
|
||||
*
|
||||
* {
|
||||
* type: REQUEST_ENABLE_AUDIO_MODERATION
|
||||
* }
|
||||
*/
|
||||
export const REQUEST_ENABLE_AUDIO_MODERATION = 'REQUEST_ENABLE_AUDIO_MODERATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that Desktop Moderation enable has been requested.
|
||||
*
|
||||
* {
|
||||
* type: REQUEST_ENABLE_DESKTOP_MODERATION
|
||||
* }
|
||||
*/
|
||||
export const REQUEST_ENABLE_DESKTOP_MODERATION = 'REQUEST_ENABLE_DESKTOP_MODERATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that Video Moderation enable has been requested.
|
||||
*
|
||||
* {
|
||||
* type: REQUEST_ENABLE_VIDEO_MODERATION
|
||||
* }
|
||||
*/
|
||||
export const REQUEST_ENABLE_VIDEO_MODERATION = 'REQUEST_ENABLE_VIDEO_MODERATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that the local participant had been approved.
|
||||
*
|
||||
* {
|
||||
* type: LOCAL_PARTICIPANT_APPROVED,
|
||||
* mediaType: MediaType
|
||||
* }
|
||||
*/
|
||||
export const LOCAL_PARTICIPANT_APPROVED = 'LOCAL_PARTICIPANT_APPROVED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that the local participant had been blocked.
|
||||
*
|
||||
* {
|
||||
* type: LOCAL_PARTICIPANT_REJECTED,
|
||||
* mediaType: MediaType
|
||||
* }
|
||||
*/
|
||||
export const LOCAL_PARTICIPANT_REJECTED = 'LOCAL_PARTICIPANT_REJECTED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals to show notification to the local participant.
|
||||
*
|
||||
* {
|
||||
* type: LOCAL_PARTICIPANT_MODERATION_NOTIFICATION
|
||||
* }
|
||||
*/
|
||||
export const LOCAL_PARTICIPANT_MODERATION_NOTIFICATION = 'LOCAL_PARTICIPANT_MODERATION_NOTIFICATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that a participant was approved for a media type.
|
||||
*
|
||||
* {
|
||||
* type: PARTICIPANT_APPROVED,
|
||||
* mediaType: MediaType
|
||||
* participantId: String
|
||||
* }
|
||||
*/
|
||||
export const PARTICIPANT_APPROVED = 'PARTICIPANT_APPROVED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that a participant was blocked for a media type.
|
||||
*
|
||||
* {
|
||||
* type: PARTICIPANT_REJECTED,
|
||||
* mediaType: MediaType
|
||||
* participantId: String
|
||||
* }
|
||||
*/
|
||||
export const PARTICIPANT_REJECTED = 'PARTICIPANT_REJECTED';
|
||||
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that a participant asked to have its audio unmuted.
|
||||
*
|
||||
* {
|
||||
* type: PARTICIPANT_PENDING_AUDIO
|
||||
* }
|
||||
*/
|
||||
export const PARTICIPANT_PENDING_AUDIO = 'PARTICIPANT_PENDING_AUDIO';
|
||||
386
react/features/av-moderation/actions.ts
Normal file
386
react/features/av-moderation/actions.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { batch } from 'react-redux';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
import { getConferenceState } from '../base/conference/functions';
|
||||
import { getParticipantById, isParticipantModerator } from '../base/participants/functions';
|
||||
import { IParticipant } from '../base/participants/types';
|
||||
|
||||
import {
|
||||
DISABLE_MODERATION,
|
||||
DISMISS_PENDING_PARTICIPANT,
|
||||
ENABLE_MODERATION,
|
||||
LOCAL_PARTICIPANT_APPROVED,
|
||||
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
|
||||
LOCAL_PARTICIPANT_REJECTED,
|
||||
PARTICIPANT_APPROVED,
|
||||
PARTICIPANT_PENDING_AUDIO,
|
||||
PARTICIPANT_REJECTED,
|
||||
REQUEST_DISABLE_AUDIO_MODERATION,
|
||||
REQUEST_DISABLE_DESKTOP_MODERATION,
|
||||
REQUEST_DISABLE_VIDEO_MODERATION,
|
||||
REQUEST_ENABLE_AUDIO_MODERATION,
|
||||
REQUEST_ENABLE_DESKTOP_MODERATION,
|
||||
REQUEST_ENABLE_VIDEO_MODERATION
|
||||
} from './actionTypes';
|
||||
import { MEDIA_TYPE, type MediaType } from './constants';
|
||||
import { isEnabledFromState, isForceMuted } from './functions';
|
||||
|
||||
/**
|
||||
* Action used by moderator to approve audio for a participant.
|
||||
*
|
||||
* @param {staring} id - The id of the participant to be approved.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const approveParticipantAudio = (id: string) => (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { conference } = getConferenceState(state);
|
||||
const participant = getParticipantById(state, id);
|
||||
|
||||
const isAudioModerationOn = isEnabledFromState(MEDIA_TYPE.AUDIO, state);
|
||||
const isVideoModerationOn = isEnabledFromState(MEDIA_TYPE.VIDEO, state);
|
||||
const isVideoForceMuted = isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
|
||||
|
||||
if (isAudioModerationOn || !isVideoModerationOn || !isVideoForceMuted) {
|
||||
conference?.avModerationApprove(MEDIA_TYPE.AUDIO, id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Action used by moderator to approve desktop for a participant.
|
||||
*
|
||||
* @param {staring} id - The id of the participant to be approved.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const approveParticipantDesktop = (id: string) => (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { conference } = getConferenceState(state);
|
||||
const participant = getParticipantById(state, id);
|
||||
|
||||
const isDesktopForceMuted = isForceMuted(participant, MEDIA_TYPE.DESKTOP, state);
|
||||
const isDesktopModerationOn = isEnabledFromState(MEDIA_TYPE.DESKTOP, state);
|
||||
|
||||
if (isDesktopModerationOn && isDesktopForceMuted) {
|
||||
conference?.avModerationApprove(MEDIA_TYPE.DESKTOP, id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Action used by moderator to approve video for a participant.
|
||||
*
|
||||
* @param {staring} id - The id of the participant to be approved.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const approveParticipantVideo = (id: string) => (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { conference } = getConferenceState(state);
|
||||
const participant = getParticipantById(state, id);
|
||||
|
||||
const isVideoForceMuted = isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
|
||||
const isVideoModerationOn = isEnabledFromState(MEDIA_TYPE.VIDEO, state);
|
||||
|
||||
if (isVideoModerationOn && isVideoForceMuted) {
|
||||
conference?.avModerationApprove(MEDIA_TYPE.VIDEO, id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Action used by moderator to approve audio and video for a participant.
|
||||
*
|
||||
* @param {staring} id - The id of the participant to be approved.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const approveParticipant = (id: string) => (dispatch: IStore['dispatch']) => {
|
||||
batch(() => {
|
||||
dispatch(approveParticipantAudio(id));
|
||||
dispatch(approveParticipantDesktop(id));
|
||||
dispatch(approveParticipantVideo(id));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Action used by moderator to reject audio for a participant.
|
||||
*
|
||||
* @param {staring} id - The id of the participant to be rejected.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const rejectParticipantAudio = (id: string) => (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { conference } = getConferenceState(state);
|
||||
const audioModeration = isEnabledFromState(MEDIA_TYPE.AUDIO, state);
|
||||
|
||||
const participant = getParticipantById(state, id);
|
||||
const isAudioForceMuted = isForceMuted(participant, MEDIA_TYPE.AUDIO, state);
|
||||
const isModerator = isParticipantModerator(participant);
|
||||
|
||||
if (audioModeration && !isAudioForceMuted && !isModerator) {
|
||||
conference?.avModerationReject(MEDIA_TYPE.AUDIO, id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Action used by moderator to reject desktop for a participant.
|
||||
*
|
||||
* @param {staring} id - The id of the participant to be rejected.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const rejectParticipantDesktop = (id: string) => (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { conference } = getConferenceState(state);
|
||||
const desktopModeration = isEnabledFromState(MEDIA_TYPE.DESKTOP, state);
|
||||
|
||||
const participant = getParticipantById(state, id);
|
||||
const isDesktopForceMuted = isForceMuted(participant, MEDIA_TYPE.DESKTOP, state);
|
||||
const isModerator = isParticipantModerator(participant);
|
||||
|
||||
if (desktopModeration && !isDesktopForceMuted && !isModerator) {
|
||||
conference?.avModerationReject(MEDIA_TYPE.DESKTOP, id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Action used by moderator to reject video for a participant.
|
||||
*
|
||||
* @param {staring} id - The id of the participant to be rejected.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const rejectParticipantVideo = (id: string) => (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { conference } = getConferenceState(state);
|
||||
const videoModeration = isEnabledFromState(MEDIA_TYPE.VIDEO, state);
|
||||
|
||||
const participant = getParticipantById(state, id);
|
||||
const isVideoForceMuted = isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
|
||||
const isModerator = isParticipantModerator(participant);
|
||||
|
||||
if (videoModeration && !isVideoForceMuted && !isModerator) {
|
||||
conference?.avModerationReject(MEDIA_TYPE.VIDEO, id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio or video moderation is disabled.
|
||||
*
|
||||
* @param {MediaType} mediaType - The media type that was disabled.
|
||||
* @param {JitsiParticipant} actor - The actor disabling.
|
||||
* @returns {{
|
||||
* type: REQUEST_DISABLE_MODERATED_AUDIO
|
||||
* }}
|
||||
*/
|
||||
export const disableModeration = (mediaType: MediaType, actor: Object) => {
|
||||
return {
|
||||
type: DISABLE_MODERATION,
|
||||
mediaType,
|
||||
actor
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Hides the notification with the participant that asked to unmute audio.
|
||||
*
|
||||
* @param {IParticipant} participant - The participant for which the notification to be hidden.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function dismissPendingAudioParticipant(participant: IParticipant) {
|
||||
return dismissPendingParticipant(participant.id, MEDIA_TYPE.AUDIO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the notification with the participant that asked to unmute.
|
||||
*
|
||||
* @param {string} id - The participant id for which the notification to be hidden.
|
||||
* @param {MediaType} mediaType - The media type.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function dismissPendingParticipant(id: string, mediaType: MediaType) {
|
||||
return {
|
||||
type: DISMISS_PENDING_PARTICIPANT,
|
||||
id,
|
||||
mediaType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio or video moderation is enabled.
|
||||
*
|
||||
* @param {MediaType} mediaType - The media type that was enabled.
|
||||
* @param {JitsiParticipant} actor - The actor enabling.
|
||||
* @returns {{
|
||||
* type: REQUEST_ENABLE_MODERATED_AUDIO
|
||||
* }}
|
||||
*/
|
||||
export const enableModeration = (mediaType: MediaType, actor: Object) => {
|
||||
return {
|
||||
type: ENABLE_MODERATION,
|
||||
mediaType,
|
||||
actor
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests disable of audio moderation.
|
||||
*
|
||||
* @returns {{
|
||||
* type: REQUEST_DISABLE_AUDIO_MODERATION
|
||||
* }}
|
||||
*/
|
||||
export const requestDisableAudioModeration = () => {
|
||||
return {
|
||||
type: REQUEST_DISABLE_AUDIO_MODERATION
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests disable of video moderation.
|
||||
*
|
||||
* @returns {{
|
||||
* type: REQUEST_DISABLE_DESKTOP_MODERATION
|
||||
* }}
|
||||
*/
|
||||
export const requestDisableDesktopModeration = () => {
|
||||
return {
|
||||
type: REQUEST_DISABLE_DESKTOP_MODERATION
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests disable of video moderation.
|
||||
*
|
||||
* @returns {{
|
||||
* type: REQUEST_DISABLE_VIDEO_MODERATION
|
||||
* }}
|
||||
*/
|
||||
export const requestDisableVideoModeration = () => {
|
||||
return {
|
||||
type: REQUEST_DISABLE_VIDEO_MODERATION
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests enable of audio moderation.
|
||||
*
|
||||
* @returns {{
|
||||
* type: REQUEST_ENABLE_AUDIO_MODERATION
|
||||
* }}
|
||||
*/
|
||||
export const requestEnableAudioModeration = () => {
|
||||
return {
|
||||
type: REQUEST_ENABLE_AUDIO_MODERATION
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests enable of video moderation.
|
||||
*
|
||||
* @returns {{
|
||||
* type: REQUEST_ENABLE_DESKTOP_MODERATION
|
||||
* }}
|
||||
*/
|
||||
export const requestEnableDesktopModeration = () => {
|
||||
return {
|
||||
type: REQUEST_ENABLE_DESKTOP_MODERATION
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests enable of video moderation.
|
||||
*
|
||||
* @returns {{
|
||||
* type: REQUEST_ENABLE_VIDEO_MODERATION
|
||||
* }}
|
||||
*/
|
||||
export const requestEnableVideoModeration = () => {
|
||||
return {
|
||||
type: REQUEST_ENABLE_VIDEO_MODERATION
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Local participant was approved to be able to unmute audio and video.
|
||||
*
|
||||
* @param {MediaType} mediaType - The media type to disable.
|
||||
* @returns {{
|
||||
* type: LOCAL_PARTICIPANT_APPROVED
|
||||
* }}
|
||||
*/
|
||||
export const localParticipantApproved = (mediaType: MediaType) => {
|
||||
return {
|
||||
type: LOCAL_PARTICIPANT_APPROVED,
|
||||
mediaType
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Local participant was blocked to be able to unmute audio and video.
|
||||
*
|
||||
* @param {MediaType} mediaType - The media type to disable.
|
||||
* @returns {{
|
||||
* type: LOCAL_PARTICIPANT_REJECTED
|
||||
* }}
|
||||
*/
|
||||
export const localParticipantRejected = (mediaType: MediaType) => {
|
||||
return {
|
||||
type: LOCAL_PARTICIPANT_REJECTED,
|
||||
mediaType
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows notification when A/V moderation is enabled and local participant is still not approved.
|
||||
*
|
||||
* @param {MediaType} mediaType - Audio or video media type.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function showModeratedNotification(mediaType: MediaType) {
|
||||
return {
|
||||
type: LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
|
||||
mediaType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification with the participant that asked to audio unmute.
|
||||
*
|
||||
* @param {IParticipant} participant - The participant for which is the notification.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function participantPendingAudio(participant: IParticipant) {
|
||||
return {
|
||||
type: PARTICIPANT_PENDING_AUDIO,
|
||||
participant
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A participant was approved to unmute for a mediaType.
|
||||
*
|
||||
* @param {string} id - The id of the approved participant.
|
||||
* @param {MediaType} mediaType - The media type which was approved.
|
||||
* @returns {{
|
||||
* type: PARTICIPANT_APPROVED,
|
||||
* }}
|
||||
*/
|
||||
export function participantApproved(id: string, mediaType: MediaType) {
|
||||
return {
|
||||
type: PARTICIPANT_APPROVED,
|
||||
id,
|
||||
mediaType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A participant was blocked to unmute for a mediaType.
|
||||
*
|
||||
* @param {string} id - The id of the approved participant.
|
||||
* @param {MediaType} mediaType - The media type which was approved.
|
||||
* @returns {{
|
||||
* type: PARTICIPANT_REJECTED,
|
||||
* }}
|
||||
*/
|
||||
export function participantRejected(id: string, mediaType: MediaType) {
|
||||
return {
|
||||
type: PARTICIPANT_REJECTED,
|
||||
id,
|
||||
mediaType
|
||||
};
|
||||
}
|
||||
51
react/features/av-moderation/constants.ts
Normal file
51
react/features/av-moderation/constants.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type MediaType = 'audio' | 'video' | 'desktop';
|
||||
|
||||
/**
|
||||
* The set of media types for AV moderation.
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const MEDIA_TYPE: {
|
||||
AUDIO: MediaType;
|
||||
DESKTOP: MediaType;
|
||||
VIDEO: MediaType;
|
||||
} = {
|
||||
AUDIO: 'audio',
|
||||
DESKTOP: 'desktop',
|
||||
VIDEO: 'video'
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping between a media type and the whitelist reducer key.
|
||||
*/
|
||||
export const MEDIA_TYPE_TO_WHITELIST_STORE_KEY: { [key: string]: string; } = {
|
||||
[MEDIA_TYPE.AUDIO]: 'audioWhitelist',
|
||||
[MEDIA_TYPE.DESKTOP]: 'desktopWhitelist',
|
||||
[MEDIA_TYPE.VIDEO]: 'videoWhitelist'
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping between a media type and the pending reducer key.
|
||||
*/
|
||||
export const MEDIA_TYPE_TO_PENDING_STORE_KEY: { [key: string]: 'pendingAudio' | 'pendingDesktop' | 'pendingVideo'; } = {
|
||||
[MEDIA_TYPE.AUDIO]: 'pendingAudio',
|
||||
[MEDIA_TYPE.DESKTOP]: 'pendingDesktop',
|
||||
[MEDIA_TYPE.VIDEO]: 'pendingVideo'
|
||||
};
|
||||
|
||||
export const ASKED_TO_UNMUTE_NOTIFICATION_ID = 'asked-to-unmute';
|
||||
export const ASKED_TO_UNMUTE_SOUND_ID = 'ASKED_TO_UNMUTE_SOUND';
|
||||
|
||||
export const AUDIO_MODERATION_NOTIFICATION_ID = 'audio-moderation';
|
||||
export const DESKTOP_MODERATION_NOTIFICATION_ID = 'desktop-moderation';
|
||||
export const VIDEO_MODERATION_NOTIFICATION_ID = 'video-moderation';
|
||||
|
||||
export const AUDIO_RAISED_HAND_NOTIFICATION_ID = 'raise-hand-audio';
|
||||
export const DESKTOP_RAISED_HAND_NOTIFICATION_ID = 'raise-hand-desktop';
|
||||
export const VIDEO_RAISED_HAND_NOTIFICATION_ID = 'raise-hand-video';
|
||||
|
||||
export const MODERATION_NOTIFICATIONS = {
|
||||
[MEDIA_TYPE.AUDIO]: AUDIO_MODERATION_NOTIFICATION_ID,
|
||||
[MEDIA_TYPE.DESKTOP]: DESKTOP_MODERATION_NOTIFICATION_ID,
|
||||
[MEDIA_TYPE.VIDEO]: VIDEO_MODERATION_NOTIFICATION_ID
|
||||
};
|
||||
182
react/features/av-moderation/functions.ts
Normal file
182
react/features/av-moderation/functions.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { isLocalParticipantModerator, isParticipantModerator } from '../base/participants/functions';
|
||||
import { IParticipant } from '../base/participants/types';
|
||||
import { isInBreakoutRoom } from '../breakout-rooms/functions';
|
||||
|
||||
import {
|
||||
MEDIA_TYPE,
|
||||
MEDIA_TYPE_TO_PENDING_STORE_KEY,
|
||||
MEDIA_TYPE_TO_WHITELIST_STORE_KEY,
|
||||
MediaType
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* Returns this feature's root state.
|
||||
*
|
||||
* @param {IReduxState} state - Global state.
|
||||
* @returns {Object} Feature state.
|
||||
*/
|
||||
const getState = (state: IReduxState) => state['features/av-moderation'];
|
||||
|
||||
/**
|
||||
* We use to construct once the empty array so we can keep the same instance between calls
|
||||
* of getParticipantsAskingToAudioUnmute.
|
||||
*
|
||||
* @type {any[]}
|
||||
*/
|
||||
const EMPTY_ARRAY: any[] = [];
|
||||
|
||||
/**
|
||||
* Returns whether moderation is enabled per media type.
|
||||
*
|
||||
* @param {MEDIA_TYPE} mediaType - The media type to check.
|
||||
* @param {IReduxState} state - Global state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isEnabledFromState = (mediaType: MediaType, state: IReduxState) => {
|
||||
switch (mediaType) {
|
||||
case MEDIA_TYPE.AUDIO:
|
||||
return getState(state)?.audioModerationEnabled === true;
|
||||
case MEDIA_TYPE.DESKTOP:
|
||||
return getState(state)?.desktopModerationEnabled === true;
|
||||
case MEDIA_TYPE.VIDEO:
|
||||
return getState(state)?.videoModerationEnabled === true;
|
||||
default:
|
||||
throw new Error(`Unknown media type: ${mediaType}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether moderation is enabled per media type.
|
||||
*
|
||||
* @param {MEDIA_TYPE} mediaType - The media type to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isEnabled = (mediaType: MediaType) => (state: IReduxState) => isEnabledFromState(mediaType, state);
|
||||
|
||||
/**
|
||||
* Returns whether moderation is supported by the backend.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isSupported = () => (state: IReduxState) => {
|
||||
const { conference } = state['features/base/conference'];
|
||||
|
||||
return Boolean(!isInBreakoutRoom(state) && conference?.isAVModerationSupported());
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether local participant is approved to unmute a media type.
|
||||
*
|
||||
* @param {MEDIA_TYPE} mediaType - The media type to check.
|
||||
* @param {IReduxState} state - Global state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isLocalParticipantApprovedFromState = (mediaType: MediaType, state: IReduxState) => {
|
||||
if (isLocalParticipantModerator(state)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (mediaType) {
|
||||
case MEDIA_TYPE.AUDIO:
|
||||
return getState(state).audioUnmuteApproved === true;
|
||||
case MEDIA_TYPE.DESKTOP:
|
||||
return getState(state).desktopUnmuteApproved === true;
|
||||
case MEDIA_TYPE.VIDEO:
|
||||
return getState(state).videoUnmuteApproved === true;
|
||||
default:
|
||||
throw new Error(`Unknown media type: ${mediaType}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether local participant is approved to unmute a media type.
|
||||
*
|
||||
* @param {MEDIA_TYPE} mediaType - The media type to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isLocalParticipantApproved = (mediaType: MediaType) =>
|
||||
(state: IReduxState) =>
|
||||
isLocalParticipantApprovedFromState(mediaType, state);
|
||||
|
||||
/**
|
||||
* Returns a selector creator which determines if the participant is approved or not for a media type.
|
||||
*
|
||||
* @param {string} id - The participant id.
|
||||
* @param {MEDIA_TYPE} mediaType - The media type to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isParticipantApproved = (id: string, mediaType: MediaType) => (state: IReduxState) => {
|
||||
const storeKey = MEDIA_TYPE_TO_WHITELIST_STORE_KEY[mediaType];
|
||||
|
||||
const avModerationState = getState(state);
|
||||
const stateForMediaType = avModerationState[storeKey as keyof typeof avModerationState];
|
||||
|
||||
return Boolean(stateForMediaType && stateForMediaType[id as keyof typeof stateForMediaType]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a selector creator which determines if the participant is pending or not for a media type.
|
||||
*
|
||||
* @param {IParticipant} participant - The participant.
|
||||
* @param {MEDIA_TYPE} mediaType - The media type to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isParticipantPending = (participant: IParticipant, mediaType: MediaType) => (state: IReduxState) => {
|
||||
const storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType];
|
||||
const arr = getState(state)[storeKey];
|
||||
|
||||
return Boolean(arr.find(pending => pending.id === participant.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Selector which returns a list with all the participants asking to audio unmute.
|
||||
* This is visible only for the moderator.
|
||||
*
|
||||
* @param {Object} state - The global state.
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export const getParticipantsAskingToAudioUnmute = (state: IReduxState) => {
|
||||
if (isLocalParticipantModerator(state)) {
|
||||
return getState(state).pendingAudio;
|
||||
}
|
||||
|
||||
return EMPTY_ARRAY;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if a special notification can be displayed when a participant
|
||||
* tries to unmute.
|
||||
*
|
||||
* @param {MediaType} mediaType - 'audio' or 'video' media type.
|
||||
* @param {Object} state - The global state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const shouldShowModeratedNotification = (mediaType: MediaType, state: IReduxState) =>
|
||||
isEnabledFromState(mediaType, state)
|
||||
&& !isLocalParticipantApprovedFromState(mediaType, state);
|
||||
|
||||
/**
|
||||
* Checks if a participant is force muted.
|
||||
*
|
||||
* @param {IParticipant|undefined} participant - The participant.
|
||||
* @param {MediaType} mediaType - The media type.
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {MediaState}
|
||||
*/
|
||||
export function isForceMuted(participant: IParticipant | undefined, mediaType: MediaType, state: IReduxState) {
|
||||
if (isEnabledFromState(mediaType, state)) {
|
||||
if (participant?.local) {
|
||||
return !isLocalParticipantApprovedFromState(mediaType, state);
|
||||
}
|
||||
|
||||
// moderators cannot be force muted
|
||||
if (isParticipantModerator(participant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isParticipantApproved(participant?.id ?? '', mediaType)(state);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
317
react/features/av-moderation/middleware.ts
Normal file
317
react/features/av-moderation/middleware.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { batch } from 'react-redux';
|
||||
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
|
||||
import { getConferenceState } from '../base/conference/functions';
|
||||
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
|
||||
import { MEDIA_TYPE as TRACK_MEDIA_TYPE } from '../base/media/constants';
|
||||
import {
|
||||
isAudioMuted,
|
||||
isScreenshareMuted,
|
||||
isVideoMuted
|
||||
} from '../base/media/functions';
|
||||
import { PARTICIPANT_UPDATED } from '../base/participants/actionTypes';
|
||||
import { raiseHand } from '../base/participants/actions';
|
||||
import {
|
||||
getLocalParticipant,
|
||||
getRemoteParticipants,
|
||||
hasRaisedHand,
|
||||
isLocalParticipantModerator,
|
||||
isParticipantModerator
|
||||
} from '../base/participants/functions';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { playSound, registerSound, unregisterSound } from '../base/sounds/actions';
|
||||
import { hideNotification, showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
|
||||
import { muteLocal } from '../video-menu/actions.any';
|
||||
|
||||
import {
|
||||
DISABLE_MODERATION,
|
||||
ENABLE_MODERATION,
|
||||
LOCAL_PARTICIPANT_APPROVED,
|
||||
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
|
||||
LOCAL_PARTICIPANT_REJECTED,
|
||||
PARTICIPANT_APPROVED,
|
||||
PARTICIPANT_REJECTED,
|
||||
REQUEST_DISABLE_AUDIO_MODERATION,
|
||||
REQUEST_DISABLE_DESKTOP_MODERATION,
|
||||
REQUEST_DISABLE_VIDEO_MODERATION,
|
||||
REQUEST_ENABLE_AUDIO_MODERATION,
|
||||
REQUEST_ENABLE_DESKTOP_MODERATION,
|
||||
REQUEST_ENABLE_VIDEO_MODERATION
|
||||
} from './actionTypes';
|
||||
import {
|
||||
disableModeration,
|
||||
dismissPendingAudioParticipant,
|
||||
dismissPendingParticipant,
|
||||
enableModeration,
|
||||
localParticipantApproved,
|
||||
localParticipantRejected,
|
||||
participantApproved,
|
||||
participantPendingAudio,
|
||||
participantRejected
|
||||
} from './actions';
|
||||
import {
|
||||
ASKED_TO_UNMUTE_NOTIFICATION_ID,
|
||||
ASKED_TO_UNMUTE_SOUND_ID,
|
||||
AUDIO_MODERATION_NOTIFICATION_ID,
|
||||
DESKTOP_MODERATION_NOTIFICATION_ID,
|
||||
MEDIA_TYPE,
|
||||
MediaType,
|
||||
VIDEO_MODERATION_NOTIFICATION_ID,
|
||||
} from './constants';
|
||||
import {
|
||||
isEnabledFromState,
|
||||
isParticipantApproved,
|
||||
isParticipantPending
|
||||
} from './functions';
|
||||
import { ASKED_TO_UNMUTE_FILE } from './sounds';
|
||||
|
||||
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
|
||||
const { type } = action;
|
||||
const { conference } = getConferenceState(getState());
|
||||
|
||||
switch (type) {
|
||||
case APP_WILL_MOUNT: {
|
||||
dispatch(registerSound(ASKED_TO_UNMUTE_SOUND_ID, ASKED_TO_UNMUTE_FILE));
|
||||
break;
|
||||
}
|
||||
case APP_WILL_UNMOUNT: {
|
||||
dispatch(unregisterSound(ASKED_TO_UNMUTE_SOUND_ID));
|
||||
break;
|
||||
}
|
||||
case LOCAL_PARTICIPANT_MODERATION_NOTIFICATION: {
|
||||
let descriptionKey;
|
||||
let titleKey;
|
||||
let uid = '';
|
||||
const localParticipant = getLocalParticipant(getState);
|
||||
const raisedHand = hasRaisedHand(localParticipant);
|
||||
|
||||
switch (action.mediaType) {
|
||||
case MEDIA_TYPE.AUDIO: {
|
||||
titleKey = 'notify.moderationInEffectTitle';
|
||||
uid = AUDIO_MODERATION_NOTIFICATION_ID;
|
||||
break;
|
||||
}
|
||||
case MEDIA_TYPE.VIDEO: {
|
||||
titleKey = 'notify.moderationInEffectVideoTitle';
|
||||
uid = VIDEO_MODERATION_NOTIFICATION_ID;
|
||||
break;
|
||||
}
|
||||
case MEDIA_TYPE.DESKTOP: {
|
||||
titleKey = 'notify.moderationInEffectCSTitle';
|
||||
uid = DESKTOP_MODERATION_NOTIFICATION_ID;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(showNotification({
|
||||
customActionNameKey: [ 'notify.raiseHandAction' ],
|
||||
customActionHandler: [ () => batch(() => {
|
||||
!raisedHand && dispatch(raiseHand(true));
|
||||
dispatch(hideNotification(uid));
|
||||
}) ],
|
||||
descriptionKey,
|
||||
sticky: true,
|
||||
titleKey,
|
||||
uid
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
|
||||
break;
|
||||
}
|
||||
case REQUEST_DISABLE_AUDIO_MODERATION: {
|
||||
conference?.disableAVModeration(MEDIA_TYPE.AUDIO);
|
||||
break;
|
||||
}
|
||||
case REQUEST_DISABLE_DESKTOP_MODERATION: {
|
||||
conference?.disableAVModeration(MEDIA_TYPE.DESKTOP);
|
||||
break;
|
||||
}
|
||||
case REQUEST_DISABLE_VIDEO_MODERATION: {
|
||||
conference?.disableAVModeration(MEDIA_TYPE.VIDEO);
|
||||
break;
|
||||
}
|
||||
case REQUEST_ENABLE_AUDIO_MODERATION: {
|
||||
conference?.enableAVModeration(MEDIA_TYPE.AUDIO);
|
||||
break;
|
||||
}
|
||||
case REQUEST_ENABLE_DESKTOP_MODERATION: {
|
||||
conference?.enableAVModeration(MEDIA_TYPE.DESKTOP);
|
||||
break;
|
||||
}
|
||||
case REQUEST_ENABLE_VIDEO_MODERATION: {
|
||||
conference?.enableAVModeration(MEDIA_TYPE.VIDEO);
|
||||
break;
|
||||
}
|
||||
case PARTICIPANT_UPDATED: {
|
||||
const state = getState();
|
||||
const audioModerationEnabled = isEnabledFromState(MEDIA_TYPE.AUDIO, state);
|
||||
const participant = action.participant;
|
||||
|
||||
if (participant && audioModerationEnabled) {
|
||||
if (isLocalParticipantModerator(state)) {
|
||||
|
||||
// this is handled only by moderators
|
||||
if (hasRaisedHand(participant)) {
|
||||
// if participant raises hand show notification
|
||||
!isParticipantApproved(participant.id, MEDIA_TYPE.AUDIO)(state)
|
||||
&& dispatch(participantPendingAudio(participant));
|
||||
} else {
|
||||
// if participant lowers hand hide notification
|
||||
isParticipantPending(participant, MEDIA_TYPE.AUDIO)(state)
|
||||
&& dispatch(dismissPendingAudioParticipant(participant));
|
||||
}
|
||||
} else if (participant.id === getLocalParticipant(state)?.id
|
||||
&& /* the new role */ isParticipantModerator(participant)) {
|
||||
|
||||
// this is the granted moderator case
|
||||
getRemoteParticipants(state).forEach(p => {
|
||||
hasRaisedHand(p) && !isParticipantApproved(p.id, MEDIA_TYPE.AUDIO)(state)
|
||||
&& dispatch(participantPendingAudio(p));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case ENABLE_MODERATION: {
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyModerationChanged(action.mediaType, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DISABLE_MODERATION: {
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyModerationChanged(action.mediaType, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LOCAL_PARTICIPANT_APPROVED: {
|
||||
if (typeof APP !== 'undefined') {
|
||||
const local = getLocalParticipant(getState());
|
||||
|
||||
APP.API.notifyParticipantApproved(local?.id, action.mediaType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PARTICIPANT_APPROVED: {
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyParticipantApproved(action.id, action.mediaType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LOCAL_PARTICIPANT_REJECTED: {
|
||||
if (typeof APP !== 'undefined') {
|
||||
const local = getLocalParticipant(getState());
|
||||
|
||||
APP.API.notifyParticipantRejected(local?.id, action.mediaType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PARTICIPANT_REJECTED: {
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyParticipantRejected(action.id, action.mediaType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Registers a change handler for state['features/base/conference'].conference to
|
||||
* set the event listeners needed for the A/V moderation feature to operate.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/base/conference'].conference,
|
||||
(conference, { dispatch, getState }, previousConference) => {
|
||||
if (conference && !previousConference) {
|
||||
// local participant is allowed to unmute
|
||||
conference.on(JitsiConferenceEvents.AV_MODERATION_APPROVED, ({ mediaType }: { mediaType: MediaType; }) => {
|
||||
dispatch(localParticipantApproved(mediaType));
|
||||
|
||||
const customActionNameKey = [];
|
||||
const customActionHandler = [];
|
||||
|
||||
if ((mediaType === MEDIA_TYPE.AUDIO || getState()['features/av-moderation'].audioUnmuteApproved)
|
||||
&& isAudioMuted(getState())) {
|
||||
customActionNameKey.push('notify.unmute');
|
||||
customActionHandler.push(() => {
|
||||
dispatch(muteLocal(false, TRACK_MEDIA_TYPE.AUDIO));
|
||||
dispatch(hideNotification(ASKED_TO_UNMUTE_NOTIFICATION_ID));
|
||||
});
|
||||
}
|
||||
|
||||
if ((mediaType === MEDIA_TYPE.DESKTOP || getState()['features/av-moderation'].desktopUnmuteApproved)
|
||||
&& isScreenshareMuted(getState())) {
|
||||
customActionNameKey.push('notify.unmuteScreen');
|
||||
customActionHandler.push(() => {
|
||||
dispatch(muteLocal(false, TRACK_MEDIA_TYPE.SCREENSHARE));
|
||||
dispatch(hideNotification(ASKED_TO_UNMUTE_NOTIFICATION_ID));
|
||||
|
||||
// Since permission is requested by raising the hand, lower it not to rely on dominant speaker detection
|
||||
// to clear the hand.
|
||||
dispatch(raiseHand(false));
|
||||
});
|
||||
}
|
||||
|
||||
if ((mediaType === MEDIA_TYPE.VIDEO || getState()['features/av-moderation'].videoUnmuteApproved)
|
||||
&& isVideoMuted(getState())) {
|
||||
customActionNameKey.push('notify.unmuteVideo');
|
||||
customActionHandler.push(() => {
|
||||
dispatch(muteLocal(false, TRACK_MEDIA_TYPE.VIDEO));
|
||||
dispatch(hideNotification(ASKED_TO_UNMUTE_NOTIFICATION_ID));
|
||||
|
||||
// Since permission is requested by raising the hand, lower it not to rely on dominant speaker detection
|
||||
// to clear the hand.
|
||||
dispatch(raiseHand(false));
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.hostAskedUnmute',
|
||||
sticky: true,
|
||||
customActionNameKey,
|
||||
customActionHandler,
|
||||
uid: ASKED_TO_UNMUTE_NOTIFICATION_ID
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
||||
|
||||
dispatch(playSound(ASKED_TO_UNMUTE_SOUND_ID));
|
||||
});
|
||||
|
||||
conference.on(JitsiConferenceEvents.AV_MODERATION_REJECTED, ({ mediaType }: { mediaType: MediaType; }) => {
|
||||
dispatch(localParticipantRejected(mediaType));
|
||||
});
|
||||
|
||||
conference.on(JitsiConferenceEvents.AV_MODERATION_CHANGED, ({ enabled, mediaType, actor }: {
|
||||
actor: Object; enabled: boolean; mediaType: MediaType;
|
||||
}) => {
|
||||
enabled ? dispatch(enableModeration(mediaType, actor)) : dispatch(disableModeration(mediaType, actor));
|
||||
});
|
||||
|
||||
// this is received by moderators
|
||||
conference.on(
|
||||
JitsiConferenceEvents.AV_MODERATION_PARTICIPANT_APPROVED,
|
||||
({ participant, mediaType }: { mediaType: MediaType; participant: { _id: string; }; }) => {
|
||||
const { _id: id } = participant;
|
||||
|
||||
batch(() => {
|
||||
// store in the whitelist
|
||||
dispatch(participantApproved(id, mediaType));
|
||||
|
||||
// remove from pending list
|
||||
dispatch(dismissPendingParticipant(id, mediaType));
|
||||
});
|
||||
});
|
||||
|
||||
// this is received by moderators
|
||||
conference.on(
|
||||
JitsiConferenceEvents.AV_MODERATION_PARTICIPANT_REJECTED,
|
||||
({ participant, mediaType }: { mediaType: MediaType; participant: { _id: string; }; }) => {
|
||||
const { _id: id } = participant;
|
||||
|
||||
dispatch(participantRejected(id, mediaType));
|
||||
});
|
||||
}
|
||||
});
|
||||
399
react/features/av-moderation/reducer.ts
Normal file
399
react/features/av-moderation/reducer.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
PARTICIPANT_LEFT,
|
||||
PARTICIPANT_UPDATED
|
||||
} from '../base/participants/actionTypes';
|
||||
import { IParticipant } from '../base/participants/types';
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import {
|
||||
DISABLE_MODERATION,
|
||||
DISMISS_PENDING_PARTICIPANT,
|
||||
ENABLE_MODERATION,
|
||||
LOCAL_PARTICIPANT_APPROVED,
|
||||
LOCAL_PARTICIPANT_REJECTED,
|
||||
PARTICIPANT_APPROVED,
|
||||
PARTICIPANT_PENDING_AUDIO,
|
||||
PARTICIPANT_REJECTED
|
||||
} from './actionTypes';
|
||||
import {
|
||||
MEDIA_TYPE,
|
||||
MEDIA_TYPE_TO_PENDING_STORE_KEY,
|
||||
type MediaType
|
||||
} from './constants';
|
||||
|
||||
const initialState = {
|
||||
audioModerationEnabled: false,
|
||||
desktopModerationEnabled: false,
|
||||
videoModerationEnabled: false,
|
||||
audioWhitelist: {},
|
||||
desktopWhitelist: {},
|
||||
videoWhitelist: {},
|
||||
pendingAudio: [],
|
||||
pendingDesktop: [],
|
||||
pendingVideo: []
|
||||
};
|
||||
|
||||
export interface IAVModerationState {
|
||||
audioModerationEnabled: boolean;
|
||||
audioUnmuteApproved?: boolean | undefined;
|
||||
audioWhitelist: { [id: string]: boolean; };
|
||||
desktopModerationEnabled: boolean;
|
||||
desktopUnmuteApproved?: boolean | undefined;
|
||||
desktopWhitelist: { [id: string]: boolean; };
|
||||
pendingAudio: Array<{ id: string; }>;
|
||||
pendingDesktop: Array<{ id: string; }>;
|
||||
pendingVideo: Array<{ id: string; }>;
|
||||
videoModerationEnabled: boolean;
|
||||
videoUnmuteApproved?: boolean | undefined;
|
||||
videoWhitelist: { [id: string]: boolean; };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a participant in the state for the specified media type.
|
||||
*
|
||||
* @param {MediaType} mediaType - The media type.
|
||||
* @param {Object} participant - Information about participant to be modified.
|
||||
* @param {Object} state - The current state.
|
||||
* @private
|
||||
* @returns {boolean} - Whether state instance was modified.
|
||||
*/
|
||||
function _updatePendingParticipant(mediaType: MediaType, participant: IParticipant, state: IAVModerationState) {
|
||||
let arrayItemChanged = false;
|
||||
const storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType];
|
||||
const arr = state[storeKey];
|
||||
const newArr = arr.map((pending: { id: string; }) => {
|
||||
if (pending.id === participant.id) {
|
||||
arrayItemChanged = true;
|
||||
|
||||
return {
|
||||
...pending,
|
||||
...participant
|
||||
};
|
||||
}
|
||||
|
||||
return pending;
|
||||
});
|
||||
|
||||
if (arrayItemChanged) {
|
||||
state[storeKey] = newArr;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
ReducerRegistry.register<IAVModerationState>('features/av-moderation',
|
||||
(state = initialState, action): IAVModerationState => {
|
||||
switch (action.type) {
|
||||
case DISABLE_MODERATION: {
|
||||
let newState = {};
|
||||
|
||||
switch (action.mediaType) {
|
||||
case MEDIA_TYPE.AUDIO:
|
||||
newState = {
|
||||
audioModerationEnabled: false,
|
||||
audioUnmuteApproved: undefined
|
||||
};
|
||||
break;
|
||||
case MEDIA_TYPE.DESKTOP:
|
||||
newState = {
|
||||
desktopModerationEnabled: false,
|
||||
desktopUnmuteApproved: undefined
|
||||
};
|
||||
break;
|
||||
case MEDIA_TYPE.VIDEO:
|
||||
newState = {
|
||||
videoModerationEnabled: false,
|
||||
videoUnmuteApproved: undefined
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...newState,
|
||||
audioWhitelist: {},
|
||||
desktopWhitelist: {},
|
||||
videoWhitelist: {},
|
||||
pendingAudio: [],
|
||||
pendingDesktop: [],
|
||||
pendingVideo: []
|
||||
};
|
||||
}
|
||||
|
||||
case ENABLE_MODERATION: {
|
||||
let newState = {};
|
||||
|
||||
switch (action.mediaType) {
|
||||
case MEDIA_TYPE.AUDIO:
|
||||
newState = {
|
||||
audioModerationEnabled: true,
|
||||
};
|
||||
break;
|
||||
case MEDIA_TYPE.DESKTOP:
|
||||
newState = {
|
||||
desktopModerationEnabled: true,
|
||||
};
|
||||
break;
|
||||
case MEDIA_TYPE.VIDEO:
|
||||
newState = {
|
||||
videoModerationEnabled: true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...newState
|
||||
};
|
||||
}
|
||||
|
||||
case LOCAL_PARTICIPANT_APPROVED: {
|
||||
let newState = {};
|
||||
|
||||
switch (action.mediaType) {
|
||||
case MEDIA_TYPE.AUDIO:
|
||||
newState = {
|
||||
audioUnmuteApproved: true
|
||||
};
|
||||
break;
|
||||
case MEDIA_TYPE.DESKTOP:
|
||||
newState = {
|
||||
desktopUnmuteApproved: true
|
||||
};
|
||||
break;
|
||||
case MEDIA_TYPE.VIDEO:
|
||||
newState = {
|
||||
videoUnmuteApproved: true
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...newState
|
||||
};
|
||||
}
|
||||
|
||||
case LOCAL_PARTICIPANT_REJECTED: {
|
||||
let newState = {};
|
||||
|
||||
switch (action.mediaType) {
|
||||
case MEDIA_TYPE.AUDIO:
|
||||
newState = {
|
||||
audioUnmuteApproved: false
|
||||
};
|
||||
break;
|
||||
case MEDIA_TYPE.DESKTOP:
|
||||
newState = {
|
||||
desktopUnmuteApproved: false
|
||||
};
|
||||
break;
|
||||
case MEDIA_TYPE.VIDEO:
|
||||
newState = {
|
||||
videoUnmuteApproved: false
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...newState
|
||||
};
|
||||
}
|
||||
|
||||
case PARTICIPANT_PENDING_AUDIO: {
|
||||
const { participant } = action;
|
||||
|
||||
// Add participant to pendingAudio array only if it's not already added
|
||||
if (!state.pendingAudio.find(pending => pending.id === participant.id)) {
|
||||
const updated = [ ...state.pendingAudio ];
|
||||
|
||||
updated.push(participant);
|
||||
|
||||
return {
|
||||
...state,
|
||||
pendingAudio: updated
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case PARTICIPANT_UPDATED: {
|
||||
const participant = action.participant;
|
||||
const { audioModerationEnabled, desktopModerationEnabled, videoModerationEnabled } = state;
|
||||
let hasStateChanged = false;
|
||||
|
||||
// skips changing the reference of pendingAudio or pendingVideo,
|
||||
// if there is no change in the elements
|
||||
if (audioModerationEnabled) {
|
||||
hasStateChanged = _updatePendingParticipant(MEDIA_TYPE.AUDIO, participant, state);
|
||||
}
|
||||
|
||||
if (desktopModerationEnabled) {
|
||||
hasStateChanged = hasStateChanged || _updatePendingParticipant(MEDIA_TYPE.DESKTOP, participant, state);
|
||||
}
|
||||
|
||||
if (videoModerationEnabled) {
|
||||
hasStateChanged = hasStateChanged || _updatePendingParticipant(MEDIA_TYPE.VIDEO, participant, state);
|
||||
}
|
||||
|
||||
// If the state has changed we need to return a new object reference in order to trigger subscriber updates.
|
||||
if (hasStateChanged) {
|
||||
return {
|
||||
...state
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case PARTICIPANT_LEFT: {
|
||||
const participant = action.participant;
|
||||
const { audioModerationEnabled, desktopModerationEnabled, videoModerationEnabled } = state;
|
||||
let hasStateChanged = false;
|
||||
|
||||
// skips changing the reference of pendingAudio or pendingVideo,
|
||||
// if there is no change in the elements
|
||||
if (audioModerationEnabled) {
|
||||
const newPendingAudio = state.pendingAudio.filter(pending => pending.id !== participant.id);
|
||||
|
||||
if (state.pendingAudio.length !== newPendingAudio.length) {
|
||||
state.pendingAudio = newPendingAudio;
|
||||
hasStateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (desktopModerationEnabled) {
|
||||
const newPendingDesktop = state.pendingDesktop.filter(pending => pending.id !== participant.id);
|
||||
|
||||
if (state.pendingDesktop.length !== newPendingDesktop.length) {
|
||||
state.pendingDesktop = newPendingDesktop;
|
||||
hasStateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (videoModerationEnabled) {
|
||||
const newPendingVideo = state.pendingVideo.filter(pending => pending.id !== participant.id);
|
||||
|
||||
if (state.pendingVideo.length !== newPendingVideo.length) {
|
||||
state.pendingVideo = newPendingVideo;
|
||||
hasStateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the state has changed we need to return a new object reference in order to trigger subscriber updates.
|
||||
if (hasStateChanged) {
|
||||
return {
|
||||
...state
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case DISMISS_PENDING_PARTICIPANT: {
|
||||
const { id, mediaType } = action;
|
||||
|
||||
if (mediaType === MEDIA_TYPE.AUDIO) {
|
||||
return {
|
||||
...state,
|
||||
pendingAudio: state.pendingAudio.filter(pending => pending.id !== id)
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaType === MEDIA_TYPE.DESKTOP) {
|
||||
return {
|
||||
...state,
|
||||
pendingDesktop: state.pendingDesktop.filter(pending => pending.id !== id)
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaType === MEDIA_TYPE.VIDEO) {
|
||||
return {
|
||||
...state,
|
||||
pendingVideo: state.pendingVideo.filter(pending => pending.id !== id)
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case PARTICIPANT_APPROVED: {
|
||||
const { mediaType, id } = action;
|
||||
|
||||
if (mediaType === MEDIA_TYPE.AUDIO) {
|
||||
return {
|
||||
...state,
|
||||
audioWhitelist: {
|
||||
...state.audioWhitelist,
|
||||
[id]: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaType === MEDIA_TYPE.DESKTOP) {
|
||||
return {
|
||||
...state,
|
||||
desktopWhitelist: {
|
||||
...state.desktopWhitelist,
|
||||
[id]: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaType === MEDIA_TYPE.VIDEO) {
|
||||
return {
|
||||
...state,
|
||||
videoWhitelist: {
|
||||
...state.videoWhitelist,
|
||||
[id]: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case PARTICIPANT_REJECTED: {
|
||||
const { mediaType, id } = action;
|
||||
|
||||
if (mediaType === MEDIA_TYPE.AUDIO) {
|
||||
return {
|
||||
...state,
|
||||
audioWhitelist: {
|
||||
...state.audioWhitelist,
|
||||
[id]: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaType === MEDIA_TYPE.DESKTOP) {
|
||||
return {
|
||||
...state,
|
||||
desktopWhitelist: {
|
||||
...state.desktopWhitelist,
|
||||
[id]: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaType === MEDIA_TYPE.VIDEO) {
|
||||
return {
|
||||
...state,
|
||||
videoWhitelist: {
|
||||
...state.videoWhitelist,
|
||||
[id]: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
6
react/features/av-moderation/sounds.ts
Normal file
6
react/features/av-moderation/sounds.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* The name of the bundled audio file which will be played for the raise hand sound.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const ASKED_TO_UNMUTE_FILE = 'asked-unmute.mp3';
|
||||
33
react/features/base/app/actionTypes.ts
Normal file
33
react/features/base/app/actionTypes.ts
Normal 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';
|
||||
69
react/features/base/app/actions.ts
Normal file
69
react/features/base/app/actions.ts
Normal 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
|
||||
};
|
||||
}
|
||||
283
react/features/base/app/components/BaseApp.tsx
Normal file
283
react/features/base/app/components/BaseApp.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
28
react/features/base/app/functions.ts
Normal file
28
react/features/base/app/functions.ts
Normal 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;
|
||||
}
|
||||
3
react/features/base/app/logger.ts
Normal file
3
react/features/base/app/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../logging/functions';
|
||||
|
||||
export default getLogger('features/base/app');
|
||||
54
react/features/base/app/middleware.web.ts
Normal file
54
react/features/base/app/middleware.web.ts
Normal 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);
|
||||
});
|
||||
34
react/features/base/app/reducer.ts
Normal file
34
react/features/base/app/reducer.ts
Normal 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;
|
||||
});
|
||||
3
react/features/base/app/types.ts
Normal file
3
react/features/base/app/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
|
||||
export type IStateful = (() => IReduxState) | IStore | IReduxState;
|
||||
10
react/features/base/audio-only/actionTypes.ts
Normal file
10
react/features/base/audio-only/actionTypes.ts
Normal 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';
|
||||
51
react/features/base/audio-only/actions.ts
Normal file
51
react/features/base/audio-only/actions.ts
Normal 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));
|
||||
};
|
||||
}
|
||||
3
react/features/base/audio-only/logger.ts
Normal file
3
react/features/base/audio-only/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../logging/functions';
|
||||
|
||||
export default getLogger('features/base/audio-only');
|
||||
25
react/features/base/audio-only/reducer.ts
Normal file
25
react/features/base/audio-only/reducer.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
295
react/features/base/avatar/components/Avatar.tsx
Normal file
295
react/features/base/avatar/components/Avatar.tsx
Normal 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);
|
||||
1
react/features/base/avatar/components/index.native.ts
Normal file
1
react/features/base/avatar/components/index.native.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as StatelessAvatar } from './native/StatelessAvatar';
|
||||
1
react/features/base/avatar/components/index.web.ts
Normal file
1
react/features/base/avatar/components/index.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as StatelessAvatar } from './web/StatelessAvatar';
|
||||
200
react/features/base/avatar/components/native/StatelessAvatar.tsx
Normal file
200
react/features/base/avatar/components/native/StatelessAvatar.tsx
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
82
react/features/base/avatar/components/native/styles.ts
Normal file
82
react/features/base/avatar/components/native/styles.ts
Normal 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
|
||||
}
|
||||
};
|
||||
5
react/features/base/avatar/components/styles.ts
Normal file
5
react/features/base/avatar/components/styles.ts
Normal 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)';
|
||||
220
react/features/base/avatar/components/web/StatelessAvatar.tsx
Normal file
220
react/features/base/avatar/components/web/StatelessAvatar.tsx
Normal 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;
|
||||
4
react/features/base/avatar/constants.ts
Normal file
4
react/features/base/avatar/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* The base URL for gravatar images.
|
||||
*/
|
||||
export const GRAVATAR_BASE_URL = 'https://www.gravatar.com/avatar/';
|
||||
95
react/features/base/avatar/functions.ts
Normal file
95
react/features/base/avatar/functions.ts
Normal 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');
|
||||
}
|
||||
32
react/features/base/avatar/types.ts
Normal file
32
react/features/base/avatar/types.ts
Normal 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;
|
||||
}
|
||||
244
react/features/base/buttons/CopyButton.web.tsx
Normal file
244
react/features/base/buttons/CopyButton.web.tsx
Normal 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;
|
||||
162
react/features/base/color-scheme/ColorSchemeRegistry.ts
Normal file
162
react/features/base/color-scheme/ColorSchemeRegistry.ts
Normal 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();
|
||||
30
react/features/base/color-scheme/defaultScheme.ts
Normal file
30
react/features/base/color-scheme/defaultScheme.ts
Normal 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)'
|
||||
}
|
||||
};
|
||||
11
react/features/base/color-scheme/functions.ts
Normal file
11
react/features/base/color-scheme/functions.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"panePadding": 24,
|
||||
"participantsPaneWidth": 315,
|
||||
"MD_BREAKPOINT": "580px"
|
||||
}
|
||||
381
react/features/base/conference/actionTypes.ts
Normal file
381
react/features/base/conference/actionTypes.ts
Normal 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';
|
||||
1152
react/features/base/conference/actions.any.ts
Normal file
1152
react/features/base/conference/actions.any.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user