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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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