diff --git a/lang/main.json b/lang/main.json index 1463454cd..2a220bc34 100644 --- a/lang/main.json +++ b/lang/main.json @@ -275,6 +275,9 @@ "dialOut": { "statusMessage": "is now {{status}}" }, + "documentSharing" : { + "title": "Shared Document" + }, "feedback": { "average": "Average", "bad": "Bad", diff --git a/react/features/conference/components/native/Conference.js b/react/features/conference/components/native/Conference.js index bb656f3c5..9efccc3af 100644 --- a/react/features/conference/components/native/Conference.js +++ b/react/features/conference/components/native/Conference.js @@ -16,6 +16,7 @@ import { TestConnectionInfo } from '../../../base/testing'; import { ConferenceNotification, isCalendarEnabled } from '../../../calendar-sync'; import { Chat } from '../../../chat'; import { DisplayNameLabel } from '../../../display-name'; +import { SharedDocument } from '../../../etherpad'; import { FILMSTRIP_SIZE, Filmstrip, @@ -179,8 +180,9 @@ class Conference extends AbstractConference { hidden = { true } translucent = { true } /> - + + {/* * The LargeVideo is the lowermost stacking layer. diff --git a/react/features/etherpad/actionTypes.js b/react/features/etherpad/actionTypes.js index 4b850c058..1ff61bc1c 100644 --- a/react/features/etherpad/actionTypes.js +++ b/react/features/etherpad/actionTypes.js @@ -15,8 +15,16 @@ export const ETHERPAD_INITIALIZED = 'ETHERPAD_INITIALIZED'; * type: SET_DOCUMENT_EDITING_STATUS * } */ -export const SET_DOCUMENT_EDITING_STATUS - = 'SET_DOCUMENT_EDITING_STATUS'; +export const SET_DOCUMENT_EDITING_STATUS = 'SET_DOCUMENT_EDITING_STATUS'; + +/** + * The type of the action which updates the shared document URL. + * + * { + * type: SET_DOCUMENT_URL + * } + */ +export const SET_DOCUMENT_URL = 'SET_DOCUMENT_URL'; /** * The type of the action which signals to start or stop editing a shared diff --git a/react/features/etherpad/actions.js b/react/features/etherpad/actions.js index 1b3aebc71..189b49b0c 100644 --- a/react/features/etherpad/actions.js +++ b/react/features/etherpad/actions.js @@ -3,6 +3,7 @@ import { ETHERPAD_INITIALIZED, SET_DOCUMENT_EDITING_STATUS, + SET_DOCUMENT_URL, TOGGLE_DOCUMENT_EDITING } from './actionTypes'; @@ -23,6 +24,22 @@ export function setDocumentEditingState(editing: boolean) { }; } +/** + * Dispatches an action to set the shared document URL. + * + * @param {string} documentUrl - The shared document URL. + * @returns {{ + * type: SET_DOCUMENT_URL, + * documentUrl: string + * }} + */ +export function setDocumentUrl(documentUrl: ?string) { + return { + type: SET_DOCUMENT_URL, + documentUrl + }; +} + /** * Dispatches an action to set Etherpad as having been initialized. * diff --git a/react/features/etherpad/components/SharedDocumentButton.js b/react/features/etherpad/components/SharedDocumentButton.js new file mode 100644 index 000000000..77ea18135 --- /dev/null +++ b/react/features/etherpad/components/SharedDocumentButton.js @@ -0,0 +1,81 @@ +// @flow + +import type { Dispatch } from 'redux'; + +import { createToolbarEvent, sendAnalytics } from '../../analytics'; +import { translate } from '../../base/i18n'; +import { IconShareDoc } from '../../base/icons'; +import { connect } from '../../base/redux'; +import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox'; + +import { toggleDocument } from '../actions'; + + +type Props = AbstractButtonProps & { + + /** + * Whether the shared document is being edited or not. + */ + _editing: boolean, + + /** + * Redux dispatch function. + */ + dispatch: Dispatch, +}; + +/** + * Implements an {@link AbstractButton} to open the chat screen on mobile. + */ +class SharedDocumentButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.document'; + icon = IconShareDoc; + label = 'toolbar.documentOpen'; + toggledLabel = 'toolbar.documentClose'; + + /** + * Handles clicking / pressing the button, and opens / closes the appropriate dialog. + * + * @private + * @returns {void} + */ + _handleClick() { + sendAnalytics(createToolbarEvent( + 'toggle.etherpad', + { + enable: !this.props._editing + })); + this.props.dispatch(toggleDocument()); + } + + /** + * Indicates whether this button is in toggled state or not. + * + * @override + * @protected + * @returns {boolean} + */ + _isToggled() { + return this.props._editing; + } +} + +/** + * Maps part of the redux state to the component's props. + * + * @param {Object} state - The redux store/state. + * @param {Object} ownProps - The properties explicitly passed to the component + * instance. + * @returns {Object} + */ +function _mapStateToProps(state: Object, ownProps: Object) { + const { documentUrl, editing } = state['features/etherpad']; + const { visible = Boolean(documentUrl) } = ownProps; + + return { + _editing: editing, + visible + }; +} + +export default translate(connect(_mapStateToProps)(SharedDocumentButton)); diff --git a/react/features/etherpad/components/index.native.js b/react/features/etherpad/components/index.native.js new file mode 100644 index 000000000..ce60f8cef --- /dev/null +++ b/react/features/etherpad/components/index.native.js @@ -0,0 +1,2 @@ +export { default as SharedDocument } from './native/SharedDocument'; +export { default as SharedDocumentButton } from './SharedDocumentButton'; diff --git a/react/features/etherpad/components/index.web.js b/react/features/etherpad/components/index.web.js new file mode 100644 index 000000000..81707fd8b --- /dev/null +++ b/react/features/etherpad/components/index.web.js @@ -0,0 +1 @@ +export { default as SharedDocumentButton } from './SharedDocumentButton'; diff --git a/react/features/etherpad/components/native/SharedDocument.js b/react/features/etherpad/components/native/SharedDocument.js new file mode 100644 index 000000000..f5c91b378 --- /dev/null +++ b/react/features/etherpad/components/native/SharedDocument.js @@ -0,0 +1,178 @@ +// @flow + +import React, { PureComponent } from 'react'; +import { SafeAreaView, View } from 'react-native'; +import { WebView } from 'react-native-webview'; +import type { Dispatch } from 'redux'; + +import { ColorSchemeRegistry } from '../../../base/color-scheme'; +import { translate } from '../../../base/i18n'; +import { HeaderWithNavigation, LoadingIndicator, SlidingView } from '../../../base/react'; +import { connect } from '../../../base/redux'; + +import { toggleDocument } from '../../actions'; +import { getSharedDocumentUrl } from '../../functions'; + +import styles, { INDICATOR_COLOR } from './styles'; + +/** + * The type of the React {@code Component} props of {@code ShareDocument}. + */ +type Props = { + + /** + * URL for the shared document. + */ + _documentUrl: string, + + /** + * Color schemed style of the header component. + */ + _headerStyles: Object, + + /** + * True if the chat window should be rendered. + */ + _isOpen: boolean, + + /** + * The Redux dispatch function. + */ + dispatch: Dispatch, + + /** + * Function to be used to translate i18n labels. + */ + t: Function +}; + +/** + * Implements a React native component that renders the shared document window. + */ +class SharedDocument extends PureComponent { + /** + * Instantiates a new instance. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._onClose = this._onClose.bind(this); + this._onError = this._onError.bind(this); + this._renderLoading = this._renderLoading.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + */ + render() { + const { _documentUrl, _isOpen } = this.props; + const webViewStyles = this._getWebViewStyles(); + + return ( + + + + + + + + + ); + } + + /** + * Computes the styles required for the WebView component. + * + * @returns {Object} + */ + _getWebViewStyles() { + return { + ...styles.webView, + backgroundColor: this.props._headerStyles.screenHeader.backgroundColor + }; + } + + _onClose: () => boolean + + /** + * Closes the window. + * + * @returns {boolean} + */ + _onClose() { + const { _isOpen, dispatch } = this.props; + + if (_isOpen) { + dispatch(toggleDocument()); + + return true; + } + + return false; + } + + _onError: () => void; + + /** + * Callback to handle the error if the page fails to load. + * + * @returns {void} + */ + _onError() { + const { _isOpen, dispatch } = this.props; + + if (_isOpen) { + dispatch(toggleDocument()); + } + } + + _renderLoading: () => React$Component; + + /** + * Renders the loading indicator. + * + * @returns {React$Component} + */ + _renderLoading() { + return ( + + + + ); + } +} + +/** + * Maps (parts of) the redux state to {@link SharedDocument} React {@code Component} props. + * + * @param {Object} state - The redux store/state. + * @private + * @returns {Object} + */ +export function _mapStateToProps(state: Object) { + const { editing } = state['features/etherpad']; + const documentUrl = getSharedDocumentUrl(state); + + return { + _documentUrl: documentUrl, + _headerStyles: ColorSchemeRegistry.get(state, 'Header'), + _isOpen: editing + }; +} + +export default translate(connect(_mapStateToProps)(SharedDocument)); diff --git a/react/features/etherpad/components/native/styles.js b/react/features/etherpad/components/native/styles.js new file mode 100644 index 000000000..15a4f1d88 --- /dev/null +++ b/react/features/etherpad/components/native/styles.js @@ -0,0 +1,24 @@ +// @flow + +import { ColorPalette } from '../../../base/styles'; + +export const INDICATOR_COLOR = ColorPalette.lightGrey; + +export default { + + indicatorWrapper: { + alignItems: 'center', + backgroundColor: ColorPalette.white, + height: '100%', + justifyContent: 'center' + }, + + webView: { + flex: 1 + }, + + webViewWrapper: { + flex: 1, + flexDirection: 'column' + } +}; diff --git a/react/features/etherpad/functions.js b/react/features/etherpad/functions.js new file mode 100644 index 000000000..a9112db71 --- /dev/null +++ b/react/features/etherpad/functions.js @@ -0,0 +1,34 @@ +// @flow + +import { toState } from '../base/redux'; + +const ETHERPAD_OPTIONS = { + showControls: 'true', + showChat: 'false', + showLineNumbers: 'true', + useMonospaceFont: 'false' +}; + +/** + * Retrieves the current sahred document URL. + * + * @param {Function|Object} stateful - The redux store or {@code getState} function. + * @returns {?string} - Current shared document URL or undefined. + */ +export function getSharedDocumentUrl(stateful: Function | Object) { + const state = toState(stateful); + const { documentUrl } = state['features/etherpad']; + const { displayName } = state['features/base/settings']; + + if (!documentUrl) { + return undefined; + } + + const params = new URLSearchParams(ETHERPAD_OPTIONS); + + if (displayName) { + params.append('userName', displayName); + } + + return `${documentUrl}?${params.toString()}`; +} diff --git a/react/features/etherpad/index.js b/react/features/etherpad/index.js index f3a2aac65..44000a1a7 100644 --- a/react/features/etherpad/index.js +++ b/react/features/etherpad/index.js @@ -1,5 +1,7 @@ export * from './actions'; export * from './actionTypes'; +export * from './components'; +export * from './functions'; import './middleware'; import './reducer'; diff --git a/react/features/etherpad/middleware.js b/react/features/etherpad/middleware.js index c847c7ae1..12fcb2e83 100644 --- a/react/features/etherpad/middleware.js +++ b/react/features/etherpad/middleware.js @@ -1,12 +1,16 @@ // @flow -import { MiddlewareRegistry } from '../base/redux'; +import { getCurrentConference } from '../base/conference'; +import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import UIEvents from '../../../service/UI/UIEvents'; import { TOGGLE_DOCUMENT_EDITING } from './actionTypes'; +import { setDocumentEditingState, setDocumentUrl } from './actions'; declare var APP: Object; +const ETHERPAD_COMMAND = 'etherpad'; + /** * Middleware that captures actions related to collaborative document editing * and notifies components not hooked into redux. @@ -15,16 +19,49 @@ declare var APP: Object; * @returns {Function} */ // eslint-disable-next-line no-unused-vars -MiddlewareRegistry.register(store => next => action => { - if (typeof APP === 'undefined') { - return next(action); - } - +MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { switch (action.type) { - case TOGGLE_DOCUMENT_EDITING: - APP.UI.emitEvent(UIEvents.ETHERPAD_CLICKED); + case TOGGLE_DOCUMENT_EDITING: { + if (typeof APP === 'undefined') { + const { editing } = getState()['features/etherpad']; + + dispatch(setDocumentEditingState(!editing)); + } else { + APP.UI.emitEvent(UIEvents.ETHERPAD_CLICKED); + } break; } + } return next(action); }); + +/** + * Set up state change listener to perform maintenance tasks when the conference + * is left or failed, e.g. clear messages or close the chat modal if it's left + * open. + */ +StateListenerRegistry.register( + state => getCurrentConference(state), + (conference, { dispatch, getState }, previousConference) => { + if (conference) { + conference.addCommandListener(ETHERPAD_COMMAND, + ({ value }) => { + let url; + const { etherpad_base: etherpadBase } = getState()['features/base/config']; + + if (etherpadBase) { + const u = new URL(value, etherpadBase); + + url = u.toString(); + } + + dispatch(setDocumentUrl(url)); + } + ); + } + + if (previousConference) { + dispatch(setDocumentUrl(undefined)); + } + }); diff --git a/react/features/etherpad/reducer.js b/react/features/etherpad/reducer.js index 61590cd0b..ff7671397 100644 --- a/react/features/etherpad/reducer.js +++ b/react/features/etherpad/reducer.js @@ -4,11 +4,17 @@ import { ReducerRegistry } from '../base/redux'; import { ETHERPAD_INITIALIZED, - SET_DOCUMENT_EDITING_STATUS + SET_DOCUMENT_EDITING_STATUS, + SET_DOCUMENT_URL } from './actionTypes'; const DEFAULT_STATE = { + /** + * URL for the shared document. + */ + documentUrl: undefined, + /** * Whether or not Etherpad is currently open. * @@ -45,6 +51,12 @@ ReducerRegistry.register( editing: action.editing }; + case SET_DOCUMENT_URL: + return { + ...state, + documentUrl: action.documentUrl + }; + default: return state; } diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index 1ededee0b..51391b493 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -8,6 +8,7 @@ import { BottomSheet, hideDialog, isDialogOpen } from '../../../base/dialog'; import { CHAT_ENABLED, IOS_RECORDING_ENABLED, getFeatureFlag } from '../../../base/flags'; import { connect } from '../../../base/redux'; import { StyleType } from '../../../base/styles'; +import { SharedDocumentButton } from '../../../etherpad'; import { InfoDialogButton, InviteButton } from '../../../invite'; import { AudioRouteButton } from '../../../mobile/audio-mode'; import { LiveStreamButton, RecordButton } from '../../../recording'; @@ -108,6 +109,7 @@ class OverflowMenu extends Component { && } + ); }