snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

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

View File

@@ -0,0 +1 @@
import './middleware.any';

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

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

View File

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

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

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

View File

@@ -0,0 +1 @@
import './middleware.any';

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

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

View File

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

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