This commit is contained in:
25
react/features/whiteboard/actionTypes.ts
Normal file
25
react/features/whiteboard/actionTypes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Close the whiteboard collaboration session.
|
||||
* {{
|
||||
* type: RESET_WHITEBOARD
|
||||
* }}
|
||||
*/
|
||||
export const RESET_WHITEBOARD: string = 'RESET_WHITEBOARD';
|
||||
|
||||
/**
|
||||
* Configure the whiteboard collaboration details.
|
||||
* {{
|
||||
* type: SETUP_WHITEBOARD,
|
||||
* collabDetails
|
||||
* }}
|
||||
*/
|
||||
export const SETUP_WHITEBOARD: string = 'SETUP_WHITEBOARD';
|
||||
|
||||
/**
|
||||
* Sets the whiteboard visibility state.
|
||||
* {{
|
||||
* type: SET_WHITEBOARD_OPEN,
|
||||
* isOpen
|
||||
* }}
|
||||
*/
|
||||
export const SET_WHITEBOARD_OPEN: string = 'SET_WHITEBOARD_OPEN';
|
||||
71
react/features/whiteboard/actions.any.ts
Normal file
71
react/features/whiteboard/actions.any.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { showWarningNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
|
||||
|
||||
import {
|
||||
RESET_WHITEBOARD,
|
||||
SETUP_WHITEBOARD,
|
||||
SET_WHITEBOARD_OPEN
|
||||
} from './actionTypes';
|
||||
import { IWhiteboardAction } from './reducer';
|
||||
|
||||
/**
|
||||
* Configures the whiteboard collaboration details.
|
||||
*
|
||||
* @param {Object} payload - The whiteboard settings.
|
||||
* @returns {{
|
||||
* type: SETUP_WHITEBOARD,
|
||||
* collabDetails: { roomId: string, roomKey: string },
|
||||
* collabServerUrl: string
|
||||
* }}
|
||||
*/
|
||||
export const setupWhiteboard = ({ collabDetails, collabServerUrl }: {
|
||||
collabDetails: { roomId: string; roomKey: string; };
|
||||
collabServerUrl?: string;
|
||||
}): IWhiteboardAction => {
|
||||
return {
|
||||
type: SETUP_WHITEBOARD,
|
||||
collabDetails,
|
||||
collabServerUrl
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleans up the whiteboard collaboration settings.
|
||||
* To be used only on native for cleanup in between conferences.
|
||||
*
|
||||
* @returns {{
|
||||
* type: RESET_WHITEBOARD
|
||||
* }}
|
||||
*/
|
||||
export const resetWhiteboard = (): IWhiteboardAction => {
|
||||
return { type: RESET_WHITEBOARD };
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the whiteboard visibility status.
|
||||
*
|
||||
* @param {boolean} isOpen - The whiteboard visibility flag.
|
||||
* @returns {{
|
||||
* type: SET_WHITEBOARD_OPEN,
|
||||
* isOpen
|
||||
* }}
|
||||
*/
|
||||
export const setWhiteboardOpen = (isOpen: boolean): IWhiteboardAction => {
|
||||
return {
|
||||
type: SET_WHITEBOARD_OPEN,
|
||||
isOpen
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows a warning notification about the whiteboard user limit.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export const notifyWhiteboardLimit = () => (dispatch: IStore['dispatch']) => {
|
||||
dispatch(showWarningNotification({
|
||||
titleKey: 'notify.whiteboardLimitTitle',
|
||||
descriptionKey: 'notify.whiteboardLimitDescription'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
};
|
||||
26
react/features/whiteboard/actions.native.ts
Normal file
26
react/features/whiteboard/actions.native.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createRestrictWhiteboardEvent } from '../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../analytics/functions';
|
||||
import { IStore } from '../app/types';
|
||||
import {
|
||||
navigate
|
||||
} from '../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../mobile/navigation/routes';
|
||||
|
||||
|
||||
import { resetWhiteboard } from './actions.any';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Restricts the whiteboard usage.
|
||||
*
|
||||
* @param {boolean} shouldCloseWhiteboard - Whether to dismiss the whiteboard.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export const restrictWhiteboard = (shouldCloseWhiteboard = true) => (dispatch: IStore['dispatch']) => {
|
||||
if (shouldCloseWhiteboard) {
|
||||
navigate(screen.conference.root);
|
||||
}
|
||||
dispatch(resetWhiteboard());
|
||||
sendAnalytics(createRestrictWhiteboardEvent());
|
||||
};
|
||||
48
react/features/whiteboard/actions.web.ts
Normal file
48
react/features/whiteboard/actions.web.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createRestrictWhiteboardEvent } from '../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../analytics/functions';
|
||||
import { IStore } from '../app/types';
|
||||
|
||||
import { resetWhiteboard, setWhiteboardOpen } from './actions.any';
|
||||
import { isWhiteboardAllowed, isWhiteboardOpen, isWhiteboardVisible } from './functions';
|
||||
import { WhiteboardStatus } from './types';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* API to toggle the whiteboard.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function toggleWhiteboard() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const isAllowed = isWhiteboardAllowed(state);
|
||||
const isOpen = isWhiteboardOpen(state);
|
||||
|
||||
if (isAllowed) {
|
||||
if (isOpen && !isWhiteboardVisible(state)) {
|
||||
dispatch(setWhiteboardOpen(true));
|
||||
} else if (isOpen && isWhiteboardVisible(state)) {
|
||||
dispatch(setWhiteboardOpen(false));
|
||||
} else if (!isOpen) {
|
||||
dispatch(setWhiteboardOpen(true));
|
||||
}
|
||||
} else if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyWhiteboardStatusChanged(WhiteboardStatus.FORBIDDEN);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts the whiteboard usage.
|
||||
*
|
||||
* @param {boolean} shouldCloseWhiteboard - Whether to dismiss the whiteboard.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export const restrictWhiteboard = (shouldCloseWhiteboard = true) => (dispatch: IStore['dispatch']) => {
|
||||
if (shouldCloseWhiteboard) {
|
||||
dispatch(setWhiteboardOpen(false));
|
||||
}
|
||||
dispatch(resetWhiteboard());
|
||||
sendAnalytics(createRestrictWhiteboardEvent());
|
||||
};
|
||||
247
react/features/whiteboard/components/native/Whiteboard.tsx
Normal file
247
react/features/whiteboard/components/native/Whiteboard.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { Route } from '@react-navigation/native';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { Platform, View, ViewStyle } from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { getCurrentConference } from '../../../base/conference/functions';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconCloseLarge } from '../../../base/icons/svg';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
|
||||
import { safeDecodeURIComponent } from '../../../base/util/uri';
|
||||
import HeaderNavigationButton
|
||||
from '../../../mobile/navigation/components/HeaderNavigationButton';
|
||||
import {
|
||||
goBack
|
||||
} from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { setupWhiteboard } from '../../actions.native';
|
||||
import { WHITEBOARD_ID } from '../../constants';
|
||||
import { getWhiteboardInfoForURIString } from '../../functions';
|
||||
import logger from '../../logger';
|
||||
|
||||
import WhiteboardErrorDialog from './WhiteboardErrorDialog';
|
||||
import styles, { INDICATOR_COLOR } from './styles';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The current Jitsi conference.
|
||||
*/
|
||||
conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* Redux store dispatch method.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Window location href.
|
||||
*/
|
||||
locationHref: string;
|
||||
|
||||
/**
|
||||
* Default prop for navigating between screen components(React Navigation).
|
||||
*/
|
||||
navigation: any;
|
||||
|
||||
/**
|
||||
* Default prop for navigating between screen components(React Navigation).
|
||||
*/
|
||||
route: Route<'', {
|
||||
collabDetails: { roomId: string; roomKey: string; };
|
||||
collabServerUrl: string;
|
||||
localParticipantName: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a React native component that displays the whiteboard page for a specific room.
|
||||
*/
|
||||
class Whiteboard extends PureComponent<IProps> {
|
||||
|
||||
/**
|
||||
* Initializes a new instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onError = this._onError.bind(this);
|
||||
this._onNavigate = this._onNavigate.bind(this);
|
||||
this._onMessage = this._onMessage.bind(this);
|
||||
this._renderLoading = this._renderLoading.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}. Invoked
|
||||
* immediately after mounting occurs.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
const { navigation, t } = this.props;
|
||||
const headerLeft = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<HeaderNavigationButton
|
||||
label = { t('dialog.close') }
|
||||
onPress = { goBack } />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HeaderNavigationButton
|
||||
onPress = { goBack }
|
||||
src = { IconCloseLarge } />
|
||||
);
|
||||
};
|
||||
|
||||
navigation.setOptions({ headerLeft });
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { locationHref, route } = this.props;
|
||||
const collabServerUrl = safeDecodeURIComponent(route.params?.collabServerUrl);
|
||||
const localParticipantName = safeDecodeURIComponent(route.params?.localParticipantName);
|
||||
const collabDetails = route.params?.collabDetails;
|
||||
const uri = getWhiteboardInfoForURIString(
|
||||
locationHref,
|
||||
collabServerUrl,
|
||||
collabDetails,
|
||||
localParticipantName
|
||||
) ?? '';
|
||||
|
||||
return (
|
||||
<JitsiScreen
|
||||
safeAreaInsets = { [ 'bottom', 'left', 'right' ] }
|
||||
style = { styles.backDrop }>
|
||||
<WebView
|
||||
domStorageEnabled = { false }
|
||||
incognito = { true }
|
||||
javaScriptEnabled = { true }
|
||||
nestedScrollEnabled = { true }
|
||||
onError = { this._onError }
|
||||
onMessage = { this._onMessage }
|
||||
onShouldStartLoadWithRequest = { this._onNavigate }
|
||||
renderLoading = { this._renderLoading }
|
||||
scrollEnabled = { true }
|
||||
setSupportMultipleWindows = { false }
|
||||
source = {{ uri }}
|
||||
startInLoadingState = { true }
|
||||
style = { styles.webView }
|
||||
webviewDebuggingEnabled = { true } />
|
||||
</JitsiScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle the error if the page fails to load.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onError() {
|
||||
this.props.dispatch(openDialog(WhiteboardErrorDialog));
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to intercept navigation inside the webview and make the native app handle the whiteboard requests.
|
||||
*
|
||||
* NOTE: We don't navigate to anywhere else from that view.
|
||||
*
|
||||
* @param {any} request - The request object.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_onNavigate(request: { url: string; }) {
|
||||
const { url } = request;
|
||||
const { locationHref, route } = this.props;
|
||||
const collabServerUrl = route.params?.collabServerUrl;
|
||||
const collabDetails = route.params?.collabDetails;
|
||||
const localParticipantName = route.params?.localParticipantName;
|
||||
|
||||
return url === getWhiteboardInfoForURIString(
|
||||
locationHref,
|
||||
collabServerUrl,
|
||||
collabDetails,
|
||||
localParticipantName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle the message events.
|
||||
*
|
||||
* @param {any} event - The event.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onMessage(event: any) {
|
||||
const { conference, dispatch } = this.props;
|
||||
const collabData = JSON.parse(event.nativeEvent.data);
|
||||
|
||||
if (!collabData) {
|
||||
logger.error('Message payload is missing whiteboard collaboration data');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { collabDetails, collabServerUrl } = collabData;
|
||||
|
||||
if (collabDetails?.roomId && collabDetails?.roomKey && collabServerUrl) {
|
||||
dispatch(setupWhiteboard({
|
||||
collabDetails,
|
||||
collabServerUrl
|
||||
}));
|
||||
|
||||
// Broadcast the collab details.
|
||||
conference?.getMetadataHandler().setMetadata(WHITEBOARD_ID, {
|
||||
collabServerUrl,
|
||||
collabDetails
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the loading indicator.
|
||||
*
|
||||
* @returns {React$Component<any>}
|
||||
*/
|
||||
_renderLoading() {
|
||||
return (
|
||||
<View style = { styles.indicatorWrapper as ViewStyle }>
|
||||
<LoadingIndicator
|
||||
color = { INDICATOR_COLOR }
|
||||
size = 'large' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated
|
||||
* {@code WaitForOwnerDialog}'s props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
const { href = '' } = locationURL ?? {};
|
||||
|
||||
return {
|
||||
conference: getCurrentConference(state),
|
||||
locationHref: href
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(Whiteboard));
|
||||
@@ -0,0 +1,43 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconWhiteboard } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { setWhiteboardOpen } from '../../actions.any';
|
||||
import { isWhiteboardButtonVisible } from '../../functions';
|
||||
|
||||
/**
|
||||
* Component that renders a toolbar button for the whiteboard.
|
||||
*/
|
||||
class WhiteboardButton extends AbstractButton<AbstractButtonProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.showWhiteboard';
|
||||
override icon = IconWhiteboard;
|
||||
override label = 'toolbar.showWhiteboard';
|
||||
override tooltip = 'toolbar.showWhiteboard';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens the whiteboard view.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
this.props.dispatch(setWhiteboardOpen(true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
visible: isWhiteboardButtonVisible(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(WhiteboardButton));
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import AlertDialog from '../../../base/dialog/components/native/AlertDialog';
|
||||
|
||||
/**
|
||||
* Dialog to inform the user that we couldn't load the whiteboard.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const WhiteboardErrorDialog = () => (
|
||||
<AlertDialog
|
||||
contentKey = 'info.whiteboardError' />
|
||||
);
|
||||
|
||||
export default WhiteboardErrorDialog;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, TextStyle } from 'react-native';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
|
||||
import Link from '../../../base/react/components/native/Link';
|
||||
import { getWhiteboardConfig } from '../../functions';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* Component that renders the whiteboard user limit dialog.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const WhiteboardLimitDialog = () => {
|
||||
const { t } = useTranslation();
|
||||
const { limitUrl } = useSelector(getWhiteboardConfig);
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
cancelLabel = { 'dialog.Ok' }
|
||||
descriptionKey = { 'dialog.whiteboardLimitContent' }
|
||||
isConfirmHidden = { true }
|
||||
title = { 'dialog.whiteboardLimitTitle' }>
|
||||
{limitUrl && (
|
||||
<Text style = { styles.limitUrlText as TextStyle }>
|
||||
{` ${t('dialog.whiteboardLimitReference')}
|
||||
`}
|
||||
<Link
|
||||
style = { styles.limitUrl }
|
||||
url = { limitUrl }>
|
||||
{t('dialog.whiteboardLimitReferenceUrl')}
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhiteboardLimitDialog;
|
||||
37
react/features/whiteboard/components/native/styles.ts
Normal file
37
react/features/whiteboard/components/native/styles.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
export const INDICATOR_COLOR = BaseTheme.palette.ui07;
|
||||
|
||||
const WV_BACKGROUND = BaseTheme.palette.ui03;
|
||||
|
||||
export default {
|
||||
|
||||
backDrop: {
|
||||
backgroundColor: WV_BACKGROUND,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
indicatorWrapper: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: BaseTheme.palette.ui10,
|
||||
height: '100%',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
webView: {
|
||||
backgroundColor: WV_BACKGROUND,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
limitUrlText: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
marginBottom: BaseTheme.spacing[2],
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
limitUrl: {
|
||||
color: BaseTheme.palette.link01,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link NoWhiteboardError}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Additional CSS classnames to append to the root of the component.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NoWhiteboardError = ({ className }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className = { className } >
|
||||
{t('info.noWhiteboard')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoWhiteboardError;
|
||||
165
react/features/whiteboard/components/web/Whiteboard.tsx
Normal file
165
react/features/whiteboard/components/web/Whiteboard.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { ExcalidrawApp } from '@jitsi/excalidraw';
|
||||
import clsx from 'clsx';
|
||||
import i18next from 'i18next';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
// @ts-expect-error
|
||||
import Filmstrip from '../../../../../modules/UI/videolayout/Filmstrip';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { getLocalParticipant } from '../../../base/participants/functions';
|
||||
import { getVerticalViewMaxWidth } from '../../../filmstrip/functions.web';
|
||||
import { getToolboxHeight } from '../../../toolbox/functions.web';
|
||||
import { shouldDisplayTileView } from '../../../video-layout/functions.any';
|
||||
import { WHITEBOARD_UI_OPTIONS } from '../../constants';
|
||||
import {
|
||||
getCollabDetails,
|
||||
getCollabServerUrl,
|
||||
isWhiteboardOpen,
|
||||
isWhiteboardVisible
|
||||
} from '../../functions';
|
||||
|
||||
/**
|
||||
* Space taken by meeting elements like the subject and the watermark.
|
||||
*/
|
||||
const HEIGHT_OFFSET = 80;
|
||||
|
||||
interface IDimensions {
|
||||
|
||||
/* The height of the component. */
|
||||
height: string;
|
||||
|
||||
/* The width of the component. */
|
||||
width: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Whiteboard component.
|
||||
*
|
||||
* @param {Props} props - The React props passed to this component.
|
||||
* @returns {JSX.Element} - The React component.
|
||||
*/
|
||||
const Whiteboard = (props: WithTranslation): JSX.Element => {
|
||||
const excalidrawRef = useRef<any>(null);
|
||||
const excalidrawAPIRef = useRef<any>(null);
|
||||
const collabAPIRef = useRef<any>(null);
|
||||
|
||||
const isOpen = useSelector(isWhiteboardOpen);
|
||||
const isVisible = useSelector(isWhiteboardVisible);
|
||||
const isInTileView = useSelector(shouldDisplayTileView);
|
||||
const { clientHeight, videoSpaceWidth } = useSelector((state: IReduxState) => state['features/base/responsive-ui']);
|
||||
const { visible: filmstripVisible, isResizing: isFilmstripResizing } = useSelector((state: IReduxState) => state['features/filmstrip']);
|
||||
const isChatResizing = useSelector((state: IReduxState) => state['features/chat'].isResizing);
|
||||
const isResizing = isFilmstripResizing || isChatResizing;
|
||||
const filmstripWidth: number = useSelector(getVerticalViewMaxWidth);
|
||||
const collabDetails = useSelector(getCollabDetails);
|
||||
const collabServerUrl = useSelector(getCollabServerUrl);
|
||||
const { defaultRemoteDisplayName } = useSelector((state: IReduxState) => state['features/base/config']);
|
||||
const localParticipantName = useSelector(getLocalParticipant)?.name || defaultRemoteDisplayName || 'Fellow Jitster';
|
||||
|
||||
useEffect(() => {
|
||||
if (!collabAPIRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
collabAPIRef.current.setUsername(localParticipantName);
|
||||
}, [ localParticipantName ]);
|
||||
|
||||
/**
|
||||
* Computes the width and the height of the component.
|
||||
*
|
||||
* @returns {IDimensions} - The dimensions of the component.
|
||||
*/
|
||||
const getDimensions = (): IDimensions => {
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if (interfaceConfig.VERTICAL_FILMSTRIP) {
|
||||
if (filmstripVisible) {
|
||||
width = videoSpaceWidth - filmstripWidth;
|
||||
} else {
|
||||
width = videoSpaceWidth;
|
||||
}
|
||||
height = clientHeight - getToolboxHeight();
|
||||
} else {
|
||||
if (filmstripVisible) {
|
||||
height = clientHeight - Filmstrip.getFilmstripHeight();
|
||||
} else {
|
||||
height = clientHeight;
|
||||
}
|
||||
width = videoSpaceWidth;
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${width}px`,
|
||||
height: `${height - HEIGHT_OFFSET}px`
|
||||
};
|
||||
};
|
||||
|
||||
const getExcalidrawAPI = useCallback(excalidrawAPI => {
|
||||
if (excalidrawAPIRef.current) {
|
||||
return;
|
||||
}
|
||||
excalidrawAPIRef.current = excalidrawAPI;
|
||||
}, []);
|
||||
|
||||
const getCollabAPI = useCallback(collabAPI => {
|
||||
if (collabAPIRef.current) {
|
||||
return;
|
||||
}
|
||||
collabAPIRef.current = collabAPI;
|
||||
collabAPIRef.current.setUsername(localParticipantName);
|
||||
}, [ localParticipantName ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { clsx(
|
||||
isResizing && 'disable-pointer',
|
||||
'whiteboard-container'
|
||||
) }
|
||||
style = {{
|
||||
...getDimensions(),
|
||||
marginTop: `${HEIGHT_OFFSET}px`,
|
||||
display: `${isInTileView || !isVisible ? 'none' : 'block'}`
|
||||
}}>
|
||||
{
|
||||
isOpen && (
|
||||
<div className = 'excalidraw-wrapper'>
|
||||
{/*
|
||||
* Excalidraw renders a few lvl 2 headings. This is
|
||||
* quite fortunate, because we actually use lvl 1
|
||||
* headings to mark the big sections of our app. So make
|
||||
* sure to mark the Excalidraw context with a lvl 1
|
||||
* heading before showing the whiteboard.
|
||||
*/
|
||||
<span
|
||||
aria-level = { 1 }
|
||||
className = 'sr-only'
|
||||
role = 'heading'>
|
||||
{ props.t('whiteboard.accessibilityLabel.heading') }
|
||||
</span>
|
||||
}
|
||||
<ExcalidrawApp
|
||||
collabDetails = { collabDetails }
|
||||
collabServerUrl = { collabServerUrl }
|
||||
excalidraw = {{
|
||||
isCollaborating: true,
|
||||
langCode: i18next.language,
|
||||
|
||||
// @ts-ignore
|
||||
ref: excalidrawRef,
|
||||
theme: 'light',
|
||||
UIOptions: WHITEBOARD_UI_OPTIONS
|
||||
}}
|
||||
getCollabAPI = { getCollabAPI }
|
||||
getExcalidrawAPI = { getExcalidrawAPI } />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate(Whiteboard);
|
||||
94
react/features/whiteboard/components/web/WhiteboardApp.tsx
Normal file
94
react/features/whiteboard/components/web/WhiteboardApp.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { generateCollaborationLinkData } from '@jitsi/excalidraw';
|
||||
import React, { ComponentType } from 'react';
|
||||
|
||||
import BaseApp from '../../../base/app/components/BaseApp';
|
||||
import GlobalStyles from '../../../base/ui/components/GlobalStyles.web';
|
||||
import JitsiThemeProvider from '../../../base/ui/components/JitsiThemeProvider.web';
|
||||
import { decodeFromBase64URL } from '../../../base/util/httpUtils';
|
||||
import { parseURLParams } from '../../../base/util/parseURLParams';
|
||||
import { safeDecodeURIComponent } from '../../../base/util/uri';
|
||||
import logger from '../../logger';
|
||||
|
||||
import NoWhiteboardError from './NoWhiteboardError';
|
||||
import WhiteboardWrapper from './WhiteboardWrapper';
|
||||
|
||||
/**
|
||||
* Wrapper application for the whiteboard.
|
||||
*
|
||||
* @augments BaseApp
|
||||
*/
|
||||
export default class WhiteboardApp extends BaseApp<any> {
|
||||
/**
|
||||
* Navigates to {@link Whiteboard} upon mount.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override async componentDidMount() {
|
||||
await super.componentDidMount();
|
||||
|
||||
const { state } = parseURLParams(window.location.href, true);
|
||||
const decodedState = JSON.parse(decodeFromBase64URL(state));
|
||||
const { collabServerUrl, localParticipantName } = decodedState;
|
||||
let { roomId, roomKey } = decodedState;
|
||||
|
||||
if (!roomId && !roomKey) {
|
||||
try {
|
||||
const collabDetails = await generateCollaborationLinkData();
|
||||
|
||||
roomId = collabDetails.roomId;
|
||||
roomKey = collabDetails.roomKey;
|
||||
|
||||
if (window.ReactNativeWebView) {
|
||||
setTimeout(() => {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
collabDetails,
|
||||
collabServerUrl
|
||||
}));
|
||||
}, 0);
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Couldn\'t generate collaboration link data.', e);
|
||||
}
|
||||
}
|
||||
|
||||
super._navigate({
|
||||
component: () => (
|
||||
<>{
|
||||
roomId && roomKey && collabServerUrl
|
||||
? <WhiteboardWrapper
|
||||
className = 'whiteboard'
|
||||
collabDetails = {{
|
||||
roomId,
|
||||
roomKey
|
||||
}}
|
||||
collabServerUrl = { safeDecodeURIComponent(collabServerUrl) }
|
||||
localParticipantName = { localParticipantName } />
|
||||
: <NoWhiteboardError className = 'whiteboard' />
|
||||
}</>
|
||||
) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the parent method to inject {@link AtlasKitThemeProvider} as
|
||||
* the top most component.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
override _createMainElement(component: ComponentType<any>, props: Object) {
|
||||
return (
|
||||
<JitsiThemeProvider>
|
||||
<GlobalStyles />
|
||||
{super._createMainElement(component, props)}
|
||||
</JitsiThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the platform specific dialog container.
|
||||
*
|
||||
* @returns {React$Element}
|
||||
*/
|
||||
override _renderDialogContainer() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconWhiteboard, IconWhiteboardHide } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { setOverflowMenuVisible } from '../../../toolbox/actions.web';
|
||||
import { setWhiteboardOpen } from '../../actions.any';
|
||||
import { isWhiteboardButtonVisible, isWhiteboardVisible } from '../../functions';
|
||||
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* Whether or not the button is toggled.
|
||||
*/
|
||||
_toggled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a toolbar button for the whiteboard.
|
||||
*/
|
||||
class WhiteboardButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.showWhiteboard';
|
||||
override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.hideWhiteboard';
|
||||
override icon = IconWhiteboard;
|
||||
override label = 'toolbar.showWhiteboard';
|
||||
override toggledIcon = IconWhiteboardHide;
|
||||
override toggledLabel = 'toolbar.hideWhiteboard';
|
||||
override toggledTooltip = 'toolbar.hideWhiteboard';
|
||||
override tooltip = 'toolbar.showWhiteboard';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens / closes the whiteboard view.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { dispatch, _toggled } = this.props;
|
||||
|
||||
dispatch(setWhiteboardOpen(!_toggled));
|
||||
dispatch(setOverflowMenuVisible(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in toggled state or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return this.props._toggled;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_toggled: isWhiteboardVisible(state),
|
||||
visible: isWhiteboardButtonVisible(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(WhiteboardButton));
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import { getWhiteboardConfig } from '../../functions';
|
||||
|
||||
/**
|
||||
* Component that renders the whiteboard user limit dialog.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const WhiteboardLimitDialog = () => {
|
||||
const { t } = useTranslation();
|
||||
const { limitUrl } = useSelector(getWhiteboardConfig);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ hidden: true }}
|
||||
ok = {{ hidden: true }}
|
||||
titleKey = { t('dialog.whiteboardLimitTitle') }>
|
||||
<span>{t('dialog.whiteboardLimitContent')}</span>
|
||||
{limitUrl && (
|
||||
<span>
|
||||
{` ${t('dialog.whiteboardLimitReference')} `}
|
||||
<a
|
||||
href = { limitUrl }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
{t('dialog.whiteboardLimitReferenceUrl')}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhiteboardLimitDialog;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ExcalidrawApp } from '@jitsi/excalidraw';
|
||||
import i18next from 'i18next';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
|
||||
import { WHITEBOARD_UI_OPTIONS } from '../../constants';
|
||||
|
||||
/**
|
||||
* Whiteboard wrapper for mobile.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const WhiteboardWrapper = ({
|
||||
className,
|
||||
collabDetails,
|
||||
collabServerUrl,
|
||||
localParticipantName
|
||||
}: {
|
||||
className?: string;
|
||||
collabDetails: {
|
||||
roomId: string;
|
||||
roomKey: string;
|
||||
};
|
||||
collabServerUrl: string;
|
||||
localParticipantName: string;
|
||||
}) => {
|
||||
const excalidrawRef = useRef<any>(null);
|
||||
const excalidrawAPIRef = useRef<any>(null);
|
||||
const collabAPIRef = useRef<any>(null);
|
||||
|
||||
const getExcalidrawAPI = useCallback(excalidrawAPI => {
|
||||
if (excalidrawAPIRef.current) {
|
||||
return;
|
||||
}
|
||||
excalidrawAPIRef.current = excalidrawAPI;
|
||||
}, []);
|
||||
|
||||
const getCollabAPI = useCallback(collabAPI => {
|
||||
if (collabAPIRef.current) {
|
||||
return;
|
||||
}
|
||||
collabAPIRef.current = collabAPI;
|
||||
collabAPIRef.current.setUsername(localParticipantName);
|
||||
}, [ localParticipantName ]);
|
||||
|
||||
return (
|
||||
<div className = { className }>
|
||||
<div className = 'excalidraw-wrapper'>
|
||||
<ExcalidrawApp
|
||||
collabDetails = { collabDetails }
|
||||
collabServerUrl = { collabServerUrl }
|
||||
detectScroll = { true }
|
||||
excalidraw = {{
|
||||
isCollaborating: true,
|
||||
langCode: i18next.language,
|
||||
|
||||
// @ts-ignore
|
||||
ref: excalidrawRef,
|
||||
theme: 'light',
|
||||
UIOptions: WHITEBOARD_UI_OPTIONS
|
||||
}}
|
||||
getCollabAPI = { getCollabAPI }
|
||||
getExcalidrawAPI = { getExcalidrawAPI } />
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhiteboardWrapper;
|
||||
71
react/features/whiteboard/constants.ts
Normal file
71
react/features/whiteboard/constants.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Fixed name of the whiteboard fake participant.
|
||||
*/
|
||||
export const WHITEBOARD_PARTICIPANT_NAME = 'Whiteboard';
|
||||
|
||||
/**
|
||||
* Whiteboard ID.
|
||||
*/
|
||||
export const WHITEBOARD_ID = 'whiteboard';
|
||||
|
||||
/**
|
||||
* Whiteboard UI Options.
|
||||
*/
|
||||
export const WHITEBOARD_UI_OPTIONS = {
|
||||
canvasActions: {
|
||||
allowedShapes: [
|
||||
'arrow', 'diamond', 'ellipse', 'freedraw', 'line', 'rectangle', 'selection', 'text'
|
||||
],
|
||||
allowedShortcuts: [
|
||||
'cut', 'deleteSelectedElements', 'redo', 'selectAll', 'undo'
|
||||
],
|
||||
disableAlignItems: true,
|
||||
disableFileDrop: true,
|
||||
disableGrouping: true,
|
||||
disableHints: true,
|
||||
disableLink: true,
|
||||
disableShortcuts: true,
|
||||
disableVerticalAlignOptions: true,
|
||||
fontSizeOptions: [ 's', 'm', 'l' ],
|
||||
hideArrowHeadsOptions: true,
|
||||
hideColorInput: true,
|
||||
hideClearCanvas: true,
|
||||
hideFontFamily: true,
|
||||
hideHelpDialog: true,
|
||||
hideIOActions: true,
|
||||
hideLayers: true,
|
||||
hideLibraries: true,
|
||||
hideLockButton: true,
|
||||
hideOpacityInput: true,
|
||||
hideSharpness: true,
|
||||
hideStrokeStyle: true,
|
||||
hideTextAlign: true,
|
||||
hideThemeControls: true,
|
||||
hideUserList: true,
|
||||
saveAsImageOptions: {
|
||||
defaultBackgroundValue: true,
|
||||
disableScale: true,
|
||||
disableSelection: true,
|
||||
disableClipboard: true,
|
||||
disableSceneEmbed: true,
|
||||
hideTheme: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whiteboard default lower limit.
|
||||
*/
|
||||
export const MIN_USER_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* Whiteboard soft limit diff.
|
||||
*/
|
||||
export const USER_LIMIT_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* The pathName for the whiteboard page.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const WHITEBOARD_PATH_NAME = 'static/whiteboard.html';
|
||||
217
react/features/whiteboard/functions.ts
Normal file
217
react/features/whiteboard/functions.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import md5 from 'js-md5';
|
||||
|
||||
import { getParticipantCount, getPinnedParticipant } from '../../features/base/participants/functions';
|
||||
import { IReduxState } from '../app/types';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import { IWhiteboardConfig } from '../base/config/configType';
|
||||
import { getRemoteParticipants, isLocalParticipantModerator } from '../base/participants/functions';
|
||||
import { encodeToBase64URL } from '../base/util/httpUtils';
|
||||
import { appendURLHashParam, appendURLParam, getBackendSafePath } from '../base/util/uri';
|
||||
import { getCurrentRoomId, isInBreakoutRoom } from '../breakout-rooms/functions';
|
||||
|
||||
import { MIN_USER_LIMIT, USER_LIMIT_THRESHOLD, WHITEBOARD_ID, WHITEBOARD_PATH_NAME } from './constants';
|
||||
import { IWhiteboardState } from './reducer';
|
||||
|
||||
const getWhiteboardState = (state: IReduxState): IWhiteboardState => state['features/whiteboard'];
|
||||
|
||||
export const getWhiteboardConfig = (state: IReduxState): IWhiteboardConfig =>
|
||||
state['features/base/config'].whiteboard || {};
|
||||
|
||||
const getWhiteboardUserLimit = (state: IReduxState): number => {
|
||||
const userLimit = getWhiteboardConfig(state).userLimit || Infinity;
|
||||
|
||||
return userLimit === Infinity
|
||||
? userLimit
|
||||
: Math.max(Number(getWhiteboardConfig(state).userLimit || 1), MIN_USER_LIMIT);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the whiteboard collaboration details.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {{ roomId: string, roomKey: string}|undefined}
|
||||
*/
|
||||
export const getCollabDetails = (state: IReduxState): {
|
||||
roomId: string; roomKey: string;
|
||||
} | undefined => getWhiteboardState(state).collabDetails;
|
||||
|
||||
/**
|
||||
* Indicates whether the whiteboard collaboration details are available.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const hasCollabDetails = (state: IReduxState): boolean => Boolean(
|
||||
getCollabDetails(state)?.roomId && getCollabDetails(state)?.roomKey
|
||||
);
|
||||
|
||||
/**
|
||||
* Indicates whether the whiteboard is enabled.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWhiteboardEnabled = (state: IReduxState): boolean =>
|
||||
(getWhiteboardConfig(state).enabled ?? hasCollabDetails(state))
|
||||
&& getWhiteboardConfig(state).collabServerBaseUrl
|
||||
&& getCurrentConference(state)?.getMetadataHandler()
|
||||
?.isSupported();
|
||||
|
||||
/**
|
||||
* Indicates whether the whiteboard is open.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWhiteboardOpen = (state: IReduxState): boolean => getWhiteboardState(state).isOpen;
|
||||
|
||||
/**
|
||||
* Indicates whether the whiteboard button is visible.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWhiteboardButtonVisible = (state: IReduxState): boolean =>
|
||||
isWhiteboardEnabled(state) && (isLocalParticipantModerator(state) || isWhiteboardOpen(state));
|
||||
|
||||
/**
|
||||
* Indicates whether the whiteboard is present as a meeting participant.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWhiteboardPresent = (state: IReduxState): boolean => getRemoteParticipants(state).has(WHITEBOARD_ID);
|
||||
|
||||
/**
|
||||
* Builds the whiteboard collaboration server url.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const generateCollabServerUrl = (state: IReduxState): string | undefined => {
|
||||
const collabServerBaseUrl = getWhiteboardConfig(state).collabServerBaseUrl;
|
||||
|
||||
if (!collabServerBaseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
const inBreakoutRoom = isInBreakoutRoom(state);
|
||||
const roomId = getCurrentRoomId(state);
|
||||
const room = md5.hex(
|
||||
`${getBackendSafePath(locationURL?.pathname)}${inBreakoutRoom ? `|${roomId}` : ''}`
|
||||
);
|
||||
|
||||
return appendURLParam(collabServerBaseUrl, 'room', room);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the whiteboard collaboration server url.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getCollabServerUrl = (state: IReduxState): string | undefined =>
|
||||
getWhiteboardState(state).collabServerUrl;
|
||||
|
||||
/**
|
||||
* Whether the whiteboard is visible on stage.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWhiteboardVisible = (state: IReduxState): boolean =>
|
||||
getPinnedParticipant(state)?.id === WHITEBOARD_ID
|
||||
|| state['features/large-video'].participantId === WHITEBOARD_ID;
|
||||
|
||||
/**
|
||||
* Indicates whether the whiteboard is accessible to a participant that has a moderator role.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWhiteboardAllowed = (state: IReduxState): boolean =>
|
||||
isWhiteboardEnabled(state) && isLocalParticipantModerator(state);
|
||||
|
||||
/**
|
||||
* Whether to enforce the whiteboard user limit.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const shouldEnforceUserLimit = (state: IReduxState): boolean => {
|
||||
const userLimit = getWhiteboardUserLimit(state);
|
||||
|
||||
if (userLimit === Infinity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const participantCount = getParticipantCount(state);
|
||||
|
||||
return participantCount > userLimit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether to show a warning about the whiteboard user limit.
|
||||
*
|
||||
* @param {IReduxState} state - The state from the Redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const shouldNotifyUserLimit = (state: IReduxState): boolean => {
|
||||
const userLimit = getWhiteboardUserLimit(state);
|
||||
|
||||
if (userLimit === Infinity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const participantCount = getParticipantCount(state);
|
||||
|
||||
return participantCount + USER_LIMIT_THRESHOLD > userLimit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the URL for the static whiteboard page.
|
||||
*
|
||||
* @param {string} locationUrl - The window location href.
|
||||
* @param {string} collabServerUrl - The whiteboard collaboration server url.
|
||||
* @param {Object} collabDetails - The whiteboard collaboration details.
|
||||
* @param {string} localParticipantName - The local participant name.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getWhiteboardInfoForURIString(
|
||||
locationUrl: any,
|
||||
collabServerUrl: string,
|
||||
collabDetails: { roomId: string; roomKey: string; },
|
||||
localParticipantName: string
|
||||
): string | undefined {
|
||||
if (!collabServerUrl || !locationUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let state = {};
|
||||
let url = `${locationUrl.substring(0, locationUrl.lastIndexOf('/'))}/${WHITEBOARD_PATH_NAME}`;
|
||||
|
||||
if (collabDetails?.roomId) {
|
||||
state = {
|
||||
...state,
|
||||
roomId: collabDetails.roomId
|
||||
};
|
||||
}
|
||||
|
||||
if (collabDetails?.roomKey) {
|
||||
state = {
|
||||
...state,
|
||||
roomKey: collabDetails.roomKey
|
||||
};
|
||||
}
|
||||
|
||||
state = {
|
||||
...state,
|
||||
collabServerUrl,
|
||||
localParticipantName
|
||||
};
|
||||
|
||||
url = appendURLHashParam(url, 'state', encodeToBase64URL(JSON.stringify(state)));
|
||||
|
||||
return url;
|
||||
}
|
||||
23
react/features/whiteboard/hooks.ts
Normal file
23
react/features/whiteboard/hooks.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import WhiteboardButton from './components/web/WhiteboardButton';
|
||||
import { isWhiteboardButtonVisible } from './functions';
|
||||
|
||||
const whiteboard = {
|
||||
key: 'whiteboard',
|
||||
Content: WhiteboardButton,
|
||||
group: 3
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that returns the whiteboard button if it is enabled and undefined otherwise.
|
||||
*
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
export function useWhiteboardButton() {
|
||||
const _isWhiteboardButtonVisible = useSelector(isWhiteboardButtonVisible);
|
||||
|
||||
if (_isWhiteboardButtonVisible) {
|
||||
return whiteboard;
|
||||
}
|
||||
}
|
||||
3
react/features/whiteboard/logger.ts
Normal file
3
react/features/whiteboard/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/whiteboard');
|
||||
94
react/features/whiteboard/middleware.any.ts
Normal file
94
react/features/whiteboard/middleware.any.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createOpenWhiteboardEvent } from '../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../analytics/functions';
|
||||
import { IStore } from '../app/types';
|
||||
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
|
||||
import { SET_WHITEBOARD_OPEN } from './actionTypes';
|
||||
import {
|
||||
notifyWhiteboardLimit,
|
||||
resetWhiteboard,
|
||||
restrictWhiteboard,
|
||||
setWhiteboardOpen,
|
||||
setupWhiteboard
|
||||
} from './actions';
|
||||
import { WHITEBOARD_ID } from './constants';
|
||||
import {
|
||||
generateCollabServerUrl,
|
||||
isWhiteboardOpen,
|
||||
shouldEnforceUserLimit,
|
||||
shouldNotifyUserLimit
|
||||
} from './functions';
|
||||
|
||||
MiddlewareRegistry.register((store: IStore) => next => action => {
|
||||
const state = store.getState();
|
||||
|
||||
switch (action.type) {
|
||||
case SET_WHITEBOARD_OPEN: {
|
||||
const enforceUserLimit = shouldEnforceUserLimit(state);
|
||||
const notifyUserLimit = shouldNotifyUserLimit(state);
|
||||
|
||||
if (action.isOpen && !enforceUserLimit && !notifyUserLimit) {
|
||||
sendAnalytics(createOpenWhiteboardEvent());
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case UPDATE_CONFERENCE_METADATA: {
|
||||
const { metadata } = action;
|
||||
|
||||
if (metadata?.[WHITEBOARD_ID]) {
|
||||
store.dispatch(setupWhiteboard({
|
||||
collabDetails: metadata[WHITEBOARD_ID].collabDetails,
|
||||
collabServerUrl: generateCollabServerUrl(store.getState())
|
||||
}));
|
||||
store.dispatch(setWhiteboardOpen(true));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Set up state change listener to perform maintenance tasks when the conference
|
||||
* is left or failed, e.g. Disable the whiteboard if it's left open.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => getCurrentConference(state),
|
||||
(conference, { dispatch }, previousConference): void => {
|
||||
if (conference !== previousConference) {
|
||||
dispatch(resetWhiteboard());
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Set up state change listener to limit whiteboard access.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => shouldEnforceUserLimit(state),
|
||||
(enforceUserLimit, { dispatch, getState }): void => {
|
||||
if (isWhiteboardOpen(getState()) && enforceUserLimit) {
|
||||
dispatch(restrictWhiteboard());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Set up state change listener to notify about whiteboard usage.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => shouldNotifyUserLimit(state),
|
||||
(notifyUserLimit, { dispatch, getState }, prevNotifyUserLimit): void => {
|
||||
if (isWhiteboardOpen(getState()) && notifyUserLimit && !prevNotifyUserLimit) {
|
||||
dispatch(notifyWhiteboardLimit());
|
||||
}
|
||||
}
|
||||
);
|
||||
84
react/features/whiteboard/middleware.native.ts
Normal file
84
react/features/whiteboard/middleware.native.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
import { hideDialog, openDialog } from '../base/dialog/actions';
|
||||
import { isDialogOpen } from '../base/dialog/functions';
|
||||
import { getLocalParticipant } from '../base/participants/functions';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import {
|
||||
navigate
|
||||
} from '../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../mobile/navigation/routes';
|
||||
|
||||
import { SET_WHITEBOARD_OPEN } from './actionTypes';
|
||||
import {
|
||||
notifyWhiteboardLimit,
|
||||
restrictWhiteboard
|
||||
} from './actions';
|
||||
import WhiteboardLimitDialog from './components/native/WhiteboardLimitDialog';
|
||||
import {
|
||||
generateCollabServerUrl,
|
||||
getCollabDetails,
|
||||
shouldEnforceUserLimit,
|
||||
shouldNotifyUserLimit
|
||||
} from './functions';
|
||||
import './middleware.any';
|
||||
|
||||
/**
|
||||
* Middleware which intercepts whiteboard actions to handle changes to the related state.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyAction) => {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
|
||||
switch (action.type) {
|
||||
case SET_WHITEBOARD_OPEN: {
|
||||
const { isOpen } = action;
|
||||
|
||||
const enforceUserLimit = shouldEnforceUserLimit(state);
|
||||
const notifyUserLimit = shouldNotifyUserLimit(state);
|
||||
|
||||
if (enforceUserLimit) {
|
||||
dispatch(restrictWhiteboard(false));
|
||||
dispatch(openDialog(WhiteboardLimitDialog));
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
if (enforceUserLimit) {
|
||||
dispatch(restrictWhiteboard());
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (notifyUserLimit) {
|
||||
dispatch(notifyWhiteboardLimit());
|
||||
}
|
||||
|
||||
if (isDialogOpen(state, WhiteboardLimitDialog)) {
|
||||
dispatch(hideDialog(WhiteboardLimitDialog));
|
||||
}
|
||||
|
||||
const collabDetails = getCollabDetails(state);
|
||||
const collabServerUrl = generateCollabServerUrl(state);
|
||||
const localParticipantName = getLocalParticipant(state)?.name;
|
||||
|
||||
navigate(screen.conference.whiteboard, {
|
||||
collabDetails,
|
||||
collabServerUrl,
|
||||
localParticipantName
|
||||
});
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
162
react/features/whiteboard/middleware.web.ts
Normal file
162
react/features/whiteboard/middleware.web.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { generateCollaborationLinkData } from '@jitsi/excalidraw';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import { hideDialog, openDialog } from '../base/dialog/actions';
|
||||
import { isDialogOpen } from '../base/dialog/functions';
|
||||
import { participantJoined, participantLeft, pinParticipant } from '../base/participants/actions';
|
||||
import { FakeParticipant } from '../base/participants/types';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { getCurrentRoomId } from '../breakout-rooms/functions';
|
||||
import { addStageParticipant } from '../filmstrip/actions.web';
|
||||
import { isStageFilmstripAvailable } from '../filmstrip/functions.web';
|
||||
|
||||
import { RESET_WHITEBOARD, SET_WHITEBOARD_OPEN } from './actionTypes';
|
||||
import {
|
||||
notifyWhiteboardLimit,
|
||||
restrictWhiteboard,
|
||||
setupWhiteboard
|
||||
} from './actions';
|
||||
import WhiteboardLimitDialog from './components/web/WhiteboardLimitDialog';
|
||||
import { WHITEBOARD_ID, WHITEBOARD_PARTICIPANT_NAME } from './constants';
|
||||
import {
|
||||
generateCollabServerUrl,
|
||||
getCollabDetails,
|
||||
isWhiteboardPresent,
|
||||
shouldEnforceUserLimit,
|
||||
shouldNotifyUserLimit
|
||||
} from './functions';
|
||||
import { WhiteboardStatus } from './types';
|
||||
|
||||
import './middleware.any';
|
||||
|
||||
const focusWhiteboard = (store: IStore) => {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const conference = getCurrentConference(state);
|
||||
const stageFilmstrip = isStageFilmstripAvailable(state);
|
||||
const isPresent = isWhiteboardPresent(state);
|
||||
|
||||
if (!isPresent) {
|
||||
dispatch(participantJoined({
|
||||
conference,
|
||||
fakeParticipant: FakeParticipant.Whiteboard,
|
||||
id: WHITEBOARD_ID,
|
||||
name: WHITEBOARD_PARTICIPANT_NAME
|
||||
}));
|
||||
}
|
||||
if (stageFilmstrip) {
|
||||
dispatch(addStageParticipant(WHITEBOARD_ID, true));
|
||||
} else {
|
||||
dispatch(pinParticipant(WHITEBOARD_ID));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware which intercepts whiteboard actions to handle changes to the related state.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyAction) => {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
switch (action.type) {
|
||||
case SET_WHITEBOARD_OPEN: {
|
||||
const existingCollabDetails = getCollabDetails(state);
|
||||
const enforceUserLimit = shouldEnforceUserLimit(state);
|
||||
const notifyUserLimit = shouldNotifyUserLimit(state);
|
||||
const iAmRecorder = Boolean(state['features/base/config'].iAmRecorder);
|
||||
|
||||
if (enforceUserLimit) {
|
||||
dispatch(restrictWhiteboard(false));
|
||||
dispatch(openDialog(WhiteboardLimitDialog));
|
||||
iAmRecorder && setTimeout(() => dispatch(hideDialog(WhiteboardLimitDialog)), 3000);
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (!existingCollabDetails) {
|
||||
setNewWhiteboardOpen(store);
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (action.isOpen) {
|
||||
if (enforceUserLimit) {
|
||||
dispatch(restrictWhiteboard());
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (notifyUserLimit) {
|
||||
dispatch(notifyWhiteboardLimit());
|
||||
}
|
||||
|
||||
if (isDialogOpen(state, WhiteboardLimitDialog)) {
|
||||
dispatch(hideDialog(WhiteboardLimitDialog));
|
||||
}
|
||||
|
||||
focusWhiteboard(store);
|
||||
raiseWhiteboardNotification(WhiteboardStatus.SHOWN);
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
dispatch(participantLeft(WHITEBOARD_ID, conference, { fakeParticipant: FakeParticipant.Whiteboard }));
|
||||
raiseWhiteboardNotification(WhiteboardStatus.HIDDEN);
|
||||
|
||||
break;
|
||||
}
|
||||
case RESET_WHITEBOARD: {
|
||||
dispatch(participantLeft(WHITEBOARD_ID, conference, { fakeParticipant: FakeParticipant.Whiteboard }));
|
||||
raiseWhiteboardNotification(WhiteboardStatus.RESET);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Raises the whiteboard status notifications changes (if API is enabled).
|
||||
*
|
||||
* @param {WhiteboardStatus} status - The whiteboard changed status.
|
||||
* @returns {Function}
|
||||
*/
|
||||
function raiseWhiteboardNotification(status: WhiteboardStatus) {
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyWhiteboardStatusChanged(status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new whiteboard open.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function setNewWhiteboardOpen(store: IStore) {
|
||||
const { dispatch, getState } = store;
|
||||
const collabLinkData = await generateCollaborationLinkData();
|
||||
const state = getState();
|
||||
const conference = getCurrentConference(state);
|
||||
const collabServerUrl = generateCollabServerUrl(state);
|
||||
const roomId = getCurrentRoomId(state);
|
||||
const collabData = {
|
||||
collabDetails: {
|
||||
roomId,
|
||||
roomKey: collabLinkData.roomKey
|
||||
},
|
||||
collabServerUrl
|
||||
};
|
||||
|
||||
focusWhiteboard(store);
|
||||
dispatch(setupWhiteboard(collabData));
|
||||
conference?.getMetadataHandler().setMetadata(WHITEBOARD_ID, collabData);
|
||||
raiseWhiteboardNotification(WhiteboardStatus.INSTANTIATED);
|
||||
}
|
||||
66
react/features/whiteboard/reducer.ts
Normal file
66
react/features/whiteboard/reducer.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import { RESET_WHITEBOARD, SETUP_WHITEBOARD } from './actionTypes';
|
||||
|
||||
export interface IWhiteboardState {
|
||||
|
||||
/**
|
||||
* The whiteboard collaboration details.
|
||||
*/
|
||||
collabDetails?: { roomId: string; roomKey: string; };
|
||||
|
||||
/**
|
||||
* The whiteboard collaboration url.
|
||||
*/
|
||||
collabServerUrl?: string;
|
||||
|
||||
/**
|
||||
* The indicator which determines whether the whiteboard is open.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: IWhiteboardState = {
|
||||
isOpen: false,
|
||||
collabDetails: undefined,
|
||||
collabServerUrl: undefined
|
||||
};
|
||||
|
||||
export interface IWhiteboardAction extends Partial<IWhiteboardState> {
|
||||
|
||||
/**
|
||||
* The whiteboard collaboration details.
|
||||
*/
|
||||
collabDetails?: { roomId: string; roomKey: string; };
|
||||
|
||||
/**
|
||||
* The whiteboard collaboration url.
|
||||
*/
|
||||
collabServerUrl?: string;
|
||||
|
||||
/**
|
||||
* The action type.
|
||||
*/
|
||||
type: string;
|
||||
}
|
||||
|
||||
ReducerRegistry.register(
|
||||
'features/whiteboard',
|
||||
(state: IWhiteboardState = DEFAULT_STATE, action: IWhiteboardAction) => {
|
||||
switch (action.type) {
|
||||
case SETUP_WHITEBOARD: {
|
||||
return {
|
||||
...state,
|
||||
isOpen: true,
|
||||
collabDetails: action.collabDetails,
|
||||
collabServerUrl: action.collabServerUrl
|
||||
};
|
||||
}
|
||||
case RESET_WHITEBOARD:
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
13
react/features/whiteboard/types.ts
Normal file
13
react/features/whiteboard/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
/**
|
||||
* Whiteboard statuses used to raise the notification when it's changed.
|
||||
*
|
||||
* @enum
|
||||
*/
|
||||
export enum WhiteboardStatus {
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
HIDDEN = 'HIDDEN',
|
||||
INSTANTIATED = 'INSTANTIATED',
|
||||
RESET = 'RESET',
|
||||
SHOWN = 'SHOWN'
|
||||
}
|
||||
Reference in New Issue
Block a user