diff --git a/lang/main.json b/lang/main.json index 888b30759..9585e5c16 100644 --- a/lang/main.json +++ b/lang/main.json @@ -1194,7 +1194,7 @@ "silence": "Silence", "speakerStats": "Speaker stats", "startScreenSharing": "Start screen sharing", - "startSubtitles": "Start subtitles", + "startSubtitles": "Subtitles • {{language}}", "stopAudioSharing": "Stop audio sharing", "stopScreenSharing": "Stop screen sharing", "stopSharedVideo": "Stop video", @@ -1217,6 +1217,8 @@ "pending": "Preparing to transcribe the meeting...", "start": "Start showing subtitles", "stop": "Stop showing subtitles", + "subtitles": "Subtitles", + "subtitlesOff": "Off", "tr": "TR" }, "userMedia": { diff --git a/react/features/base/i18n/i18next.ts b/react/features/base/i18n/i18next.ts index a0dc35540..701f2fc56 100644 --- a/react/features/base/i18n/i18next.ts +++ b/react/features/base/i18n/i18next.ts @@ -36,6 +36,23 @@ const COUNTRIES = _.merge({}, COUNTRIES_RESOURCES, COUNTRIES_RESOURCES_OVERRIDES */ export const LANGUAGES: Array = Object.keys(LANGUAGES_RESOURCES); +/** + * The languages for the top section of the translation language list. + * + * @public + * @type {Array} + */ +export const TRANSLATION_LANGUAGES_HEAD: Array = [ 'en' ]; + +/** + * The languages to explude from the translation language list. + * + * @public + * @type {Array} + */ +export const TRANSLATION_LANGUAGES_EXCLUDE: Array += [ 'enGB', 'esUS', 'frCA', 'hsb', 'kab', 'ptBR', 'zhCN', 'zhTW' ]; + /** * The default language. * diff --git a/react/features/base/i18n/index.js b/react/features/base/i18n/index.js index 41ecb0cd8..668a1a35e 100644 --- a/react/features/base/i18n/index.js +++ b/react/features/base/i18n/index.js @@ -3,4 +3,5 @@ export * from './functions'; // TODO Eventually (e.g. when the non-React Web app is rewritten into React), it // should not be necessary to export i18next. -export { default as i18next, DEFAULT_LANGUAGE, LANGUAGES } from './i18next'; +export { default as i18next, DEFAULT_LANGUAGE, + LANGUAGES, TRANSLATION_LANGUAGES_HEAD, TRANSLATION_LANGUAGES_EXCLUDE } from './i18next'; diff --git a/react/features/base/toolbox/components/AbstractButton.js b/react/features/base/toolbox/components/AbstractButton.js index 6b40a08a2..b82618490 100644 --- a/react/features/base/toolbox/components/AbstractButton.js +++ b/react/features/base/toolbox/components/AbstractButton.js @@ -112,6 +112,8 @@ export default class AbstractButton extends Component { */ accessibilityLabel: string; + labelProps: Object; + /** * The icon of this button. * @@ -326,6 +328,7 @@ export default class AbstractButton extends Component { elementAfter: this._getElementAfter(), icon: this._getIcon(), label: this._getLabel(), + labelProps: this.labelProps, styles: this._getStyles(), toggled: this._isToggled(), tooltip: this._getTooltip() diff --git a/react/features/base/toolbox/components/AbstractToolboxItem.js b/react/features/base/toolbox/components/AbstractToolboxItem.js index 2f5034d39..13f41dfde 100644 --- a/react/features/base/toolbox/components/AbstractToolboxItem.js +++ b/react/features/base/toolbox/components/AbstractToolboxItem.js @@ -148,7 +148,7 @@ export default class AbstractToolboxItem

extends Component

{ * @returns {?string} */ get label(): ?string { - return this._maybeTranslateAttribute(this.props.label); + return this._maybeTranslateAttribute(this.props.label, this.props.labelProps); } /** @@ -178,12 +178,18 @@ export default class AbstractToolboxItem

extends Component

{ * function is available. * * @param {string} text - What needs translating. + * @param {string} textProps - Additional properties for translation text. * @private * @returns {string} */ - _maybeTranslateAttribute(text) { + _maybeTranslateAttribute(text, textProps) { const { t } = this.props; + if (textProps) { + + return typeof t === 'function' ? t(text, textProps) : `${text} ${textProps}`; + } + return typeof t === 'function' ? t(text) : text; } diff --git a/react/features/subtitles/actionTypes.ts b/react/features/subtitles/actionTypes.ts index 346c3ffff..2ec28ee01 100644 --- a/react/features/subtitles/actionTypes.ts +++ b/react/features/subtitles/actionTypes.ts @@ -34,6 +34,18 @@ export const REMOVE_TRANSCRIPT_MESSAGE = 'REMOVE_TRANSCRIPT_MESSAGE'; */ export const UPDATE_TRANSCRIPT_MESSAGE = 'UPDATE_TRANSCRIPT_MESSAGE'; +/** + * The type of (redux) action which indicates that a transcript with an + * given message_id to be added or updated is received. + * + * { + * type: UPDATE_TRANSLATION_LANGUAGE, + * transcriptMessageID: string, + * newTranscriptMessage: Object + * } + */ +export const UPDATE_TRANSLATION_LANGUAGE = 'UPDATE_TRANSLATION_LANGUAGE'; + /** * The type of (redux) action which indicates that the user pressed the * ClosedCaption button, to either enable or disable subtitles based on the diff --git a/react/features/subtitles/actions.js b/react/features/subtitles/actions.js index 647ddd650..ac6f7b56f 100644 --- a/react/features/subtitles/actions.js +++ b/react/features/subtitles/actions.js @@ -1,12 +1,16 @@ // @flow +import { toggleDialog } from '../base/dialog'; + import { ENDPOINT_MESSAGE_RECEIVED, REMOVE_TRANSCRIPT_MESSAGE, TOGGLE_REQUESTING_SUBTITLES, SET_REQUESTING_SUBTITLES, - UPDATE_TRANSCRIPT_MESSAGE + UPDATE_TRANSCRIPT_MESSAGE, + UPDATE_TRANSLATION_LANGUAGE } from './actionTypes'; +import LanguageSelectorDialogWeb from './components/LanguageSelectorDialog.web'; /** * Signals that a participant sent an endpoint message on the data channel. @@ -92,3 +96,31 @@ export function setRequestingSubtitles(enabled: boolean) { enabled }; } + +/** + * Signals that the local user has selected language for the translation. + * + * @param {boolean} value - The selected language for translation. + * @returns {{ + * type: UPDATE_TRANSLATION_LANGUAGE + * }} + */ +export function updateTranslationLanguage(value) { + return { + type: UPDATE_TRANSLATION_LANGUAGE, + value + }; +} + +/** + * Signals that the local user has toggled the LanguageSelector button. + * + * @returns {{ + * type: UPDATE_TRANSLATION_LANGUAGE + * }} + */ +export function toggleLangugeSelectorDialog() { + return function(dispatch: (Object) => Object) { + dispatch(toggleDialog(LanguageSelectorDialogWeb)); + }; +} diff --git a/react/features/subtitles/components/AbstractClosedCaptionButton.js b/react/features/subtitles/components/AbstractClosedCaptionButton.js index 37303ab3b..131426baf 100644 --- a/react/features/subtitles/components/AbstractClosedCaptionButton.js +++ b/react/features/subtitles/components/AbstractClosedCaptionButton.js @@ -5,7 +5,6 @@ import { isLocalParticipantModerator } from '../../base/participants'; import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components'; import { maybeShowPremiumFeatureDialog } from '../../jaas/actions'; import { FEATURES } from '../../jaas/constants'; -import { toggleRequestingSubtitles } from '../actions'; export type AbstractProps = AbstractButtonProps & { @@ -22,7 +21,12 @@ export type AbstractProps = AbstractButtonProps & { /** * Whether the local participant is currently requesting subtitles. */ - _requestingSubtitles: Boolean + _requestingSubtitles: Boolean, + + /** + * Selected language for subtitle. + */ + _subtitles: String }; /** @@ -30,6 +34,18 @@ export type AbstractProps = AbstractButtonProps & { */ export class AbstractClosedCaptionButton extends AbstractButton { + + /** + * Helper function to be implemented by subclasses, which should be used + * to handle the closed caption button being clicked / pressed. + * + * @protected + * @returns {void} + */ + _handleClickOpenLanguageSelector() { + // To be implemented by subclass. + } + /** * Handles clicking / pressing the button. * @@ -45,11 +61,10 @@ export class AbstractClosedCaptionButton 'requesting_subtitles': Boolean(_requestingSubtitles) })); - const dialogShown = await dispatch(maybeShowPremiumFeatureDialog(FEATURES.RECORDING)); if (!dialogShown) { - dispatch(toggleRequestingSubtitles()); + this._handleClickOpenLanguageSelector(); } } @@ -86,11 +101,12 @@ export class AbstractClosedCaptionButton * @private * @returns {{ * _requestingSubtitles: boolean, + * _language: string, * visible: boolean * }} */ export function _abstractMapStateToProps(state: Object, ownProps: Object) { - const { _requestingSubtitles } = state['features/subtitles']; + const { _requestingSubtitles, _language } = state['features/subtitles']; const { transcription } = state['features/base/config']; const { isTranscribing } = state['features/transcribing']; @@ -101,6 +117,7 @@ export function _abstractMapStateToProps(state: Object, ownProps: Object) { return { _requestingSubtitles, + _language, visible }; } diff --git a/react/features/subtitles/components/ClosedCaptionButton.web.js b/react/features/subtitles/components/ClosedCaptionButton.web.js index 9e761837f..2a12e0737 100644 --- a/react/features/subtitles/components/ClosedCaptionButton.web.js +++ b/react/features/subtitles/components/ClosedCaptionButton.web.js @@ -3,6 +3,7 @@ import { translate } from '../../base/i18n'; import { IconClosedCaption } from '../../base/icons'; import { connect } from '../../base/redux'; +import { toggleLangugeSelectorDialog } from '../actions'; import { AbstractClosedCaptionButton, @@ -14,12 +15,24 @@ import { */ class ClosedCaptionButton extends AbstractClosedCaptionButton { - accessibilityLabel = 'toolbar.accessibilityLabel.cc'; icon = IconClosedCaption; tooltip = 'transcribing.ccButtonTooltip'; label = 'toolbar.startSubtitles'; - toggledLabel = 'toolbar.stopSubtitles'; + labelProps = { + language: this.props.t(this.props._language) + }; + + /** + * Toggle language selection dialog. + * + * @returns {void} + */ + _handleClickOpenLanguageSelector() { + const { dispatch } = this.props; + + dispatch(toggleLangugeSelectorDialog()); + } } export default translate(connect(_abstractMapStateToProps)(ClosedCaptionButton)); diff --git a/react/features/subtitles/components/LanguageList.tsx b/react/features/subtitles/components/LanguageList.tsx new file mode 100644 index 000000000..2c8e77a1c --- /dev/null +++ b/react/features/subtitles/components/LanguageList.tsx @@ -0,0 +1,50 @@ +import { makeStyles } from '@material-ui/styles'; +import React from 'react'; + + +import LanguageListItem from './LanguageListItem'; + +interface ILanguageListProps { + items: Array, + onLanguageSelected: (lang: string) => void; + selectedLanguage: string +} + +const useStyles = makeStyles(() => { + return { + itemsContainer: { + display: 'flex', + flexFlow: 'column' + } + }; +}); + + +interface LanguageItem { + id: string, + lang: string, + selected: boolean, +} + +/** + * Component that renders the security options dialog. + * + * @returns {React$Element} + */ +const LanguageList = ({ + items, + onLanguageSelected +}: ILanguageListProps) => { + const styles = useStyles(); + const listItems = items.map(item => ()); + + return ( +

{listItems}
+ ); +}; + +export default LanguageList; diff --git a/react/features/subtitles/components/LanguageListItem.tsx b/react/features/subtitles/components/LanguageListItem.tsx new file mode 100644 index 000000000..cebb8b7a7 --- /dev/null +++ b/react/features/subtitles/components/LanguageListItem.tsx @@ -0,0 +1,78 @@ +// @ts-ignore +import { makeStyles } from '@material-ui/styles'; +import React, { useCallback } from 'react'; +import { WithTranslation } from 'react-i18next'; + +// @ts-ignore +// eslint-disable-next-line import/order +import { translate } from '../../base/i18n'; + +// @ts-ignore +import { Icon } from '../../base/icons/components'; +import { IconCheck } from '../../base/icons/svg/index'; +import { Theme } from '../../base/ui/types'; + +interface ILanguageListItemProps extends WithTranslation { + + /** + * Whether or not the button should be full width. + */ + lang: string, + + /** + * Click callback. + */ + onLanguageSelected: (lang: string) => void; + + /** + * The id of the button. + */ + selected?: boolean; +} + +const useStyles = makeStyles((theme: Theme) => { + return { + itemContainer: { + display: 'flex', + color: theme.palette.text01, + alignItems: 'center', + fontSize: '14px' + }, + iconWrapper: { + margin: '4px 10px', + width: '22px', + height: '22px' + }, + activeItemContainer: { + fontWeight: 700 + } + }; +}); + +/** + * Component that renders the language list item. + * + * @returns {React$Element} + */ + +const LanguageListItem = ({ + t, + lang, + selected, + onLanguageSelected +}: ILanguageListItemProps) => { + const styles = useStyles(); + const onLanguageSelectedWrapper = useCallback(() => onLanguageSelected(lang), [ lang ]); + + return ( +
+ { selected + && } + { t(lang) } +
+ ); +}; + +export default translate(LanguageListItem); diff --git a/react/features/subtitles/components/LanguageSelectorDialog.web.tsx b/react/features/subtitles/components/LanguageSelectorDialog.web.tsx new file mode 100644 index 000000000..18e36c04d --- /dev/null +++ b/react/features/subtitles/components/LanguageSelectorDialog.web.tsx @@ -0,0 +1,100 @@ +/* eslint-disable lines-around-comment */ +import React, { useState, useEffect, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +// @ts-ignore +import { Dialog } from '../../base/dialog'; +// @ts-ignore +import { LANGUAGES, TRANSLATION_LANGUAGES_HEAD, TRANSLATION_LANGUAGES_EXCLUDE } from '../../base/i18n'; +// @ts-ignore +import { connect } from '../../base/redux'; +// @ts-ignore +import { updateTranslationLanguage, setRequestingSubtitles, toggleLangugeSelectorDialog } from '../actions'; + +import LanguageList from './LanguageList'; + +interface ILanguageSelectorDialogProps { + _language: string, + t: Function, +} + +/** + * Component that renders the subtitle language selector dialog. + * + * @returns {React$Element} + */ +const LanguageSelectorDialog = ({ _language }: ILanguageSelectorDialogProps) => { + const dispatch = useDispatch(); + const off = 'transcribing.subtitlesOff'; + const [ language, setLanguage ] = useState(off); + + const importantLanguages = TRANSLATION_LANGUAGES_HEAD.map((lang: string) => `languages:${lang}`); + const fixedItems = [ off, ...importantLanguages ]; + + const languages = LANGUAGES + .filter((lang: string) => !TRANSLATION_LANGUAGES_EXCLUDE.includes(lang)) + .map((lang: string) => `languages:${lang}`) + .filter((lang: string) => !(lang === language || importantLanguages.includes(lang))); + + const listItems = (fixedItems.includes(language) + ? [ ...fixedItems, ...languages ] + : [ ...fixedItems, language, ...languages ]) + .map((lang, index) => { + return { + id: lang + index, + lang, + selected: lang === language + }; + }); + + useEffect(() => { + _language ? setLanguage(_language) : setLanguage(off); + }, []); + + const onLanguageSelected = useCallback((e: string) => { + setLanguage(e); + dispatch(updateTranslationLanguage(e)); + dispatch(setRequestingSubtitles(e !== off)); + dispatch(toggleLangugeSelectorDialog()); + }, [ _language ]); + + return ( + + + + ); +}; + +/** + * Maps (parts of) the Redux state to the associated props for the + * {@code LanguageSelectorDialog} component. + * + * @param {Object} state - The Redux state. + * @private + * @returns {Props} + */ +function mapStateToProps(state: any) { + const { + conference + } = state['features/base/conference']; + + const { + _language + } = state['features/subtitles']; + + return { + _conference: conference, + _language + }; +} + +const mapDispatchToProps = {}; + +export default connect(mapStateToProps, mapDispatchToProps)(LanguageSelectorDialog); diff --git a/react/features/subtitles/middleware.js b/react/features/subtitles/middleware.js index 622645a7c..3c2eda9a1 100644 --- a/react/features/subtitles/middleware.js +++ b/react/features/subtitles/middleware.js @@ -55,9 +55,10 @@ MiddlewareRegistry.register(store => next => action => { return _endpointMessageReceived(store, next, action); case TOGGLE_REQUESTING_SUBTITLES: - _requestingSubtitlesToggled(store); + _requestingSubtitlesChange(store); break; case SET_REQUESTING_SUBTITLES: + _requestingSubtitlesChange(store); _requestingSubtitlesSet(store, action.enabled); break; } @@ -171,14 +172,22 @@ function _endpointMessageReceived({ dispatch, getState }, next, action) { * @private * @returns {void} */ -function _requestingSubtitlesToggled({ getState }) { +function _requestingSubtitlesChange({ getState }) { const state = getState(); - const { _requestingSubtitles } = state['features/subtitles']; + const { _language } = state['features/subtitles']; const { conference } = state['features/base/conference']; + const requestingSubtitles = _language !== 'transcribing.subtitlesOff'; + conference.setLocalParticipantProperty( P_NAME_REQUESTING_TRANSCRIPTION, - !_requestingSubtitles); + requestingSubtitles); + + if (requestingSubtitles) { + conference.setLocalParticipantProperty( + P_NAME_TRANSLATION_LANGUAGE, + _language.replace('languages:', '')); + } } /** diff --git a/react/features/subtitles/reducer.js b/react/features/subtitles/reducer.js index 248b9402f..dc97dc302 100644 --- a/react/features/subtitles/reducer.js +++ b/react/features/subtitles/reducer.js @@ -1,8 +1,8 @@ import { ReducerRegistry } from '../base/redux'; import { - REMOVE_TRANSCRIPT_MESSAGE, TOGGLE_REQUESTING_SUBTITLES, - SET_REQUESTING_SUBTITLES, UPDATE_TRANSCRIPT_MESSAGE + REMOVE_TRANSCRIPT_MESSAGE, + SET_REQUESTING_SUBTITLES, UPDATE_TRANSCRIPT_MESSAGE, UPDATE_TRANSLATION_LANGUAGE } from './actionTypes'; /** @@ -10,7 +10,8 @@ import { */ const defaultState = { _transcriptMessages: new Map(), - _requestingSubtitles: false + _requestingSubtitles: false, + _language: 'transcribing.subtitlesOff' }; /** @@ -24,11 +25,10 @@ ReducerRegistry.register('features/subtitles', ( return _removeTranscriptMessage(state, action); case UPDATE_TRANSCRIPT_MESSAGE: return _updateTranscriptMessage(state, action); - - case TOGGLE_REQUESTING_SUBTITLES: + case UPDATE_TRANSLATION_LANGUAGE: return { ...state, - _requestingSubtitles: !state._requestingSubtitles + _language: action.value }; case SET_REQUESTING_SUBTITLES: return {