snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
249
react/features/always-on-top/AlwaysOnTop.tsx
Normal file
249
react/features/always-on-top/AlwaysOnTop.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import AlwaysOnTopView from './AlwaysOnTopView';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const {
|
||||
avatarURL,
|
||||
customAvatarBackgrounds,
|
||||
displayName,
|
||||
formattedDisplayName,
|
||||
isVideoDisplayed,
|
||||
visible
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<AlwaysOnTopView
|
||||
avatarURL = { avatarURL }
|
||||
customAvatarBackgrounds = { customAvatarBackgrounds }
|
||||
displayName = { displayName }
|
||||
formattedDisplayName = { formattedDisplayName }
|
||||
isVideoDisplayed = { isVideoDisplayed }
|
||||
onMouseOut = { this._onMouseOut }
|
||||
onMouseOver = { this._onMouseOver }
|
||||
toolbarVisible = { visible } />
|
||||
);
|
||||
}
|
||||
}
|
||||
85
react/features/always-on-top/AlwaysOnTopView.tsx
Normal file
85
react/features/always-on-top/AlwaysOnTopView.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { ReactNode } 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';
|
||||
|
||||
/**
|
||||
* Props shared by the legacy Always-on-Top and dedicated Document PiP renderers.
|
||||
*/
|
||||
interface IProps {
|
||||
/** Participant avatar URL. */
|
||||
avatarURL: string;
|
||||
|
||||
/** Optional renderer-owned media element. */
|
||||
children?: ReactNode;
|
||||
|
||||
/** Configured avatar background palette. */
|
||||
customAvatarBackgrounds: string[];
|
||||
|
||||
/** Participant display name used for initials and avatar hashing. */
|
||||
displayName: string;
|
||||
|
||||
/** Display name formatted for presentation. */
|
||||
formattedDisplayName: string;
|
||||
|
||||
/** Whether media is visible instead of the avatar fallback. */
|
||||
isVideoDisplayed: boolean;
|
||||
|
||||
/** Pointer-leave handler used by toolbar visibility management. */
|
||||
onMouseOut: (event?: React.MouseEvent) => void;
|
||||
|
||||
/** Pointer-enter handler used by toolbar visibility management. */
|
||||
onMouseOver: (event?: React.MouseEvent) => void;
|
||||
|
||||
/** Whether the shared toolbar is visible. */
|
||||
toolbarVisible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared presentation used by the legacy and Document PiP always-on-top renderers.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
export default function AlwaysOnTopView({
|
||||
avatarURL,
|
||||
children,
|
||||
customAvatarBackgrounds,
|
||||
displayName,
|
||||
formattedDisplayName,
|
||||
isVideoDisplayed,
|
||||
onMouseOut,
|
||||
onMouseOver,
|
||||
toolbarVisible
|
||||
}: IProps) {
|
||||
return (
|
||||
<div id = 'alwaysOnTop'>
|
||||
{ children }
|
||||
<Toolbar
|
||||
className = { toolbarVisible ? 'fadeIn' : 'fadeOut' }
|
||||
onMouseOut = { onMouseOut }
|
||||
onMouseOver = { onMouseOver } />
|
||||
{ !isVideoDisplayed && (
|
||||
<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>
|
||||
) }
|
||||
</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 } />
|
||||
);
|
||||
}
|
||||
}
|
||||
329
react/features/always-on-top/DocumentPiP.tsx
Normal file
329
react/features/always-on-top/DocumentPiP.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import MediaCastReceiver from '../base/media-cast/MediaCastReceiver';
|
||||
import type { MediaCastSignal, MediaCastSignalHandler } from '../base/media-cast/types.web';
|
||||
|
||||
import AlwaysOnTopView from './AlwaysOnTopView';
|
||||
|
||||
/**
|
||||
* External API instance exposed to the dedicated Document PiP window.
|
||||
*/
|
||||
const { api } = window.alwaysOnTop;
|
||||
|
||||
/**
|
||||
* Delay in milliseconds before hiding the PiP toolbar after pointer activity stops.
|
||||
*/
|
||||
const TOOLBAR_TIMEOUT = 4000;
|
||||
|
||||
/**
|
||||
* Layout applied to the React-owned video element without cropping shared content.
|
||||
*/
|
||||
const VIDEO_STYLE: React.CSSProperties = {
|
||||
height: '100%',
|
||||
left: 0,
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%'
|
||||
};
|
||||
|
||||
/**
|
||||
* Participant metadata rendered by the shared always-on-top presentation.
|
||||
*/
|
||||
interface IParticipantState {
|
||||
/** Participant avatar URL. */
|
||||
avatarURL: string;
|
||||
|
||||
/** Configured avatar background palette. */
|
||||
customAvatarBackgrounds: string[];
|
||||
|
||||
/** Participant display name used for avatar hashing. */
|
||||
displayName: string;
|
||||
|
||||
/** Participant display name formatted for presentation. */
|
||||
formattedDisplayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals received from the meeting iframe before the media-cast receiver is ready are buffered
|
||||
* here and flushed once the receiver registers itself. The host forwards signals through the
|
||||
* embedder API, which is available from the moment this bundle executes, so buffering must start
|
||||
* at module scope rather than on React mount.
|
||||
*/
|
||||
const pendingSignals: MediaCastSignal[] = [];
|
||||
|
||||
/**
|
||||
* The signal handler the media-cast receiver has registered, if any.
|
||||
*/
|
||||
let receiverSignalHandler: MediaCastSignalHandler | undefined;
|
||||
|
||||
/**
|
||||
* Whether the API signal listener is currently registered.
|
||||
*/
|
||||
let signalBridgeRegistered = false;
|
||||
|
||||
/**
|
||||
* Forwards one media-cast signal to the receiver, buffering it until the receiver is ready.
|
||||
*
|
||||
* @param {Object} payload - Event payload received from the embedder API.
|
||||
* @param {MediaCastSignal} payload.signal - The signal to forward.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onSignal = ({ signal }: { signal: MediaCastSignal; }) => {
|
||||
if (receiverSignalHandler) {
|
||||
receiverSignalHandler(signal);
|
||||
} else {
|
||||
pendingSignals.push(signal);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers the media-cast receiver's signal handler, flushing any buffered signals, and returns
|
||||
* a function that unregisters it.
|
||||
*
|
||||
* @param {MediaCastSignalHandler} handler - The receiver's signal handler.
|
||||
* @returns {Function} Unregister function.
|
||||
*/
|
||||
const registerSignalHandler = (handler: MediaCastSignalHandler): (() => void) => {
|
||||
receiverSignalHandler = handler;
|
||||
|
||||
for (const signal of pendingSignals.splice(0)) {
|
||||
handler(signal);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (receiverSignalHandler === handler) {
|
||||
receiverSignalHandler = undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers the API listener at most once. It is invoked at module load so signals arriving before
|
||||
* React mounts are buffered, and again on mount to tolerate development-only remounts.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const registerSignalBridge = () => {
|
||||
if (signalBridgeRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
signalBridgeRegistered = true;
|
||||
api.on('_documentPiPSignal', onSignal);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the API listener and clears all receiver and buffered-signal references.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const cleanupSignalBridge = () => {
|
||||
if (signalBridgeRegistered) {
|
||||
api.removeListener('_documentPiPSignal', onSignal);
|
||||
signalBridgeRegistered = false;
|
||||
}
|
||||
|
||||
receiverSignalHandler = undefined;
|
||||
pendingSignals.length = 0;
|
||||
};
|
||||
|
||||
registerSignalBridge();
|
||||
|
||||
/**
|
||||
* Document PiP renderer backed by a media-cast receiver.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
export default function DocumentPiP() {
|
||||
const [ participant, setParticipant ] = useState<IParticipantState>({
|
||||
avatarURL: '',
|
||||
customAvatarBackgrounds: [],
|
||||
displayName: '',
|
||||
formattedDisplayName: ''
|
||||
});
|
||||
const [ sourceVisible, setSourceVisible ] = useState(
|
||||
() => Boolean(api._isLargeVideoVisible() || api._isPrejoinVideoVisible()));
|
||||
const [ toolbarVisible, setToolbarVisible ] = useState(true);
|
||||
const [ track, setTrack ] = useState<MediaStreamTrack | null>(null);
|
||||
const [ trackMuted, setTrackMuted ] = useState(true);
|
||||
const hoveredRef = useRef(false);
|
||||
const toolbarTimerRef = useRef<number | undefined>(undefined);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const updateParticipant = useCallback(() => {
|
||||
const userID = api._getOnStageParticipant();
|
||||
|
||||
setParticipant(current => ({
|
||||
...current,
|
||||
avatarURL: api.getAvatarURL(userID),
|
||||
displayName: api.getDisplayName(userID),
|
||||
formattedDisplayName: api._getFormattedDisplayName(userID)
|
||||
}));
|
||||
|
||||
setSourceVisible(Boolean(api._isLargeVideoVisible() || api._isPrejoinVideoVisible()));
|
||||
}, []);
|
||||
|
||||
const scheduleToolbarHide = useCallback(() => {
|
||||
const hideWhenIdle = () => {
|
||||
if (hoveredRef.current) {
|
||||
toolbarTimerRef.current = window.setTimeout(hideWhenIdle, TOOLBAR_TIMEOUT);
|
||||
} else {
|
||||
setToolbarVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.clearTimeout(toolbarTimerRef.current);
|
||||
toolbarTimerRef.current = window.setTimeout(hideWhenIdle, TOOLBAR_TIMEOUT);
|
||||
}, []);
|
||||
const onMouseOut = useCallback(() => {
|
||||
hoveredRef.current = false;
|
||||
}, []);
|
||||
const onMouseOver = useCallback(() => {
|
||||
hoveredRef.current = true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
registerSignalBridge();
|
||||
|
||||
const receiver = new MediaCastReceiver({
|
||||
onError: error => console.error('Document PiP receiver failed', error),
|
||||
onSignal: signal => api._sendDocumentPiPSignal(signal),
|
||||
onTrack: nextTrack => {
|
||||
if (active) {
|
||||
setTrack(nextTrack);
|
||||
}
|
||||
}
|
||||
});
|
||||
const unregister = registerSignalHandler(signal => receiver.handleSignal(signal));
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
unregister();
|
||||
receiver.dispose();
|
||||
cleanupSignalBridge();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!track) {
|
||||
setTrackMuted(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const updateMuted = () => setTrackMuted(track.muted || track.readyState !== 'live');
|
||||
|
||||
track.addEventListener('mute', updateMuted);
|
||||
track.addEventListener('unmute', updateMuted);
|
||||
track.addEventListener('ended', updateMuted);
|
||||
updateMuted();
|
||||
|
||||
return () => {
|
||||
track.removeEventListener('mute', updateMuted);
|
||||
track.removeEventListener('unmute', updateMuted);
|
||||
track.removeEventListener('ended', updateMuted);
|
||||
};
|
||||
}, [ track ]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.srcObject = track ? new MediaStream([ track ]) : null;
|
||||
|
||||
return () => {
|
||||
video.srcObject = null;
|
||||
};
|
||||
}, [ track ]);
|
||||
|
||||
useEffect(() => {
|
||||
const avatarChanged = ({ avatarURL, id }: { avatarURL: string; id: string; }) => {
|
||||
if (api._getOnStageParticipant() === id) {
|
||||
setParticipant(current => ({
|
||||
...current,
|
||||
avatarURL
|
||||
}));
|
||||
}
|
||||
};
|
||||
const displayNameChanged = ({ displayname, formattedDisplayName, id }: {
|
||||
displayname: string;
|
||||
formattedDisplayName: string;
|
||||
id: string;
|
||||
}) => {
|
||||
if (api._getOnStageParticipant() === id) {
|
||||
setParticipant(current => ({
|
||||
...current,
|
||||
displayName: displayname,
|
||||
formattedDisplayName
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
api.on('avatarChanged', avatarChanged);
|
||||
api.on('displayNameChange', displayNameChanged);
|
||||
api.on('largeVideoChanged', updateParticipant);
|
||||
api.on('prejoinVideoChanged', updateParticipant);
|
||||
api.on('videoConferenceJoined', updateParticipant);
|
||||
updateParticipant();
|
||||
|
||||
api.getCustomAvatarBackgrounds()
|
||||
.then(({ avatarBackgrounds = [] }: { avatarBackgrounds?: string[]; }) => {
|
||||
setParticipant(current => ({
|
||||
...current,
|
||||
customAvatarBackgrounds: avatarBackgrounds
|
||||
}));
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return () => {
|
||||
api.removeListener('avatarChanged', avatarChanged);
|
||||
api.removeListener('displayNameChange', displayNameChanged);
|
||||
api.removeListener('largeVideoChanged', updateParticipant);
|
||||
api.removeListener('prejoinVideoChanged', updateParticipant);
|
||||
api.removeListener('videoConferenceJoined', updateParticipant);
|
||||
};
|
||||
}, [ updateParticipant ]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = () => {
|
||||
setToolbarVisible(true);
|
||||
scheduleToolbarHide();
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
scheduleToolbarHide();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.clearTimeout(toolbarTimerRef.current);
|
||||
};
|
||||
}, [ scheduleToolbarHide ]);
|
||||
|
||||
const isVideoDisplayed = Boolean(track && !trackMuted && sourceVisible);
|
||||
|
||||
return (
|
||||
<AlwaysOnTopView
|
||||
avatarURL = { participant.avatarURL }
|
||||
customAvatarBackgrounds = { participant.customAvatarBackgrounds }
|
||||
displayName = { participant.displayName }
|
||||
formattedDisplayName = { participant.formattedDisplayName }
|
||||
isVideoDisplayed = { isVideoDisplayed }
|
||||
onMouseOut = { onMouseOut }
|
||||
onMouseOver = { onMouseOver }
|
||||
toolbarVisible = { toolbarVisible }>
|
||||
<video
|
||||
autoPlay = { true }
|
||||
hidden = { !isVideoDisplayed }
|
||||
muted = { true }
|
||||
playsInline = { true }
|
||||
ref = { videoRef }
|
||||
style = { VIDEO_STYLE } />
|
||||
</AlwaysOnTopView>
|
||||
);
|
||||
}
|
||||
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 } />
|
||||
);
|
||||
}
|
||||
}
|
||||
41
react/features/always-on-top/document-pip-index.tsx
Normal file
41
react/features/always-on-top/document-pip-index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Entry point for the Document PiP window renderer.
|
||||
*
|
||||
* Bootstraps the React tree into the PiP window document and unmounts it when
|
||||
* the window is closed or reloaded.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import DocumentPiP from './DocumentPiP';
|
||||
|
||||
/**
|
||||
* React root attached to the Document PiP window document.
|
||||
*/
|
||||
const root = createRoot(document.getElementById('react') as HTMLElement);
|
||||
|
||||
root.render(<DocumentPiP />);
|
||||
|
||||
/**
|
||||
* Whether the renderer has already been cleaned up.
|
||||
*/
|
||||
let cleanedUp = false;
|
||||
|
||||
/**
|
||||
* Unmounts the Document PiP renderer before the PiP window is closed or reloaded.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanedUp = true;
|
||||
window.removeEventListener('beforeunload', cleanup);
|
||||
window.removeEventListener('pagehide', cleanup);
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', cleanup);
|
||||
window.addEventListener('pagehide', cleanup);
|
||||
11
react/features/always-on-top/index.tsx
Normal file
11
react/features/always-on-top/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import AlwaysOnTop from './AlwaysOnTop';
|
||||
|
||||
// Render the main/root Component.
|
||||
const root = createRoot(document.getElementById('react') as HTMLElement);
|
||||
|
||||
root.render(<AlwaysOnTop />);
|
||||
|
||||
window.addEventListener('beforeunload', () => root.unmount());
|
||||
958
react/features/analytics/AnalyticsEvents.ts
Normal file
958
react/features/analytics/AnalyticsEvents.ts
Normal file
@@ -0,0 +1,958 @@
|
||||
/**
|
||||
* 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 createLowBandwidthModeChangedEvent(enabled: boolean) {
|
||||
return {
|
||||
action: `low.bandwidth.mode.${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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 createStartLowBandwidthModeEvent(audioOnly: boolean) {
|
||||
return {
|
||||
action: 'start.low.bandwidth.mode',
|
||||
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 * as 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;
|
||||
}
|
||||
80
react/features/analytics/handlers/amplitude/lib.web.ts
Normal file
80
react/features/analytics/handlers/amplitude/lib.web.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createInstance } from '@amplitude/analytics-browser';
|
||||
import { EnrichmentPlugin, Event, PluginType } from '@amplitude/analytics-types';
|
||||
|
||||
const amplitude = createInstance();
|
||||
|
||||
export default amplitude;
|
||||
|
||||
function stripParam(url?: string): string | undefined {
|
||||
if (!url) return url;
|
||||
|
||||
try {
|
||||
const u = new URL(url);
|
||||
|
||||
u.hash = '';
|
||||
u.search = '';
|
||||
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url.split('#')[0].split('?')[0];
|
||||
}
|
||||
}
|
||||
|
||||
class StripParamsPlugin implements EnrichmentPlugin {
|
||||
name: 'strip-params-plugin';
|
||||
type: PluginType.ENRICHMENT;
|
||||
|
||||
async setup(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async execute(event: Event): Promise<Event | null> {
|
||||
const { event_properties } = event;
|
||||
|
||||
if (event_properties) {
|
||||
// These are only needed to make TS happy.
|
||||
type EventPropertiesKey = keyof typeof event_properties;
|
||||
const PAGE_LOCATION_KEY = '[Amplitude] Page Location' as EventPropertiesKey;
|
||||
const PAGE_URL_KEY = '[Amplitude] Page URL' as EventPropertiesKey;
|
||||
|
||||
event_properties[PAGE_LOCATION_KEY] = stripParam(event_properties[PAGE_LOCATION_KEY]);
|
||||
event_properties[PAGE_URL_KEY] = stripParam(event_properties[PAGE_URL_KEY]);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
};
|
||||
|
||||
amplitude.add(new StripParamsPlugin());
|
||||
|
||||
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('app: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;
|
||||
}
|
||||
});
|
||||
161
react/features/app/actions.any.ts
Normal file
161
react/features/app/actions.any.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// @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 as Mutable<URL>).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 as Mutable<typeof windowLocation>).hash = hashParam;
|
||||
}
|
||||
|
||||
(windowLocation as Mutable<typeof 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: lowBandwidthModeEnabled } = state['features/base/low-bandwidth-mode'];
|
||||
const { startLowBandwidthMode } = 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(state)) {
|
||||
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 refreshToken = state['features/base/jwt'].refreshToken;
|
||||
const expirationDate = getJwtExpirationDate(jwt);
|
||||
|
||||
// if there is jwt token, and it is expired let's obtain a new one
|
||||
if (expirationDate && expirationDate.getTime() <= Date.now()) {
|
||||
const room = state['features/base/conference'].room;
|
||||
const { tenant } = parseURIString(locationURL.href) || {};
|
||||
|
||||
getTokenAuthUrl(
|
||||
config,
|
||||
locationURL,
|
||||
{
|
||||
audioMuted,
|
||||
lowBandwidthModeEnabled: lowBandwidthModeEnabled || startLowBandwidthMode,
|
||||
skipPrejoin: true,
|
||||
videoMuted
|
||||
},
|
||||
room,
|
||||
tenant,
|
||||
refreshToken
|
||||
)
|
||||
.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(e => {
|
||||
failureCallback(e);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
214
react/features/app/actions.native.ts
Normal file
214
react/features/app/actions.native.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { setRoom } from '../base/conference/actions.native';
|
||||
import { getConferenceState } from '../base/conference/functions';
|
||||
import {
|
||||
configWillLoad,
|
||||
loadConfigError,
|
||||
setConfig,
|
||||
storeConfig
|
||||
} from '../base/config/actions';
|
||||
import { buildConfigURL } from '../base/config/functions.any';
|
||||
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 {
|
||||
parseURIString,
|
||||
toURLString
|
||||
} from '../base/util/uri';
|
||||
import { isPrejoinPageEnabled } from '../mobile/navigation/functions';
|
||||
import {
|
||||
goBackToRoot,
|
||||
navigateRoot,
|
||||
replaceRoot
|
||||
} from '../mobile/navigation/rootNavigationContainerRef';
|
||||
import { screen } from '../mobile/navigation/routes';
|
||||
import { clearNotifications } from '../notifications/actions';
|
||||
import { isUnsafeRoomWarningEnabled } from '../prejoin/functions.native';
|
||||
|
||||
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));
|
||||
|
||||
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 || '/'}`;
|
||||
const url = buildConfigURL(locationURL, room);
|
||||
|
||||
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)) {
|
||||
replaceRoot(screen.unsafeRoomWarning);
|
||||
} else {
|
||||
replaceRoot(screen.preJoin);
|
||||
}
|
||||
} else {
|
||||
dispatch(connect());
|
||||
replaceRoot(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();
|
||||
};
|
||||
}
|
||||
210
react/features/app/actions.web.ts
Normal file
210
react/features/app/actions.web.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// @ts-expect-error
|
||||
import { API_ID } from '../../../modules/API';
|
||||
import { setRoom } from '../base/conference/actions';
|
||||
import {
|
||||
configWillLoad,
|
||||
setConfig
|
||||
} from '../base/config/actions';
|
||||
import { buildConfigURL } from '../base/config/functions.any';
|
||||
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));
|
||||
dispatch(setLocationURL(locationURL));
|
||||
|
||||
let config = window.config;
|
||||
|
||||
if (!config) {
|
||||
const url = buildConfigURL(locationURL, room);
|
||||
|
||||
try {
|
||||
config = await loadConfig(url);
|
||||
|
||||
// Mirror to window.config so legacy bare `config` global
|
||||
// references (UI.js, etc.) resolve when SSI did not inject it.
|
||||
window.config = config;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to load config from ${url}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
// Fire videoConferenceLeft before navigation so it is delivered
|
||||
// while the postis channel is still active. postMessage sent from
|
||||
// beforeunload is unreliable in Chrome when the iframe navigates.
|
||||
// @ts-ignore
|
||||
APP.API.notifyConferenceLeft(APP.conference.roomName);
|
||||
|
||||
// @ts-ignore
|
||||
APP.API.dispose(); // prevent double-fire from the beforeunload handler
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
69
react/features/app/components/App.web.tsx
Normal file
69
react/features/app/components/App.web.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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 PiP from '../../pip/components/PiP';
|
||||
|
||||
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 />
|
||||
<PiP />
|
||||
{ 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);
|
||||
}
|
||||
}
|
||||
19
react/features/app/getRouteToRender.native.ts
Normal file
19
react/features/app/getRouteToRender.native.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
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.
|
||||
* @param {any} _dispatch - Used on web.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function _getRouteToRender(_stateful?: any): Promise<object> {
|
||||
return Promise.resolve(route);
|
||||
}
|
||||
116
react/features/app/getRouteToRender.web.ts
Normal file
116
react/features/app/getRouteToRender.web.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { generateRoomWithoutSeparator } from '@jitsi/js-utils/random';
|
||||
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { isRoomValid } from '../base/conference/functions';
|
||||
import { isSupportedBrowser } from '../base/environment/environment';
|
||||
import { toState } from '../base/redux/functions';
|
||||
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.
|
||||
* @param {Dispatch} dispatch - The Redux dispatch function.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function _getRouteToRender(stateful: IStateful): Promise<object> {
|
||||
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.
|
||||
* @param {Dispatch} dispatch - The Redux dispatch function.
|
||||
* @returns {Promise|undefined}
|
||||
*/
|
||||
function _getWebConferenceRoute(state: IReduxState): Promise<any> | undefined {
|
||||
const room = state['features/base/conference'].room;
|
||||
|
||||
if (!isRoomValid(room)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = _getEmptyRoute();
|
||||
|
||||
// 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('app:core');
|
||||
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;
|
||||
}
|
||||
59
react/features/app/middlewares.any.ts
Normal file
59
react/features/app/middlewares.any.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import '../analytics/middleware';
|
||||
import '../audio-translation/middleware';
|
||||
import '../authentication/middleware';
|
||||
import '../av-moderation/middleware';
|
||||
import '../base/conference/middleware';
|
||||
import '../base/i18n/middleware';
|
||||
import '../base/jwt/middleware.any';
|
||||
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 '../client-requirements/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 '../time-timer/middleware';
|
||||
import '../transcribing/middleware';
|
||||
import '../video-layout/middleware';
|
||||
import '../video-quality/middleware';
|
||||
import '../videosipgw/middleware';
|
||||
import '../visitors/middleware';
|
||||
import '../whiteboard/middleware.any';
|
||||
|
||||
import './middleware';
|
||||
18
react/features/app/middlewares.native.ts
Normal file
18
react/features/app/middlewares.native.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import '../base/config/middleware';
|
||||
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/navigation/middleware';
|
||||
import '../mobile/permissions/middleware';
|
||||
import '../mobile/proximity/middleware';
|
||||
import '../mobile/wake-lock/middleware';
|
||||
import '../mobile/react-native-sdk/middleware';
|
||||
import '../share-room/middleware';
|
||||
import '../shared-video/middleware';
|
||||
import '../toolbox/middleware.native';
|
||||
import '../whiteboard/middleware.native';
|
||||
|
||||
import './middlewares.any';
|
||||
33
react/features/app/middlewares.web.ts
Normal file
33
react/features/app/middlewares.web.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import '../base/app/middleware';
|
||||
import '../base/jwt/middleware.web';
|
||||
import '../base/config/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 '../multi-screen/middleware.web';
|
||||
import '../no-audio-signal/middleware';
|
||||
import '../notifications/middleware';
|
||||
import '../noise-detection/middleware';
|
||||
import '../old-client-notification/middleware';
|
||||
import '../pip/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 '../custom-panel/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;
|
||||
}
|
||||
});
|
||||
59
react/features/app/reducers.any.ts
Normal file
59
react/features/app/reducers.any.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import '../analytics/reducer';
|
||||
import '../audio-translation/reducer';
|
||||
import '../authentication/reducer';
|
||||
import '../av-moderation/reducer';
|
||||
import '../base/app/reducer';
|
||||
import '../base/low-bandwidth-mode/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';
|
||||
10
react/features/app/reducers.native.ts
Normal file
10
react/features/app/reducers.native.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import '../mobile/audio-mode/reducer';
|
||||
import '../mobile/background/reducer';
|
||||
import '../mobile/call-integration/reducer';
|
||||
import '../mobile/external-api/reducer';
|
||||
import '../share-room/reducer';
|
||||
import '../time-timer/reducer';
|
||||
|
||||
import './reducer.native';
|
||||
|
||||
import './reducers.any';
|
||||
26
react/features/app/reducers.web.ts
Normal file
26
react/features/app/reducers.web.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import '../base/devices/reducer';
|
||||
import '../base/premeeting/reducer';
|
||||
import '../custom-panel/reducer';
|
||||
import '../base/tooltip/reducer';
|
||||
import '../e2ee/reducer';
|
||||
import '../face-landmarks/reducer';
|
||||
import '../feedback/reducer';
|
||||
import '../keyboard-shortcuts/reducer';
|
||||
import '../multi-screen/reducer';
|
||||
import '../no-audio-signal/reducer';
|
||||
import '../noise-detection/reducer';
|
||||
import '../participants-pane/reducer';
|
||||
import '../pip/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 '../time-timer/reducer';
|
||||
import '../virtual-background/reducer';
|
||||
import '../web-hid/reducer';
|
||||
import '../file-sharing/reducer';
|
||||
|
||||
import './reducers.any';
|
||||
189
react/features/app/types.ts
Normal file
189
react/features/app/types.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { AnyAction } from 'redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
|
||||
import { IAnalyticsState } from '../analytics/reducer';
|
||||
import { IAudioTranslationState } from '../audio-translation/reducer';
|
||||
import { IAuthenticationState } from '../authentication/reducer';
|
||||
import { IAVModerationState } from '../av-moderation/reducer';
|
||||
import { IAppState } from '../base/app/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 { ILowBandwidthModeState } from '../base/low-bandwidth-mode/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 { ICustomPanelState } from '../custom-panel/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 { IMultiScreenState } from '../multi-screen/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 { IPipState } from '../pip/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 { ITimeTimerState } from '../time-timer/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/audio-translation': IAudioTranslationState;
|
||||
'features/authentication': IAuthenticationState;
|
||||
'features/av-moderation': IAVModerationState;
|
||||
'features/base/app': IAppState;
|
||||
'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/low-bandwidth-mode': ILowBandwidthModeState;
|
||||
'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/custom-panel': ICustomPanelState;
|
||||
'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/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/multi-screen': IMultiScreenState;
|
||||
'features/no-audio-signal': INoAudioSignalState;
|
||||
'features/noise-detection': INoiseDetectionState;
|
||||
'features/noise-suppression': INoiseSuppressionState;
|
||||
'features/notifications': INotificationsState;
|
||||
'features/participants-pane': IParticipantsPaneState;
|
||||
'features/pip': IPipState;
|
||||
'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/time-timer': ITimeTimerState;
|
||||
'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;
|
||||
36
react/features/audio-translation/actionTypes.ts
Normal file
36
react/features/audio-translation/actionTypes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The type of (redux) action which clears all AI audio translation state (the default
|
||||
* language and every per-participant override), e.g. after the bridge rejects a request.
|
||||
*/
|
||||
export const CLEAR_AUDIO_TRANSLATION = 'CLEAR_AUDIO_TRANSLATION';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which clears the set of translated sources the bridge is currently
|
||||
* forwarding to us, e.g. when the conference is left.
|
||||
*/
|
||||
export const CLEAR_RECEIVING_TRANSLATED_SOURCES = 'CLEAR_RECEIVING_TRANSLATED_SOURCES';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the default AI audio translation target
|
||||
* language for every speaker, or null when translation is turned off.
|
||||
*/
|
||||
export const SET_AUDIO_TRANSLATION_LANGUAGE = 'SET_AUDIO_TRANSLATION_LANGUAGE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the AI audio translation target language
|
||||
* for a single participant (overriding the default), or null to disable it for
|
||||
* that participant.
|
||||
*/
|
||||
export const SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE = 'SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the list of remote participants currently translating the local
|
||||
* participant's audio (i.e. who to show the "translation enabled" badge on).
|
||||
*/
|
||||
export const SET_TRANSLATION_LISTENERS = 'SET_TRANSLATION_LISTENERS';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which records that the bridge started or stopped forwarding a translated
|
||||
* source to the local endpoint (i.e. whether we are hearing that source translated).
|
||||
*/
|
||||
export const UPDATE_TRANSLATED_SOURCE_SENDING = 'UPDATE_TRANSLATED_SOURCE_SENDING';
|
||||
118
react/features/audio-translation/actions.ts
Normal file
118
react/features/audio-translation/actions.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
CLEAR_AUDIO_TRANSLATION,
|
||||
CLEAR_RECEIVING_TRANSLATED_SOURCES,
|
||||
SET_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_TRANSLATION_LISTENERS,
|
||||
UPDATE_TRANSLATED_SOURCE_SENDING
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Clears all AI audio translation state — the default language and every per-participant
|
||||
* override. Used to reset the feature, e.g. when the bridge rejects a translation request.
|
||||
*
|
||||
* @returns {{
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function clearAudioTranslation() {
|
||||
return {
|
||||
type: CLEAR_AUDIO_TRANSLATION
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the set of translated sources the bridge is currently forwarding to us. Used when the
|
||||
* conference is left so stale received-translation state does not leak into the next conference.
|
||||
*
|
||||
* @returns {{
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function clearReceivingTranslatedSources() {
|
||||
return {
|
||||
type: CLEAR_RECEIVING_TRANSLATED_SOURCES
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the default target language for AI audio translation. The bridge is asked
|
||||
* to translate every remote speaker into this language and the local endpoint
|
||||
* subscribes to the resulting translated sources (in addition to the originals,
|
||||
* which are ducked during playback). Pass null to turn translation off.
|
||||
*
|
||||
* @param {string|null} language - The 2-letter ISO target language code, or null
|
||||
* to disable translation.
|
||||
* @returns {{
|
||||
* language: (string | null),
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function setAudioTranslationLanguage(language: string | null) {
|
||||
return {
|
||||
type: SET_AUDIO_TRANSLATION_LANGUAGE,
|
||||
language
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the AI audio translation target language for a single participant,
|
||||
* overriding the default. Pass null to disable translation for that participant.
|
||||
*
|
||||
* @param {string} participantId - The participant whose translation language to set.
|
||||
* @param {string|null} language - The 2-letter ISO target language code, or null
|
||||
* to disable translation for this participant.
|
||||
* @returns {{
|
||||
* language: (string | null),
|
||||
* participantId: string,
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function setParticipantAudioTranslationLanguage(participantId: string, language: string | null) {
|
||||
return {
|
||||
type: SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE,
|
||||
participantId,
|
||||
language
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the endpoint ids of the remote participants currently translating the local participant's audio,
|
||||
* as pushed by the audio-translation component. Drives the per-participant "translation enabled" badge.
|
||||
*
|
||||
* @param {Array<string>} ids - The endpoint ids translating the local participant.
|
||||
* @returns {{
|
||||
* ids: Array<string>,
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function setTranslationListeners(ids: string[]) {
|
||||
return {
|
||||
type: SET_TRANSLATION_LISTENERS,
|
||||
ids
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whether the bridge is currently forwarding a translated source to the local endpoint. A translated
|
||||
* source follows the {@code <endpointId>-a<idx>.<lang>} convention. The timestamp is the RTP timestamp from the
|
||||
* event (48 kHz, wraps) and is kept for completeness only — the boolean state is last-write-wins.
|
||||
*
|
||||
* @param {string} sourceName - The translated source name.
|
||||
* @param {boolean} sending - Whether the bridge started (true) or stopped (false) forwarding the source.
|
||||
* @param {number} timestamp - The RTP timestamp carried by the event.
|
||||
* @returns {{
|
||||
* sending: boolean,
|
||||
* sourceName: string,
|
||||
* timestamp: number,
|
||||
* type: string
|
||||
* }}
|
||||
*/
|
||||
export function updateTranslatedSourceSending(sourceName: string, sending: boolean, timestamp: number) {
|
||||
return {
|
||||
type: UPDATE_TRANSLATED_SOURCE_SENDING,
|
||||
sending,
|
||||
sourceName,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconTranslate } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../mobile/navigation/routes';
|
||||
import { isAudioTranslationAvailable } from '../../functions';
|
||||
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* The target language remote audio is currently translated into, or null.
|
||||
*/
|
||||
_language: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An overflow-menu button that opens the AI audio-translation language selector screen.
|
||||
*/
|
||||
class AudioTranslationButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.audioTranslation';
|
||||
override icon = IconTranslate;
|
||||
override label = 'toolbar.audioTranslation';
|
||||
|
||||
/**
|
||||
* Handles clicking the button by opening the language selector screen.
|
||||
*
|
||||
* @override
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
navigate(screen.conference.audioTranslation, { participantId: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in the toggled (translation-on) state.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return Boolean(this.props._language);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the button's props.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_language: state['features/audio-translation'].language,
|
||||
visible: isAudioTranslationAvailable(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AudioTranslationButton));
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { ScrollView, StyleProp, ViewStyle } from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import { goBack } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { setAudioTranslationLanguage, setParticipantAudioTranslationLanguage } from '../../actions';
|
||||
import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../constants';
|
||||
|
||||
import AudioTranslationLanguageItem from './AudioTranslationLanguageItem';
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Navigation route; params.participantId scopes the selector to one participant.
|
||||
*/
|
||||
route?: { params?: { participantId?: string; }; };
|
||||
}
|
||||
|
||||
/**
|
||||
* A screen for selecting the AI audio-translation target language (or off).
|
||||
*
|
||||
* @param {IProps} props - The component's props.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const AudioTranslationDialog = ({ route, t }: IProps) => {
|
||||
const participantId = route?.params?.participantId;
|
||||
const dispatch = useDispatch();
|
||||
const defaultLanguage = useSelector((state: IReduxState) => state['features/audio-translation'].language);
|
||||
const participantLanguages
|
||||
= useSelector((state: IReduxState) => state['features/audio-translation'].participantLanguages);
|
||||
|
||||
// Highlight the effective language for the target: the per-participant override if one is set, else the
|
||||
// default. (For the conference-wide screen there is no participantId, so this is just the default.)
|
||||
const language = participantId && participantId in participantLanguages
|
||||
? participantLanguages[participantId]
|
||||
: defaultLanguage;
|
||||
|
||||
const onSelect = useCallback((code: string | null) => {
|
||||
if (participantId) {
|
||||
dispatch(setParticipantAudioTranslationLanguage(participantId, code));
|
||||
} else {
|
||||
dispatch(setAudioTranslationLanguage(code));
|
||||
}
|
||||
goBack();
|
||||
}, [ dispatch, participantId ]);
|
||||
|
||||
return (
|
||||
<JitsiScreen style = { styles.container }>
|
||||
<ScrollView
|
||||
bounces = { false }
|
||||
style = { styles.itemsContainer as StyleProp<ViewStyle> }>
|
||||
<AudioTranslationLanguageItem
|
||||
code = { null }
|
||||
label = { t('toolbar.audioTranslationOff') }
|
||||
onSelect = { onSelect }
|
||||
selected = { !language } />
|
||||
{ SUPPORTED_TRANSLATION_LANGUAGES.map(({ code, label }) => (
|
||||
<AudioTranslationLanguageItem
|
||||
code = { code }
|
||||
key = { code }
|
||||
label = { label }
|
||||
onSelect = { onSelect }
|
||||
selected = { language === code } />
|
||||
)) }
|
||||
</ScrollView>
|
||||
</JitsiScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate(AudioTranslationDialog);
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { StyleProp, TouchableHighlight, View, ViewStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCheck } from '../../../base/icons/svg';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The language code this item selects, or null for the "off" item.
|
||||
*/
|
||||
code: string | null;
|
||||
|
||||
/**
|
||||
* The label shown for the item.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Invoked with the item's code when the item is pressed.
|
||||
*/
|
||||
onSelect: (code: string | null) => void;
|
||||
|
||||
/**
|
||||
* Whether this item is the current selection.
|
||||
*/
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row in the native audio-translation language selector.
|
||||
*
|
||||
* @param {IProps} props - The component's props.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const AudioTranslationLanguageItem = ({ code, label, onSelect, selected }: IProps) => {
|
||||
const onPress = useCallback(() => onSelect(code), [ code, onSelect ]);
|
||||
|
||||
return (
|
||||
<View style = { styles.languageItemWrapper as StyleProp<ViewStyle> }>
|
||||
<View style = { styles.iconWrapper as StyleProp<ViewStyle> }>
|
||||
{ selected && <Icon
|
||||
size = { 20 }
|
||||
src = { IconCheck } /> }
|
||||
</View>
|
||||
<TouchableHighlight
|
||||
onPress = { onPress }
|
||||
underlayColor = { 'transparent' }>
|
||||
<Text style = { [ styles.languageItemText, selected && styles.selectedLanguageItemText ] }>
|
||||
{ label }
|
||||
</Text>
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioTranslationLanguageItem;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
|
||||
|
||||
type Props = AbstractProps & WithTranslation;
|
||||
|
||||
/**
|
||||
* A tooltip-like expanded label explaining the meaning of the audio-translation conference label.
|
||||
*/
|
||||
class TranslationExpandedLabel extends ExpandedLabel<Props> {
|
||||
|
||||
/**
|
||||
* Returns the label specific text of this {@code ExpandedLabel}.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_getLabel() {
|
||||
return this.props.t('audioTranslation.labelTooltip');
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(TranslationExpandedLabel);
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IconTranslate } from '../../../base/icons/svg';
|
||||
import Label from '../../../base/label/components/native/Label';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
import { isAudioTranslationActiveInMeeting, isAudioTranslationAvailable } from '../../functions';
|
||||
|
||||
const styles = {
|
||||
translationLabel: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: BaseTheme.palette.action01,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
flexDirection: 'row',
|
||||
marginBottom: BaseTheme.spacing[0],
|
||||
marginLeft: BaseTheme.spacing[0]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Conference-header label shown while audio translation is active in the meeting.
|
||||
*
|
||||
* @returns {ReactElement|null}
|
||||
*/
|
||||
const TranslationLabel = () => {
|
||||
const available = useSelector(isAudioTranslationAvailable);
|
||||
const active = useSelector(isAudioTranslationActiveInMeeting);
|
||||
|
||||
return available && active ? (
|
||||
<Label
|
||||
icon = { IconTranslate }
|
||||
iconColor = { BaseTheme.palette.icon01 }
|
||||
style = { styles.translationLabel } />
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default TranslationLabel;
|
||||
37
react/features/audio-translation/components/native/styles.ts
Normal file
37
react/features/audio-translation/components/native/styles.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
/**
|
||||
* The styles of the native audio-translation components.
|
||||
*/
|
||||
export default {
|
||||
container: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
iconWrapper: {
|
||||
width: 32
|
||||
},
|
||||
|
||||
itemsContainer: {
|
||||
marginHorizontal: BaseTheme.spacing[4],
|
||||
marginVertical: BaseTheme.spacing[4]
|
||||
},
|
||||
|
||||
languageItemText: {
|
||||
...BaseTheme.typography.bodyShortRegularLarge,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginLeft: BaseTheme.spacing[2],
|
||||
marginVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
languageItemWrapper: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'row'
|
||||
},
|
||||
|
||||
selectedLanguageItemText: {
|
||||
...BaseTheme.typography.bodyShortBoldLarge
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconTranslate } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
|
||||
import AudioTranslationDialog from './AudioTranslationDialog';
|
||||
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* The target language remote audio is currently translated into, or null.
|
||||
*/
|
||||
_language: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A toolbar button that opens the AI audio-translation language selector.
|
||||
*/
|
||||
class AudioTranslationButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.audioTranslation';
|
||||
override icon = IconTranslate;
|
||||
override label = 'toolbar.audioTranslation';
|
||||
override tooltip = 'toolbar.audioTranslation';
|
||||
|
||||
/**
|
||||
* Handles clicking the button by opening the language selector dialog.
|
||||
*
|
||||
* @override
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
this.props.dispatch(openDialog('AudioTranslationDialog', AudioTranslationDialog));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in the toggled (translation-on) state.
|
||||
*
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return Boolean(this.props._language);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the button's props.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_language: state['features/audio-translation'].language
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AudioTranslationButton));
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { hideDialog } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import { setAudioTranslationLanguage, setParticipantAudioTranslationLanguage } from '../../actions';
|
||||
import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../constants';
|
||||
|
||||
import AudioTranslationLanguageItem from './AudioTranslationLanguageItem';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* When set, the dialog targets this single participant (overriding the default); otherwise it sets the
|
||||
* default language for every speaker.
|
||||
*/
|
||||
participantId?: string;
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
list: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* A dialog for selecting the AI audio-translation target language (or off).
|
||||
*
|
||||
* @param {IProps} props - The component's props.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const AudioTranslationDialog = ({ participantId, t }: IProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const { classes: styles } = useStyles();
|
||||
const defaultLanguage = useSelector((state: IReduxState) => state['features/audio-translation'].language);
|
||||
const participantLanguages
|
||||
= useSelector((state: IReduxState) => state['features/audio-translation'].participantLanguages);
|
||||
|
||||
// Highlight the effective language for the target: the per-participant override if one is set, else the
|
||||
// default. (For the conference-wide dialog there is no participantId, so this is just the default.)
|
||||
const language = participantId && participantId in participantLanguages
|
||||
? participantLanguages[participantId]
|
||||
: defaultLanguage;
|
||||
|
||||
const onSelect = useCallback((code: string | null) => {
|
||||
if (participantId) {
|
||||
dispatch(setParticipantAudioTranslationLanguage(participantId, code));
|
||||
} else {
|
||||
dispatch(setAudioTranslationLanguage(code));
|
||||
}
|
||||
dispatch(hideDialog());
|
||||
}, [ dispatch, participantId ]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ hidden: true }}
|
||||
ok = {{ hidden: true }}
|
||||
titleKey = 'toolbar.audioTranslation'>
|
||||
<div className = { styles.list }>
|
||||
<AudioTranslationLanguageItem
|
||||
code = { null }
|
||||
label = { t('toolbar.audioTranslationOff') }
|
||||
onSelect = { onSelect }
|
||||
selected = { !language } />
|
||||
{ SUPPORTED_TRANSLATION_LANGUAGES.map(({ code, label }) => (
|
||||
<AudioTranslationLanguageItem
|
||||
code = { code }
|
||||
key = { code }
|
||||
label = { label }
|
||||
onSelect = { onSelect }
|
||||
selected = { language === code } />
|
||||
)) }
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate(AudioTranslationDialog);
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The target language code, or null for the "off" option.
|
||||
*/
|
||||
code: string | null;
|
||||
|
||||
/**
|
||||
* The label to display.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Called with this item's code when it is selected.
|
||||
*/
|
||||
onSelect: (code: string | null) => void;
|
||||
|
||||
/**
|
||||
* Whether this item is the currently-selected option.
|
||||
*/
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
fontSize: '0.875rem',
|
||||
color: theme.palette.text01,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.ui02
|
||||
}
|
||||
},
|
||||
selected: {
|
||||
fontWeight: 700,
|
||||
color: theme.palette.link01
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* A single selectable language row in the audio-translation dialog.
|
||||
*
|
||||
* @param {IProps} props - The component's props.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const AudioTranslationLanguageItem = ({ code, label, onSelect, selected }: IProps) => {
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const onClick = useCallback(() => onSelect(code), [ code, onSelect ]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className = { cx(styles.item, selected && styles.selected) }
|
||||
onClick = { onClick }
|
||||
type = 'button'>
|
||||
{ label }
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioTranslationLanguageItem;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IconTranslate } from '../../../base/icons/svg';
|
||||
import Label from '../../../base/label/components/web/Label';
|
||||
import Tooltip from '../../../base/tooltip/components/Tooltip';
|
||||
import { isAudioTranslationActiveInMeeting } from '../../functions';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
translation: {
|
||||
background: theme.palette.action01
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Conference-header label shown while audio translation is active in the meeting.
|
||||
*
|
||||
* @returns {ReactElement|null}
|
||||
*/
|
||||
const TranslationLabel = () => {
|
||||
const { classes: styles } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
const active = useSelector(isAudioTranslationActiveInMeeting);
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = t('audioTranslation.labelTooltip');
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content = { content }
|
||||
position = { 'bottom' }>
|
||||
<Label
|
||||
accessibilityText = { content }
|
||||
className = { styles.translation }
|
||||
icon = { IconTranslate } />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranslationLabel;
|
||||
55
react/features/audio-translation/constants.ts
Normal file
55
react/features/audio-translation/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Supported AI audio-translation target languages: ISO code + picker label.
|
||||
*/
|
||||
export const SUPPORTED_TRANSLATION_LANGUAGES: Array<{ code: string; label: string; }> = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'pt', label: 'Português' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'it', label: 'Italiano' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'hi', label: 'हिन्दी' },
|
||||
{ code: 'id', label: 'Bahasa Indonesia' },
|
||||
{ code: 'vi', label: 'Tiếng Việt' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Full volume (0..1) a speaker's original audio is restored to once it is no longer ducked.
|
||||
*/
|
||||
export const DEFAULT_ORIGINAL_VOLUME = 1;
|
||||
|
||||
/**
|
||||
* Volume (0..1) a speaker's original audio is ducked to while its translation plays.
|
||||
*/
|
||||
export const DUCKED_ORIGINAL_VOLUME = 0.15;
|
||||
|
||||
/**
|
||||
* The per-participant audio-translation status the thumbnail indicator renders, combining whether translation
|
||||
* is enabled for the local user and whether translated audio is actually being received.
|
||||
*/
|
||||
export enum TranslationTreatment {
|
||||
|
||||
/**
|
||||
* Translation is enabled for the local user AND translated audio is being received.
|
||||
*/
|
||||
BOTH = 'both',
|
||||
|
||||
/**
|
||||
* Translation is enabled for the local user but no translated audio is being received yet.
|
||||
*/
|
||||
ENABLED = 'enabled',
|
||||
|
||||
/**
|
||||
* Neither enabled nor receiving — the indicator renders nothing.
|
||||
*/
|
||||
NONE = 'none',
|
||||
|
||||
/**
|
||||
* Translated audio is being received without translation having been explicitly enabled.
|
||||
*/
|
||||
RECEIVING = 'receiving'
|
||||
}
|
||||
301
react/features/audio-translation/functions.ts
Normal file
301
react/features/audio-translation/functions.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { AUDIO_TRANSLATION_ENABLED } from '../base/flags/constants';
|
||||
import { getFeatureFlag } from '../base/flags/functions';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { isLocalParticipantModerator } from '../base/participants/functions';
|
||||
import { ITrack } from '../base/tracks/types';
|
||||
|
||||
import { DUCKED_ORIGINAL_VOLUME, TranslationTreatment } from './constants';
|
||||
|
||||
/**
|
||||
* Whether audio translation is enabled for the room. Driven by the {@code audioTranslation} RoomMetadata flag,
|
||||
* which a moderator can toggle; absent or any value other than an explicit {@code false} means enabled.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationRoomEnabled(state: IReduxState): boolean {
|
||||
return state['features/base/conference'].metadata?.audioTranslation?.enabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given participant is currently translating the local participant's audio, per the directed
|
||||
* {@code translationListeners} list pushed by the audio-translation component.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isParticipantAudioTranslationActive(state: IReduxState, participantId: string): boolean {
|
||||
return state['features/audio-translation'].translationListeners.includes(participantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The target language encoded in a translated source name — the substring after the last {@code .}.
|
||||
*
|
||||
* @param {string} sourceName - The translated source name.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSourceLanguage(sourceName: string): string {
|
||||
const dotIndex = sourceName.lastIndexOf('.');
|
||||
|
||||
return dotIndex === -1 ? '' : sourceName.substring(dotIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner endpoint id encoded in a translated source name — the substring before the first {@code -}.
|
||||
* Translated sources follow the {@code <endpointId>-a<idx>.<lang>} convention (endpoint ids are dash/dot-free).
|
||||
* Returns an empty string for a source without a dash.
|
||||
*
|
||||
* @param {string} sourceName - The translated source name.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSourceOwnerEndpointId(sourceName: string): string {
|
||||
const dashIndex = sourceName.indexOf('-');
|
||||
|
||||
return dashIndex === -1 ? '' : sourceName.substring(0, dashIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we are hearing the given participant translated. The bridge floods sending changes to every
|
||||
* endpoint, so {@code receivingSources} is every translated source in flight anywhere, not just ours; a
|
||||
* source counts as ours only when its language matches the one we selected for this participant.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isReceivingTranslationFrom(state: IReduxState, participantId: string): boolean {
|
||||
const language = getEffectiveTranslationLanguage(state, participantId);
|
||||
|
||||
// The bridge announces sending changes to every endpoint, so a source being in flight does not mean we
|
||||
// subscribed to it; require our own selection for this speaker to match the source's language.
|
||||
if (!language) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return state['features/audio-translation'].receivingSources
|
||||
.some(sourceName => getSourceOwnerEndpointId(sourceName) === participantId
|
||||
&& sourceName.endsWith(`.${language}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio-translation status treatment for a participant, combining whether translation is enabled for the
|
||||
* local user ({@link isParticipantAudioTranslationActive}) and whether translated audio is being received
|
||||
* ({@link isReceivingTranslationFrom}).
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {TranslationTreatment}
|
||||
*/
|
||||
export function getTranslationTreatment(state: IReduxState, participantId: string): TranslationTreatment {
|
||||
const enabled = isParticipantAudioTranslationActive(state, participantId);
|
||||
const receiving = isReceivingTranslationFrom(state, participantId);
|
||||
|
||||
if (enabled && receiving) {
|
||||
return TranslationTreatment.BOTH;
|
||||
}
|
||||
if (enabled) {
|
||||
return TranslationTreatment.ENABLED;
|
||||
}
|
||||
if (receiving) {
|
||||
return TranslationTreatment.RECEIVING;
|
||||
}
|
||||
|
||||
return TranslationTreatment.NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether audio translation is currently active anywhere in the meeting: a remote participant is translating
|
||||
* the local user, translated audio is being received, or the local user has translation on.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationActiveInMeeting(state: IReduxState): boolean {
|
||||
const { language, participantLanguages, receivingSources, translationListeners }
|
||||
= state['features/audio-translation'];
|
||||
|
||||
return translationListeners.length > 0
|
||||
|| receivingSources.length > 0
|
||||
|| Boolean(language)
|
||||
|| Object.values(participantLanguages).some(lang => lang !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local user may manage audio translation for the room (i.e. toggle the room-wide flag). Mirrors the
|
||||
* prosody {@code live-translation} permission: the token's explicit value wins, otherwise moderators have it.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canManageAudioTranslation(state: IReduxState): boolean {
|
||||
return isJwtFeatureEnabled(state, MEET_FEATURES.LIVE_TRANSLATION, isLocalParticipantModerator(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the audio-translation UI should be available to the local user: the feature must be deployed
|
||||
* ({@code config.audioTranslation.enabled}) and either the user can manage it or it is enabled for the room.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationAvailable(state: IReduxState): boolean {
|
||||
// SDK embedder kill switch; defaults true, so web (no flags state) is unaffected.
|
||||
if (!getFeatureFlag(state, AUDIO_TRANSLATION_ENABLED, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state['features/base/config'].audioTranslation?.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per-room availability via RoomMetadata; only an explicit false hides it.
|
||||
if (state['features/base/conference'].metadata?.audioTranslationAvailable === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canManageAudioTranslation(state) || isAudioTranslationRoomEnabled(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoization for {@link getTranslatedSourceNames}: the tracks array is replaced on every tracks update, so
|
||||
* the set is rebuilt only when that reference changes and is shared by all callers in between.
|
||||
*/
|
||||
let _lastTracks: ITrack[] | undefined;
|
||||
let _lastTranslatedSourceNames = new Set<string>();
|
||||
|
||||
/**
|
||||
* Returns the set of translated audio source names currently present in the conference. Translated sources
|
||||
* follow the {@code <sourceName>.<language>} convention, so any source name containing a dot is one. Memoized
|
||||
* on the tracks array reference, letting callers do an O(1) lookup instead of scanning every track.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Set<string>} The translated source names present in the conference.
|
||||
*/
|
||||
export function getTranslatedSourceNames(state: IReduxState): Set<string> {
|
||||
const tracks = state['features/base/tracks'];
|
||||
|
||||
if (tracks === _lastTracks) {
|
||||
return _lastTranslatedSourceNames;
|
||||
}
|
||||
|
||||
const translated = new Set<string>();
|
||||
|
||||
for (const track of tracks) {
|
||||
const sourceName = track.jitsiTrack?.getSourceName?.();
|
||||
|
||||
if (typeof sourceName === 'string' && sourceName.includes('.')) {
|
||||
translated.add(sourceName);
|
||||
}
|
||||
}
|
||||
|
||||
_lastTracks = tracks;
|
||||
_lastTranslatedSourceNames = translated;
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-participant override language if set, else the conference-wide default.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {string | null} The target language, or null.
|
||||
*/
|
||||
export function getEffectiveTranslationLanguage(state: IReduxState, participantId?: string): string | null {
|
||||
const { language, participantLanguages } = state['features/audio-translation'];
|
||||
|
||||
return participantId && participantId in participantLanguages
|
||||
? participantLanguages[participantId]
|
||||
: language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to duck a speaker's original: a language is set and its translated counterpart is present.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [sourceName] - The original audio source name.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldDuckOriginalAudio(state: IReduxState, sourceName?: string, participantId?: string): boolean {
|
||||
const language = getEffectiveTranslationLanguage(state, participantId);
|
||||
|
||||
if (!language || typeof sourceName !== 'string' || sourceName.endsWith(`.${language}`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getTranslatedSourceNames(state).has(`${sourceName}.${language}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ducked volume (0..1) from config.audioTranslation.duckedVolume; invalid values fall back to default.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getDuckedVolume(state: IReduxState): number {
|
||||
const configured = state['features/base/config'].audioTranslation?.duckedVolume;
|
||||
|
||||
return typeof configured === 'number' && configured >= 0 && configured <= 1
|
||||
? configured
|
||||
: DUCKED_ORIGINAL_VOLUME;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ducked volume, capped at the user's chosen volume so ducking never makes a quieted speaker louder.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getDuckedVolumeForParticipant(state: IReduxState, participantId?: string): number {
|
||||
const duckedVolume = getDuckedVolume(state);
|
||||
const userVolume = participantId ? state['features/filmstrip'].participantsVolume[participantId] : undefined;
|
||||
|
||||
return typeof userVolume === 'number' ? Math.min(userVolume, duckedVolume) : duckedVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of participants still hearing the given speaker's translated audio, as published by that
|
||||
* speaker. 0 when nothing is pending or the speaker's client does not publish the count.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The speaker's participant id.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getTranslationDeliveryPendingCount(state: IReduxState, participantId: string): number {
|
||||
const counts = state['features/base/conference'].metadata?.audioTranslationListenerCounts?.[participantId];
|
||||
|
||||
if (!counts) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sum the subscribers of only the languages the bridge is still sending for this speaker: the in-flight
|
||||
// language count alone undercounts (one source serves every listener of that language) and the speaker's
|
||||
// subscriber total overcounts (a language whose stream already finished).
|
||||
return state['features/audio-translation'].receivingSources.reduce((total, sourceName) =>
|
||||
getSourceOwnerEndpointId(sourceName) === participantId
|
||||
? total + (counts[getSourceLanguage(sourceName)] ?? 0)
|
||||
: total, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether translated audio for the given speaker is still being delivered, so others should wait before
|
||||
* speaking. Falls back to any in-flight translated source owned by the speaker, so the ring still shows when
|
||||
* the speaker's client does not publish a count.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The speaker's participant id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTranslationDeliveryPending(state: IReduxState, participantId: string): boolean {
|
||||
if (getTranslationDeliveryPendingCount(state, participantId) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return state['features/audio-translation'].receivingSources
|
||||
.some(sourceName => getSourceOwnerEndpointId(sourceName) === participantId);
|
||||
}
|
||||
28
react/features/audio-translation/hooks.web.ts
Normal file
28
react/features/audio-translation/hooks.web.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import AudioTranslationButton from './components/web/AudioTranslationButton';
|
||||
import { isAudioTranslationAvailable } from './functions';
|
||||
|
||||
const audioTranslation = {
|
||||
key: 'audiotranslation',
|
||||
Content: AudioTranslationButton,
|
||||
group: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that returns the audio-translation toolbar button when the feature is available to the local user,
|
||||
* and undefined otherwise. Audio translation requires the bridge translation backend (opt-in via
|
||||
* config.audioTranslation.enabled), is hidden when a moderator has disabled it for the room, and is always
|
||||
* shown to those who can manage it.
|
||||
*
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
export function useAudioTranslationButton() {
|
||||
const available = useSelector(isAudioTranslationAvailable);
|
||||
|
||||
if (!available) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return audioTranslation;
|
||||
}
|
||||
3
react/features/audio-translation/logger.ts
Normal file
3
react/features/audio-translation/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('app:audio-translation');
|
||||
168
react/features/audio-translation/middleware.any.ts
Normal file
168
react/features/audio-translation/middleware.any.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { JitsiAudioTranslationErrors, JitsiConferenceEvents } from '../base/lib-jitsi-meet';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { showErrorNotification, showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
|
||||
|
||||
import {
|
||||
CLEAR_AUDIO_TRANSLATION,
|
||||
SET_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE
|
||||
} from './actionTypes';
|
||||
import {
|
||||
clearAudioTranslation,
|
||||
clearReceivingTranslatedSources,
|
||||
setTranslationListeners,
|
||||
updateTranslatedSourceSending
|
||||
} from './actions';
|
||||
import { isAudioTranslationRoomEnabled } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Maps an audio-translation component error condition to a notification message key. Unmapped conditions
|
||||
* fall back to {@link DEFAULT_ERROR_KEY}.
|
||||
*/
|
||||
const ERROR_NOTIFICATION_KEYS: { [condition: string]: string; } = {
|
||||
[JitsiAudioTranslationErrors.FORBIDDEN]: 'audioTranslation.errorForbidden',
|
||||
[JitsiAudioTranslationErrors.SPEAKER_UNAVAILABLE]: 'audioTranslation.errorSpeakerUnavailable',
|
||||
[JitsiAudioTranslationErrors.SUBSCRIPTION_LIMIT_REACHED]: 'audioTranslation.errorLimitReached'
|
||||
};
|
||||
|
||||
const DEFAULT_ERROR_KEY = 'audioTranslation.errorGeneric';
|
||||
|
||||
|
||||
/**
|
||||
* Middleware that drives the bridge-side translation when the local user changes
|
||||
* the default or a per-participant audio-translation target language.
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
// Block enabling a translation language while a moderator has disabled the feature for the room. Managers
|
||||
// keep the translation UI even when the room flag is off, and the bridge silently drops such requests, so
|
||||
// surface a notification here instead. Disabling (language === null) is always allowed through.
|
||||
if ((action.type === SET_AUDIO_TRANSLATION_LANGUAGE || action.type === SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE)
|
||||
&& action.language
|
||||
&& !isAudioTranslationRoomEnabled(store.getState())) {
|
||||
store.dispatch(showErrorNotification({
|
||||
titleKey: 'audioTranslation.errorRoomDisabled'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case CLEAR_AUDIO_TRANSLATION: {
|
||||
const { conference } = store.getState()['features/base/conference'];
|
||||
|
||||
if (conference) {
|
||||
logger.info('Clearing all audio translation state');
|
||||
conference.clearTranslation();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SET_AUDIO_TRANSLATION_LANGUAGE: {
|
||||
const { conference } = store.getState()['features/base/conference'];
|
||||
|
||||
if (conference) {
|
||||
logger.info(`Setting audio translation language to ${action.language ?? 'off'}`);
|
||||
conference.setReceiverTranslationLanguage(action.language);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE: {
|
||||
const { conference } = store.getState()['features/base/conference'];
|
||||
|
||||
if (conference) {
|
||||
logger.info(`Setting audio translation language for ${action.participantId} to ${action.language ?? 'off'}`);
|
||||
conference.setParticipantTranslationLanguage(action.participantId, action.language);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Surfaces audio-translation request failures reported by the bridge-side component: shows a notification and
|
||||
* resets translation state (redux + lib-jitsi-meet) so the UI reflects the failure and re-enabling works.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/base/conference'].conference,
|
||||
(conference, { dispatch }, previousConference) => {
|
||||
if (!conference || previousConference) {
|
||||
return;
|
||||
}
|
||||
|
||||
conference.on(JitsiConferenceEvents.AUDIO_TRANSLATION_FAILED,
|
||||
({ endpointIds, error }: { endpointIds: string[]; error: string; }) => {
|
||||
logger.warn(`Audio translation request failed: ${error} for [${endpointIds.join(', ')}]`);
|
||||
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: ERROR_NOTIFICATION_KEYS[error] ?? DEFAULT_ERROR_KEY
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
|
||||
// The optimistic selection didn't take. Reset everything: clears redux (UI reflects off)
|
||||
// and, via conference.clearTranslation(), resets lib-jitsi-meet's language state so
|
||||
// re-enabling isn't deduped to a no-op.
|
||||
dispatch(clearAudioTranslation());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ingests the two per-participant translation signals from the conference: which translated sources the bridge
|
||||
* is forwarding to us (receiving), and which remote participants are translating us (enabled). Both are cleared
|
||||
* when the conference goes away.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/base/conference'].conference,
|
||||
(conference, { dispatch, getState }, previousConference) => {
|
||||
if (previousConference) {
|
||||
dispatch(clearReceivingTranslatedSources());
|
||||
dispatch(setTranslationListeners([]));
|
||||
}
|
||||
|
||||
if (!conference) {
|
||||
return;
|
||||
}
|
||||
|
||||
conference.on(JitsiConferenceEvents.TRANSLATED_SOURCE_SENDING_CHANGED,
|
||||
({ sending, sourceName, timestamp }: { sending: boolean; sourceName: string; timestamp: number; }) => {
|
||||
// The bridge currently emits only sending=true, which would leave the receiving state
|
||||
// stuck on; ignore the events unless explicitly enabled.
|
||||
if (!getState()['features/base/config'].audioTranslation?.enableSendingChangeEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(updateTranslatedSourceSending(sourceName, sending, timestamp));
|
||||
});
|
||||
|
||||
conference.on(JitsiConferenceEvents.AUDIO_TRANSLATION_LISTENERS_CHANGED,
|
||||
(ids: string[]) => {
|
||||
dispatch(setTranslationListeners(ids));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* When a moderator disables audio translation for the room, clear the local user's active translation and notify
|
||||
* them. The room flag only blocks new requests on the bridge; it does not tear down active subscriptions. Clearing
|
||||
* locally drops the synthetic-source Include subscription (so the bridge stops forwarding the translated audio) and
|
||||
* un-ducks the original.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => isAudioTranslationRoomEnabled(state),
|
||||
(roomEnabled, { dispatch, getState }) => {
|
||||
if (roomEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { language, participantLanguages } = getState()['features/audio-translation'];
|
||||
|
||||
if (language || Object.keys(participantLanguages).length > 0) {
|
||||
dispatch(clearAudioTranslation());
|
||||
dispatch(showNotification({
|
||||
titleKey: 'audioTranslation.disabledForRoom'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
}
|
||||
});
|
||||
83
react/features/audio-translation/middleware.native.ts
Normal file
83
react/features/audio-translation/middleware.native.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
|
||||
import { DEFAULT_ORIGINAL_VOLUME } from './constants';
|
||||
import { getDuckedVolumeForParticipant, shouldDuckOriginalAudio } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
import './middleware.any';
|
||||
|
||||
// Playout gain last applied per track id; untouched tracks are left to the volume slider.
|
||||
const appliedVolumes = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Ducks original remote audio via _setVolume (native has no per-track audio elements), restoring the
|
||||
* user/full volume afterwards.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @param {boolean} reassertDucked - Re-apply the duck after a slider move overwrote the gain.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _applyDucking({ getState }: IStore, reassertDucked = false): void {
|
||||
const state = getState();
|
||||
const { participantsVolume } = state['features/filmstrip'];
|
||||
const currentIds = new Set<string>();
|
||||
|
||||
for (const track of state['features/base/tracks']) {
|
||||
if (track.local || track.mediaType !== MEDIA_TYPE.AUDIO) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const webrtcTrack = track.jitsiTrack?.getTrack?.();
|
||||
const trackId: string | undefined = webrtcTrack?.id;
|
||||
|
||||
if (!webrtcTrack || typeof webrtcTrack._setVolume !== 'function' || !trackId) {
|
||||
continue;
|
||||
}
|
||||
currentIds.add(trackId);
|
||||
|
||||
const sourceName: string | undefined = track.jitsiTrack?.getSourceName?.();
|
||||
const ducked = shouldDuckOriginalAudio(state, sourceName, track.participantId);
|
||||
const userVolume = track.participantId ? participantsVolume[track.participantId] : undefined;
|
||||
const target = ducked
|
||||
? getDuckedVolumeForParticipant(state, track.participantId)
|
||||
: typeof userVolume === 'number' ? userVolume : DEFAULT_ORIGINAL_VOLUME;
|
||||
const applied = appliedVolumes.get(trackId);
|
||||
|
||||
if ((!ducked && applied === undefined)
|
||||
|| (applied === target && !(ducked && reassertDucked))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
webrtcTrack._setVolume(target);
|
||||
appliedVolumes.set(trackId, target);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to set playout volume for track ${trackId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of Array.from(appliedVolumes.keys())) {
|
||||
if (!currentIds.has(id)) {
|
||||
appliedVolumes.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/base/tracks'],
|
||||
(_, store) => _applyDucking(store));
|
||||
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/audio-translation'].language,
|
||||
(_, store) => _applyDucking(store));
|
||||
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/audio-translation'].participantLanguages,
|
||||
(_, store) => _applyDucking(store));
|
||||
|
||||
// A slider move writes the gain directly, so re-assert the duck over it.
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/filmstrip'].participantsVolume,
|
||||
(_, store) => _applyDucking(store, true));
|
||||
1
react/features/audio-translation/middleware.web.ts
Normal file
1
react/features/audio-translation/middleware.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './middleware.any';
|
||||
94
react/features/audio-translation/reducer.ts
Normal file
94
react/features/audio-translation/reducer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import {
|
||||
CLEAR_AUDIO_TRANSLATION,
|
||||
CLEAR_RECEIVING_TRANSLATED_SOURCES,
|
||||
SET_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE,
|
||||
SET_TRANSLATION_LISTENERS,
|
||||
UPDATE_TRANSLATED_SOURCE_SENDING
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* The redux state of the audio-translation feature.
|
||||
*/
|
||||
export interface IAudioTranslationState {
|
||||
|
||||
/**
|
||||
* The default target language remote audio is translated into, or null when off.
|
||||
*/
|
||||
language: string | null;
|
||||
|
||||
/**
|
||||
* Per-participant target-language overrides (participantId -> language, or null to disable for that
|
||||
* participant). An entry present here takes precedence over {@link language}; an absent entry inherits it.
|
||||
*/
|
||||
participantLanguages: { [participantId: string]: string | null; };
|
||||
|
||||
/**
|
||||
* Translated source names the bridge is currently forwarding to us (i.e. we are hearing them translated).
|
||||
* A source follows the {@code <endpointId>-a<idx>.<lang>} convention; membership is last-write-wins.
|
||||
*/
|
||||
receivingSources: string[];
|
||||
|
||||
/**
|
||||
* Endpoint ids of the remote participants currently translating the local participant's audio, pushed by
|
||||
* the audio-translation component. Drives the per-participant "translation enabled" badge.
|
||||
*/
|
||||
translationListeners: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: IAudioTranslationState = {
|
||||
language: null,
|
||||
participantLanguages: {},
|
||||
receivingSources: [],
|
||||
translationListeners: []
|
||||
};
|
||||
|
||||
ReducerRegistry.register<IAudioTranslationState>(
|
||||
'features/audio-translation',
|
||||
(state = DEFAULT_STATE, action): IAudioTranslationState => {
|
||||
switch (action.type) {
|
||||
case CLEAR_AUDIO_TRANSLATION:
|
||||
return DEFAULT_STATE;
|
||||
case CLEAR_RECEIVING_TRANSLATED_SOURCES:
|
||||
return {
|
||||
...state,
|
||||
receivingSources: []
|
||||
};
|
||||
case SET_AUDIO_TRANSLATION_LANGUAGE:
|
||||
return {
|
||||
...state,
|
||||
language: action.language
|
||||
};
|
||||
case SET_PARTICIPANT_AUDIO_TRANSLATION_LANGUAGE:
|
||||
return {
|
||||
...state,
|
||||
participantLanguages: {
|
||||
...state.participantLanguages,
|
||||
[action.participantId]: action.language
|
||||
}
|
||||
};
|
||||
case SET_TRANSLATION_LISTENERS:
|
||||
return {
|
||||
...state,
|
||||
translationListeners: action.ids
|
||||
};
|
||||
case UPDATE_TRANSLATED_SOURCE_SENDING: {
|
||||
const has = state.receivingSources.includes(action.sourceName);
|
||||
|
||||
if (action.sending === has) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
receivingSources: action.sending
|
||||
? [ ...state.receivingSources, action.sourceName ]
|
||||
: state.receivingSources.filter((source: string) => source !== action.sourceName)
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
108
react/features/authentication/actionTypes.ts
Normal file
108
react/features/authentication/actionTypes.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 an external token
|
||||
* authentication (started by opening {@code tokenAuthUrl} in the system
|
||||
* browser) is pending or has finished. Native only.
|
||||
*
|
||||
* {
|
||||
* type: SET_TOKEN_AUTH_PENDING,
|
||||
* pending: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_TOKEN_AUTH_PENDING = 'SET_TOKEN_AUTH_PENDING';
|
||||
|
||||
/**
|
||||
* 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 disables moderator login.
|
||||
*
|
||||
* {
|
||||
* type: DISABLE_MODERATOR_LOGIN
|
||||
* }
|
||||
*/
|
||||
export const DISABLE_MODERATOR_LOGIN = 'DISABLE_MODERATOR_LOGIN';
|
||||
|
||||
/**
|
||||
* 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 which enables moderator login.
|
||||
*
|
||||
* {
|
||||
* type: ENABLE_MODERATOR_LOGIN
|
||||
* }
|
||||
*/
|
||||
export const ENABLE_MODERATOR_LOGIN = 'ENABLE_MODERATOR_LOGIN';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
243
react/features/authentication/actions.any.ts
Normal file
243
react/features/authentication/actions.any.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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 {
|
||||
DISABLE_MODERATOR_LOGIN,
|
||||
ENABLE_MODERATOR_LOGIN,
|
||||
LOGIN,
|
||||
LOGOUT,
|
||||
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', LoginDialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login.
|
||||
*
|
||||
* @returns {{
|
||||
* type: LOGIN
|
||||
* }}
|
||||
*/
|
||||
export function login() {
|
||||
return {
|
||||
type: LOGIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout.
|
||||
*
|
||||
* @returns {{
|
||||
* type: LOGOUT
|
||||
* }}
|
||||
*/
|
||||
export function logout() {
|
||||
return {
|
||||
type: LOGOUT
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables moderator login.
|
||||
*
|
||||
* @returns {{
|
||||
* type: DISABLE_MODERATOR_LOGIN
|
||||
* }}
|
||||
*/
|
||||
export function disableModeratorLogin() {
|
||||
return {
|
||||
type: DISABLE_MODERATOR_LOGIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables moderator login.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function enableModeratorLogin() {
|
||||
return {
|
||||
type: ENABLE_MODERATOR_LOGIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens {@link WaitForOnwerDialog}.
|
||||
*
|
||||
* @protected
|
||||
* @returns {Action}
|
||||
*/
|
||||
export function openWaitForOwnerDialog() {
|
||||
return openDialog('WaitForOwnerDialog', 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', LoginDialog);
|
||||
}
|
||||
161
react/features/authentication/actions.native.ts
Normal file
161
react/features/authentication/actions.native.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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 { hideDialog, openDialog } from '../base/dialog/actions';
|
||||
import { set } from '../base/redux/functions';
|
||||
|
||||
import { CANCEL_LOGIN, SET_TOKEN_AUTH_PENDING } from './actionTypes';
|
||||
import { stopWaitForOwner } from './actions.any';
|
||||
import LoginRetryDialog from './components/native/LoginRetryDialog';
|
||||
import logger from './logger';
|
||||
|
||||
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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides {@link LoginRetryDialog}.
|
||||
*
|
||||
* @returns {Action}
|
||||
*/
|
||||
export function hideLoginRetryDialog() {
|
||||
return hideDialog('LoginRetryDialog', LoginRetryDialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens {@link LoginRetryDialog} which warns the user that they are not
|
||||
* connected to the meeting because the external login was not completed.
|
||||
*
|
||||
* @returns {Action}
|
||||
*/
|
||||
export function openLoginRetryDialog() {
|
||||
return openDialog('LoginRetryDialog', LoginRetryDialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the default location (e.g. Welcome page).
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function redirectToDefaultLocation() {
|
||||
return (dispatch: IStore['dispatch']) => dispatch(appNavigate(undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether an external token authentication (started by opening
|
||||
* {@code tokenAuthUrl} in the system browser) is pending. While the flag is
|
||||
* set, returning to the foreground without a connection means the login was
|
||||
* abandoned and {@link LoginRetryDialog} is shown.
|
||||
*
|
||||
* @param {boolean} pending - Whether the external login is pending.
|
||||
* @returns {{
|
||||
* type: SET_TOKEN_AUTH_PENDING,
|
||||
* pending: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setTokenAuthPending(pending: boolean) {
|
||||
return {
|
||||
type: SET_TOKEN_AUTH_PENDING,
|
||||
pending
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens token auth URL page.
|
||||
*
|
||||
* @param {string} tokenAuthServiceUrl - Authentication service URL.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function openTokenAuthUrl(tokenAuthServiceUrl: string) {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
dispatch(setTokenAuthPending(true));
|
||||
|
||||
// Show the dialog right away, behind the browser: if the login is
|
||||
// completed, the deep link triggers a new connection attempt which
|
||||
// dismisses it; if the login is abandoned, it is what the user finds
|
||||
// when they return. The app may be put into picture-in-picture while
|
||||
// the browser is up, in which case no AppState change is emitted on
|
||||
// return, so the dialog cannot be reliably shown at that point.
|
||||
dispatch(openLoginRetryDialog());
|
||||
|
||||
Linking.openURL(tokenAuthServiceUrl)
|
||||
.catch(error => logger.error('Failed to open token auth URL', error));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used.
|
||||
*
|
||||
* @param {string} tokenAuthServiceUrl - Authentication service URL.
|
||||
* @returns {Promise<any>} Resolves.
|
||||
*/
|
||||
export function loginWithPopup(tokenAuthServiceUrl: string): Promise<any> {
|
||||
return Promise.resolve(tokenAuthServiceUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used. There is no inline login popup on native.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function closeLoginPopup() {
|
||||
// No-op on native.
|
||||
}
|
||||
359
react/features/authentication/actions.web.ts
Normal file
359
react/features/authentication/actions.web.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { maybeRedirectToWelcomePage } from '../app/actions.web';
|
||||
import { IStore } from '../app/types';
|
||||
import { connect } from '../base/connection/actions';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
import { setJWT } from '../base/jwt/actions';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { hideNotification, showErrorNotification, showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
|
||||
|
||||
import { CANCEL_LOGIN } from './actionTypes';
|
||||
import LoginQuestionDialog from './components/web/LoginQuestionDialog';
|
||||
import { isTokenAuthInline } from './functions.any';
|
||||
import logger from './logger';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
const PROMPT_POPUP_NOTIFICATION_ID = 'PROMPT_POPUP_NOTIFICATION_ID';
|
||||
|
||||
/**
|
||||
* Custom error that includes a recovery callback.
|
||||
*/
|
||||
class PopupBlockedError extends Error {
|
||||
constructor(
|
||||
message: string = 'Popup was blocked by browser',
|
||||
public readonly retry?: () => Window | null
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PopupBlockedError';
|
||||
Error.captureStackTrace(this, PopupBlockedError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error thrown when a pending inline login popup is closed
|
||||
* programmatically (e.g. the participant is admitted to the conference while
|
||||
* the popup is still open), as opposed to failing to authenticate.
|
||||
*/
|
||||
class LoginCancelledError extends Error {
|
||||
constructor(message: string = 'Login cancelled') {
|
||||
super(message);
|
||||
this.name = 'LoginCancelledError';
|
||||
Error.captureStackTrace(this, LoginCancelledError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds a function that closes the currently open inline login popup (if any)
|
||||
* and settles its pending promise. It is set while a popup is open and cleared
|
||||
* once the popup is closed, so it can be used to dismiss the popup when it is no
|
||||
* longer needed (e.g. when the participant is admitted to the conference).
|
||||
*/
|
||||
let closeActiveLoginPopup: (() => void) | undefined;
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographic nonce.
|
||||
*
|
||||
* @returns {string} The generated nonce.
|
||||
*/
|
||||
function generateNonce(): string {
|
||||
const array = new Uint8Array(32);
|
||||
|
||||
crypto.getRandomValues(array);
|
||||
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs login with a popup window.
|
||||
*
|
||||
* @param {string} tokenAuthServiceUrl - Authentication service URL.
|
||||
* @param {Window|undefined} popup - Authentication service URL.
|
||||
* @returns {Promise<any>} A promise that resolves with the authentication
|
||||
* result or rejects with an error.
|
||||
*/
|
||||
export function loginWithPopup(tokenAuthServiceUrl: string, popup?: Window | null): Promise<any> {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
let nonceParam = '';
|
||||
|
||||
try {
|
||||
const nonce = generateNonce();
|
||||
|
||||
sessionStorage.setItem('oauth_nonce', nonce);
|
||||
|
||||
nonceParam = `&nonce=${nonce}`;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'SecurityError') {
|
||||
logger.warn(
|
||||
'sessionStorage access denied (cross-origin or restricted context) enable it to improve security',
|
||||
e);
|
||||
} else {
|
||||
logger.error('Unable to save nonce in session storage', e);
|
||||
}
|
||||
}
|
||||
|
||||
const openPopup = () => {
|
||||
// Open popup
|
||||
const width = 500;
|
||||
const height = 600;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
return window.open(
|
||||
`${tokenAuthServiceUrl}${nonceParam}`,
|
||||
`Auth-${Date.now()}`,
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
);
|
||||
};
|
||||
|
||||
if (!popup) {
|
||||
popup = openPopup();
|
||||
}
|
||||
|
||||
if (!popup) {
|
||||
// adds a callback to the error that can be used to directly retry where window.open will be executed
|
||||
// on the user click without any promises(dispatches) incoved
|
||||
reject(new PopupBlockedError('Popup blocked', () => {
|
||||
return openPopup();
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let closedPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
|
||||
const cleanup = (handler: (event: MessageEvent) => void) => {
|
||||
window.removeEventListener('message', handler);
|
||||
clearInterval(closedPollInterval);
|
||||
popup?.close();
|
||||
closeActiveLoginPopup = undefined;
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem('code_verifier');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
// Verify origin
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'oauth-success') {
|
||||
cleanup(handler);
|
||||
|
||||
resolve({
|
||||
accessToken: event.data.accessToken,
|
||||
idToken: event.data.idToken,
|
||||
refreshToken: event.data.refreshToken
|
||||
});
|
||||
} else if (event.data.type === 'oauth-error') {
|
||||
cleanup(handler);
|
||||
|
||||
reject(new Error(event.data.error));
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for messages from the popup
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
// Expose a way to close this popup externally, so it can be dismissed
|
||||
// when it is no longer needed (e.g. the participant was admitted to the
|
||||
// conference while the popup was still open).
|
||||
closeActiveLoginPopup = () => {
|
||||
cleanup(handler);
|
||||
reject(new LoginCancelledError());
|
||||
};
|
||||
|
||||
// Detect manual popup close before authentication completes
|
||||
closedPollInterval = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
cleanup(handler);
|
||||
reject(new Error('Login cancelled'));
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs silent logout by loading the token authentication logout service URL in an
|
||||
* invisible iframe.
|
||||
*
|
||||
* @param {string} tokenAuthLogoutServiceUrl - Logout service URL.
|
||||
* @returns {Promise<any>} A promise that resolves when logout is complete.
|
||||
*/
|
||||
export function silentLogout(tokenAuthLogoutServiceUrl: string): any {
|
||||
return new Promise<void>(resolve => {
|
||||
const iframe = document.createElement('iframe');
|
||||
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = tokenAuthLogoutServiceUrl;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
// Listen for logout completion
|
||||
const handler = (event: any) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data.type === 'logout-success') {
|
||||
window.removeEventListener('message', handler);
|
||||
document.body.removeChild(iframe);
|
||||
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the currently open inline login popup (if any) and settles its pending
|
||||
* promise. Used to dismiss the popup when it is no longer needed, e.g. when the
|
||||
* participant is admitted to the conference while the popup is still open.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function closeLoginPopup() {
|
||||
closeActiveLoginPopup?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
|
||||
if (!browser.isElectron() && isTokenAuthInline(getState()['features/base/config'])) {
|
||||
const login = (popup?: Window | null) => {
|
||||
return loginWithPopup(tokenAuthServiceUrl, popup)
|
||||
.then((result: { accessToken: string; idToken: string; refreshToken?: string; }) => {
|
||||
// @ts-ignore
|
||||
const token: string = result.accessToken;
|
||||
const idToken: string = result.idToken;
|
||||
const refreshToken: string | undefined = result.refreshToken;
|
||||
|
||||
// @ts-ignore
|
||||
dispatch(setJWT(token, idToken, refreshToken));
|
||||
|
||||
logger.info('Reconnecting to conference with new token.');
|
||||
|
||||
const { connection } = getState()['features/base/connection'];
|
||||
|
||||
if (connection) {
|
||||
connection.refreshToken(token).then(
|
||||
() => {
|
||||
const { membersOnly } = getState()['features/base/conference'];
|
||||
|
||||
membersOnly?.join();
|
||||
})
|
||||
.catch((err: any) => {
|
||||
dispatch(setJWT());
|
||||
logger.error(err);
|
||||
});
|
||||
} else {
|
||||
dispatch(connect());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
login().catch(err => {
|
||||
if (err instanceof PopupBlockedError) {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'dialog.loginPopupBlocked',
|
||||
uid: PROMPT_POPUP_NOTIFICATION_ID,
|
||||
customActionNameKey: [ 'dialog.retry' ],
|
||||
customActionHandler: [ () => {
|
||||
dispatch(hideNotification(PROMPT_POPUP_NOTIFICATION_ID));
|
||||
|
||||
// the window.open will be executed directly from the onClick handler of the notification action button
|
||||
login(err.retry ? err.retry() : undefined)
|
||||
.catch(logger.error);
|
||||
} ],
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
||||
logger.error(err);
|
||||
} else if (err instanceof LoginCancelledError) {
|
||||
// The popup was closed programmatically (e.g. we were
|
||||
// admitted to the conference while it was still open); this
|
||||
// is not an authentication failure, so nothing to report.
|
||||
logger.info('Inline login popup closed:', err.message);
|
||||
} else {
|
||||
// let's add expand that will show the error message in the notification
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: 'dialog.loginFailed'
|
||||
}));
|
||||
logger.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Show warning for leaving conference only when in a conference.
|
||||
if (!browser.isElectron() && getState()['features/base/conference'].conference) {
|
||||
dispatch(openDialog('LoginQuestionDialog', 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,42 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
|
||||
import { cancelLogin, login, redirectToDefaultLocation } from '../../actions.native';
|
||||
|
||||
/**
|
||||
* Dialog shown when the app returns to the foreground while an external token
|
||||
* authentication (started by opening {@code tokenAuthUrl} in the system
|
||||
* browser) was not completed. It warns the user that they are not connected
|
||||
* to the meeting and offers to either retry the login or leave.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export default function LoginRetryDialog(): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const handleSubmit = useCallback(() => {
|
||||
dispatch(login());
|
||||
|
||||
return true; // close dialog
|
||||
}, [ dispatch ]);
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch(cancelLogin());
|
||||
|
||||
// cancelLogin() only navigates away when the connection is still
|
||||
// parked on passwordRequired; on WebSocket deployments that state is
|
||||
// reset by the subsequent connection-dropped failure, so leave the
|
||||
// dead conference screen explicitly.
|
||||
dispatch(redirectToDefaultLocation());
|
||||
|
||||
return true; // close dialog
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
cancelLabel = 'toolbar.leaveConference'
|
||||
confirmLabel = 'dialog.login'
|
||||
descriptionKey = 'dialog.loginNotCompleted'
|
||||
onCancel = { handleCancel }
|
||||
onSubmit = { handleSubmit } />
|
||||
);
|
||||
}
|
||||
@@ -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 = 'lobby.waitForModerator'
|
||||
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('lobby.waitForModerator') }
|
||||
</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));
|
||||
104
react/features/authentication/functions.any.ts
Normal file
104
react/features/authentication/functions.any.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { IConfig } from '../base/config/configType';
|
||||
import { parseURLParams } from '../base/util/parseURLParams';
|
||||
import { getBackendSafeRoomName } from '../base/util/uri';
|
||||
import { isVpaasMeeting } from '../jaas/functions';
|
||||
|
||||
/**
|
||||
* Checks if the token for authentication URL is available and the meeting is not jaas.
|
||||
*
|
||||
* @param {IReduxState} state - The state of the app.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTokenAuthEnabled = (state: IReduxState): boolean => {
|
||||
const config = state['features/base/config'];
|
||||
|
||||
return typeof config.tokenAuthUrl === 'string' && config.tokenAuthUrl.length > 0
|
||||
&& !isVpaasMeeting(state);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the token authentication should be done inline.
|
||||
*
|
||||
* @param {Object} config - Configuration state object from store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTokenAuthInline = (config: IConfig): boolean =>
|
||||
config.tokenAuthInline === true;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* lowBandwidthModeEnabled: boolean | undefined,
|
||||
* skipPrejoin: boolean | undefined,
|
||||
* videoMuted: boolean | undefined
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
* @param {string?} refreshToken - The refresh token if available.
|
||||
*
|
||||
* @returns {Object} The state object.
|
||||
*/
|
||||
export const _getTokenAuthState = (
|
||||
locationURL: URL,
|
||||
options: {
|
||||
audioMuted: boolean | undefined;
|
||||
lowBandwidthModeEnabled: boolean | undefined;
|
||||
skipPrejoin: boolean | undefined;
|
||||
videoMuted: boolean | undefined;
|
||||
},
|
||||
roomName: string | undefined,
|
||||
tenant: string | undefined,
|
||||
refreshToken?: string): object => {
|
||||
const state = {
|
||||
refreshToken,
|
||||
room: roomName,
|
||||
roomSafe: getBackendSafeRoomName(roomName),
|
||||
tenant
|
||||
};
|
||||
|
||||
const {
|
||||
audioMuted = false,
|
||||
lowBandwidthModeEnabled = false,
|
||||
skipPrejoin = false,
|
||||
videoMuted = false
|
||||
} = options;
|
||||
|
||||
if (audioMuted) {
|
||||
|
||||
// @ts-ignore
|
||||
state['config.startWithAudioMuted'] = true;
|
||||
}
|
||||
|
||||
if (lowBandwidthModeEnabled) {
|
||||
|
||||
// @ts-ignore
|
||||
state['config.startLowBandwidthMode'] = 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;
|
||||
};
|
||||
82
react/features/authentication/functions.native.ts
Normal file
82
react/features/authentication/functions.native.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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
|
||||
* lowBandwidthModeEnabled: boolean | undefined,
|
||||
* skipPrejoin: boolean | undefined,
|
||||
* videoMuted: boolean | undefined
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
* @param {string?} refreshToken - The refreshToken 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;
|
||||
lowBandwidthModeEnabled: boolean | undefined;
|
||||
skipPrejoin: boolean | undefined;
|
||||
videoMuted: boolean | undefined;
|
||||
},
|
||||
roomName: string | undefined,
|
||||
// eslint-disable-next-line max-params
|
||||
tenant: string | undefined,
|
||||
// eslint-disable-next-line max-params
|
||||
refreshToken?: string | undefined): Promise<string | undefined> => {
|
||||
|
||||
const {
|
||||
audioMuted = false,
|
||||
lowBandwidthModeEnabled = 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,
|
||||
lowBandwidthModeEnabled,
|
||||
skipPrejoin,
|
||||
videoMuted
|
||||
},
|
||||
roomName,
|
||||
tenant,
|
||||
refreshToken
|
||||
);
|
||||
|
||||
// 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));
|
||||
};
|
||||
136
react/features/authentication/functions.web.ts
Normal file
136
react/features/authentication/functions.web.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import base64js from 'base64-js';
|
||||
|
||||
import { IConfig } from '../base/config/configType';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
|
||||
import { _getTokenAuthState } from './functions.any';
|
||||
import logger from './logger';
|
||||
|
||||
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
|
||||
* lowBandwidthModeEnabled: boolean | undefined,
|
||||
* skipPrejoin: boolean | undefined,
|
||||
* videoMuted: boolean | undefined
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
* @param {string?} refreshToken - The refresh token if available.
|
||||
*
|
||||
* @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;
|
||||
lowBandwidthModeEnabled: boolean | undefined;
|
||||
skipPrejoin: boolean | undefined;
|
||||
videoMuted: boolean | undefined;
|
||||
},
|
||||
roomName: string | undefined,
|
||||
// eslint-disable max-params
|
||||
tenant: string | undefined,
|
||||
refreshToken?: string): Promise<string | undefined> => {
|
||||
// eslint-enable max-params
|
||||
const {
|
||||
audioMuted = false,
|
||||
lowBandwidthModeEnabled = 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,
|
||||
lowBandwidthModeEnabled,
|
||||
skipPrejoin,
|
||||
videoMuted
|
||||
},
|
||||
roomName,
|
||||
tenant,
|
||||
refreshToken
|
||||
);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem('code_verifier', codeVerifier);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'SecurityError') {
|
||||
logger.warn(
|
||||
'sessionStorage access denied (cross-origin or restricted context) enable it to improve security',
|
||||
e);
|
||||
} else {
|
||||
logger.error('Unable to save code verifier in session storage', e);
|
||||
}
|
||||
}
|
||||
|
||||
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('app:authentication');
|
||||
320
react/features/authentication/middleware.any.ts
Normal file
320
react/features/authentication/middleware.any.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { IStore } from '../app/types';
|
||||
import {
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_LEFT
|
||||
} from '../base/conference/actionTypes';
|
||||
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 {
|
||||
closeLoginPopup,
|
||||
disableModeratorLogin,
|
||||
enableModeratorLogin,
|
||||
hideLoginDialog,
|
||||
openLoginDialog,
|
||||
openTokenAuthUrl,
|
||||
openWaitForOwnerDialog,
|
||||
redirectToDefaultLocation,
|
||||
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
|
||||
* moderator login availability 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 (error.name === JitsiConferenceErrors.MEMBERS_ONLY_ERROR && lobbyWaitingForHost) {
|
||||
if (recoverable) {
|
||||
store.dispatch(enableModeratorLogin());
|
||||
} else {
|
||||
store.dispatch(disableModeratorLogin());
|
||||
}
|
||||
} else if (error.name === JitsiConferenceErrors.AUTHENTICATION_REQUIRED) {
|
||||
if (recoverable) {
|
||||
store.dispatch(waitForOwner());
|
||||
} else {
|
||||
store.dispatch(stopWaitForOwner());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_JOINED: {
|
||||
const { dispatch } = store;
|
||||
|
||||
if (_isWaitingForModerator(store)) {
|
||||
dispatch(disableModeratorLogin());
|
||||
}
|
||||
if (_isWaitingForOwner(store)) {
|
||||
dispatch(stopWaitForOwner());
|
||||
}
|
||||
dispatch(hideLoginDialog());
|
||||
|
||||
// We may have been admitted to the conference while an inline token
|
||||
// auth popup was still open (e.g. an authenticated participant joined
|
||||
// and the backend let us in automatically). Dismiss the popup so it
|
||||
// doesn't linger on top of the conference.
|
||||
closeLoginPopup();
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_LEFT:
|
||||
store.dispatch(disableModeratorLogin());
|
||||
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 STOP_WAIT_FOR_OWNER:
|
||||
_clearExistingWaitForOwnerTimeout(store);
|
||||
store.dispatch(hideDialog('WaitForOwnerDialog', 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the cyclic "wait for moderator" task is currently scheduled.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isWaitingForModerator({ getState }: IStore) {
|
||||
return getState()['features/authentication'].showModeratorLogin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: lowBandwidthModeEnabled } = state['features/base/low-bandwidth-mode'];
|
||||
const audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
|
||||
const videoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
|
||||
const refreshToken = state['features/base/jwt'].refreshToken;
|
||||
|
||||
if (!room) {
|
||||
logger.warn('Cannot handle login, room is undefined!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTokenAuthEnabled(state)) {
|
||||
dispatch(openLoginDialog());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
getTokenAuthUrl(
|
||||
config,
|
||||
locationURL,
|
||||
{
|
||||
audioMuted,
|
||||
lowBandwidthModeEnabled,
|
||||
skipPrejoin: true,
|
||||
videoMuted
|
||||
},
|
||||
room,
|
||||
tenant,
|
||||
refreshToken
|
||||
)
|
||||
.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());
|
||||
}
|
||||
84
react/features/authentication/middleware.native.ts
Normal file
84
react/features/authentication/middleware.native.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { CONNECTION_ESTABLISHED, CONNECTION_WILL_CONNECT } from '../base/connection/actionTypes';
|
||||
import { isDialogOpen } from '../base/dialog/functions';
|
||||
import { SET_JWT } from '../base/jwt/actionTypes';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { APP_STATE_CHANGED } from '../mobile/background/actionTypes';
|
||||
|
||||
import { hideLoginRetryDialog, openLoginRetryDialog, setTokenAuthPending } from './actions.native';
|
||||
import LoginRetryDialog from './components/native/LoginRetryDialog';
|
||||
import { isTokenAuthEnabled } from './functions.native';
|
||||
|
||||
import './middleware.any';
|
||||
|
||||
/**
|
||||
* Middleware that detects the app returning to the foreground while an
|
||||
* external token authentication is still pending and warns the user that they
|
||||
* are not connected to the meeting.
|
||||
*
|
||||
* Opening {@code tokenAuthUrl} in the system browser is fire-and-forget: if
|
||||
* the user abandons the login page and switches back to the app, no deep link
|
||||
* is ever delivered and the app keeps showing the conference screen with no
|
||||
* connection and no indication of failure. The pending state is tracked with
|
||||
* an explicit flag (set when the browser is opened, cleared when a new
|
||||
* connection attempt starts) because the connection-level error state cannot
|
||||
* be relied upon: on WebSocket deployments the not-authorized failure is
|
||||
* immediately followed by a connection-dropped failure which resets it.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case APP_STATE_CHANGED: {
|
||||
if (action.appState !== 'active') {
|
||||
break;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const { tokenAuthPending } = state['features/authentication'];
|
||||
const { conference, room } = state['features/base/conference'];
|
||||
const { jwt } = state['features/base/jwt'];
|
||||
|
||||
// The app is back in the foreground on the conference screen with the
|
||||
// external login still pending: no conference was joined and no JWT
|
||||
// has been delivered via a deep link, so the login was abandoned and
|
||||
// the app is not connected.
|
||||
if (tokenAuthPending
|
||||
&& room
|
||||
&& !conference
|
||||
&& !jwt
|
||||
&& isTokenAuthEnabled(state)
|
||||
&& !isDialogOpen(store, LoginRetryDialog)) {
|
||||
store.dispatch(openLoginRetryDialog());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SET_JWT:
|
||||
// SET_JWT with no token is the periodic clearing that happens on
|
||||
// every navigation; only a delivered token completes the login.
|
||||
if (!action.jwt) {
|
||||
break;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case CONNECTION_ESTABLISHED:
|
||||
case CONNECTION_WILL_CONNECT: {
|
||||
// The external login is no longer pending once the deep link delivers
|
||||
// a JWT (the room may first land on the prejoin screen, where no
|
||||
// connection attempt is started yet) or a new connection attempt
|
||||
// starts.
|
||||
const { dispatch, getState } = store;
|
||||
|
||||
if (getState()['features/authentication'].tokenAuthPending) {
|
||||
dispatch(setTokenAuthPending(false));
|
||||
}
|
||||
if (isDialogOpen(store, LoginRetryDialog)) {
|
||||
dispatch(hideLoginRetryDialog());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
1
react/features/authentication/middleware.web.ts
Normal file
1
react/features/authentication/middleware.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './middleware.any';
|
||||
103
react/features/authentication/reducer.ts
Normal file
103
react/features/authentication/reducer.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { assign } from '../base/redux/functions';
|
||||
|
||||
import {
|
||||
CANCEL_LOGIN,
|
||||
DISABLE_MODERATOR_LOGIN,
|
||||
ENABLE_MODERATOR_LOGIN,
|
||||
SET_TOKEN_AUTH_PENDING,
|
||||
STOP_WAIT_FOR_OWNER,
|
||||
UPGRADE_ROLE_FINISHED,
|
||||
UPGRADE_ROLE_STARTED,
|
||||
WAIT_FOR_OWNER
|
||||
} from './actionTypes';
|
||||
|
||||
export interface IAuthenticationState {
|
||||
error?: Object | undefined;
|
||||
progress?: number | undefined;
|
||||
showModeratorLogin?: boolean;
|
||||
thenableWithCancel?: {
|
||||
cancel: Function;
|
||||
};
|
||||
tokenAuthPending?: boolean;
|
||||
waitForOwnerTimeoutID?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
tokenAuthPending: undefined
|
||||
});
|
||||
|
||||
case SET_TOKEN_AUTH_PENDING:
|
||||
return assign(state, {
|
||||
tokenAuthPending: action.pending || undefined
|
||||
});
|
||||
case ENABLE_MODERATOR_LOGIN:
|
||||
return assign(state, {
|
||||
showModeratorLogin: true
|
||||
});
|
||||
|
||||
case STOP_WAIT_FOR_OWNER:
|
||||
return assign(state, {
|
||||
error: undefined,
|
||||
waitForOwnerTimeoutID: undefined
|
||||
});
|
||||
|
||||
case DISABLE_MODERATOR_LOGIN:
|
||||
return assign(state, {
|
||||
error: undefined,
|
||||
showModeratorLogin: false
|
||||
});
|
||||
|
||||
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';
|
||||
374
react/features/av-moderation/actions.ts
Normal file
374
react/features/av-moderation/actions.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { batch } from 'react-redux';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
import { getConferenceState } from '../base/conference/functions';
|
||||
import { getParticipantById } 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 { canRejectParticipant, 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 participant = getParticipantById(state, id);
|
||||
|
||||
if (canRejectParticipant(participant, MEDIA_TYPE.AUDIO, state)) {
|
||||
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 participant = getParticipantById(state, id);
|
||||
|
||||
if (canRejectParticipant(participant, MEDIA_TYPE.DESKTOP, state)) {
|
||||
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 participant = getParticipantById(state, id);
|
||||
|
||||
if (canRejectParticipant(participant, MEDIA_TYPE.VIDEO, state)) {
|
||||
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
|
||||
};
|
||||
200
react/features/av-moderation/functions.ts
Normal file
200
react/features/av-moderation/functions.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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 whether rejecting a participant for a media type has an effect. The mute state that a participant
|
||||
* reports in presence is not used, because a participant can report a state that is not correct.
|
||||
*
|
||||
* @param {IParticipant|undefined} participant - The participant.
|
||||
* @param {MediaType} mediaType - The media type.
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canRejectParticipant(participant: IParticipant | undefined, mediaType: MediaType,
|
||||
state: IReduxState) {
|
||||
// Moderators are never subject to A/V moderation, and a participant that is already force muted has nothing
|
||||
// left to reject.
|
||||
return isEnabledFromState(mediaType, state)
|
||||
&& !isParticipantModerator(participant)
|
||||
&& !isForceMuted(participant, 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;
|
||||
}
|
||||
313
react/features/av-moderation/middleware.ts
Normal file
313
react/features/av-moderation/middleware.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
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 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));
|
||||
}) ],
|
||||
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',
|
||||
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
|
||||
};
|
||||
}
|
||||
314
react/features/base/app/components/BaseApp.tsx
Normal file
314
react/features/base/app/components/BaseApp.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
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 {
|
||||
|
||||
/**
|
||||
* Whether an error was caught while rendering the tree below this
|
||||
* {@code BaseApp}.
|
||||
*/
|
||||
hasError?: boolean;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
hasError: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state so the next render shows the fallback instead of
|
||||
* unmounting the whole tree when a descendant throws while rendering.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// On react-native, global.localStorage is replaced at startup with the
|
||||
// Storage polyfill in react/features/mobile/polyfills/Storage.js, which
|
||||
// stores its AsyncStorage-loading Promise as an instance property named
|
||||
// `_initializing`. Its getItem() returns own-property values, so
|
||||
// getItem('_initializing') surfaces that Promise and lets us wait for
|
||||
// AsyncStorage to finish loading persisted state. On web this is always
|
||||
// null (no such key in window.localStorage) and the cast is a no-op.
|
||||
// The new @jitsi/js-utils types declare getItem as `string | null`,
|
||||
// which is accurate for the standard Web Storage shape but doesn't
|
||||
// capture this RN-only side channel — hence the cast.
|
||||
const _initializing = jitsiLocalStorage.getItem('_initializing') as
|
||||
Promise<unknown> | null;
|
||||
|
||||
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 { hasError, route: { component, props }, store } = this.state;
|
||||
|
||||
if (hasError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 as Mutable<typeof 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('app: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);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user