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

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

View File

@@ -0,0 +1,8 @@
/**
* The type of (redux) action which dismisses calendar notification.
*
* {
* type: DISMISS_CALENDAR_NOTIFICATION
* }
*/
export const DISMISS_CALENDAR_NOTIFICATION = 'DISMISS_CALENDAR_NOTIFICATION';

View File

@@ -0,0 +1,78 @@
import { IStore } from '../app/types';
import { hideDialog, openDialog } from '../base/dialog/actions';
import AlertDialog from '../base/dialog/components/native/AlertDialog';
import { getParticipantDisplayName } from '../base/participants/functions';
import { DISMISS_CALENDAR_NOTIFICATION } from './actionTypes';
/**
* Notify that we've been kicked out of the conference.
*
* @param {JitsiParticipant} participant - The {@link JitsiParticipant}
* instance which initiated the kick event.
* @param {?Function} submit - The function to execute after submitting the dialog.
* @returns {Function}
*/
export function notifyKickedOut(participant: any, submit?: Function) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (participant?.isReplaced?.()) {
submit?.();
return;
}
dispatch(openDialog(AlertDialog, {
contentKey: {
key: participant ? 'dialog.kickTitle' : 'dialog.kickSystemTitle',
params: {
participantDisplayName: participant && getParticipantDisplayName(getState, participant.getId())
}
},
onSubmit: submit
}));
};
}
/**
* Notify that we've been kicked out of the conference.
*
* @param {string} reasonKey - The translation key for the reason why the conference failed.
* @param {?Function} submit - The function to execute after submitting the dialog.
* @returns {Function}
*/
export function notifyConferenceFailed(reasonKey: string, submit?: Function) {
return (dispatch: IStore['dispatch']) => {
if (!reasonKey) {
submit?.();
return;
}
// we have to push the opening of the dialog to the queue
// so that we make sure it will be visible after the events
// of conference destroyed are done
setTimeout(() => dispatch(openDialog(AlertDialog, {
contentKey: {
key: reasonKey
},
params: {
},
onSubmit: () => {
submit?.();
dispatch(hideDialog(AlertDialog));
}
})));
};
}
/**
* Dismisses calendar notification about next or ongoing event.
*
* @returns {Object}
*/
export function dismissCalendarNotification() {
return {
type: DISMISS_CALENDAR_NOTIFICATION
};
}

View File

@@ -0,0 +1,91 @@
import { IStore } from '../app/types';
import { configureInitialDevices, getAvailableDevices } from '../base/devices/actions.web';
import { openDialog } from '../base/dialog/actions';
import { getJitsiMeetGlobalNSConnectionTimes } from '../base/util/helpers';
import { getBackendSafeRoomName } from '../base/util/uri';
import { DISMISS_CALENDAR_NOTIFICATION } from './actionTypes';
import LeaveReasonDialog from './components/web/LeaveReasonDialog.web';
import logger from './logger';
/**
* Opens {@code LeaveReasonDialog}.
*
* @param {string} [title] - The dialog title.
*
* @returns {Promise} Resolved when the dialog is closed.
*/
export function openLeaveReasonDialog(title?: string) {
return (dispatch: IStore['dispatch']): Promise<void> => new Promise(resolve => {
dispatch(openDialog(LeaveReasonDialog, {
onClose: resolve,
title
}));
});
}
/**
* Dismisses calendar notification about next or ongoing event.
*
* @returns {Object}
*/
export function dismissCalendarNotification() {
return {
type: DISMISS_CALENDAR_NOTIFICATION
};
}
/**
* Setups initial devices. Makes sure we populate availableDevices list before configuring.
*
* @param {boolean} recordTimeMetrics - If true, an analytics time metrics will be sent.
* @returns {Promise<any>}
*/
export function setupInitialDevices(recordTimeMetrics = false) {
return async (dispatch: IStore['dispatch']) => {
if (recordTimeMetrics) {
getJitsiMeetGlobalNSConnectionTimes()['setupInitialDevices.start'] = window.performance.now();
}
await dispatch(getAvailableDevices());
if (recordTimeMetrics) {
getJitsiMeetGlobalNSConnectionTimes()['setupInitialDevices.getAD.finished'] = window.performance.now();
}
await dispatch(configureInitialDevices());
const now = window.performance.now();
if (recordTimeMetrics) {
getJitsiMeetGlobalNSConnectionTimes()['setupInitialDevices.end'] = now;
}
logger.debug(`(TIME) setupInitialDevices finished: ${now}`);
};
}
/**
* Init.
*
* @param {boolean} shouldDispatchConnect - Whether or not connect should be dispatched. This should be false only when
* prejoin is enabled.
* @returns {Promise<JitsiConnection>}
*/
export function init(shouldDispatchConnect: boolean) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
logger.debug(`(TIME) init action dispatched: ${window.performance.now()}`);
const room = getBackendSafeRoomName(getState()['features/base/conference'].room);
// XXX For web based version we use conference initialization logic
// from the old app (at the moment of writing).
return dispatch(setupInitialDevices(true)).then(
() => APP.conference.init({
roomName: room,
shouldDispatchConnect
}).catch((error: Error) => {
APP.API.notifyConferenceLeft(APP.conference.roomName);
logger.error(error);
}));
};
}

View File

@@ -0,0 +1,80 @@
import React, { Component } from 'react';
import { IReduxState } from '../../app/types';
import { NotificationsContainer } from '../../notifications/components';
import { shouldDisplayTileView } from '../../video-layout/functions.any';
import { shouldDisplayNotifications } from '../functions';
/**
* The type of the React {@code Component} props of {@link AbstractLabels}.
*/
export type AbstractProps = {
/**
* Set to {@code true} when the notifications are to be displayed.
*
* @protected
* @type {boolean}
*/
_notificationsVisible: boolean;
/**
* Conference room name.
*
* @protected
* @type {string}
*/
_room: string;
/**
* Whether or not the layout should change to support tile view mode.
*
* @protected
* @type {boolean}
*/
_shouldDisplayTileView: boolean;
};
/**
* A container to hold video status labels, including recording status and
* current large video quality.
*
* @augments Component
*/
export class AbstractConference<P extends AbstractProps, S>
extends Component<P, S> {
/**
* Renders the {@code LocalRecordingLabel}.
*
* @param {Object} props - The properties to be passed to
* the {@code NotificationsContainer}.
* @protected
* @returns {React$Element}
*/
renderNotificationsContainer(props?: any) {
if (this.props._notificationsVisible) {
return (
React.createElement(NotificationsContainer, props)
);
}
return null;
}
}
/**
* Maps (parts of) the redux state to the associated props of the {@link Labels}
* {@code Component}.
*
* @param {Object} state - The redux state.
* @private
* @returns {AbstractProps}
*/
export function abstractMapStateToProps(state: IReduxState) {
return {
_notificationsVisible: shouldDisplayNotifications(state),
_room: state['features/base/conference'].room ?? '',
_shouldDisplayTileView: shouldDisplayTileView(state)
};
}

View File

@@ -0,0 +1,58 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState } from '../../app/types';
import isInsecureRoomName from '../../base/util/isInsecureRoomName';
import { isUnsafeRoomWarningEnabled } from '../../prejoin/functions';
interface IProps extends WithTranslation {
/**
* True of the label should be visible.
*/
_visible: boolean;
}
/**
* Abstract class for the {@Code InsecureRoomNameLabel} component.
*/
export default class AbstractInsecureRoomNameLabel extends PureComponent<IProps> {
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
if (!this.props._visible) {
return null;
}
return this._render();
}
/**
* Renders the platform dependent content.
*
* @returns {ReactElement}
*/
_render() {
return <></>;
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState) {
const { locked, room } = state['features/base/conference'];
const { lobbyEnabled } = state['features/lobby'];
return {
_visible: Boolean(isUnsafeRoomWarningEnabled(state)
&& room && isInsecureRoomName(room)
&& !(lobbyEnabled || Boolean(locked)))
};
}

View File

@@ -0,0 +1,107 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { getConferenceTimestamp } from '../../base/conference/functions';
import { getLocalizedDurationFormatter } from '../../base/i18n/dateUtil';
import { ConferenceTimerDisplay } from './index';
/**
* The type of the React {@code Component} props of {@link ConferenceTimer}.
*/
interface IProps {
/**
* Style to be applied to the rendered text.
*/
textStyle?: Object;
}
export interface IDisplayProps {
/**
* Style to be applied to text (native only).
*/
textStyle?: Object;
/**
* String to display as time.
*/
timerValue: string;
}
const ConferenceTimer = ({ textStyle }: IProps) => {
const startTimestamp = useSelector(getConferenceTimestamp);
const [ timerValue, setTimerValue ] = useState(getLocalizedDurationFormatter(0));
const interval = useRef<number>();
/**
* Sets the current state values that will be used to render the timer.
*
* @param {number} refValueUTC - The initial UTC timestamp value.
* @param {number} currentValueUTC - The current UTC timestamp value.
*
* @returns {void}
*/
const setStateFromUTC = useCallback((refValueUTC, currentValueUTC) => {
if (!refValueUTC || !currentValueUTC) {
return;
}
if (currentValueUTC < refValueUTC) {
return;
}
const timerMsValue = currentValueUTC - refValueUTC;
const localizedTime = getLocalizedDurationFormatter(timerMsValue);
setTimerValue(localizedTime);
}, []);
/**
* Start conference timer.
*
* @returns {void}
*/
const startTimer = useCallback(() => {
if (!interval.current && startTimestamp) {
setStateFromUTC(startTimestamp, new Date().getTime());
interval.current = window.setInterval(() => {
setStateFromUTC(startTimestamp, new Date().getTime());
}, 1000);
}
}, [ startTimestamp, interval ]);
/**
* Stop conference timer.
*
* @returns {void}
*/
const stopTimer = useCallback(() => {
if (interval.current) {
clearInterval(interval.current);
interval.current = undefined;
}
setTimerValue(getLocalizedDurationFormatter(0));
}, [ interval ]);
useEffect(() => {
startTimer();
return () => stopTimer();
}, [ startTimestamp ]);
if (!startTimestamp) {
return null;
}
return (<ConferenceTimerDisplay
textStyle = { textStyle }
timerValue = { timerValue } />);
};
export default ConferenceTimer;

View File

@@ -0,0 +1,15 @@
export const CONFERENCE_INFO = {
alwaysVisible: [ 'raised-hands-count', 'recording' ],
autoHide: [
'highlight-moment',
'subject',
'conference-timer',
'participants-count',
'e2ee',
'transcribing',
'video-quality',
'visitors-count',
'insecure-room',
'top-panel-toggle'
]
};

View File

@@ -0,0 +1,22 @@
import { IReduxState } from '../../app/types';
import { CONFERENCE_INFO } from './constants';
/**
* Retrieves the conference info labels based on config values and defaults.
*
* @param {Object} state - The redux state.
* @returns {Object} The conferenceInfo object.
*/
export const getConferenceInfo = (state: IReduxState) => {
const { conferenceInfo } = state['features/base/config'];
if (conferenceInfo) {
return {
alwaysVisible: conferenceInfo.alwaysVisible ?? CONFERENCE_INFO.alwaysVisible,
autoHide: conferenceInfo.autoHide ?? CONFERENCE_INFO.autoHide
};
}
return CONFERENCE_INFO;
};

View File

@@ -0,0 +1,32 @@
import { IReduxState } from '../../app/types';
export * from './functions.any';
/**
* Returns whether the conference is in connecting state.
*
* @param {Object} state - The redux state.
* @returns {boolean} Whether conference is connecting.
*/
export const isConnecting = (state: IReduxState) => {
const { connecting, connection } = state['features/base/connection'];
const {
conference,
joining,
membersOnly,
leaving
} = state['features/base/conference'];
// XXX There is a window of time between the successful establishment of the
// XMPP connection and the subsequent commencement of joining the MUC during
// which the app does not appear to be doing anything according to the redux
// state. In order to not toggle the _connecting props during the window of
// time in question, define _connecting as follows:
// - the XMPP connection is connecting, or
// - the XMPP connection is connected and the conference is joining, or
// - the XMPP connection is connected and we have no conference yet, nor we
// are leaving one.
return Boolean(
connecting || (connection && (!membersOnly && (joining || (!conference && !leaving))))
);
};

View File

@@ -0,0 +1,12 @@
export * from './functions.any';
/**
* Whether or not there are always on labels.
*
* @returns {boolean}
*/
export function isAlwaysOnTitleBarEmpty() {
const bar = document.querySelector('#alwaysVisible>div');
return bar?.childNodes.length === 0;
}

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
import React, { useCallback } from 'react';
import { TouchableOpacity } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { openHighlightDialog } from '../../../recording/actions.native';
import HighlightButton from '../../../recording/components/Recording/native/HighlightButton';
import RecordingLabel from '../../../recording/components/native/RecordingLabel';
import { isLiveStreamingRunning } from '../../../recording/functions';
import VisitorsCountLabel from '../../../visitors/components/native/VisitorsCountLabel';
import RaisedHandsCountLabel from './RaisedHandsCountLabel';
import {
LABEL_ID_RAISED_HANDS_COUNT,
LABEL_ID_RECORDING,
LABEL_ID_STREAMING,
LABEL_ID_VISITORS_COUNT,
LabelHitSlop
} from './constants';
interface IProps {
/**
* Creates a function to be invoked when the onPress of the touchables are
* triggered.
*/
createOnPress: Function;
}
const AlwaysOnLabels = ({ createOnPress }: IProps) => {
const dispatch = useDispatch();
const isStreaming = useSelector(isLiveStreamingRunning);
const openHighlightDialogCallback = useCallback(() =>
dispatch(openHighlightDialog()), [ dispatch ]);
return (<>
<TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = { createOnPress(LABEL_ID_RECORDING) } >
<RecordingLabel mode = { JitsiRecordingConstants.mode.FILE } />
</TouchableOpacity>
{
isStreaming
&& <TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = { createOnPress(LABEL_ID_STREAMING) } >
<RecordingLabel mode = { JitsiRecordingConstants.mode.STREAM } />
</TouchableOpacity>
}
<TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = { openHighlightDialogCallback }>
<HighlightButton />
</TouchableOpacity>
<TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = { createOnPress(LABEL_ID_RAISED_HANDS_COUNT) } >
<RaisedHandsCountLabel />
</TouchableOpacity>
<TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = { createOnPress(LABEL_ID_VISITORS_COUNT) } >
<VisitorsCountLabel />
</TouchableOpacity>
</>);
};
export default AlwaysOnLabels;

View File

@@ -0,0 +1,621 @@
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback } from 'react';
import {
BackHandler,
NativeModules,
Platform,
SafeAreaView,
StatusBar,
View,
ViewStyle
} from 'react-native';
import { EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context';
import { connect, useDispatch } from 'react-redux';
import { appNavigate } from '../../../app/actions.native';
import { IReduxState, IStore } from '../../../app/types';
import { CONFERENCE_BLURRED, CONFERENCE_FOCUSED } from '../../../base/conference/actionTypes';
import { isDisplayNameVisible } from '../../../base/config/functions.native';
import { FULLSCREEN_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import Container from '../../../base/react/components/native/Container';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
import TintedView from '../../../base/react/components/native/TintedView';
import {
ASPECT_RATIO_NARROW,
ASPECT_RATIO_WIDE
} from '../../../base/responsive-ui/constants';
import { StyleType } from '../../../base/styles/functions.any';
import TestConnectionInfo from '../../../base/testing/components/TestConnectionInfo';
import { isCalendarEnabled } from '../../../calendar-sync/functions.native';
import DisplayNameLabel from '../../../display-name/components/native/DisplayNameLabel';
import BrandingImageBackground from '../../../dynamic-branding/components/native/BrandingImageBackground';
import Filmstrip from '../../../filmstrip/components/native/Filmstrip';
import TileView from '../../../filmstrip/components/native/TileView';
import { FILMSTRIP_SIZE } from '../../../filmstrip/constants';
import { isFilmstripVisible } from '../../../filmstrip/functions.native';
import CalleeInfoContainer from '../../../invite/components/callee-info/CalleeInfoContainer';
import LargeVideo from '../../../large-video/components/LargeVideo.native';
import { getIsLobbyVisible } from '../../../lobby/functions';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { isPipEnabled, setPictureInPictureEnabled } from '../../../mobile/picture-in-picture/functions';
import Captions from '../../../subtitles/components/native/Captions';
import { setToolboxVisible } from '../../../toolbox/actions.native';
import Toolbox from '../../../toolbox/components/native/Toolbox';
import { isToolboxVisible } from '../../../toolbox/functions.native';
import {
AbstractConference,
type AbstractProps,
abstractMapStateToProps
} from '../AbstractConference';
import { isConnecting } from '../functions.native';
import AlwaysOnLabels from './AlwaysOnLabels';
import ExpandedLabelPopup from './ExpandedLabelPopup';
import LonelyMeetingExperience from './LonelyMeetingExperience';
import TitleBar from './TitleBar';
import { EXPANDED_LABEL_TIMEOUT } from './constants';
import styles from './styles';
/**
* The type of the React {@code Component} props of {@link Conference}.
*/
interface IProps extends AbstractProps {
/**
* Application's aspect ratio.
*/
_aspectRatio: Symbol;
/**
* Whether the audio only is enabled or not.
*/
_audioOnlyEnabled: boolean;
/**
* Branding styles for conference.
*/
_brandingStyles: StyleType;
/**
* Whether the calendar feature is enabled or not.
*/
_calendarEnabled: boolean;
/**
* The indicator which determines that we are still connecting to the
* conference which includes establishing the XMPP connection and then
* joining the room. If truthy, then an activity/loading indicator will be
* rendered.
*/
_connecting: boolean;
/**
* Set to {@code true} when the filmstrip is currently visible.
*/
_filmstripVisible: boolean;
/**
* The indicator which determines whether fullscreen (immersive) mode is enabled.
*/
_fullscreenEnabled: boolean;
/**
* The indicator which determines if the display name is visible.
*/
_isDisplayNameVisible: boolean;
/**
* The indicator which determines if the participants pane is open.
*/
_isParticipantsPaneOpen: boolean;
/**
* The ID of the participant currently on stage (if any).
*/
_largeVideoParticipantId: string;
/**
* Local participant's display name.
*/
_localParticipantDisplayName: string;
/**
* Whether Picture-in-Picture is enabled.
*/
_pictureInPictureEnabled: boolean;
/**
* The indicator which determines whether the UI is reduced (to accommodate
* smaller display areas).
*/
_reducedUI: boolean;
/**
* Indicates whether the lobby screen should be visible.
*/
_showLobby: boolean;
/**
* Indicates whether the car mode is enabled.
*/
_startCarMode: boolean;
/**
* The indicator which determines whether the Toolbox is visible.
*/
_toolboxVisible: boolean;
/**
* The redux {@code dispatch} function.
*/
dispatch: IStore['dispatch'];
/**
* Object containing the safe area insets.
*/
insets: EdgeInsets;
/**
* Default prop for navigating between screen components(React Navigation).
*/
navigation: any;
}
type State = {
/**
* The label that is currently expanded.
*/
visibleExpandedLabel?: string;
};
/**
* The conference page of the mobile (i.e. React Native) application.
*/
class Conference extends AbstractConference<IProps, State> {
/**
* Timeout ref.
*/
_expandedLabelTimeout: any;
/**
* Initializes hardwareBackPress subscription.
*/
_hardwareBackPressSubscription: any;
/**
* Initializes a new Conference instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
this.state = {
visibleExpandedLabel: undefined
};
this._expandedLabelTimeout = React.createRef<number>();
// Bind event handlers so they are only bound once per instance.
this._onClick = this._onClick.bind(this);
this._onHardwareBackPress = this._onHardwareBackPress.bind(this);
this._setToolboxVisible = this._setToolboxVisible.bind(this);
this._createOnPress = this._createOnPress.bind(this);
}
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const {
_audioOnlyEnabled,
_startCarMode,
navigation
} = this.props;
this._hardwareBackPressSubscription = BackHandler.addEventListener('hardwareBackPress', this._onHardwareBackPress);
if (_audioOnlyEnabled && _startCarMode) {
navigation.navigate(screen.conference.carmode);
}
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: IProps) {
const {
_audioOnlyEnabled,
_showLobby,
_startCarMode
} = this.props;
if (!prevProps._showLobby && _showLobby) {
navigate(screen.lobby.root);
}
if (prevProps._showLobby && !_showLobby) {
if (_audioOnlyEnabled && _startCarMode) {
return;
}
navigate(screen.conference.main);
}
}
/**
* Implements {@link Component#componentWillUnmount()}. Invoked immediately
* before this component is unmounted and destroyed. Disconnects the
* conference described by the redux store/state.
*
* @inheritdoc
* @returns {void}
*/
override componentWillUnmount() {
// Tear handling any hardware button presses for back navigation down.
this._hardwareBackPressSubscription?.remove();
clearTimeout(this._expandedLabelTimeout.current ?? 0);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_brandingStyles,
_fullscreenEnabled
} = this.props;
return (
<Container
style = { [
styles.conference,
_brandingStyles
] }>
<BrandingImageBackground />
{
Platform.OS === 'android'
&& <StatusBar
barStyle = 'light-content'
hidden = { _fullscreenEnabled }
translucent = { _fullscreenEnabled } />
}
{ this._renderContent() }
</Container>
);
}
/**
* Changes the value of the toolboxVisible state, thus allowing us to switch
* between Toolbox and Filmstrip and change their visibility.
*
* @private
* @returns {void}
*/
_onClick() {
this._setToolboxVisible(!this.props._toolboxVisible);
}
/**
* Handles a hardware button press for back navigation. Enters Picture-in-Picture mode
* (if supported) or leaves the associated {@code Conference} otherwise.
*
* @returns {boolean} Exiting the app is undesired, so {@code true} is always returned.
*/
_onHardwareBackPress() {
let p;
if (this.props._pictureInPictureEnabled) {
const { PictureInPicture } = NativeModules;
p = PictureInPicture.enterPictureInPicture();
} else {
p = Promise.reject(new Error('PiP not enabled'));
}
p.catch(() => {
this.props.dispatch(appNavigate(undefined));
});
return true;
}
/**
* Creates a function to be invoked when the onPress of the touchables are
* triggered.
*
* @param {string} label - The identifier of the label that's onLayout is
* triggered.
* @returns {Function}
*/
_createOnPress(label: string) {
return () => {
const { visibleExpandedLabel } = this.state;
const newVisibleExpandedLabel
= visibleExpandedLabel === label ? undefined : label;
clearTimeout(this._expandedLabelTimeout.current);
this.setState({
visibleExpandedLabel: newVisibleExpandedLabel
});
if (newVisibleExpandedLabel) {
this._expandedLabelTimeout.current = setTimeout(() => {
this.setState({
visibleExpandedLabel: undefined
});
}, EXPANDED_LABEL_TIMEOUT);
}
};
}
/**
* Renders the content for the Conference container.
*
* @private
* @returns {React$Element}
*/
_renderContent() {
const {
_aspectRatio,
_connecting,
_filmstripVisible,
_isDisplayNameVisible,
_largeVideoParticipantId,
_reducedUI,
_shouldDisplayTileView,
_toolboxVisible
} = this.props;
let alwaysOnTitleBarStyles;
if (_reducedUI) {
return this._renderContentForReducedUi();
}
if (_aspectRatio === ASPECT_RATIO_WIDE) {
alwaysOnTitleBarStyles
= !_shouldDisplayTileView && _filmstripVisible
? styles.alwaysOnTitleBarWide
: styles.alwaysOnTitleBar;
} else {
alwaysOnTitleBarStyles = styles.alwaysOnTitleBar;
}
return (
<>
{/*
* The LargeVideo is the lowermost stacking layer.
*/
_shouldDisplayTileView
? <TileView onClick = { this._onClick } />
: <LargeVideo onClick = { this._onClick } />
}
{/*
* If there is a ringing call, show the callee's info.
*/
<CalleeInfoContainer />
}
{/*
* The activity/loading indicator goes above everything, except
* the toolbox/toolbars and the dialogs.
*/
_connecting
&& <TintedView>
<LoadingIndicator />
</TintedView>
}
<View
pointerEvents = 'box-none'
style = { styles.toolboxAndFilmstripContainer as ViewStyle }>
<Captions onPress = { this._onClick } />
{
_shouldDisplayTileView
|| (_isDisplayNameVisible && (
<Container style = { styles.displayNameContainer }>
<DisplayNameLabel
participantId = { _largeVideoParticipantId } />
</Container>
))
}
{ !_shouldDisplayTileView && <LonelyMeetingExperience /> }
{
_shouldDisplayTileView
|| <>
<Filmstrip />
{ this._renderNotificationsContainer() }
<Toolbox />
</>
}
</View>
<SafeAreaView
pointerEvents = 'box-none'
style = {
(_toolboxVisible
? styles.titleBarSafeViewColor
: styles.titleBarSafeViewTransparent) as ViewStyle }>
<TitleBar _createOnPress = { this._createOnPress } />
</SafeAreaView>
<SafeAreaView
pointerEvents = 'box-none'
style = {
(_toolboxVisible
? [ styles.titleBarSafeViewTransparent, { top: this.props.insets.top + 50 } ]
: styles.titleBarSafeViewTransparent) as ViewStyle
}>
<View
pointerEvents = 'box-none'
style = { styles.expandedLabelWrapper }>
<ExpandedLabelPopup visibleExpandedLabel = { this.state.visibleExpandedLabel } />
</View>
<View
pointerEvents = 'box-none'
style = { alwaysOnTitleBarStyles as ViewStyle }>
{/* eslint-disable-next-line react/jsx-no-bind */}
<AlwaysOnLabels createOnPress = { this._createOnPress } />
</View>
</SafeAreaView>
<TestConnectionInfo />
{
_shouldDisplayTileView
&& <>
{ this._renderNotificationsContainer() }
<Toolbox />
</>
}
</>
);
}
/**
* Renders the content for the Conference container when in "reduced UI" mode.
*
* @private
* @returns {React$Element}
*/
_renderContentForReducedUi() {
const { _connecting } = this.props;
return (
<>
<LargeVideo onClick = { this._onClick } />
{
_connecting
&& <TintedView>
<LoadingIndicator />
</TintedView>
}
</>
);
}
/**
* Renders a container for notifications to be displayed by the
* base/notifications feature.
*
* @private
* @returns {React$Element}
*/
_renderNotificationsContainer() {
const notificationsStyle: ViewStyle = {};
// In the landscape mode (wide) there's problem with notifications being
// shadowed by the filmstrip rendered on the right. This makes the "x"
// button not clickable. In order to avoid that a margin of the
// filmstrip's size is added to the right.
//
// Pawel: after many attempts I failed to make notifications adjust to
// their contents width because of column and rows being used in the
// flex layout. The only option that seemed to limit the notification's
// size was explicit 'width' value which is not better than the margin
// added here.
const { _aspectRatio, _filmstripVisible } = this.props;
if (_filmstripVisible && _aspectRatio !== ASPECT_RATIO_NARROW) {
notificationsStyle.marginRight = FILMSTRIP_SIZE;
}
return super.renderNotificationsContainer(
{
shouldDisplayTileView: this.props._shouldDisplayTileView,
style: notificationsStyle,
toolboxVisible: this.props._toolboxVisible
}
);
}
/**
* Dispatches an action changing the visibility of the {@link Toolbox}.
*
* @private
* @param {boolean} visible - Pass {@code true} to show the
* {@code Toolbox} or {@code false} to hide it.
* @returns {void}
*/
_setToolboxVisible(visible: boolean) {
this.props.dispatch(setToolboxVisible(visible));
}
}
/**
* Maps (parts of) the redux state to the associated {@code Conference}'s props.
*
* @param {Object} state - The redux state.
* @param {any} _ownProps - Component's own props.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { isOpen } = state['features/participants-pane'];
const { aspectRatio, reducedUI } = state['features/base/responsive-ui'];
const { backgroundColor } = state['features/dynamic-branding'];
const { startCarMode } = state['features/base/settings'];
const { enabled: audioOnlyEnabled } = state['features/base/audio-only'];
const brandingStyles = backgroundColor ? {
background: backgroundColor
} : undefined;
return {
...abstractMapStateToProps(state),
_aspectRatio: aspectRatio,
_audioOnlyEnabled: Boolean(audioOnlyEnabled),
_brandingStyles: brandingStyles,
_calendarEnabled: isCalendarEnabled(state),
_connecting: isConnecting(state),
_filmstripVisible: isFilmstripVisible(state),
_fullscreenEnabled: getFeatureFlag(state, FULLSCREEN_ENABLED, true),
_isDisplayNameVisible: isDisplayNameVisible(state),
_isParticipantsPaneOpen: isOpen,
_largeVideoParticipantId: state['features/large-video'].participantId,
_pictureInPictureEnabled: isPipEnabled(state),
_reducedUI: reducedUI,
_showLobby: getIsLobbyVisible(state),
_startCarMode: startCarMode,
_toolboxVisible: isToolboxVisible(state)
};
}
export default withSafeAreaInsets(connect(_mapStateToProps)(props => {
const dispatch = useDispatch();
useFocusEffect(useCallback(() => {
dispatch({ type: CONFERENCE_FOCUSED });
setPictureInPictureEnabled(true);
return () => {
dispatch({ type: CONFERENCE_BLURRED });
setPictureInPictureEnabled(false);
};
}, []));
return ( // @ts-ignore
<Conference { ...props } />
);
}));

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Text } from 'react-native';
import { IDisplayProps } from '../ConferenceTimer';
/**
* Returns native element to be rendered.
*
* @param {Object} props - Component props.
*
* @returns {ReactElement}
*/
export default function ConferenceTimerDisplay({ timerValue, textStyle }: IDisplayProps) {
return (
<Text
numberOfLines = { 1 }
style = { textStyle }>
{ timerValue }
</Text>
);
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import BaseTheme from '../../../base/ui/components/BaseTheme';
import { EXPANDED_LABELS } from './constants';
interface IProps {
/**
* The selected label to show details.
*/
visibleExpandedLabel?: string;
}
const ExpandedLabelPopup = ({ visibleExpandedLabel }: IProps) => {
if (visibleExpandedLabel) {
const expandedLabel = EXPANDED_LABELS[visibleExpandedLabel as keyof typeof EXPANDED_LABELS];
if (expandedLabel) {
const LabelComponent = expandedLabel.component;
const { props, alwaysOn } = expandedLabel;
const style = {
top: alwaysOn ? BaseTheme.spacing[6] : BaseTheme.spacing[1]
};
return (<LabelComponent
{ ...props }
style = { style } />);
}
}
return null;
};
export default ExpandedLabelPopup;

View File

@@ -0,0 +1,51 @@
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
import getUnsafeRoomText from '../../../base/util/getUnsafeRoomText.native';
import { INSECURE_ROOM_NAME_LABEL_COLOR } from './styles';
interface IProps extends AbstractProps, WithTranslation {
getUnsafeRoomTextFn: Function;
}
/**
* A react {@code Component} that implements an expanded label as tooltip-like
* component to explain the meaning of the {@code InsecureRoomNameExpandedLabel}.
*/
class InsecureRoomNameExpandedLabel extends ExpandedLabel<IProps> {
/**
* Returns the color this expanded label should be rendered with.
*
* @returns {string}
*/
_getColor() {
return INSECURE_ROOM_NAME_LABEL_COLOR;
}
/**
* Returns the label specific text of this {@code ExpandedLabel}.
*
* @returns {string}
*/
_getLabel() {
return this.props.getUnsafeRoomTextFn(this.props.t);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
return {
getUnsafeRoomTextFn: (t: Function) => getUnsafeRoomText(state, t, 'meeting')
};
}
export default translate(connect(_mapStateToProps)(InsecureRoomNameExpandedLabel));

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import { IconWarning } from '../../../base/icons/svg';
import Label from '../../../base/label/components/native/Label';
import AbstractInsecureRoomNameLabel, { _mapStateToProps } from '../AbstractInsecureRoomNameLabel';
import styles from './styles';
/**
* Renders a label indicating that we are in a room with an insecure name.
*/
class InsecureRoomNameLabel extends AbstractInsecureRoomNameLabel {
/**
* Renders the platform dependent content.
*
* @inheritdoc
*/
_render() {
return (
<Label
icon = { IconWarning }
style = { styles.insecureRoomNameLabel } />
);
}
}
export default translate(connect(_mapStateToProps)(InsecureRoomNameLabel));

View File

@@ -0,0 +1,54 @@
import React, { Component } from 'react';
import { TouchableOpacity, View, ViewStyle } from 'react-native';
import VideoQualityLabel from '../../../video-quality/components/VideoQualityLabel.native';
import InsecureRoomNameLabel from './InsecureRoomNameLabel';
import { LABEL_ID_INSECURE_ROOM_NAME, LABEL_ID_QUALITY, LabelHitSlop } from './constants';
import styles from './styles';
interface IProps {
/**
* Creates a function to be invoked when the onPress of the touchables are
* triggered.
*/
createOnPress: Function;
}
/**
* A container that renders the conference indicators, if any.
*/
class Labels extends Component<IProps> {
/**
* Implements React {@code Component}'s render.
*
* @inheritdoc
*/
override render() {
return (
<View pointerEvents = 'box-none'>
<View
pointerEvents = 'box-none'
style = { styles.indicatorContainer as ViewStyle }>
<TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = {
this.props.createOnPress(LABEL_ID_INSECURE_ROOM_NAME)
} >
<InsecureRoomNameLabel />
</TouchableOpacity>
<TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = {
this.props.createOnPress(LABEL_ID_QUALITY) } >
<VideoQualityLabel />
</TouchableOpacity>
</View>
</View>
);
}
}
export default Labels;

View File

@@ -0,0 +1,159 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { INVITE_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconAddUser } from '../../../base/icons/svg';
import {
addPeopleFeatureControl,
getParticipantCountWithFake,
setShareDialogVisiblity
} from '../../../base/participants/functions';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { doInvitePeople } from '../../../invite/actions.native';
import { getInviteOthersControl } from '../../../share-room/functions';
import styles from './styles';
/**
* Props type of the component.
*/
interface IProps extends WithTranslation {
/**
* Control for invite other button.
*/
_inviteOthersControl: any;
/**
* Checks if add-people feature is enabled.
*/
_isAddPeopleFeatureEnabled: boolean;
/**
* True if currently in a breakout room.
*/
_isInBreakoutRoom: boolean;
/**
* True if the invite functions (dial out, invite, share...etc) are disabled.
*/
_isInviteFunctionsDisabled: boolean;
/**
* True if it's a lonely meeting (participant count excluding fakes is 1).
*/
_isLonelyMeeting: boolean;
/**
* The Redux Dispatch function.
*/
dispatch: IStore['dispatch'];
}
/**
* Implements the UI elements to be displayed in the lonely meeting experience.
*/
class LonelyMeetingExperience extends PureComponent<IProps> {
/**
* Instantiates a new component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onPress = this._onPress.bind(this);
}
/**
* Implements {@code PureComponent#render}.
*
* @inheritdoc
*/
override render() {
const {
_inviteOthersControl,
_isAddPeopleFeatureEnabled,
_isInBreakoutRoom,
_isInviteFunctionsDisabled,
_isLonelyMeeting,
t
} = this.props;
const { color, shareDialogVisible } = _inviteOthersControl;
if (!_isLonelyMeeting || !_isAddPeopleFeatureEnabled) {
return null;
}
return (
<View style = { styles.lonelyMeetingContainer as ViewStyle }>
<Text style = { styles.lonelyMessage }>
{ t('lonelyMeetingExperience.youAreAlone') }
</Text>
{ !_isInviteFunctionsDisabled && !_isInBreakoutRoom && (
<Button
accessibilityLabel = 'lonelyMeetingExperience.button'
disabled = { shareDialogVisible }
// eslint-disable-next-line react/jsx-no-bind
icon = { () => (
<Icon
color = { color }
size = { 20 }
src = { IconAddUser } />
) }
labelKey = 'lonelyMeetingExperience.button'
onClick = { this._onPress }
type = { BUTTON_TYPES.PRIMARY } />
) }
</View>
);
}
/**
* Callback for the onPress function of the button.
*
* @returns {void}
*/
_onPress() {
const { _isAddPeopleFeatureEnabled, dispatch } = this.props;
setShareDialogVisiblity(_isAddPeopleFeatureEnabled, dispatch);
dispatch(doInvitePeople());
}
}
/**
* Maps parts of the Redux state to the props of this Component.
*
* @param {Object} state - The redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { disableInviteFunctions } = state['features/base/config'];
const { conference } = state['features/base/conference'];
const _inviteOthersControl = getInviteOthersControl(state);
const flag = getFeatureFlag(state, INVITE_ENABLED, true);
const _isAddPeopleFeatureEnabled = addPeopleFeatureControl(state);
const _isInBreakoutRoom = isInBreakoutRoom(state);
return {
_isAddPeopleFeatureEnabled,
_inviteOthersControl,
_isInBreakoutRoom,
_isInviteFunctionsDisabled: Boolean(!flag || disableInviteFunctions),
_isLonelyMeeting: Boolean(conference && getParticipantCountWithFake(state) === 1)
};
}
export default connect(_mapStateToProps)(translate(LonelyMeetingExperience));

View File

@@ -0,0 +1,24 @@
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 react {@code Component} that implements an expanded label as tooltip-like
* component to explain the meaning of the {@code RaisedHandsCountExpandedLabel}.
*/
class RaisedHandsCountExpandedLabel extends ExpandedLabel<Props> {
/**
* Returns the label specific text of this {@code ExpandedLabel}.
*
* @returns {string}
*/
_getLabel() {
return this.props.t('raisedHandsLabel');
}
}
export default translate(RaisedHandsCountExpandedLabel);

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { IconRaiseHand } from '../../../base/icons/svg';
import Label from '../../../base/label/components/native/Label';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import styles from './styles';
const RaisedHandsCountLabel = () => {
const raisedHandsCount = useSelector((state: IReduxState) =>
(state['features/base/participants'].raisedHandsQueue || []).length);
return raisedHandsCount > 0 ? (
<Label
icon = { IconRaiseHand }
iconColor = { BaseTheme.palette.uiBackground }
style = { styles.raisedHandsCountLabel }
text = { `${raisedHandsCount}` }
textStyle = { styles.raisedHandsCountLabelText } />
) : null;
};
export default RaisedHandsCountLabel;

View File

@@ -0,0 +1,157 @@
import React from 'react';
import { Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getConferenceName, getConferenceTimestamp } from '../../../base/conference/functions';
import {
AUDIO_DEVICE_BUTTON_ENABLED,
CONFERENCE_TIMER_ENABLED,
TOGGLE_CAMERA_BUTTON_ENABLED
} from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import AudioDeviceToggleButton from '../../../mobile/audio-mode/components/AudioDeviceToggleButton';
import PictureInPictureButton from '../../../mobile/picture-in-picture/components/PictureInPictureButton';
import ParticipantsPaneButton from '../../../participants-pane/components/native/ParticipantsPaneButton';
import { isParticipantsPaneEnabled } from '../../../participants-pane/functions';
import { isRoomNameEnabled } from '../../../prejoin/functions.native';
import ToggleCameraButton from '../../../toolbox/components/native/ToggleCameraButton';
import { isToolboxVisible } from '../../../toolbox/functions.native';
import ConferenceTimer from '../ConferenceTimer';
import Labels from './Labels';
import styles from './styles';
interface IProps {
/**
* Whether the audio device button should be displayed.
*/
_audioDeviceButtonEnabled: boolean;
/**
* Whether displaying the current conference timer is enabled or not.
*/
_conferenceTimerEnabled: boolean;
/**
* Creates a function to be invoked when the onPress of the touchables are
* triggered.
*/
_createOnPress: Function;
/**
* Whether participants feature is enabled or not.
*/
_isParticipantsPaneEnabled: boolean;
/**
* Name of the meeting we're currently in.
*/
_meetingName: string;
/**
* Whether displaying the current room name is enabled or not.
*/
_roomNameEnabled: boolean;
/**
* Whether the toggle camera button should be displayed.
*/
_toggleCameraButtonEnabled: boolean;
/**
* True if the navigation bar should be visible.
*/
_visible: boolean;
}
/**
* Implements a navigation bar component that is rendered on top of the
* conference screen.
*
* @param {IProps} props - The React props passed to this component.
* @returns {JSX.Element}
*/
const TitleBar = (props: IProps) => {
const { _isParticipantsPaneEnabled, _visible } = props;
if (!_visible) {
return null;
}
return (
<View
style = { styles.titleBarWrapper as ViewStyle }>
<View style = { styles.pipButtonContainer as ViewStyle }>
<PictureInPictureButton styles = { styles.pipButton } />
</View>
<View
pointerEvents = 'box-none'
style = { styles.roomNameWrapper as ViewStyle }>
{
props._conferenceTimerEnabled
&& <View style = { styles.roomTimerView as ViewStyle }>
<ConferenceTimer textStyle = { styles.roomTimer } />
</View>
}
{
props._roomNameEnabled
&& <View style = { styles.roomNameView as ViewStyle }>
<Text
numberOfLines = { 1 }
style = { styles.roomName }>
{ props._meetingName }
</Text>
</View>
}
{/* eslint-disable-next-line react/jsx-no-bind */}
<Labels createOnPress = { props._createOnPress } />
</View>
{
props._toggleCameraButtonEnabled
&& <View style = { styles.titleBarButtonContainer }>
<ToggleCameraButton styles = { styles.titleBarButton } />
</View>
}
{
props._audioDeviceButtonEnabled
&& <View style = { styles.titleBarButtonContainer }>
<AudioDeviceToggleButton styles = { styles.titleBarButton } />
</View>
}
{
_isParticipantsPaneEnabled
&& <View style = { styles.titleBarButtonContainer }>
<ParticipantsPaneButton
styles = { styles.titleBarButton } />
</View>
}
</View>
);
};
/**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { hideConferenceTimer } = state['features/base/config'];
const startTimestamp = getConferenceTimestamp(state);
return {
_audioDeviceButtonEnabled: getFeatureFlag(state, AUDIO_DEVICE_BUTTON_ENABLED, true),
_conferenceTimerEnabled:
Boolean(getFeatureFlag(state, CONFERENCE_TIMER_ENABLED, true) && !hideConferenceTimer && startTimestamp),
_isParticipantsPaneEnabled: isParticipantsPaneEnabled(state),
_meetingName: getConferenceName(state),
_roomNameEnabled: isRoomNameEnabled(state),
_toggleCameraButtonEnabled: getFeatureFlag(state, TOGGLE_CAMERA_BUTTON_ENABLED, true),
_visible: isToolboxVisible(state)
};
}
export default connect(_mapStateToProps)(TitleBar);

View File

@@ -0,0 +1,17 @@
import React from 'react';
import Icon from '../../../../base/icons/components/Icon';
import { IconVolumeUp } from '../../../../base/icons/svg';
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
/**
* React component for Audio icon.
*
* @returns {JSX.Element} - The Audio icon.
*/
const AudioIcon = (): JSX.Element => (<Icon
color = { BaseTheme.palette.ui02 }
size = { 20 }
src = { IconVolumeUp } />);
export default AudioIcon;

View File

@@ -0,0 +1,76 @@
import React, { useEffect } from 'react';
import { View, ViewStyle } from 'react-native';
import Orientation from 'react-native-orientation-locker';
import { withSafeAreaInsets } from 'react-native-safe-area-context';
import { useDispatch, useSelector } from 'react-redux';
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
import LoadingIndicator from '../../../../base/react/components/native/LoadingIndicator';
import TintedView from '../../../../base/react/components/native/TintedView';
import { isLocalVideoTrackDesktop } from '../../../../base/tracks/functions.native';
import { setPictureInPictureEnabled } from '../../../../mobile/picture-in-picture/functions';
import { setIsCarmode } from '../../../../video-layout/actions';
import ConferenceTimer from '../../ConferenceTimer';
import { isConnecting } from '../../functions';
import CarModeFooter from './CarModeFooter';
import MicrophoneButton from './MicrophoneButton';
import TitleBar from './TitleBar';
import styles from './styles';
/**
* Implements the carmode component.
*
* @returns { JSX.Element} - The carmode component.
*/
const CarMode = (): JSX.Element => {
const dispatch = useDispatch();
const connecting = useSelector(isConnecting);
const isSharing = useSelector(isLocalVideoTrackDesktop);
useEffect(() => {
dispatch(setIsCarmode(true));
setPictureInPictureEnabled(false);
Orientation.lockToPortrait();
return () => {
Orientation.unlockAllOrientations();
dispatch(setIsCarmode(false));
if (!isSharing) {
setPictureInPictureEnabled(true);
}
};
}, []);
return (
<JitsiScreen
footerComponent = { CarModeFooter }
style = { styles.conference }>
{/*
* The activity/loading indicator goes above everything, except
* the toolbox/toolbars and the dialogs.
*/
connecting
&& <TintedView>
<LoadingIndicator />
</TintedView>
}
<View
pointerEvents = 'box-none'
style = { styles.titleBarSafeViewColor as ViewStyle }>
<View
style = { styles.titleBar as ViewStyle }>
<TitleBar />
</View>
<ConferenceTimer textStyle = { styles.roomTimer } />
</View>
<View
pointerEvents = 'box-none'
style = { styles.microphoneContainer as ViewStyle }>
<MicrophoneButton />
</View>
</JitsiScreen>
);
};
export default withSafeAreaInsets(CarMode);

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import EndMeetingButton from './EndMeetingButton';
import SoundDeviceButton from './SoundDeviceButton';
import styles from './styles';
/**
* Implements the car mode footer component.
*
* @returns { JSX.Element} - The car mode footer component.
*/
const CarModeFooter = (): JSX.Element => {
const { t } = useTranslation();
return (
<View
pointerEvents = 'box-none'
style = { styles.bottomContainer as ViewStyle }>
<Text style = { styles.videoStoppedLabel }>
{ t('carmode.labels.videoStopped') }
</Text>
<SoundDeviceButton />
<EndMeetingButton />
</View>
);
};
export default CarModeFooter;

View File

@@ -0,0 +1,38 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { createToolbarEvent } from '../../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../../analytics/functions';
import { appNavigate } from '../../../../app/actions.native';
import Button from '../../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
import EndMeetingIcon from './EndMeetingIcon';
import styles from './styles';
/**
* Button for ending meeting from carmode.
*
* @returns {JSX.Element} - The end meeting button.
*/
const EndMeetingButton = (): JSX.Element => {
const dispatch = useDispatch();
const onSelect = useCallback(() => {
sendAnalytics(createToolbarEvent('hangup'));
dispatch(appNavigate(undefined));
}, [ dispatch ]);
return (
<Button
accessibilityLabel = 'toolbar.accessibilityLabel.leaveConference'
icon = { EndMeetingIcon }
labelKey = 'toolbar.leaveConference'
onClick = { onSelect }
style = { styles.endMeetingButton }
type = { BUTTON_TYPES.DESTRUCTIVE } />
);
};
export default EndMeetingButton;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import Icon from '../../../../base/icons/components/Icon';
import { IconHangup } from '../../../../base/icons/svg';
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
/**
* Implements an end meeting icon.
*
* @returns {JSX.Element} - The end meeting icon.
*/
const EndMeetingIcon = (): JSX.Element => (<Icon
color = { BaseTheme.palette.icon01 }
size = { 20 }
src = { IconHangup } />);
export default EndMeetingIcon;

View File

@@ -0,0 +1,91 @@
import React, { useCallback, useState } from 'react';
import { TouchableOpacity, View, ViewStyle } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import {
ACTION_SHORTCUT_PRESSED as PRESSED,
ACTION_SHORTCUT_RELEASED as RELEASED,
createShortcutEvent
} from '../../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../../analytics/functions';
import { IReduxState } from '../../../../app/types';
import { AUDIO_MUTE_BUTTON_ENABLED } from '../../../../base/flags/constants';
import { getFeatureFlag } from '../../../../base/flags/functions';
import Icon from '../../../../base/icons/components/Icon';
import { IconMic, IconMicSlash } from '../../../../base/icons/svg';
import { MEDIA_TYPE } from '../../../../base/media/constants';
import { isLocalTrackMuted } from '../../../../base/tracks/functions';
import { isAudioMuteButtonDisabled } from '../../../../toolbox/functions.any';
import { muteLocal } from '../../../../video-menu/actions';
import styles from './styles';
const LONG_PRESS = 'long.press';
/**
* Implements a round audio mute/unmute button of a custom size.
*
* @returns {JSX.Element} - The audio mute round button.
*/
const MicrophoneButton = (): JSX.Element | null => {
const dispatch = useDispatch();
const audioMuted = useSelector((state: IReduxState) => isLocalTrackMuted(state['features/base/tracks'],
MEDIA_TYPE.AUDIO));
const disabled = useSelector(isAudioMuteButtonDisabled);
const enabledFlag = useSelector((state: IReduxState) => getFeatureFlag(state, AUDIO_MUTE_BUTTON_ENABLED, true));
const [ longPress, setLongPress ] = useState(false);
if (!enabledFlag) {
return null;
}
const onPressIn = useCallback(() => {
!disabled && dispatch(muteLocal(!audioMuted, MEDIA_TYPE.AUDIO));
}, [ audioMuted, disabled ]);
const onLongPress = useCallback(() => {
if (!disabled && !audioMuted) {
sendAnalytics(createShortcutEvent(
'push.to.talk',
PRESSED,
{},
LONG_PRESS));
setLongPress(true);
}
}, [ audioMuted, disabled, setLongPress ]);
const onPressOut = useCallback(() => {
if (longPress) {
setLongPress(false);
sendAnalytics(createShortcutEvent(
'push.to.talk',
RELEASED,
{},
LONG_PRESS
));
dispatch(muteLocal(true, MEDIA_TYPE.AUDIO));
}
}, [ longPress, setLongPress ]);
return (
<TouchableOpacity
onLongPress = { onLongPress }
onPressIn = { onPressIn }
onPressOut = { onPressOut } >
<View
style = { [
styles.microphoneStyles.container,
!audioMuted && styles.microphoneStyles.unmuted
] as ViewStyle[] }>
<View
style = { styles.microphoneStyles.iconContainer as ViewStyle }>
<Icon
src = { audioMuted ? IconMicSlash : IconMic }
style = { styles.microphoneStyles.icon } />
</View>
</View>
</TouchableOpacity>
);
};
export default MicrophoneButton;

View File

@@ -0,0 +1,35 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { openSheet } from '../../../../base/dialog/actions';
import Button from '../../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
import AudioRoutePickerDialog from '../../../../mobile/audio-mode/components/AudioRoutePickerDialog';
import AudioIcon from './AudioIcon';
import styles from './styles';
/**
* Button for selecting sound device in carmode.
*
* @returns {JSX.Element} - The sound device button.
*/
const SelectSoundDevice = (): JSX.Element => {
const dispatch = useDispatch();
const onSelect = useCallback(() =>
dispatch(openSheet(AudioRoutePickerDialog))
, [ dispatch ]);
return (
<Button
accessibilityLabel = 'carmode.actions.selectSoundDevice'
icon = { AudioIcon }
labelKey = 'carmode.actions.selectSoundDevice'
onClick = { onSelect }
style = { styles.soundDeviceButton }
type = { BUTTON_TYPES.SECONDARY } />
);
};
export default SelectSoundDevice;

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { StyleProp, Text, View, ViewStyle } from 'react-native';
import { connect, useSelector } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { getConferenceName } from '../../../../base/conference/functions';
import { JitsiRecordingConstants } from '../../../../base/lib-jitsi-meet';
import { getLocalParticipant } from '../../../../base/participants/functions';
import ConnectionIndicator from '../../../../connection-indicator/components/native/ConnectionIndicator';
import { isRoomNameEnabled } from '../../../../prejoin/functions';
import RecordingLabel from '../../../../recording/components/native/RecordingLabel';
import VideoQualityLabel from '../../../../video-quality/components/VideoQualityLabel.native';
import styles from './styles';
interface IProps {
/**
* Name of the meeting we're currently in.
*/
_meetingName: string;
/**
* Whether displaying the current meeting name is enabled or not.
*/
_meetingNameEnabled: boolean;
}
/**
* Implements a navigation bar component that is rendered on top of the
* carmode screen.
*
* @param {IProps} props - The React props passed to this component.
* @returns {JSX.Element}
*/
const TitleBar = (props: IProps): JSX.Element => {
const localParticipant = useSelector(getLocalParticipant);
const localParticipantId = localParticipant?.id;
return (
<View
style = { styles.titleBarWrapper as StyleProp<ViewStyle> }>
<View
pointerEvents = 'box-none'
style = { styles.roomNameWrapper as StyleProp<ViewStyle> }>
<View style = { styles.qualityLabelContainer as StyleProp<ViewStyle> }>
<VideoQualityLabel />
</View>
<ConnectionIndicator
iconStyle = { styles.connectionIndicatorIcon }
participantId = { localParticipantId } />
<View style = { styles.headerLabels as StyleProp<ViewStyle> }>
<RecordingLabel mode = { JitsiRecordingConstants.mode.FILE } />
<RecordingLabel mode = { JitsiRecordingConstants.mode.STREAM } />
</View>
{
props._meetingNameEnabled
&& <View style = { styles.roomNameView as StyleProp<ViewStyle> }>
<Text
numberOfLines = { 1 }
style = { styles.roomName }>
{ props._meetingName }
</Text>
</View>
}
</View>
</View>
);
};
/**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
return {
_meetingName: getConferenceName(state),
_meetingNameEnabled: isRoomNameEnabled(state)
};
}
export default connect(_mapStateToProps)(TitleBar);

View File

@@ -0,0 +1,173 @@
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
/**
* The size of the microphone icon.
*/
const MICROPHONE_SIZE = 180;
/**
* The styles of the safe area view that contains the title bar.
*/
const titleBarSafeView = {
left: 0,
position: 'absolute',
right: 0,
top: 0
};
/**
* The styles of the native components of Carmode.
*/
export default {
bottomContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
bottom: BaseTheme.spacing[8]
},
/**
* {@code Conference} Style.
*/
conference: {
backgroundColor: BaseTheme.palette.uiBackground,
flex: 1,
justifyContent: 'center'
},
microphoneStyles: {
container: {
borderRadius: MICROPHONE_SIZE / 2,
height: MICROPHONE_SIZE,
maxHeight: MICROPHONE_SIZE,
justifyContent: 'center',
overflow: 'hidden',
width: MICROPHONE_SIZE,
maxWidth: MICROPHONE_SIZE,
flex: 1,
zIndex: 1,
elevation: 1
},
icon: {
color: BaseTheme.palette.text01,
fontSize: MICROPHONE_SIZE * 0.45,
fontWeight: '100'
},
iconContainer: {
alignItems: 'center',
alignSelf: 'stretch',
flex: 1,
justifyContent: 'center',
backgroundColor: BaseTheme.palette.ui03
},
unmuted: {
borderWidth: 4,
borderColor: BaseTheme.palette.success01
}
},
qualityLabelContainer: {
borderRadius: BaseTheme.shape.borderRadius,
flexShrink: 1,
paddingHorizontal: 2,
justifyContent: 'center',
marginTop: BaseTheme.spacing[2]
},
roomTimer: {
...BaseTheme.typography.bodyShortBold,
color: BaseTheme.palette.text01,
textAlign: 'center'
},
titleView: {
width: 152,
height: 28,
backgroundColor: BaseTheme.palette.ui02,
borderRadius: 12,
alignSelf: 'center'
},
title: {
margin: 'auto',
textAlign: 'center',
paddingVertical: BaseTheme.spacing[1],
paddingHorizontal: BaseTheme.spacing[3],
color: BaseTheme.palette.text02
},
soundDeviceButton: {
marginBottom: BaseTheme.spacing[3],
width: 240
},
endMeetingButton: {
width: 240
},
headerLabels: {
borderBottomLeftRadius: 3,
borderTopLeftRadius: 3,
flexShrink: 1,
paddingHorizontal: 2,
justifyContent: 'center'
},
titleBarSafeViewColor: {
...titleBarSafeView,
backgroundColor: BaseTheme.palette.uiBackground
},
microphoneContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
titleBarWrapper: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
justifyContent: 'center'
},
roomNameWrapper: {
flexDirection: 'row',
marginRight: BaseTheme.spacing[2],
flexShrink: 1,
flexGrow: 1
},
roomNameView: {
backgroundColor: 'rgba(0,0,0,0.6)',
flexShrink: 1,
justifyContent: 'center',
paddingHorizontal: BaseTheme.spacing[2]
},
roomName: {
color: BaseTheme.palette.text01,
...BaseTheme.typography.bodyShortBold
},
titleBar: {
alignSelf: 'center',
marginTop: BaseTheme.spacing[1]
},
videoStoppedLabel: {
...BaseTheme.typography.bodyShortRegularLarge,
color: BaseTheme.palette.text01,
marginBottom: BaseTheme.spacing[3],
textAlign: 'center',
width: '100%'
},
connectionIndicatorIcon: {
fontSize: 20
}
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import RecordingExpandedLabel from '../../../recording/components/native/RecordingExpandedLabel';
import VideoQualityExpandedLabel from '../../../video-quality/components/VideoQualityExpandedLabel.native';
import InsecureRoomNameExpandedLabel from './InsecureRoomNameExpandedLabel';
import RaisedHandsCountExpandedLabel from './RaisedHandsCountExpandedLabel';
export const LabelHitSlop = {
top: 10,
bottom: 10,
left: 0,
right: 0
};
/**
* Timeout to hide the {@ExpandedLabel}.
*/
export const EXPANDED_LABEL_TIMEOUT = 5000;
export const LABEL_ID_QUALITY = 'quality';
export const LABEL_ID_RECORDING = 'recording';
export const LABEL_ID_STREAMING = 'streaming';
export const LABEL_ID_INSECURE_ROOM_NAME = 'insecure-room-name';
export const LABEL_ID_RAISED_HANDS_COUNT = 'raised-hands-count';
export const LABEL_ID_VISITORS_COUNT = 'visitors-count';
interface IExpandedLabel {
alwaysOn?: boolean;
component: React.ComponentType<any>;
props?: any;
}
/**
* The {@code ExpandedLabel} components to be rendered for the individual
* {@code Label}s.
*/
export const EXPANDED_LABELS: {
[key: string]: IExpandedLabel;
} = {
[LABEL_ID_QUALITY]: {
component: VideoQualityExpandedLabel
},
[LABEL_ID_RECORDING]: {
component: RecordingExpandedLabel,
props: {
mode: JitsiRecordingConstants.mode.FILE
},
alwaysOn: true
},
[LABEL_ID_STREAMING]: {
component: RecordingExpandedLabel,
props: {
mode: JitsiRecordingConstants.mode.STREAM
},
alwaysOn: true
},
[LABEL_ID_INSECURE_ROOM_NAME]: {
component: InsecureRoomNameExpandedLabel
},
[LABEL_ID_RAISED_HANDS_COUNT]: {
component: RaisedHandsCountExpandedLabel,
alwaysOn: true
}
};

View File

@@ -0,0 +1,212 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
export const INSECURE_ROOM_NAME_LABEL_COLOR = BaseTheme.palette.actionDanger;
const TITLE_BAR_BUTTON_SIZE = 24;
/**
* The styles of the safe area view that contains the title bar.
*/
const titleBarSafeView = {
left: 0,
position: 'absolute',
right: 0,
top: 0
};
const alwaysOnTitleBar = {
alignItems: 'center',
alignSelf: 'flex-end',
backgroundColor: 'rgba(0, 0, 0, .5)',
borderRadius: BaseTheme.shape.borderRadius,
flexDirection: 'row',
justifyContent: 'center',
marginTop: BaseTheme.spacing[3],
paddingRight: BaseTheme.spacing[0],
'&:not(:empty)': {
padding: BaseTheme.spacing[1]
}
};
/**
* The styles of the feature conference.
*/
export default {
/**
* {@code Conference} Style.
*/
conference: {
alignSelf: 'stretch',
backgroundColor: BaseTheme.palette.uiBackground,
flex: 1
},
displayNameContainer: {
margin: BaseTheme.spacing[3]
},
/**
* View that contains the indicators.
*/
indicatorContainer: {
flex: 1,
flexDirection: 'row'
},
titleBarButtonContainer: {
borderRadius: 3,
height: BaseTheme.spacing[7],
marginTop: BaseTheme.spacing[1],
marginRight: BaseTheme.spacing[1],
zIndex: 1,
width: BaseTheme.spacing[7]
},
titleBarButton: {
iconStyle: {
color: BaseTheme.palette.icon01,
padding: 12,
fontSize: TITLE_BAR_BUTTON_SIZE
},
underlayColor: 'transparent'
},
lonelyMeetingContainer: {
alignSelf: 'stretch',
alignItems: 'center',
padding: BaseTheme.spacing[3]
},
lonelyMessage: {
color: BaseTheme.palette.text01,
paddingVertical: BaseTheme.spacing[2]
},
pipButtonContainer: {
'&:not(:empty)': {
borderRadius: 3,
height: BaseTheme.spacing[7],
marginTop: BaseTheme.spacing[1],
marginLeft: BaseTheme.spacing[1],
zIndex: 1,
width: BaseTheme.spacing[7]
}
},
pipButton: {
iconStyle: {
color: BaseTheme.palette.icon01,
padding: 12,
fontSize: TITLE_BAR_BUTTON_SIZE
},
underlayColor: 'transparent'
},
titleBarSafeViewColor: {
...titleBarSafeView,
backgroundColor: BaseTheme.palette.uiBackground
},
titleBarSafeViewTransparent: {
...titleBarSafeView
},
titleBarWrapper: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
height: BaseTheme.spacing[8],
justifyContent: 'center'
},
alwaysOnTitleBar: {
...alwaysOnTitleBar,
marginRight: BaseTheme.spacing[2]
},
alwaysOnTitleBarWide: {
...alwaysOnTitleBar,
marginRight: BaseTheme.spacing[12]
},
expandedLabelWrapper: {
zIndex: 1
},
roomTimer: {
...BaseTheme.typography.bodyShortBold,
color: BaseTheme.palette.text01,
lineHeight: 14,
textAlign: 'center'
},
roomTimerView: {
backgroundColor: BaseTheme.palette.ui03,
borderRadius: BaseTheme.shape.borderRadius,
height: 32,
justifyContent: 'center',
paddingHorizontal: BaseTheme.spacing[2],
paddingVertical: BaseTheme.spacing[1],
minWidth: 50
},
roomName: {
color: BaseTheme.palette.text01,
...BaseTheme.typography.bodyShortBold,
paddingVertical: 6
},
roomNameView: {
backgroundColor: 'rgba(0,0,0,0.6)',
borderBottomLeftRadius: 3,
borderTopLeftRadius: 3,
flexShrink: 1,
justifyContent: 'center',
paddingHorizontal: 10
},
roomNameWrapper: {
flexDirection: 'row',
marginRight: 10,
marginLeft: 8,
flexShrink: 1,
flexGrow: 1
},
/**
* The style of the {@link View} which expands over the whole
* {@link Conference} area and splits it between the {@link Filmstrip} and
* the {@link Toolbox}.
*/
toolboxAndFilmstripContainer: {
bottom: 0,
flexDirection: 'column',
justifyContent: 'flex-end',
left: 0,
position: 'absolute',
right: 0,
top: 0
},
insecureRoomNameLabel: {
backgroundColor: INSECURE_ROOM_NAME_LABEL_COLOR,
borderRadius: BaseTheme.shape.borderRadius,
height: 32
},
raisedHandsCountLabel: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.warning02,
borderRadius: BaseTheme.shape.borderRadius,
flexDirection: 'row',
marginBottom: BaseTheme.spacing[0],
marginLeft: BaseTheme.spacing[0]
},
raisedHandsCountLabelText: {
color: BaseTheme.palette.uiBackground,
paddingLeft: BaseTheme.spacing[2]
}
};

View File

@@ -0,0 +1,497 @@
import { throttle } from 'lodash-es';
import React, { useCallback, useState } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect as reactReduxConnect, useDispatch, useSelector, useStore } from 'react-redux';
// @ts-expect-error
import VideoLayout from '../../../../../modules/UI/videolayout/VideoLayout';
import { IReduxState, IStore } from '../../../app/types';
import { getConferenceNameForTitle } from '../../../base/conference/functions';
import { hangup } from '../../../base/connection/actions.web';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { setColorAlpha } from '../../../base/util/helpers';
import { openChat, setFocusedTab } from '../../../chat/actions.web';
import Chat from '../../../chat/components/web/Chat';
import { ChatTabs } from '../../../chat/constants';
import { isFileUploadingEnabled, processFiles } from '../../../file-sharing/functions.any';
import MainFilmstrip from '../../../filmstrip/components/web/MainFilmstrip';
import ScreenshareFilmstrip from '../../../filmstrip/components/web/ScreenshareFilmstrip';
import StageFilmstrip from '../../../filmstrip/components/web/StageFilmstrip';
import CalleeInfoContainer from '../../../invite/components/callee-info/CalleeInfoContainer';
import LargeVideo from '../../../large-video/components/LargeVideo.web';
import LobbyScreen from '../../../lobby/components/web/LobbyScreen';
import { getIsLobbyVisible } from '../../../lobby/functions';
import { getOverlayToRender } from '../../../overlay/functions.web';
import ParticipantsPane from '../../../participants-pane/components/web/ParticipantsPane';
import Prejoin from '../../../prejoin/components/web/Prejoin';
import { isPrejoinPageVisible } from '../../../prejoin/functions.web';
import ReactionAnimations from '../../../reactions/components/web/ReactionsAnimations';
import { toggleToolboxVisible } from '../../../toolbox/actions.any';
import { fullScreenChanged, showToolbox } from '../../../toolbox/actions.web';
import JitsiPortal from '../../../toolbox/components/web/JitsiPortal';
import Toolbox from '../../../toolbox/components/web/Toolbox';
import { LAYOUT_CLASSNAMES } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.any';
import VisitorsQueue from '../../../visitors/components/web/VisitorsQueue';
import { showVisitorsQueue } from '../../../visitors/functions';
import { init } from '../../actions.web';
import { maybeShowSuboptimalExperienceNotification } from '../../functions.web';
import {
AbstractConference,
type AbstractProps,
abstractMapStateToProps
} from '../AbstractConference';
import ConferenceInfo from './ConferenceInfo';
import { default as Notice } from './Notice';
/**
* DOM events for when full screen mode has changed. Different browsers need
* different vendor prefixes.
*
* @private
* @type {Array<string>}
*/
const FULL_SCREEN_EVENTS = [
'webkitfullscreenchange',
'mozfullscreenchange',
'fullscreenchange'
];
/**
* The type of the React {@code Component} props of {@link Conference}.
*/
interface IProps extends AbstractProps, WithTranslation {
/**
* The alpha(opacity) of the background.
*/
_backgroundAlpha?: number;
/**
* Are any overlays visible?
*/
_isAnyOverlayVisible: boolean;
/**
* The CSS class to apply to the root of {@link Conference} to modify the
* application layout.
*/
_layoutClassName: string;
/**
* The config specified interval for triggering mouseMoved iframe api events.
*/
_mouseMoveCallbackInterval?: number;
/**
*Whether or not the notifications should be displayed in the overflow drawer.
*/
_overflowDrawer: boolean;
/**
* Name for this conference room.
*/
_roomName: string;
/**
* If lobby page is visible or not.
*/
_showLobby: boolean;
/**
* If prejoin page is visible or not.
*/
_showPrejoin: boolean;
/**
* If visitors queue page is visible or not.
* NOTE: This should be set to true once we received an error on connect. Before the first connect this will always
* be false.
*/
_showVisitorsQueue: boolean;
dispatch: IStore['dispatch'];
}
/**
* Returns true if the prejoin screen should be displayed and false otherwise.
*
* @param {IProps} props - The props object.
* @returns {boolean} - True if the prejoin screen should be displayed and false otherwise.
*/
function shouldShowPrejoin({ _showLobby, _showPrejoin, _showVisitorsQueue }: IProps) {
return _showPrejoin && !_showVisitorsQueue && !_showLobby;
}
/**
* The conference page of the Web application.
*/
class Conference extends AbstractConference<IProps, any> {
_originalOnMouseMove: Function;
_originalOnShowToolbar: Function;
/**
* Initializes a new Conference instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
const { _mouseMoveCallbackInterval } = props;
// Throttle and bind this component's mousemove handler to prevent it
// from firing too often.
this._originalOnShowToolbar = this._onShowToolbar;
this._originalOnMouseMove = this._onMouseMove;
this._onShowToolbar = throttle(
() => this._originalOnShowToolbar(),
100,
{
leading: true,
trailing: false
});
this._onMouseMove = throttle(
event => this._originalOnMouseMove(event),
_mouseMoveCallbackInterval,
{
leading: true,
trailing: false
});
// Bind event handler so it is only bound once for every instance.
this._onFullScreenChange = this._onFullScreenChange.bind(this);
this._onVideospaceTouchStart = this._onVideospaceTouchStart.bind(this);
this._setBackground = this._setBackground.bind(this);
}
/**
* Start the connection and get the UI ready for the conference.
*
* @inheritdoc
*/
override componentDidMount() {
document.title = `${this.props._roomName} | ${interfaceConfig.APP_NAME}`;
this._start();
}
/**
* Calls into legacy UI to update the application layout, if necessary.
*
* @inheritdoc
* returns {void}
*/
override componentDidUpdate(prevProps: IProps) {
if (this.props._shouldDisplayTileView
=== prevProps._shouldDisplayTileView) {
return;
}
// TODO: For now VideoLayout is being called as LargeVideo and Filmstrip
// sizing logic is still handled outside of React. Once all components
// are in react they should calculate size on their own as much as
// possible and pass down sizings.
VideoLayout.refreshLayout();
}
/**
* Disconnect from the conference when component will be
* unmounted.
*
* @inheritdoc
*/
override componentWillUnmount() {
APP.UI.unbindEvents();
FULL_SCREEN_EVENTS.forEach(name =>
document.removeEventListener(name, this._onFullScreenChange));
APP.conference.isJoined() && this.props.dispatch(hangup());
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_isAnyOverlayVisible,
_layoutClassName,
_notificationsVisible,
_overflowDrawer,
_showLobby,
_showPrejoin,
_showVisitorsQueue,
t
} = this.props;
return (
<div
id = 'layout_wrapper'
onMouseEnter = { this._onMouseEnter }
onMouseLeave = { this._onMouseLeave }
onMouseMove = { this._onMouseMove }
ref = { this._setBackground }>
<Chat />
<div
className = { _layoutClassName }
id = 'videoconference_page'
onMouseMove = { isMobileBrowser() ? undefined : this._onShowToolbar }>
{ _showPrejoin || _showLobby || <ConferenceInfo /> }
<Notice />
<div
id = 'videospace'
onTouchStart = { this._onVideospaceTouchStart }>
<LargeVideo />
{
_showPrejoin || _showLobby || (<>
<StageFilmstrip />
<ScreenshareFilmstrip />
<MainFilmstrip />
</>)
}
</div>
{ _showPrejoin || _showLobby || (
<>
<span
aria-level = { 1 }
className = 'sr-only'
role = 'heading'>
{ t('toolbar.accessibilityLabel.heading') }
</span>
<Toolbox />
</>
)}
{_notificationsVisible && !_isAnyOverlayVisible && (_overflowDrawer
? <JitsiPortal className = 'notification-portal'>
{this.renderNotificationsContainer({ portal: true })}
</JitsiPortal>
: this.renderNotificationsContainer())
}
<CalleeInfoContainer />
{ shouldShowPrejoin(this.props) && <Prejoin />}
{ (_showLobby && !_showVisitorsQueue) && <LobbyScreen />}
{ _showVisitorsQueue && <VisitorsQueue />}
</div>
<ParticipantsPane />
<ReactionAnimations />
</div>
);
}
/**
* Sets custom background opacity based on config. It also applies the
* opacity on parent element, as the parent element is not accessible directly,
* only though it's child.
*
* @param {Object} element - The DOM element for which to apply opacity.
*
* @private
* @returns {void}
*/
_setBackground(element: HTMLDivElement) {
if (!element) {
return;
}
if (this.props._backgroundAlpha !== undefined) {
const elemColor = element.style.background;
const alphaElemColor = setColorAlpha(elemColor, this.props._backgroundAlpha);
element.style.background = alphaElemColor;
if (element.parentElement) {
const parentColor = element.parentElement.style.background;
const alphaParentColor = setColorAlpha(parentColor, this.props._backgroundAlpha);
element.parentElement.style.background = alphaParentColor;
}
}
}
/**
* Handler used for touch start on Video container.
*
* @private
* @returns {void}
*/
_onVideospaceTouchStart() {
this.props.dispatch(toggleToolboxVisible());
}
/**
* Updates the Redux state when full screen mode has been enabled or
* disabled.
*
* @private
* @returns {void}
*/
_onFullScreenChange() {
this.props.dispatch(fullScreenChanged(APP.UI.isFullScreen()));
}
/**
* Triggers iframe API mouseEnter event.
*
* @param {MouseEvent} event - The mouse event.
* @private
* @returns {void}
*/
_onMouseEnter(event: React.MouseEvent) {
APP.API.notifyMouseEnter(event);
}
/**
* Triggers iframe API mouseLeave event.
*
* @param {MouseEvent} event - The mouse event.
* @private
* @returns {void}
*/
_onMouseLeave(event: React.MouseEvent) {
APP.API.notifyMouseLeave(event);
}
/**
* Triggers iframe API mouseMove event.
*
* @param {MouseEvent} event - The mouse event.
* @private
* @returns {void}
*/
_onMouseMove(event: React.MouseEvent) {
APP.API.notifyMouseMove(event);
}
/**
* Displays the toolbar.
*
* @private
* @returns {void}
*/
_onShowToolbar() {
this.props.dispatch(showToolbox());
}
/**
* Until we don't rewrite UI using react components
* we use UI.start from old app. Also method translates
* component right after it has been mounted.
*
* @inheritdoc
*/
_start() {
APP.UI.start();
APP.UI.bindEvents();
FULL_SCREEN_EVENTS.forEach(name =>
document.addEventListener(name, this._onFullScreenChange));
const { dispatch, t } = this.props;
// if we will be showing prejoin we don't want to call connect from init.
// Connect will be dispatched from prejoin screen.
dispatch(init(!shouldShowPrejoin(this.props)));
maybeShowSuboptimalExperienceNotification(dispatch, t);
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code Conference} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { backgroundAlpha, mouseMoveCallbackInterval } = state['features/base/config'];
const { overflowDrawer } = state['features/toolbox'];
return {
...abstractMapStateToProps(state),
_backgroundAlpha: backgroundAlpha,
_isAnyOverlayVisible: Boolean(getOverlayToRender(state)),
_layoutClassName: LAYOUT_CLASSNAMES[getCurrentLayout(state) ?? ''],
_mouseMoveCallbackInterval: mouseMoveCallbackInterval,
_overflowDrawer: overflowDrawer,
_roomName: getConferenceNameForTitle(state),
_showLobby: getIsLobbyVisible(state),
_showPrejoin: isPrejoinPageVisible(state),
_showVisitorsQueue: showVisitorsQueue(state)
};
}
export default reactReduxConnect(_mapStateToProps)(translate(props => {
const dispatch = useDispatch();
const store = useStore();
const [ isDragging, setIsDragging ] = useState(false);
const { isOpen: isChatOpen } = useSelector((state: IReduxState) => state['features/chat']);
const isFileUploadEnabled = useSelector(isFileUploadingEnabled);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isFileUploadEnabled) {
return;
}
if (isDragging) {
if (!isChatOpen) {
dispatch(openChat());
}
dispatch(setFocusedTab(ChatTabs.FILE_SHARING));
}
}, [ isChatOpen, isDragging, isFileUploadEnabled ]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!isFileUploadEnabled) {
return;
}
if (e.dataTransfer.files?.length > 0) {
processFiles(e.dataTransfer.files, store);
}
}, [ isFileUploadEnabled, processFiles ]);
return (
<div
onDragEnter = { handleDragEnter }
onDragLeave = { handleDragLeave }
onDragOver = { handleDragOver }
onDrop = { handleDrop }>
{/* @ts-ignore */}
<Conference { ...props } />
</div>
);
}));

View File

@@ -0,0 +1,226 @@
/* eslint-disable react/no-multi-comp */
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import E2EELabel from '../../../e2ee/components/E2EELabel';
import HighlightButton from '../../../recording/components/Recording/web/HighlightButton';
import RecordingLabel from '../../../recording/components/web/RecordingLabel';
import { showToolbox } from '../../../toolbox/actions.web';
import { isToolboxVisible } from '../../../toolbox/functions.web';
import VideoQualityLabel from '../../../video-quality/components/VideoQualityLabel.web';
import VisitorsCountLabel from '../../../visitors/components/web/VisitorsCountLabel';
import ConferenceTimer from '../ConferenceTimer';
import { getConferenceInfo } from '../functions.web';
import ConferenceInfoContainer from './ConferenceInfoContainer';
import InsecureRoomNameLabel from './InsecureRoomNameLabel';
import RaisedHandsCountLabel from './RaisedHandsCountLabel';
import SpeakerStatsLabel from './SpeakerStatsLabel';
import SubjectText from './SubjectText';
import ToggleTopPanelLabel from './ToggleTopPanelLabel';
/**
* The type of the React {@code Component} props of {@link Subject}.
*/
interface IProps {
/**
* The conference info labels to be shown in the conference header.
*/
_conferenceInfo: {
alwaysVisible?: string[];
autoHide?: string[];
};
/**
* Indicates whether the component should be visible or not.
*/
_visible: boolean;
/**
* Invoked to active other features of the app.
*/
dispatch: IStore['dispatch'];
}
const COMPONENTS: Array<{
Component: React.ComponentType<any>;
id: string;
}> = [
{
Component: HighlightButton,
id: 'highlight-moment'
},
{
Component: SubjectText,
id: 'subject'
},
{
Component: ConferenceTimer,
id: 'conference-timer'
},
{
Component: SpeakerStatsLabel,
id: 'participants-count'
},
{
Component: E2EELabel,
id: 'e2ee'
},
{
Component: () => (
<>
<RecordingLabel mode = { JitsiRecordingConstants.mode.FILE } />
<RecordingLabel mode = { JitsiRecordingConstants.mode.STREAM } />
</>
),
id: 'recording'
},
{
Component: RaisedHandsCountLabel,
id: 'raised-hands-count'
},
{
Component: VideoQualityLabel,
id: 'video-quality'
},
{
Component: VisitorsCountLabel,
id: 'visitors-count'
},
{
Component: InsecureRoomNameLabel,
id: 'insecure-room'
},
{
Component: ToggleTopPanelLabel,
id: 'top-panel-toggle'
}
];
/**
* The upper band of the meeing containing the conference name, timer and labels.
*
* @param {Object} props - The props of the component.
* @returns {React$None}
*/
class ConferenceInfo extends Component<IProps> {
/**
* Initializes a new {@code ConferenceInfo} 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);
this._renderAutoHide = this._renderAutoHide.bind(this);
this._renderAlwaysVisible = this._renderAlwaysVisible.bind(this);
this._onTabIn = this._onTabIn.bind(this);
}
/**
* Callback invoked when the component is focused to show the conference
* info if necessary.
*
* @returns {void}
*/
_onTabIn() {
if (this.props._conferenceInfo.autoHide?.length && !this.props._visible) {
this.props.dispatch(showToolbox());
}
}
/**
* Renders auto-hidden info header labels.
*
* @returns {void}
*/
_renderAutoHide() {
const { autoHide } = this.props._conferenceInfo;
if (!autoHide?.length) {
return null;
}
return (
<ConferenceInfoContainer
id = 'autoHide'
visible = { this.props._visible }>
{
COMPONENTS
.filter(comp => autoHide.includes(comp.id))
.map(c =>
<c.Component key = { c.id } />
)
}
</ConferenceInfoContainer>
);
}
/**
* Renders the always visible info header labels.
*
* @returns {void}
*/
_renderAlwaysVisible() {
const { alwaysVisible } = this.props._conferenceInfo;
if (!alwaysVisible?.length) {
return null;
}
return (
<ConferenceInfoContainer
id = 'alwaysVisible'
visible = { true } >
{
COMPONENTS
.filter(comp => alwaysVisible.includes(comp.id))
.map(c =>
<c.Component key = { c.id } />
)
}
</ConferenceInfoContainer>
);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<div
className = 'details-container'
onFocus = { this._onTabIn }>
{ this._renderAlwaysVisible() }
{ this._renderAutoHide() }
</div>
);
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code Subject}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _visible: boolean,
* _conferenceInfo: Object
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
_visible: isToolboxVisible(state),
_conferenceInfo: getConferenceInfo(state)
};
}
export default connect(_mapStateToProps)(ConferenceInfo);

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { isAlwaysOnTitleBarEmpty } from '../functions.web';
interface IProps {
/**
* The children components.
*/
children: React.ReactNode;
/**
* Id of the component.
*/
id?: string;
/**
* Whether this conference info container should be visible or not.
*/
visible: boolean;
}
export default ({ visible, children, id }: IProps) => (
<div
className = { `subject${isAlwaysOnTitleBarEmpty() ? '' : ' with-always-on'}${visible ? ' visible' : ''}` }
id = { id }>
<div className = { 'subject-info-container' }>
{children}
</div>
</div>
);

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import { IDisplayProps } from '../ConferenceTimer';
const useStyles = makeStyles()(theme => {
return {
timer: {
...theme.typography.labelRegular,
color: theme.palette.text01,
padding: '6px 8px',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
boxSizing: 'border-box',
height: '28px',
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
marginRight: '2px',
'@media (max-width: 300px)': {
display: 'none'
}
}
};
});
/**
* Returns web element to be rendered.
*
* @returns {ReactElement}
*/
export default function ConferenceTimerDisplay({ timerValue, textStyle: _textStyle }: IDisplayProps) {
const { classes } = useStyles();
return (
<span className = { classes.timer }>{ timerValue }</span>
);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import { IconExclamationTriangle } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import { COLORS } from '../../../base/label/constants';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import getUnsafeRoomText from '../../../base/util/getUnsafeRoomText.web';
import AbstractInsecureRoomNameLabel, { _mapStateToProps } from '../AbstractInsecureRoomNameLabel';
/**
* Renders a label indicating that we are in a room with an insecure name.
*/
class InsecureRoomNameLabel extends AbstractInsecureRoomNameLabel {
/**
* Renders the platform dependent content.
*
* @inheritdoc
*/
override _render() {
return (
<Tooltip
content = { getUnsafeRoomText(this.props.t, 'meeting') }
position = 'bottom'>
<Label
color = { COLORS.red }
icon = { IconExclamationTriangle } />
</Tooltip>
);
}
}
export default translate(connect(_mapStateToProps)(InsecureRoomNameLabel));

View File

@@ -0,0 +1,62 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import Dialog from '../../../base/ui/components/web/Dialog';
const useStyles = makeStyles()(theme => {
return {
dialog: {
marginBottom: theme.spacing(1)
},
text: {
fontSize: '1.25rem'
}
};
});
/**
* The type of the React {@code Component} props of {@link LeaveReasonDialog}.
*/
interface IProps {
/**
* Callback invoked when {@code LeaveReasonDialog} is unmounted.
*/
onClose: () => void;
/**
* The title to display in the dialog.
*/
title?: string;
}
/**
* A React {@code Component} for displaying a dialog with a reason that ended the conference.
*
* @param {IProps} props - Component's props.
* @returns {JSX}
*/
const LeaveReasonDialog = ({ onClose, title }: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
useEffect(() => () => {
onClose?.();
}, []);
return (
<Dialog
cancel = {{ hidden: true }}
onSubmit = { onClose }
size = 'medium'
testId = 'dialog.leaveReason'>
<div className = { classes.dialog }>
{title ? <div className = { classes.text }>{t(title)}</div> : null}
</div>
</Dialog>
);
};
export default LeaveReasonDialog;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
const useStyles = makeStyles()(theme => {
return {
notice: {
position: 'absolute',
left: '50%',
zIndex: 3,
marginTop: theme.spacing(2),
transform: 'translateX(-50%)'
},
message: {
backgroundColor: theme.palette.uiBackground,
color: theme.palette.text01,
padding: '3px',
borderRadius: '5px'
}
};
});
const Notice = () => {
const message = useSelector((state: IReduxState) => state['features/base/config'].noticeMessage);
const { classes } = useStyles();
if (!message) {
return null;
}
return (
<div className = { classes.notice }>
<span className = { classes.message } >
{message}
</span>
</div>
);
};
export default Notice;

View File

@@ -0,0 +1,45 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { IconRaiseHand } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { open as openParticipantsPane } from '../../../participants-pane/actions.web';
const useStyles = makeStyles()(theme => {
return {
label: {
backgroundColor: theme.palette.warning02,
color: theme.palette.uiBackground
}
};
});
const RaisedHandsCountLabel = () => {
const { classes: styles, theme } = useStyles();
const dispatch = useDispatch();
const raisedHandsCount = useSelector((state: IReduxState) =>
(state['features/base/participants'].raisedHandsQueue || []).length);
const { t } = useTranslation();
const onClick = useCallback(() => {
dispatch(openParticipantsPane());
}, []);
return raisedHandsCount > 0 ? (<Tooltip
content = { t('raisedHandsLabel') }
position = { 'bottom' }>
<Label
accessibilityText = { t('raisedHandsLabel') }
className = { styles.label }
icon = { IconRaiseHand }
iconColor = { theme.palette.icon04 }
id = 'raisedHandsCountLabel'
onClick = { onClick }
text = { `${raisedHandsCount}` } />
</Tooltip>) : null;
};
export default RaisedHandsCountLabel;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { IconUsers } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import { COLORS } from '../../../base/label/constants';
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import SpeakerStats from '../../../speaker-stats/components/web/SpeakerStats';
import { isSpeakerStatsDisabled } from '../../../speaker-stats/functions';
/**
* ParticipantsCount react component.
* Displays the number of participants and opens Speaker stats on click.
*
* @class ParticipantsCount
*/
function SpeakerStatsLabel() {
const conference = useSelector((state: IReduxState) => state['features/base/conference'].conference);
const count = useSelector(getParticipantCountForDisplay);
const _isSpeakerStatsDisabled = useSelector(isSpeakerStatsDisabled);
const dispatch = useDispatch();
const { t } = useTranslation();
const onClick = () => {
dispatch(openDialog(SpeakerStats, { conference }));
};
if (count <= 2 || _isSpeakerStatsDisabled) {
return null;
}
return (
<Tooltip
content = { t('speakerStats.labelTooltip', { count }) }
position = { 'bottom' }>
<Label
color = { COLORS.white }
icon = { IconUsers }
iconColor = '#fff'
// eslint-disable-next-line react/jsx-no-bind
onClick = { onClick }
text = { `${count}` } />
</Tooltip>
);
}
export default SpeakerStatsLabel;

View File

@@ -0,0 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { getConferenceName } from '../../../base/conference/functions';
import Tooltip from '../../../base/tooltip/components/Tooltip';
const useStyles = makeStyles()(theme => {
return {
container: {
...theme.typography.bodyLongRegular,
color: theme.palette.text01,
padding: '2px 16px',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
maxWidth: '324px',
boxSizing: 'border-box',
height: '28px',
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
marginLeft: '2px',
'@media (max-width: 300px)': {
display: 'none'
}
},
content: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
};
});
/**
* Label for the conference name.
*
* @returns {ReactElement}
*/
const SubjectText = () => {
const subject = useSelector(getConferenceName);
const { classes } = useStyles();
return (
<Tooltip
content = { subject }
position = 'bottom'>
<div className = { classes.container }>
<div className = { clsx('subject-text--content', classes.content) }>{subject}</div>
</div>
</Tooltip>
);
};
export default SubjectText;

View File

@@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { IconArrowDown } from '../../../base/icons/svg/index';
import Label from '../../../base/label/components/web/Label';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { setTopPanelVisible } from '../../../filmstrip/actions.web';
const ToggleTopPanelLabel = () => {
const dispatch = useDispatch();
const { t } = useTranslation();
const topPanelHidden = !useSelector((state: IReduxState) => state['features/filmstrip'].topPanelVisible);
const onClick = useCallback(() => {
dispatch(setTopPanelVisible(true));
}, []);
return topPanelHidden ? (<Tooltip
content = { t('toggleTopPanelLabel') }
position = { 'bottom' }>
<Label
icon = { IconArrowDown }
onClick = { onClick } />
</Tooltip>) : null;
};
export default ToggleTopPanelLabel;

View File

@@ -0,0 +1,12 @@
import { IFRAME_EMBED_ALLOWED_LOCATIONS as ADDITIONAL_LOCATIONS } from './extraConstants';
/**
* Timeout of the conference when iframe is disabled in minutes.
*/
export const IFRAME_DISABLED_TIMEOUT_MINUTES = 5;
/**
* A list of allowed location to embed iframe.
*/
/* eslint-disable-next-line no-extra-parens */
export const IFRAME_EMBED_ALLOWED_LOCATIONS = ([] as string[]).concat(ADDITIONAL_LOCATIONS);

View File

@@ -0,0 +1,5 @@
/**
* Deploy-specific configuration constants.
*/
export const IFRAME_EMBED_ALLOWED_LOCATIONS = [];

View File

@@ -0,0 +1,34 @@
import { IStateful } from '../base/app/types';
import { toState } from '../base/redux/functions';
import { iAmVisitor } from '../visitors/functions';
/**
* Tells whether or not the notifications should be displayed within
* the conference feature based on the current Redux state.
*
* @param {Object|Function} stateful - The redux store state.
* @returns {boolean}
*/
export function shouldDisplayNotifications(stateful: IStateful) {
const state = toState(stateful);
const { calleeInfoVisible } = state['features/invite'];
return !calleeInfoVisible;
}
/**
*
* Returns true if polls feature is disabled.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/config.
* @returns {boolean}
*/
export function arePollsDisabled(stateful: IStateful) {
const state = toState(stateful);
return state['features/base/config']?.disablePolls || iAmVisitor(state);
}

View File

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

View File

@@ -0,0 +1,33 @@
import { IStore } from '../app/types';
import { isSuboptimalBrowser } from '../base/environment/environment';
import { translateToHTML } from '../base/i18n/functions';
import { showWarningNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
export * from './functions.any';
/**
* Shows the suboptimal experience notification if needed.
*
* @param {Function} dispatch - The dispatch method.
* @param {Function} t - The translation function.
* @returns {void}
*/
export function maybeShowSuboptimalExperienceNotification(dispatch: IStore['dispatch'], t: Function) {
if (isSuboptimalBrowser()) {
dispatch(
showWarningNotification(
{
titleKey: 'notify.suboptimalExperienceTitle',
description: translateToHTML(
t,
'notify.suboptimalBrowserWarning',
{
recommendedBrowserPageLink: `${window.location.origin}/static/recommendedBrowsers.html`
}
)
}, NOTIFICATION_TIMEOUT_TYPE.LONG
)
);
}
}

View File

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

View File

@@ -0,0 +1,350 @@
import i18n from 'i18next';
import { batch } from 'react-redux';
// @ts-expect-error
import { API_ID } from '../../../modules/API/constants';
import { appNavigate } from '../app/actions';
import { redirectToStaticPage } from '../app/actions.any';
import { IReduxState, IStore } from '../app/types';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_LEFT,
ENDPOINT_MESSAGE_RECEIVED
} from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
import { getDisableLowerHandByModerator } from '../base/config/functions.any';
import { hangup } from '../base/connection/actions';
import { getURLWithoutParamsNormalized } from '../base/connection/utils';
import { hideDialog } from '../base/dialog/actions';
import { isDialogOpen } from '../base/dialog/functions';
import { getLocalizedDateFormatter } from '../base/i18n/dateUtil';
import { translateToHTML } from '../base/i18n/functions';
import i18next from '../base/i18n/i18next';
import { browser } from '../base/lib-jitsi-meet';
import { pinParticipant, raiseHand, raiseHandClear } from '../base/participants/actions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { SET_REDUCED_UI } from '../base/responsive-ui/actionTypes';
import { LOWER_HAND_MESSAGE } from '../base/tracks/constants';
import { BUTTON_TYPES } from '../base/ui/constants.any';
import { isEmbedded } from '../base/util/embedUtils';
import { isCalendarEnabled } from '../calendar-sync/functions';
import FeedbackDialog from '../feedback/components/FeedbackDialog';
import { setFilmstripEnabled } from '../filmstrip/actions.any';
import { isVpaasMeeting } from '../jaas/functions';
import { hideNotification, showNotification, showWarningNotification } from '../notifications/actions';
import {
CALENDAR_NOTIFICATION_ID,
NOTIFICATION_ICON,
NOTIFICATION_TIMEOUT_TYPE
} from '../notifications/constants';
import { showStartRecordingNotification } from '../recording/actions';
import { showSalesforceNotification } from '../salesforce/actions';
import { setToolboxEnabled } from '../toolbox/actions.any';
import { DISMISS_CALENDAR_NOTIFICATION } from './actionTypes';
import { dismissCalendarNotification } from './actions';
import { IFRAME_DISABLED_TIMEOUT_MINUTES, IFRAME_EMBED_ALLOWED_LOCATIONS } from './constants';
let intervalID: any;
MiddlewareRegistry.register(store => next => action => {
const result = next(action);
switch (action.type) {
case CONFERENCE_JOINED: {
_conferenceJoined(store);
break;
}
case SET_REDUCED_UI: {
_setReducedUI(store);
break;
}
case DISMISS_CALENDAR_NOTIFICATION:
case CONFERENCE_LEFT:
case CONFERENCE_FAILED: {
clearInterval(intervalID);
intervalID = null;
break;
}
case ENDPOINT_MESSAGE_RECEIVED: {
const { participant, data } = action;
const { dispatch, getState } = store;
if (data.name === LOWER_HAND_MESSAGE
&& participant.isModerator()
&& !getDisableLowerHandByModerator(getState())) {
dispatch(raiseHand(false));
}
break;
}
}
return result;
});
/**
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed, close all dialogs and unpin any pinned participants.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch, getState }, prevConference) => {
const { authRequired, membersOnly, passwordRequired }
= getState()['features/base/conference'];
if (conference !== prevConference) {
// Unpin participant, in order to avoid the local participant
// remaining pinned, since it's not destroyed across runs.
dispatch(pinParticipant(null));
// Clear raised hands.
dispatch(raiseHandClear());
// XXX I wonder if there is a better way to do this. At this stage
// we do know what dialogs we want to keep but the list of those
// we want to hide is a lot longer. Thus we take a bit of a shortcut
// and explicitly check.
if (typeof authRequired === 'undefined'
&& typeof passwordRequired === 'undefined'
&& typeof membersOnly === 'undefined'
&& !isDialogOpen(getState(), FeedbackDialog)) {
// Conference changed, left or failed... and there is no
// pending authentication, nor feedback request, so close any
// dialog we might have open.
dispatch(hideDialog());
}
}
});
/**
* Configures the UI. In reduced UI mode some components will
* be hidden if there is no space to render them.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @private
* @returns {void}
*/
function _setReducedUI({ dispatch, getState }: IStore) {
const { reducedUI } = getState()['features/base/responsive-ui'];
dispatch(setToolboxEnabled(!reducedUI));
dispatch(setFilmstripEnabled(!reducedUI));
}
/**
* Does extra sync up on properties that may need to be updated after the
* conference was joined.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @private
* @returns {void}
*/
function _conferenceJoined({ dispatch, getState }: IStore) {
_setReducedUI({
dispatch,
getState
});
if (!intervalID) {
intervalID = setInterval(() =>
_maybeDisplayCalendarNotification({
dispatch,
getState
}), 10 * 1000);
}
dispatch(showSalesforceNotification());
dispatch(showStartRecordingNotification());
_checkIframe(getState(), dispatch);
}
/**
* Additional checks for embedding in iframe.
*
* @param {IReduxState} state - The current state of the app.
* @param {Function} dispatch - The Redux dispatch function.
* @private
* @returns {void}
*/
function _checkIframe(state: IReduxState, dispatch: IStore['dispatch']) {
let allowIframe = false;
if (document.referrer === '' && browser.isElectron()) {
// no iframe
allowIframe = true;
} else {
try {
allowIframe = IFRAME_EMBED_ALLOWED_LOCATIONS.includes(new URL(document.referrer).hostname);
} catch (e) {
// wrong URL in referrer
}
}
if (isEmbedded() && state['features/base/config'].disableIframeAPI && !isVpaasMeeting(state) && !allowIframe) {
// show sticky notification and redirect in 5 minutes
const { locationURL } = state['features/base/connection'];
let translationKey = 'notify.disabledIframe';
const hostname = locationURL?.hostname ?? '';
let domain = '';
const mapping: Record<string, string> = {
'8x8.vc': 'https://jaas.8x8.vc',
'meet.jit.si': 'https://jitsi.org/jaas'
};
const jaasDomain = mapping[hostname];
if (jaasDomain) {
translationKey = `notify.disabledIframeSecondary${browser.isReactNative() ? 'Native' : 'Web'}`;
domain = hostname;
}
if (browser.isReactNative()) {
dispatch(showWarningNotification({
description: i18next.t(translationKey,
{
domain,
timeout: IFRAME_DISABLED_TIMEOUT_MINUTES
}
)
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
setTimeout(() => {
dispatch(hangup());
}, IFRAME_DISABLED_TIMEOUT_MINUTES * 60 * 1000);
} else {
dispatch(showWarningNotification({
description: translateToHTML(
i18next.t.bind(i18next),
translationKey,
{
domain,
jaasDomain,
timeout: IFRAME_DISABLED_TIMEOUT_MINUTES
}
)
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
setTimeout(() => {
// redirect to the promotional page
dispatch(redirectToStaticPage('static/close3.html', `#jitsi_meet_external_api_id=${API_ID}`));
}, IFRAME_DISABLED_TIMEOUT_MINUTES * 60 * 1000);
}
}
}
/**
* Periodically checks if there is an event in the calendar for which we
* need to show a notification.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @private
* @returns {void}
*/
function _maybeDisplayCalendarNotification({ dispatch, getState }: IStore) {
const state = getState();
const calendarEnabled = isCalendarEnabled(state);
const { events: eventList } = state['features/calendar-sync'];
const { locationURL } = state['features/base/connection'];
const { reducedUI } = state['features/base/responsive-ui'];
const currentConferenceURL
= locationURL ? getURLWithoutParamsNormalized(locationURL) : '';
const ALERT_MILLISECONDS = 5 * 60 * 1000;
const now = Date.now();
let eventToShow;
if (!calendarEnabled && reducedUI) {
return;
}
if (eventList?.length) {
for (const event of eventList) {
const eventURL
= event?.url && getURLWithoutParamsNormalized(new URL(event.url));
if (eventURL && eventURL !== currentConferenceURL) {
// @ts-ignore
if ((!eventToShow && event.startDate > now && event.startDate < now + ALERT_MILLISECONDS)
// @ts-ignore
|| (event.startDate < now && event.endDate > now)) {
eventToShow = event;
}
}
}
}
_calendarNotification(
{
dispatch,
getState
}, eventToShow
);
}
/**
* Calendar notification.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {eventToShow} eventToShow - Next or ongoing event.
* @private
* @returns {void}
*/
function _calendarNotification({ dispatch, getState }: IStore, eventToShow: any) {
const state = getState();
const { locationURL } = state['features/base/connection'];
const currentConferenceURL
= locationURL ? getURLWithoutParamsNormalized(locationURL) : '';
const now = Date.now();
if (!eventToShow) {
return;
}
const customActionNameKey = [ 'notify.joinMeeting', 'notify.dontRemindMe' ];
const customActionType = [ BUTTON_TYPES.PRIMARY, BUTTON_TYPES.DESTRUCTIVE ];
const customActionHandler = [ () => batch(() => {
dispatch(hideNotification(CALENDAR_NOTIFICATION_ID));
if (eventToShow?.url && (eventToShow.url !== currentConferenceURL)) {
dispatch(appNavigate(eventToShow.url));
}
}), () => dispatch(dismissCalendarNotification()) ];
const description
= getLocalizedDateFormatter(eventToShow.startDate).fromNow();
const icon = NOTIFICATION_ICON.WARNING;
const title = (eventToShow.startDate < now) && (eventToShow.endDate > now)
? `${i18n.t('calendarSync.ongoingMeeting')}: \n${eventToShow.title}`
: `${i18n.t('calendarSync.nextMeeting')}: \n${eventToShow.title}`;
const uid = CALENDAR_NOTIFICATION_ID;
dispatch(showNotification({
customActionHandler,
customActionNameKey,
customActionType,
description,
icon,
maxLines: 1,
title,
uid
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}

View File

@@ -0,0 +1,28 @@
import { appNavigate } from '../app/actions.native';
import { KICKED_OUT } from '../base/conference/actionTypes';
import { conferenceLeft } from '../base/conference/actions.native';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { notifyKickedOut } from './actions.native';
import './middleware.any';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case KICKED_OUT: {
const { dispatch } = store;
dispatch(notifyKickedOut(
action.participant,
() => {
dispatch(conferenceLeft(action.conference));
dispatch(appNavigate(undefined));
}
));
break;
}
}
return next(action);
});

View File

@@ -0,0 +1,46 @@
import i18next from 'i18next';
import { ENDPOINT_MESSAGE_RECEIVED, KICKED_OUT } from '../base/conference/actionTypes';
import { hangup } from '../base/connection/actions.web';
import { getParticipantDisplayName } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { openAllowToggleCameraDialog, setCameraFacingMode } from '../base/tracks/actions.web';
import { CAMERA_FACING_MODE_MESSAGE } from '../base/tracks/constants';
import './middleware.any';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case ENDPOINT_MESSAGE_RECEIVED: {
const { participant, data } = action;
if (data?.name === CAMERA_FACING_MODE_MESSAGE) {
APP.store.dispatch(openAllowToggleCameraDialog(
/* onAllow */ () => APP.store.dispatch(setCameraFacingMode(data.facingMode)),
/* initiatorId */ participant.getId()
));
}
break;
}
case KICKED_OUT: {
const { dispatch } = store;
const { participant } = action;
// we need to first finish dispatching or the notification can be cleared out
const result = next(action);
const participantDisplayName
= participant && getParticipantDisplayName(store.getState, participant.getId());
dispatch(hangup(true,
participantDisplayName ? i18next.t('dialog.kickTitle', { participantDisplayName })
: i18next.t('dialog.kickSystemTitle'),
true));
return result;
}
}
return next(action);
});