diff --git a/lang/main.json b/lang/main.json index d2ac86c2b9..edd9f9acbe 100644 --- a/lang/main.json +++ b/lang/main.json @@ -77,6 +77,17 @@ "refresh": "Refresh calendar", "today": "Today" }, + "carmode": { + "actions": { + "leaveMeeting": " Leave meeting", + "selectSoundDevice": "Select sound device" + }, + "labels": { + "buttonLabel": "Car mode", + "title": "Safe driving mode", + "videoStopped": "Your video is stopped" + } + }, "chat": { "enter": "Enter room", "error": "Error: your message was not sent. Reason: {{error}}", @@ -1010,6 +1021,7 @@ "boo": "Boo", "breakoutRoom": "Join/leave breakout room", "callQuality": "Manage video quality", + "carmode": "Carmode", "cc": "Toggle subtitles", "chat": "Open / Close chat", "clap": "Clap", diff --git a/react/features/analytics/AnalyticsEvents.js b/react/features/analytics/AnalyticsEvents.js index 99ab51275b..6eb97a8f0b 100644 --- a/react/features/analytics/AnalyticsEvents.js +++ b/react/features/analytics/AnalyticsEvents.js @@ -641,18 +641,20 @@ export function createSharedVideoEvent(action, attributes = {}) { * of ACTION_SHORTCUT_PRESSED, ACTION_SHORTCUT_RELEASED * or ACTION_SHORTCUT_TRIGGERED). * @param {Object} attributes - Attributes to attach to the event. + * @param {string} source - The event's source. * @returns {Object} The event in a format suitable for sending via * sendAnalytics. */ export function createShortcutEvent( shortcut, action = ACTION_SHORTCUT_TRIGGERED, - attributes = {}) { + attributes = {}, + source = 'keyboard.shortcut') { return { action, actionSubjectId: shortcut, attributes, - source: 'keyboard.shortcut', + source, type: TYPE_UI }; } @@ -901,7 +903,7 @@ export function createBreakoutRoomsEvent(actionSubject) { } /** - * Creates and event which indicates a GIF was sent. + * Creates an event which indicates a GIF was sent. * * @returns {Object} The event in a format suitable for sending via * sendAnalytics. diff --git a/react/features/base/flags/constants.js b/react/features/base/flags/constants.js index 2e17c84582..afbb328e56 100644 --- a/react/features/base/flags/constants.js +++ b/react/features/base/flags/constants.js @@ -38,6 +38,12 @@ export const CALENDAR_ENABLED = 'calendar.enabled'; */ export const CALL_INTEGRATION_ENABLED = 'call-integration.enabled'; +/** + * Flag indicating if car mode should be enabled. + * Default: enabled (true). + */ +export const CAR_MODE_ENABLED = 'car-mode.enabled'; + /** * Flag indicating if close captions should be enabled. * Default: enabled (true). diff --git a/react/features/base/icons/svg/car.svg b/react/features/base/icons/svg/car.svg index f43424e9d0..4a2f988f5f 100644 --- a/react/features/base/icons/svg/car.svg +++ b/react/features/base/icons/svg/car.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/react/features/base/icons/svg/circle.svg b/react/features/base/icons/svg/circle.svg new file mode 100644 index 0000000000..5fadd334cf --- /dev/null +++ b/react/features/base/icons/svg/circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index 9bf6feae0e..fc0b76b43c 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -28,6 +28,7 @@ export { default as IconChatSend } from './send.svg'; export { default as IconChatUnread } from './chat-unread.svg'; export { default as IconCheck } from './check.svg'; export { default as IconCheckSolid } from './check-solid.svg'; +export { default as IconCircle } from './circle.svg'; export { default as IconClose } from './close.svg'; export { default as IconCloseCircle } from './close-circle.svg'; export { default as IconCloseSolid } from './close-solid.svg'; diff --git a/react/features/base/lastn/middleware.js b/react/features/base/lastn/middleware.js index 993a2805c2..3fce14c992 100644 --- a/react/features/base/lastn/middleware.js +++ b/react/features/base/lastn/middleware.js @@ -7,6 +7,7 @@ import { SELECT_LARGE_VIDEO_PARTICIPANT } from '../../large-video/actionTypes'; import { APP_STATE_CHANGED } from '../../mobile/background/actionTypes'; import { SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED, + SET_CAR_MODE, SET_TILE_VIEW, VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED } from '../../video-layout/actionTypes'; @@ -51,6 +52,7 @@ const _updateLastN = debounce(({ dispatch, getState }) => { const config = state['features/base/config']; const { lastNLimits } = state['features/base/lastn']; const participantCount = getParticipantCount(state); + const { carMode } = state['features/video-layout']; // Select the (initial) lastN value based on the following preference order. // 1. The last-n value from 'startLastN' if it is specified in config.js @@ -67,6 +69,8 @@ const _updateLastN = debounce(({ dispatch, getState }) => { if (typeof appState !== 'undefined' && appState !== 'active') { lastNSelected = isLocalVideoTrackDesktop(state) ? 1 : 0; + } else if (carMode) { + lastNSelected = 0; } else if (audioOnly) { const { remoteScreenShares, tileViewEnabled } = state['features/video-layout']; const largeVideoParticipantId = state['features/large-video'].participantId; @@ -101,6 +105,7 @@ MiddlewareRegistry.register(store => next => action => { case SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED: case SELECT_LARGE_VIDEO_PARTICIPANT: case SET_AUDIO_ONLY: + case SET_CAR_MODE: case SET_FILMSTRIP_ENABLED: case SET_TILE_VIEW: case VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED: diff --git a/react/features/base/media/actions.js b/react/features/base/media/actions.js index b53481ba6a..76f2e75f0e 100644 --- a/react/features/base/media/actions.js +++ b/react/features/base/media/actions.js @@ -166,7 +166,7 @@ export function setVideoAvailable(available: boolean) { */ export function setVideoMuted( muted: boolean, - mediaType: MediaType = MEDIA_TYPE.VIDEO, + mediaType: string = MEDIA_TYPE.VIDEO, authority: number = VIDEO_MUTISM_AUTHORITY.USER, ensureTrack: boolean = false) { return (dispatch: Dispatch, getState: Function) => { diff --git a/react/features/base/media/constants.js b/react/features/base/media/constants.js index 4846e99bee..67d9a30939 100644 --- a/react/features/base/media/constants.js +++ b/react/features/base/media/constants.js @@ -45,7 +45,8 @@ export const SCREENSHARE_MUTISM_AUTHORITY = { export const VIDEO_MUTISM_AUTHORITY = { AUDIO_ONLY: 1 << 0, BACKGROUND: 1 << 1, - USER: 1 << 2 + USER: 1 << 2, + CAR_MODE: 1 << 3 }; /* eslint-enable no-bitwise */ diff --git a/react/features/mobile/navigation/components/chat/components/ChatAndPollsNavigationContainer.js b/react/features/chat/components/native/ChatAndPolls.js similarity index 70% rename from react/features/mobile/navigation/components/chat/components/ChatAndPollsNavigationContainer.js rename to react/features/chat/components/native/ChatAndPolls.js index 7690c51317..526fb1ad33 100644 --- a/react/features/mobile/navigation/components/chat/components/ChatAndPollsNavigationContainer.js +++ b/react/features/chat/components/native/ChatAndPolls.js @@ -4,20 +4,19 @@ import { createMaterialTopTabNavigator } from '@react-navigation/material-top-ta import React from 'react'; import { useSelector } from 'react-redux'; +import { Chat } from '../..'; import { getClientHeight, getClientWidth -} from '../../../../../base/modal/components/functions.native'; -import BaseTheme from '../../../../../base/ui/components/BaseTheme.native'; -import { Chat } from '../../../../../chat'; -import { PollsPane } from '../../../../../polls/components'; -import { screen } from '../../../routes'; -import { chatTabBarOptions } from '../../../screenOptions'; +} from '../../../base/modal/components/functions.native'; +import BaseTheme from '../../../base/ui/components/BaseTheme.native'; +import { screen } from '../../../mobile/navigation/routes'; +import { chatTabBarOptions } from '../../../mobile/navigation/screenOptions'; +import { PollsPane } from '../../../polls/components'; const ChatTab = createMaterialTopTabNavigator(); - -const ChatAndPollsNavigationContainer = () => { +const ChatAndPolls = () => { const clientHeight = useSelector(getClientHeight); const clientWidth = useSelector(getClientWidth); @@ -44,4 +43,4 @@ const ChatAndPollsNavigationContainer = () => { ); }; -export default ChatAndPollsNavigationContainer; +export default ChatAndPolls; diff --git a/react/features/chat/components/native/index.js b/react/features/chat/components/native/index.js index 0e3e81918d..1c87ba6a5a 100644 --- a/react/features/chat/components/native/index.js +++ b/react/features/chat/components/native/index.js @@ -1,5 +1,6 @@ // @flow export { default as Chat } from './Chat'; +export { default as ChatAndPolls } from './ChatAndPolls'; export { default as ChatButton } from './ChatButton'; export { default as ChatPrivacyDialog } from './ChatPrivacyDialog'; diff --git a/react/features/conference/components/functions.native.js b/react/features/conference/components/functions.native.js index fb2a6bc39a..83bb99c573 100644 --- a/react/features/conference/components/functions.native.js +++ b/react/features/conference/components/functions.native.js @@ -1 +1,30 @@ export * from './functions.any'; + +/** + * Returns whether the conference is in connecting state. + * + * @param {Object} state - The redux state. + * @returns {boolean} Whether conference is connecting. + */ +export const isConnecting = (state: Object) => { + const { connecting, connection } = state['features/base/connection']; + const { + conference, + joining, + membersOnly, + leaving + } = state['features/base/conference']; + + // XXX There is a window of time between the successful establishment of the + // XMPP connection and the subsequent commencement of joining the MUC during + // which the app does not appear to be doing anything according to the redux + // state. In order to not toggle the _connecting props during the window of + // time in question, define _connecting as follows: + // - the XMPP connection is connecting, or + // - the XMPP connection is connected and the conference is joining, or + // - the XMPP connection is connected and we have no conference yet, nor we + // are leaving one. + return Boolean( + connecting || (connection && (!membersOnly && (joining || (!conference && !leaving)))) + ); +}; diff --git a/react/features/conference/components/native/Conference.js b/react/features/conference/components/native/Conference.js index 09a082d122..369108a125 100644 --- a/react/features/conference/components/native/Conference.js +++ b/react/features/conference/components/native/Conference.js @@ -35,6 +35,7 @@ import { abstractMapStateToProps } from '../AbstractConference'; import type { AbstractProps } from '../AbstractConference'; +import { isConnecting } from '../functions'; import AlwaysOnLabels from './AlwaysOnLabels'; import ExpandedLabelPopup from './ExpandedLabelPopup'; @@ -496,34 +497,15 @@ class Conference extends AbstractConference { * @returns {Props} */ function _mapStateToProps(state) { - const { connecting, connection } = state['features/base/connection']; - const { - conference, - joining, - membersOnly, - leaving - } = state['features/base/conference']; const { isOpen } = state['features/participants-pane']; const { aspectRatio, reducedUI } = state['features/base/responsive-ui']; const participantCount = getParticipantCount(state); - // XXX There is a window of time between the successful establishment of the - // XMPP connection and the subsequent commencement of joining the MUC during - // which the app does not appear to be doing anything according to the redux - // state. In order to not toggle the _connecting props during the window of - // time in question, define _connecting as follows: - // - the XMPP connection is connecting, or - // - the XMPP connection is connected and the conference is joining, or - // - the XMPP connection is connected and we have no conference yet, nor we - // are leaving one. - const connecting_ - = connecting || (connection && (!membersOnly && (joining || (!conference && !leaving)))); - return { ...abstractMapStateToProps(state), _aspectRatio: aspectRatio, _calendarEnabled: isCalendarEnabled(state), - _connecting: Boolean(connecting_), + _connecting: isConnecting(state), _filmstripVisible: isFilmstripVisible(state), _fullscreenEnabled: getFeatureFlag(state, FULLSCREEN_ENABLED, true), _isOneToOneConference: Boolean(participantCount === 2), diff --git a/react/features/conference/components/native/carmode/AudioIcon.tsx b/react/features/conference/components/native/carmode/AudioIcon.tsx new file mode 100644 index 0000000000..9781d31f75 --- /dev/null +++ b/react/features/conference/components/native/carmode/AudioIcon.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Icon, IconVolumeEmpty } from '../../../../base/icons'; +import BaseTheme from '../../../../base/ui/components/BaseTheme.native'; + +/** + * React component for Audio icon. + * + * @returns {JSX.Element} - the Audio icon. + * + */ +const AudioIcon = () : JSX.Element => (); + +export default AudioIcon; diff --git a/react/features/conference/components/native/carmode/Conference.tsx b/react/features/conference/components/native/carmode/Conference.tsx new file mode 100644 index 0000000000..f78f2c17d8 --- /dev/null +++ b/react/features/conference/components/native/carmode/Conference.tsx @@ -0,0 +1,82 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Text, SafeAreaView, View } from 'react-native'; +import { withSafeAreaInsets } from 'react-native-safe-area-context'; +import { useDispatch, useSelector } from 'react-redux'; +import JitsiScreen from '../../../../base/modal/components/JitsiScreen'; + +import { LoadingIndicator, TintedView } from '../../../../base/react'; +import { setIsCarmode } from '../../../../video-layout/actions'; +import ConferenceTimer from '../../ConferenceTimer'; +import { isConnecting } from '../../functions'; + +import EndMeetingButton from './EndMeetingButton'; +import MicrophoneButton from './MicrophoneButton'; +import SoundDeviceButton from './SoundDeviceButton'; +import TitleBar from './TitleBar'; +import styles from './styles'; +import { isLocalVideoTrackDesktop } from '../../../../base/tracks'; +import { setPictureInPictureDisabled } from '../../../../mobile/picture-in-picture/functions'; + +/** + * Implements the carmode tab. + * + * @returns { JSX.Element} - The carmode tab. + */ +const CarmodeTab = (): JSX.Element => { + const dispatch = useDispatch(); + const { t } = useTranslation(); + const connecting = useSelector(isConnecting); + const isSharing = useSelector(isLocalVideoTrackDesktop); + + useEffect(() => { + dispatch(setIsCarmode(true)); + setPictureInPictureDisabled(true); + + return () => { + dispatch(setIsCarmode(false)); + if (!isSharing) { + setPictureInPictureDisabled(false); + } + } + }, []); + + return ( + + {/* + * The activity/loading indicator goes above everything, except + * the toolbox/toolbars and the dialogs. + */ + connecting + && + + + } + + + + + + + + + + + + {t('carmode.labels.videoStopped')} + + + + + + ); +}; + +export default withSafeAreaInsets(CarmodeTab); diff --git a/react/features/conference/components/native/carmode/EndMeetingButton.tsx b/react/features/conference/components/native/carmode/EndMeetingButton.tsx new file mode 100644 index 0000000000..08f5acf0b7 --- /dev/null +++ b/react/features/conference/components/native/carmode/EndMeetingButton.tsx @@ -0,0 +1,39 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from 'react-native-paper'; +import { useDispatch } from 'react-redux'; + +import { createToolbarEvent, sendAnalytics } from '../../../../analytics'; +import { appNavigate } from '../../../../app/actions'; + +import EndMeetingIcon from './EndMeetingIcon'; +import styles from './styles'; + +/** + * Button for ending meeting from carmode. + * + * @returns {JSX.Element} - The end meeting button. + */ +const EndMeetingButton = () : JSX.Element => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const onSelect = useCallback(() => { + sendAnalytics(createToolbarEvent('hangup')); + + dispatch(appNavigate(undefined)); + }, [ dispatch ]); + + return ( + + ); +}; + +export default EndMeetingButton; diff --git a/react/features/conference/components/native/carmode/EndMeetingIcon.tsx b/react/features/conference/components/native/carmode/EndMeetingIcon.tsx new file mode 100644 index 0000000000..c4325a0b9e --- /dev/null +++ b/react/features/conference/components/native/carmode/EndMeetingIcon.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Icon, IconHangup } from '../../../../base/icons'; +import BaseTheme from '../../../../base/ui/components/BaseTheme.native'; + +/** + * Implements an end meeting icon. + * + * @returns {JSX.Element} - the end meeting icon. + */ +const EndMeetingIcon = () : JSX.Element => (); + +export default EndMeetingIcon; diff --git a/react/features/conference/components/native/carmode/MicrophoneButton.tsx b/react/features/conference/components/native/carmode/MicrophoneButton.tsx new file mode 100644 index 0000000000..64f341a2f5 --- /dev/null +++ b/react/features/conference/components/native/carmode/MicrophoneButton.tsx @@ -0,0 +1,88 @@ +import React, { useCallback } from 'react'; +import { useState } from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { useDispatch, useSelector } from 'react-redux'; +import { + createShortcutEvent, + sendAnalytics, + ACTION_SHORTCUT_PRESSED as PRESSED, + ACTION_SHORTCUT_RELEASED as RELEASED +} from '../../../../analytics'; + +import { getFeatureFlag, AUDIO_MUTE_BUTTON_ENABLED } from '../../../../base/flags'; +import { Icon, IconMicrophone, IconMicrophoneEmptySlash } from '../../../../base/icons'; +import { MEDIA_TYPE } from '../../../../base/media'; +import { isLocalTrackMuted } from '../../../../base/tracks'; +import { isAudioMuteButtonDisabled } from '../../../../toolbox/functions.any'; +import { muteLocal } from '../../../../video-menu/actions'; + +import styles from './styles'; + +const LONG_PRESS = 'long.press'; + +/** + * Implements a round audio mute/unmute button of a custom size. + * + * @returns {JSX.Element} - The audio mute round button. + */ +const MicrophoneButton = () : JSX.Element => { + const dispatch = useDispatch(); + const audioMuted = useSelector(state => isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO)); + const disabled = useSelector(isAudioMuteButtonDisabled); + const enabledFlag = useSelector(state => getFeatureFlag(state, AUDIO_MUTE_BUTTON_ENABLED, true)); + const [ longPress, setLongPress ] = useState(false); + + if (!enabledFlag) { + return null; + } + + const onPressIn = useCallback(() => { + !disabled && dispatch(muteLocal(!audioMuted, MEDIA_TYPE.AUDIO)); + }, [ audioMuted, disabled ]); + + const onLongPress = useCallback(() => { + if ( !disabled && !audioMuted) { + sendAnalytics(createShortcutEvent( + 'push.to.talk', + PRESSED, + {}, + LONG_PRESS)); + setLongPress(true); + } + }, [audioMuted, disabled, setLongPress]); + + const onPressOut = useCallback(() => { + if (longPress) { + setLongPress(false); + sendAnalytics(createShortcutEvent( + 'push.to.talk', + RELEASED, + {}, + LONG_PRESS + )); + dispatch(muteLocal(true, MEDIA_TYPE.AUDIO)); + } + }, [longPress, setLongPress]); + + return ( + + + + + + + + ); +}; + +export default MicrophoneButton; diff --git a/react/features/conference/components/native/carmode/SoundDeviceButton.tsx b/react/features/conference/components/native/carmode/SoundDeviceButton.tsx new file mode 100644 index 0000000000..96921f19b9 --- /dev/null +++ b/react/features/conference/components/native/carmode/SoundDeviceButton.tsx @@ -0,0 +1,37 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from 'react-native-paper'; +import { useDispatch } from 'react-redux'; + +import { openDialog } from '../../../../base/dialog/actions'; +import AudioRoutePickerDialog from '../../../../mobile/audio-mode/components/AudioRoutePickerDialog'; + +import AudioIcon from './AudioIcon'; +import styles from './styles'; + +/** + * Button for selecting sound device in carmode. + * + * @returns {JSX.Element} - The sound device button. + */ +const SelectSoundDevice = () : JSX.Element => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const onSelect = useCallback(() => + dispatch(openDialog(AudioRoutePickerDialog)) + , [ dispatch ]); + + return ( + + ); +}; + +export default SelectSoundDevice; diff --git a/react/features/conference/components/native/carmode/TitleBar.tsx b/react/features/conference/components/native/carmode/TitleBar.tsx new file mode 100644 index 0000000000..8095e7f6b9 --- /dev/null +++ b/react/features/conference/components/native/carmode/TitleBar.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Text, View } from 'react-native'; + +import { getConferenceName } from '../../../../base/conference/functions'; +import { getFeatureFlag, MEETING_NAME_ENABLED } from '../../../../base/flags'; +import { JitsiRecordingConstants } from '../../../../base/lib-jitsi-meet'; +import { connect } from '../../../../base/redux';; +import { RecordingLabel } from '../../../../recording'; +import { VideoQualityLabel } from '../../../../video-quality'; + +import styles from './styles'; + + +type Props = { + + /** + * Name of the meeting we're currently in. + */ + _meetingName: string, + + /** + * Whether displaying the current meeting name is enabled or not. + */ + _meetingNameEnabled: boolean, + +}; + +/** + * Implements a navigation bar component that is rendered on top of the + * carmode screen. + * + * @param {Props} props - The React props passed to this component. + * @returns {JSX.Element} + */ +const TitleBar = (props: Props) : JSX.Element => (<> + + + + + + + + + + + + { + props._meetingNameEnabled + && + + {props._meetingName} + + + } + + +); + +/** + * Maps part of the Redux store to the props of this component. + * + * @param {Object} state - The Redux state. + * @returns {Props} + */ +function _mapStateToProps(state: Object) { + const { hideConferenceSubject } = state['features/base/config']; + + return { + _meetingName: getConferenceName(state), + _meetingNameEnabled: + getFeatureFlag(state, MEETING_NAME_ENABLED, true) && !hideConferenceSubject + }; +} + +export default connect(_mapStateToProps)(TitleBar); diff --git a/react/features/conference/components/native/carmode/styles.js b/react/features/conference/components/native/carmode/styles.js new file mode 100644 index 0000000000..ca48dc8c74 --- /dev/null +++ b/react/features/conference/components/native/carmode/styles.js @@ -0,0 +1,207 @@ +import BaseTheme from '../../../../base/ui/components/BaseTheme.native'; + +/** + * The size of the microphone icon. + */ +const MICROPHONE_SIZE = 180; + +/** + * Base button style. + */ +const baseButton = { + borderRadius: BaseTheme.shape.borderRadius, + height: BaseTheme.spacing[7], + marginTop: BaseTheme.spacing[3], + marginLeft: BaseTheme.spacing[10], + marginRight: BaseTheme.spacing[10], + display: 'flex', + justifyContent: 'space-around', + width: 300 +}; + +/** + * Base label style. + */ +const baseLabel = { + display: 'flex', + fontSize: 16, + textTransform: 'capitalize' +}; + +/** + * The styles of the safe area view that contains the title bar. + */ +const titleBarSafeView = { + left: 0, + position: 'absolute', + right: 0, + top: 0 +}; + +/** + * The styles of the native components of Carmode. + */ +export default { + + bottomContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + bottom: 0, + left: 0, + right: 0, + position: 'absolute' + }, + + /** + * {@code Conference} Style. + */ + conference: { + backgroundColor: BaseTheme.palette.uiBackground, + flex: 1, + justifyContent: 'center' + }, + + microphoneStyles: { + container: { + borderRadius: MICROPHONE_SIZE / 2, + height: MICROPHONE_SIZE, + maxHeight: MICROPHONE_SIZE, + justifyContent: 'center', + overflow: 'hidden', + width: MICROPHONE_SIZE, + maxWidth: MICROPHONE_SIZE, + flex: 1, + zIndex: 1, + elevation: 1 + }, + + icon: { + color: BaseTheme.palette.text01, + fontSize: MICROPHONE_SIZE * 0.45, + fontWeight: '100' + }, + + iconContainer: { + alignItems: 'center', + alignSelf: 'stretch', + flex: 1, + justifyContent: 'center', + backgroundColor: BaseTheme.palette.ui03 + }, + + unmuted: { + borderWidth: 4, + borderColor: BaseTheme.palette.success01 + } + }, + + qualityLabelContainer: { + borderBottomLeftRadius: 3, + borderTopLeftRadius: 3, + flexShrink: 1, + paddingHorizontal: 2, + justifyContent: 'center', + marginTop: 8 + }, + + roomTimer: { + color: BaseTheme.palette.text01, + ...BaseTheme.typography.bodyShortBold, + paddingHorizontal: 8, + paddingVertical: 6, + textAlign: 'center' + }, + + titleView: { + width: 152, + height: 28, + backgroundColor: BaseTheme.palette.ui02, + borderRadius: 12, + alignSelf: 'center' + }, + + title: { + margin: 'auto', + textAlign: 'center', + paddingVertical: 4, + paddingHorizontal: 16, + color: BaseTheme.palette.text02 + }, + + soundDeviceButtonLabel: { + ...baseLabel, + color: BaseTheme.palette.text06 + }, + + soundDeviceButton: { + ...baseButton, + backgroundColor: BaseTheme.palette.section01 + }, + + endMeetingButton: { + ...baseButton, + backgroundColor: BaseTheme.palette.actionDanger, + marginBottom: 60 + }, + + endMeetingButtonLabel: { + ...baseLabel, + color: BaseTheme.palette.text01 + }, + + headerLabels: { + borderBottomLeftRadius: 3, + borderTopLeftRadius: 3, + flexShrink: 1, + paddingHorizontal: 2, + justifyContent: 'center' + }, + + titleBarSafeViewColor: { + ...titleBarSafeView, + backgroundColor: BaseTheme.palette.uiBackground + }, + + microphoneContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + + titleBarWrapper: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + justifyContent: 'center' + }, + + roomNameWrapper: { + flexDirection: 'row', + marginRight: 10, + flexShrink: 1, + flexGrow: 1 + }, + + roomNameView: { + backgroundColor: 'rgba(0,0,0,0.6)', + flexShrink: 1, + justifyContent: 'center', + paddingHorizontal: 5 + }, + + roomName: { + color: BaseTheme.palette.text01, + ...BaseTheme.typography.bodyShortBold + }, + + titleBar: { + alignSelf: 'center' + }, + + videoStoppedLabel: { + color: BaseTheme.palette.text01, + marginBottom: 32, + ...BaseTheme.typography.bodyShortRegularLarge + } +}; diff --git a/react/features/mobile/navigation/components/conference/ConferenceNavigationContainerRef.js b/react/features/mobile/navigation/components/conference/ConferenceNavigationContainerRef.js index 892da4851e..70a5fc047f 100644 --- a/react/features/mobile/navigation/components/conference/ConferenceNavigationContainerRef.js +++ b/react/features/mobile/navigation/components/conference/ConferenceNavigationContainerRef.js @@ -9,7 +9,7 @@ export const conferenceNavigationRef = React.createRef(); * @param {Object} params - Params to pass to the destination route. * @returns {Function} */ -export function navigate(name: string, params: Object) { +export function navigate(name: string, params?: Object) { return conferenceNavigationRef.current?.navigate(name, params); } diff --git a/react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.js b/react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.tsx similarity index 91% rename from react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.js rename to react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.tsx index dffba8485d..b4b0fdf532 100644 --- a/react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.js +++ b/react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.tsx @@ -4,8 +4,10 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import { Chat } from '../../../../../chat'; +import { Chat, ChatAndPolls } from '../../../../../chat'; + import Conference from '../../../../../conference/components/native/Conference'; +import CarmodeTab from '../../../../../conference/components/native/carmode/Conference'; import { getDisablePolls } from '../../../../../conference/functions'; import { SharedDocument } from '../../../../../etherpad'; import { GifsMenu } from '../../../../../gifs/components'; @@ -23,6 +25,7 @@ import SpeakerStats from '../../../../../speaker-stats/components/native/SpeakerStats'; import { screen } from '../../../routes'; import { + carmodeScreenOptions, chatScreenOptions, conferenceScreenOptions, gifsMenuOptions, @@ -36,8 +39,6 @@ import { sharedDocumentScreenOptions, speakerStatsScreenOptions } from '../../../screenOptions'; -import ChatAndPollsNavigationContainer - from '../../chat/components/ChatAndPollsNavigationContainer'; import LobbyNavigationContainer from '../../lobby/components/LobbyNavigationContainer'; import { @@ -46,7 +47,6 @@ import { const ConferenceStack = createStackNavigator(); - const ConferenceNavigationContainer = () => { const isPollsDisabled = useSelector(getDisablePolls); let ChatScreen; @@ -58,7 +58,7 @@ const ConferenceNavigationContainer = () => { chatScreenName = screen.conference.chat; chatTitleString = 'chat.title'; } else { - ChatScreen = ChatAndPollsNavigationContainer; + ChatScreen = ChatAndPolls; chatScreenName = screen.conference.chatandpolls.main; chatTitleString = 'chat.titleWithPolls'; } @@ -152,9 +152,16 @@ const ConferenceNavigationContainer = () => { ...sharedDocumentScreenOptions, title: t('documentSharing.title') }} /> + ); }; -export default ConferenceNavigationContainer; +export default ConferenceNavigationContainer; \ No newline at end of file diff --git a/react/features/mobile/navigation/routes.js b/react/features/mobile/navigation/routes.js index 139a3c5ca0..09a6050970 100644 --- a/react/features/mobile/navigation/routes.js +++ b/react/features/mobile/navigation/routes.js @@ -16,6 +16,7 @@ export const screen = { conference: { root: 'Conference root', main: 'Conference', + carmode: 'Car Mode', chat: 'Chat', chatandpolls: { main: 'Chat and Polls', @@ -24,6 +25,7 @@ export const screen = { polls: 'Polls' } }, + container: 'Conference container', security: 'Security Options', recording: 'Recording', liveStream: 'Live stream', diff --git a/react/features/mobile/navigation/screenOptions.js b/react/features/mobile/navigation/screenOptions.js index bb9b2c5381..0c4a198774 100644 --- a/react/features/mobile/navigation/screenOptions.js +++ b/react/features/mobile/navigation/screenOptions.js @@ -183,6 +183,11 @@ export const presentationScreenOptions = { } }; +/** + * Screen options for car mode. + */ +export const carmodeScreenOptions = presentationScreenOptions; + /** * Screen options for chat. */ diff --git a/react/features/toolbox/components/native/OpenCarmodeButton.tsx b/react/features/toolbox/components/native/OpenCarmodeButton.tsx new file mode 100644 index 0000000000..141e34c959 --- /dev/null +++ b/react/features/toolbox/components/native/OpenCarmodeButton.tsx @@ -0,0 +1,46 @@ +import { CAR_MODE_ENABLED, getFeatureFlag } from '../../../base/flags'; +import { translate } from '../../../base/i18n'; +import { IconCar } from '../../../base/icons'; +import { connect } from '../../../base/redux'; +import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components'; +import { navigate } + from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef'; +import { screen } from '../../../mobile/navigation/routes'; + +/** + * Implements an {@link AbstractButton} to open the carmode. + */ +class OpenCarmodeButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.carmode'; + icon = IconCar; + label = 'carmode.labels.buttonLabel'; + + /** + * Handles clicking / pressing the button, and opens the carmode mode. + * + * @private + * @returns {void} + */ + _handleClick() { + return navigate(screen.conference.carmode); + } +} + +/** + * Maps part of the Redux state to the props of this component. + * + * @param {Object} state - The Redux state. + * @param {AbstractButtonProps} ownProps - The properties explicitly passed to the component instance. + * @private + * @returns {Object} + */ + function _mapStateToProps(state: Object, ownProps: AbstractButtonProps): Object { + const enabled = getFeatureFlag(state, CAR_MODE_ENABLED, true); + const { visible = enabled } = ownProps; + + return { + visible + }; +} + +export default translate(connect(_mapStateToProps)(OpenCarmodeButton)); diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index d27d44c4c9..af28446467 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -24,6 +24,7 @@ import HelpButton from '../HelpButton'; import AudioOnlyButton from './AudioOnlyButton'; import LinkToSalesforceButton from './LinkToSalesforceButton'; +import OpenCarmodeButton from './OpenCarmodeButton'; import RaiseHandButton from './RaiseHandButton'; import ScreenSharingButton from './ScreenSharingButton'; import ToggleCameraButton from './ToggleCameraButton'; @@ -143,6 +144,7 @@ class OverflowMenu extends PureComponent { ? this._renderReactionMenu : null }> + {!_reactionsEnabled && !toolbarButtons.has('raisehand') && } diff --git a/react/features/video-layout/actionTypes.ts b/react/features/video-layout/actionTypes.ts index bd3ba8d3ec..32100f5a42 100644 --- a/react/features/video-layout/actionTypes.ts +++ b/react/features/video-layout/actionTypes.ts @@ -11,14 +11,14 @@ export const SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED = 'SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED'; /** - * The type of the action which sets the list of known remote virtual screen share participant IDs. + * The type of the action which tells whether we are in carmode. * * @returns {{ - * type: VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED, - * participantIds: Array + * type: SET_CAR_MODE, + * enabled: boolean * }} */ -export const VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED = 'VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED'; +export const SET_CAR_MODE = ' SET_CAR_MODE'; /** * The type of the action which enables or disables the feature for showing @@ -30,3 +30,13 @@ export const VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED = 'VIRTUAL_SCREENSH * }} */ export const SET_TILE_VIEW = 'SET_TILE_VIEW'; + +/** + * The type of the action which sets the list of known remote virtual screen share participant IDs. + * + * @returns {{ + * type: VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED, + * participantIds: Array + * }} + */ +export const VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED = 'VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED'; diff --git a/react/features/video-layout/actions.js b/react/features/video-layout/actions.any.js similarity index 100% rename from react/features/video-layout/actions.js rename to react/features/video-layout/actions.any.js diff --git a/react/features/video-layout/actions.native.js b/react/features/video-layout/actions.native.js new file mode 100644 index 0000000000..c9aaad117f --- /dev/null +++ b/react/features/video-layout/actions.native.js @@ -0,0 +1,19 @@ +import { SET_CAR_MODE } from './actionTypes'; + +export * from './actions.any'; + +/** + * Creates a (redux) action which tells whether we are in carmode. + * + * @param {boolean} enabled - Whether we are in carmode. + * @returns {{ + * type: SET_CAR_MODE, + * enabled: boolean + * }} + */ +export function setIsCarmode(enabled) { + return { + type: SET_CAR_MODE, + enabled + }; +} diff --git a/react/features/video-layout/actions.web.js b/react/features/video-layout/actions.web.js new file mode 100644 index 0000000000..02b37d475d --- /dev/null +++ b/react/features/video-layout/actions.web.js @@ -0,0 +1 @@ +export * from './actions.any'; diff --git a/react/features/video-layout/middleware.native.js b/react/features/video-layout/middleware.native.js index fefd329e8a..d7f6466b05 100644 --- a/react/features/video-layout/middleware.native.js +++ b/react/features/video-layout/middleware.native.js @@ -1 +1,24 @@ +import { MEDIA_TYPE, setVideoMuted, VIDEO_MUTISM_AUTHORITY } from '../base/media'; +import { MiddlewareRegistry } from '../base/redux'; + +import { SET_CAR_MODE } from './actionTypes'; import './middleware.any'; + +/** + * Middleware which intercepts actions and updates the legacy component. + * + * @param {Store} store - The redux store. + * @returns {Function} + */ +MiddlewareRegistry.register(store => next => action => { + const result = next(action); + const { dispatch } = store; + + switch (action.type) { + case SET_CAR_MODE: + dispatch(setVideoMuted(action.enabled, MEDIA_TYPE.VIDEO, VIDEO_MUTISM_AUTHORITY.CAR_MODE)); + break; + } + + return result; +}); diff --git a/react/features/video-layout/reducer.js b/react/features/video-layout/reducer.js index ba915da9c7..324e2bc28e 100644 --- a/react/features/video-layout/reducer.js +++ b/react/features/video-layout/reducer.js @@ -4,11 +4,20 @@ import { ReducerRegistry } from '../base/redux'; import { SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED, + SET_CAR_MODE, SET_TILE_VIEW, VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED } from './actionTypes'; const DEFAULT_STATE = { + /** + * Whether we are in carmode. + * + * @public + * @type {boolean} + */ + carMode: false, + remoteScreenShares: [], /** @@ -29,12 +38,17 @@ const STORE_NAME = 'features/video-layout'; ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => { switch (action.type) { case SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED: - case VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED: { + case VIRTUAL_SCREENSHARE_REMOTE_PARTICIPANTS_UPDATED: return { ...state, remoteScreenShares: action.participantIds }; - } + + case SET_CAR_MODE: + return { + ...state, + carMode: action.enabled + }; case SET_TILE_VIEW: return {