diff --git a/react/features/app/components/App.web.js b/react/features/app/components/App.web.js index 950ba7b97..4953fcf76 100644 --- a/react/features/app/components/App.web.js +++ b/react/features/app/components/App.web.js @@ -3,9 +3,9 @@ import { AtlasKitThemeProvider } from '@atlaskit/theme'; import React from 'react'; -import { DialogContainer } from '../../base/dialog'; import GlobalStyles from '../../base/ui/components/GlobalStyles'; import JitsiThemeProvider from '../../base/ui/components/JitsiThemeProvider.web'; +import DialogContainer from '../../base/ui/components/web/DialogContainer'; import { ChromeExtensionBanner } from '../../chrome-extension-banner'; import { AbstractApp } from './AbstractApp'; diff --git a/react/features/base/dialog/actions.js b/react/features/base/dialog/actions.ts similarity index 83% rename from react/features/base/dialog/actions.js rename to react/features/base/dialog/actions.ts index 8ec133466..d504342a9 100644 --- a/react/features/base/dialog/actions.js +++ b/react/features/base/dialog/actions.ts @@ -1,6 +1,6 @@ -// @flow +import { ComponentType } from 'react'; -import type { Dispatch } from 'redux'; +import { IStore } from '../../app/types'; import { HIDE_DIALOG, @@ -22,7 +22,7 @@ import { isDialogOpen } from './functions'; * component: (React.Component | undefined) * }} */ -export function hideDialog(component: ?Object) { +export function hideDialog(component?: ComponentType) { return { type: HIDE_DIALOG, component @@ -54,7 +54,7 @@ export function hideSheet() { * componentProps: (Object | undefined) * }} */ -export function openDialog(component: Object, componentProps: ?Object) { +export function openDialog(component: ComponentType, componentProps?: Object) { return { type: OPEN_DIALOG, component, @@ -74,7 +74,7 @@ export function openDialog(component: Object, componentProps: ?Object) { * componentProps: (Object | undefined) * }} */ -export function openSheet(component: Object, componentProps: ?Object) { +export function openSheet(component: ComponentType, componentProps?: Object) { return { type: OPEN_SHEET, component, @@ -92,8 +92,8 @@ export function openSheet(component: Object, componentProps: ?Object) { * specified {@code component}. * @returns {Function} */ -export function toggleDialog(component: Object, componentProps: ?Object) { - return (dispatch: Dispatch, getState: Function) => { +export function toggleDialog(component: ComponentType, componentProps?: Object) { + return (dispatch: IStore['dispatch'], getState: IStore['getState']) => { if (isDialogOpen(getState, component)) { dispatch(hideDialog(component)); } else { diff --git a/react/features/base/dialog/components/AbstractDialogContainer.js b/react/features/base/dialog/components/AbstractDialogContainer.ts similarity index 79% rename from react/features/base/dialog/components/AbstractDialogContainer.js rename to react/features/base/dialog/components/AbstractDialogContainer.ts index 7ed1ae228..278e78b9e 100644 --- a/react/features/base/dialog/components/AbstractDialogContainer.js +++ b/react/features/base/dialog/components/AbstractDialogContainer.ts @@ -1,34 +1,33 @@ -/* @flow */ +import React, { Component, ComponentType } from 'react'; -import React, { Component } from 'react'; - -import { type ReactionEmojiProps } from '../../../reactions/constants'; +import { IState } from '../../../app/types'; +import { ReactionEmojiProps } from '../../../reactions/constants'; /** * The type of the React {@code Component} props of {@link DialogContainer}. */ -type Props = { +interface Props { /** * The component to render. */ - _component: Function, + _component: ComponentType; /** * The props to pass to the component that will be rendered. */ - _componentProps: Object, - - /** - * True if the UI is in a compact state where we don't show dialogs. - */ - _reducedUI: boolean, + _componentProps: Object; /** * Array of reactions to be displayed. */ - _reactionsQueue: Array -}; + _reactionsQueue: Array; + + /** + * True if the UI is in a compact state where we don't show dialogs. + */ + _reducedUI: boolean; +} /** * Implements a DialogContainer responsible for showing all dialogs. @@ -61,7 +60,7 @@ export default class AbstractDialogContainer extends Component { * @private * @returns {Props} */ -export function abstractMapStateToProps(state: Object): $Shape { +export function abstractMapStateToProps(state: IState) { const stateFeaturesBaseDialog = state['features/base/dialog']; const { reducedUI } = state['features/base/responsive-ui']; diff --git a/react/features/base/dialog/components/web/Dialog.js b/react/features/base/dialog/components/web/Dialog-old.js similarity index 100% rename from react/features/base/dialog/components/web/Dialog.js rename to react/features/base/dialog/components/web/Dialog-old.js diff --git a/react/features/base/dialog/components/web/DialogContainer.js b/react/features/base/dialog/components/web/DialogContainer.js deleted file mode 100644 index c14ef1e6b..000000000 --- a/react/features/base/dialog/components/web/DialogContainer.js +++ /dev/null @@ -1,31 +0,0 @@ -import { ModalTransition } from '@atlaskit/modal-dialog'; -import React from 'react'; - -import { connect } from '../../../redux'; -import AbstractDialogContainer, { - abstractMapStateToProps -} from '../AbstractDialogContainer'; - -/** - * Implements a DialogContainer responsible for showing all dialogs. Necessary - * for supporting @atlaskit's modal animations. - * - * @augments AbstractDialogContainer - */ -class DialogContainer extends AbstractDialogContainer { - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - return ( - - { this._renderDialogContent() } - - ); - } -} - -export default connect(abstractMapStateToProps)(DialogContainer); diff --git a/react/features/base/dialog/components/web/index.js b/react/features/base/dialog/components/web/index.js index 8a0b716b3..5db9d1a44 100644 --- a/react/features/base/dialog/components/web/index.js +++ b/react/features/base/dialog/components/web/index.js @@ -2,7 +2,7 @@ export { default as AbstractDialogTab } from './AbstractDialogTab'; export type { Props as AbstractDialogTabProps } from './AbstractDialogTab'; -export { default as Dialog } from './Dialog'; -export { default as DialogContainer } from './DialogContainer'; +export { default as Dialog } from './Dialog-old'; export { default as DialogWithTabs } from './DialogWithTabs'; export { default as StatelessDialog } from './StatelessDialog'; +export { default as DialogContainer } from '../../../ui/components/web/DialogContainer'; diff --git a/react/features/base/dialog/functions.js b/react/features/base/dialog/functions.ts similarity index 59% rename from react/features/base/dialog/functions.js rename to react/features/base/dialog/functions.ts index dec602008..7fe1482b9 100644 --- a/react/features/base/dialog/functions.js +++ b/react/features/base/dialog/functions.ts @@ -1,16 +1,20 @@ -/* @flow */ +import { ComponentType } from 'react'; +import { IState } from '../../app/types'; +import { IStateful } from '../app/types'; +// eslint-disable-next-line lines-around-comment +// @ts-ignore import { ColorSchemeRegistry } from '../color-scheme'; -import { toState } from '../redux'; +import { toState } from '../redux/functions'; /** * Checks if any {@code Dialog} is currently open. * - * @param {Function|Object} stateful - The redux store, the redux + * @param {IStateful} stateful - The redux store, the redux * {@code getState} function, or the redux state itself. * @returns {boolean} */ -export function isAnyDialogOpen(stateful: Function) { +export function isAnyDialogOpen(stateful: IStateful) { return Boolean(toState(stateful)['features/base/dialog'].component); } @@ -18,25 +22,25 @@ export function isAnyDialogOpen(stateful: Function) { * Checks if a {@code Dialog} with a specific {@code component} is currently * open. * - * @param {Function|Object} stateful - The redux store, the redux + * @param {IStateful} stateful - The redux store, the redux * {@code getState} function, or the redux state itself. * @param {React.Component} component - The {@code component} of a * {@code Dialog} to be checked. * @returns {boolean} */ -export function isDialogOpen(stateful: Function | Object, component: Object) { +export function isDialogOpen(stateful: IStateful, component: ComponentType) { return toState(stateful)['features/base/dialog'].component === component; } /** * Maps part of the Redux state to the props of any Dialog based component. * - * @param {Object} state - The Redux state. + * @param {IState} state - The Redux state. * @returns {{ * _dialogStyles: StyleType * }} */ -export function _abstractMapStateToProps(state: Object): Object { +export function _abstractMapStateToProps(state: IState) { return { _dialogStyles: ColorSchemeRegistry.get(state, 'Dialog') }; diff --git a/react/features/base/ui/components/web/ClickableIcon.tsx b/react/features/base/ui/components/web/ClickableIcon.tsx index 9c43a74f5..8dde79d1c 100644 --- a/react/features/base/ui/components/web/ClickableIcon.tsx +++ b/react/features/base/ui/components/web/ClickableIcon.tsx @@ -24,6 +24,11 @@ const useStyles = makeStyles()((theme: Theme) => { backgroundColor: theme.palette.ui02 }, + '&:focus': { + outline: 0, + boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}` + }, + '&:active': { backgroundColor: theme.palette.ui03 }, diff --git a/react/features/base/ui/components/web/Dialog.tsx b/react/features/base/ui/components/web/Dialog.tsx new file mode 100644 index 000000000..46adcfa74 --- /dev/null +++ b/react/features/base/ui/components/web/Dialog.tsx @@ -0,0 +1,250 @@ +import { Theme } from '@mui/material'; +import React, { ReactElement, useCallback, useContext, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { keyframes } from 'tss-react'; +import { makeStyles } from 'tss-react/mui'; + +import { IconClose } from '../../../icons/svg'; +import { withPixelLineHeight } from '../../../styles/functions.web'; + +import Button from './Button'; +import ClickableIcon from './ClickableIcon'; +import { DialogTransitionContext } from './DialogTransition'; + + +const useStyles = makeStyles()((theme: Theme) => { + return { + container: { + width: '100%', + height: '100%', + position: 'fixed', + top: 0, + left: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'flex-start', + animation: `${keyframes` + 0% { + opacity: 0.4; + } + 100% { + opacity: 1; + } + `} 0.2s forwards ease-out`, + + '&.unmount': { + animation: `${keyframes` + 0% { + opacity: 1; + } + 100% { + opacity: 0.5; + } + `} 0.15s forwards ease-in` + } + }, + + backdrop: { + position: 'absolute', + width: '100%', + height: '100%', + top: 0, + left: 0, + backgroundColor: theme.palette.ui02, + opacity: 0.75 + }, + + modal: { + zIndex: 1, + backgroundColor: theme.palette.ui01, + border: `1px solid ${theme.palette.ui03}`, + boxShadow: '0px 4px 25px 4px rgba(20, 20, 20, 0.6)', + borderRadius: `${theme.shape.borderRadius}px`, + display: 'flex', + flexDirection: 'column', + height: 'auto', + minHeight: '200px', + maxHeight: '560px', + marginTop: '64px', + animation: `${keyframes` + 0% { + margin-top: 85px + } + 100% { + margin-top: 64px + } + `} 0.2s forwards ease-out`, + + '&.medium': { + width: '400px' + }, + + '&.large': { + width: '664px' + }, + + '&.unmount': { + animation: `${keyframes` + 0% { + margin-top: 64px + } + 100% { + margin-top: 40px + } + `} 0.15s forwards ease-in` + }, + + '@media (max-width: 448px)': { + width: '100% !important', + maxHeight: 'initial', + height: '100%', + margin: 0, + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + animation: `${keyframes` + 0% { + margin-top: 15px + } + 100% { + margin-top: 0 + } + `} 0.2s forwards ease-out`, + + '&.unmount': { + animation: `${keyframes` + 0% { + margin-top: 0 + } + 100% { + margin-top: 15px + } + `} 0.15s forwards ease-in` + } + } + }, + + header: { + width: '100%', + padding: '24px', + boxSizing: 'border-box', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between' + }, + + title: { + color: theme.palette.text01, + ...withPixelLineHeight(theme.typography.heading5), + margin: 0, + padding: 0 + }, + + content: { + height: 'auto', + overflowY: 'auto', + width: '100%', + boxSizing: 'border-box', + padding: '0 24px', + overflowX: 'hidden', + + '@media (max-width: 448px)': { + height: '100%' + } + }, + + footer: { + width: '100%', + boxSizing: 'border-box', + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + padding: '24px', + + '& button:last-child': { + marginLeft: '16px' + } + } + }; +}); + +interface DialogProps { + cancelKey?: string; + children?: ReactElement | ReactElement[]; + description?: string; + ok?: { + disabled?: boolean; + key: string; + }; + onCancel: () => void; + onSubmit?: () => void; + size?: 'large' | 'medium'; + title?: string; + titleKey?: string; +} + +const Dialog = ({ + title, + titleKey, + description, + size = 'medium', + onCancel, + children, + ok, + cancelKey, + onSubmit +}: DialogProps) => { + const { classes, cx } = useStyles(); + const { t } = useTranslation(); + const { isUnmounting } = useContext(DialogTransitionContext); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel(); + } + }, []); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + return ( +
+
+
+
+

{title ?? t(titleKey ?? '')}

+ +
+
{children}
+
+ {cancelKey &&
+
+
+ ); +}; + +export default Dialog; diff --git a/react/features/base/ui/components/web/DialogContainer.tsx b/react/features/base/ui/components/web/DialogContainer.tsx new file mode 100644 index 000000000..adbb48b1a --- /dev/null +++ b/react/features/base/ui/components/web/DialogContainer.tsx @@ -0,0 +1,138 @@ +import { ModalTransition } from '@atlaskit/modal-dialog'; +import React, { Component, ComponentType } from 'react'; + +import { IState } from '../../../../app/types'; +import KeyboardShortcutsDialog from '../../../../keyboard-shortcuts/components/web/KeyboardShortcutsDialog'; +import { ReactionEmojiProps } from '../../../../reactions/constants'; +import { connect } from '../../../redux/functions'; + +import DialogTransition from './DialogTransition'; + +interface Props { + + /** + * The component to render. + */ + _component: ComponentType; + + /** + * The props to pass to the component that will be rendered. + */ + _componentProps: Object; + + /** + * Array of reactions to be displayed. + */ + _reactionsQueue: Array; + + /** + * True if the UI is in a compact state where we don't show dialogs. + */ + _reducedUI: boolean; +} + +// This function is necessary while the transition from @atlaskit dialog to our component is ongoing. +const isNewDialog = (component: any) => { + const list = [ KeyboardShortcutsDialog ]; + + return Boolean(list.find(comp => comp === component)); +}; + +// Needed for the transition to our component. +type State = { + isNewDialog: boolean; +}; + +/** + * Implements a DialogContainer responsible for showing all dialogs. Necessary + * for supporting @atlaskit's modal animations. + * + */ +class DialogContainer extends Component { + + /** + * Initializes a new {@code DialogContainer} instance. + * + * @param {Props} props - The React {@code Component} props to initialize + * the new {@code DialogContainer} instance with. + */ + constructor(props: Props) { + super(props); + this.state = { + isNewDialog: false + }; + } + + /** + * Check which Dialog container to render. + * Needed during transition from atlaskit. + * + * @inheritdoc + * @returns {void} + */ + componentDidUpdate(prevProps: Props) { + if (this.props._component && prevProps._component !== this.props._component) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + isNewDialog: isNewDialog(this.props._component) + }); + } + } + + /** + * Returns the dialog to be displayed. + * + * @private + * @returns {ReactElement|null} + */ + _renderDialogContent() { + const { + _component: component, + _reducedUI: reducedUI + } = this.props; + + return ( + component && !reducedUI + ? React.createElement(component, this.props._componentProps) + : null); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return this.state.isNewDialog ? ( + + {this._renderDialogContent()} + + ) : ( + + { this._renderDialogContent() } + + ); + } +} + +/** + * Maps (parts of) the redux state to the associated + * {@code AbstractDialogContainer}'s props. + * + * @param {Object} state - The redux state. + * @private + * @returns {Props} + */ +function mapStateToProps(state: IState) { + const stateFeaturesBaseDialog = state['features/base/dialog']; + const { reducedUI } = state['features/base/responsive-ui']; + + return { + _component: stateFeaturesBaseDialog.component, + _componentProps: stateFeaturesBaseDialog.componentProps, + _reducedUI: reducedUI + }; +} + +export default connect(mapStateToProps)(DialogContainer); diff --git a/react/features/base/ui/components/web/DialogTransition.tsx b/react/features/base/ui/components/web/DialogTransition.tsx new file mode 100644 index 000000000..408a0d3a7 --- /dev/null +++ b/react/features/base/ui/components/web/DialogTransition.tsx @@ -0,0 +1,28 @@ +import React, { ReactElement, useEffect, useState } from 'react'; + +export const DialogTransitionContext = React.createContext({ isUnmounting: false }); + +const DialogTransition = ({ children }: { children: ReactElement | null; }) => { + const [ childrenToRender, setChildrenToRender ] = useState(children); + const [ isUnmounting, setIsUnmounting ] = useState(false); + + useEffect(() => { + if (children === null) { + setIsUnmounting(true); + setTimeout(() => { + setChildrenToRender(children); + setIsUnmounting(false); + }, 150); + } else { + setChildrenToRender(children); + } + }, [ children ]); + + return ( + + {childrenToRender} + + ); +}; + +export default DialogTransition; diff --git a/react/features/keyboard-shortcuts/components/web/KeyboardShortcutsDialog.tsx b/react/features/keyboard-shortcuts/components/web/KeyboardShortcutsDialog.tsx index 458392fb6..55ed3da83 100644 --- a/react/features/keyboard-shortcuts/components/web/KeyboardShortcutsDialog.tsx +++ b/react/features/keyboard-shortcuts/components/web/KeyboardShortcutsDialog.tsx @@ -3,10 +3,12 @@ import { withStyles } from '@mui/styles'; import clsx from 'clsx'; import React, { Component } from 'react'; import { WithTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; -// @ts-ignore -import { Dialog } from '../../../base/dialog'; +import { IStore } from '../../../app/types'; +import { hideDialog } from '../../../base/dialog/actions'; import { translate } from '../../../base/i18n/functions'; +import Dialog from '../../../base/ui/components/web/Dialog'; /** * The type of the React {@code Component} props of @@ -14,6 +16,11 @@ import { translate } from '../../../base/i18n/functions'; */ interface Props extends WithTranslation { + /** + * Dispatches close dialog. + */ + _onCloseDialog: () => void; + /** * An object containing the CSS classes. */ @@ -73,10 +80,8 @@ class KeyboardShortcutsDialog extends Component { return ( + onCancel = { this.props._onCloseDialog } + titleKey = 'keyboardShortcuts.keyboardShortcuts'>
    { } } -export default translate(withStyles(styles)(KeyboardShortcutsDialog)); +/** + * Function that maps parts of Redux actions into component props. + * + * @param {Object} dispatch - Redux dispatch. + * @returns {Object} + */ +function mapDispatchToProps(dispatch: IStore['dispatch']) { + return { + _onCloseDialog: () => dispatch(hideDialog()) + }; +} + +export default connect(null, mapDispatchToProps)(translate(withStyles(styles)(KeyboardShortcutsDialog)));