diff --git a/lang/main.json b/lang/main.json index 4644e436bd..92e76dc1fa 100644 --- a/lang/main.json +++ b/lang/main.json @@ -300,6 +300,12 @@ "alreadySharedVideoTitle": "Only one shared video is allowed at a time", "applicationWindow": "Application window", "authenticationRequired": "Authentication required", + "cameraCaptureDialog": { + "description": "Take and send a picture using your mobile camera", + "ok": "Open camera", + "reject": "Not now", + "title": "Take a picture" + }, "cameraConstraintFailedError": "Your camera does not satisfy some of the required constraints.", "cameraNotFoundError": "Camera was not found.", "cameraNotSendingData": "We are unable to access your camera. Please check if another application is using this device, select another device from the settings menu or try to reload the application.", diff --git a/modules/API/API.js b/modules/API/API.js index 2184a3ccf9..30dd00f953 100755 --- a/modules/API/API.js +++ b/modules/API/API.js @@ -30,6 +30,7 @@ import { overwriteConfig } from '../../react/features/base/config/actions'; import { getWhitelistedJSON } from '../../react/features/base/config/functions.any'; import { toggleDialog } from '../../react/features/base/dialog/actions'; import { isSupportedBrowser } from '../../react/features/base/environment/environment'; +import { isMobileBrowser } from '../../react/features/base/environment/utils'; import { parseJWTFromURLParams } from '../../react/features/base/jwt/functions'; import JitsiMeetJS, { JitsiRecordingConstants } from '../../react/features/base/lib-jitsi-meet'; import { MEDIA_TYPE, VIDEO_TYPE } from '../../react/features/base/media/constants'; @@ -113,7 +114,10 @@ import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../react/features/rec import { getActiveSession, supportsLocalRecording } from '../../react/features/recording/functions'; import { startAudioScreenShareFlow, startScreenShareFlow } from '../../react/features/screen-share/actions'; import { isScreenAudioSupported } from '../../react/features/screen-share/functions'; -import { toggleScreenshotCaptureSummary } from '../../react/features/screenshot-capture/actions'; +import { + openCameraCaptureDialog, + toggleScreenshotCaptureSummary +} from '../../react/features/screenshot-capture/actions'; import { isScreenshotCaptureEnabled } from '../../react/features/screenshot-capture/functions'; import SettingsDialog from '../../react/features/settings/components/web/SettingsDialog'; import { SETTINGS_TABS } from '../../react/features/settings/constants'; @@ -940,6 +944,20 @@ function initCommands() { }); }); break; + case 'capture-camera-picture' : { + const { cameraFacingMode, descriptionText, titleText } = request; + + if (!isMobileBrowser()) { + logger.error('This feature is only supported on mobile'); + + return; + } + + APP.store.dispatch(openCameraCaptureDialog(callback, { cameraFacingMode, + descriptionText, + titleText })); + break; + } case 'deployment-info': callback(APP.store.getState()['features/base/config'].deploymentInfo); break; diff --git a/modules/API/external/external_api.js b/modules/API/external/external_api.js index fe7b351efa..4c2e01a5b5 100644 --- a/modules/API/external/external_api.js +++ b/modules/API/external/external_api.js @@ -820,6 +820,27 @@ export default class JitsiMeetExternalAPI extends EventEmitter { }); } + /** + * Captures a picture through OS camera. + * + * @param {string} cameraFacingMode - The OS camera facing mode (environment/user). + * @param {string} descriptionText - The OS camera facing mode (environment/user). + * @param {string} titleText - The OS camera facing mode (environment/user). + * @returns {Promise} - Resolves with a base64 encoded image data of the screenshot. + */ + captureCameraPicture( + cameraFacingMode, + descriptionText, + titleText + ) { + return this._transport.sendRequest({ + name: 'capture-camera-picture', + cameraFacingMode, + descriptionText, + titleText + }); + } + /** * Removes the listeners and removes the Jitsi Meet frame. * diff --git a/react/features/base/media/constants.ts b/react/features/base/media/constants.ts index 679cf76b60..4ede658577 100644 --- a/react/features/base/media/constants.ts +++ b/react/features/base/media/constants.ts @@ -3,7 +3,7 @@ * * @enum {string} */ -export const CAMERA_FACING_MODE = { +export const CAMERA_FACING_MODE: Record = { ENVIRONMENT: 'environment', USER: 'user' }; diff --git a/react/features/screenshot-capture/CameraCaptureDialog.tsx b/react/features/screenshot-capture/CameraCaptureDialog.tsx new file mode 100644 index 0000000000..6b1d4d3e79 --- /dev/null +++ b/react/features/screenshot-capture/CameraCaptureDialog.tsx @@ -0,0 +1,154 @@ +import { Theme } from '@mui/material'; +import React, { useCallback, useRef } from 'react'; +import { WithTranslation } from 'react-i18next'; +import { connect, useDispatch } from 'react-redux'; +import { makeStyles } from 'tss-react/mui'; + +import { hideDialog } from '../base/dialog/actions'; +import { translate } from '../base/i18n/functions'; +import Label from '../base/label/components/web/Label'; +import { CAMERA_FACING_MODE } from '../base/media/constants'; +import Button from '../base/ui/components/web/Button'; +import Dialog from '../base/ui/components/web/Dialog'; +import { BUTTON_TYPES } from '../base/ui/constants.any'; + +import { ICameraCapturePayload } from './actionTypes'; + +const useStyles = makeStyles()((theme: Theme) => ({ + container: { + display: 'flex', + height: '100%', + width: '100%', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', + gap: theme.spacing(3), + textAlign: 'center' + }, + buttonsContainer: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + width: '100%', + maxWidth: '100%' + }, + + hidden: { + display: 'none' + }, + label: { + background: 'transparent', + margin: `${theme.spacing(3)} 0 ${theme.spacing(6)}`, + }, + button: { + width: '100%', + height: '48px', + maxWidth: '400px' + } +})); + +/** + * The type of {@link CameraCaptureDialog}'s React {@code Component} props. + */ +interface IProps extends WithTranslation { + /** + * Callback function on file input changed. + */ + callback: ({ error, dataURL }: { dataURL?: string; error?: string; }) => void; + + /** + * The camera capture payload. + */ + componentProps: ICameraCapturePayload; +} + +/** + * Implements the Camera capture dialog. + * + * @param {Object} props - The props of the component. + * @returns {React$Element} + */ +const CameraCaptureDialog = ({ + callback, + componentProps, + t, +}: IProps) => { + const { cameraFacingMode = CAMERA_FACING_MODE.ENVIRONMENT, + descriptionText, + titleText } = componentProps; + const dispatch = useDispatch(); + const { classes } = useStyles(); + const inputRef = useRef(null); + const onCancel = useCallback(() => { + callback({ + error: 'User canceled!' + }); + dispatch(hideDialog()); + }, []); + + const onSubmit = useCallback(() => { + inputRef.current?.click(); + }, []); + + const onInputChange = useCallback(event => { + const reader = new FileReader(); + const files = event.target.files; + + if (!files?.[0]) { + callback({ + error: 'No picture selected!' + }); + + return; + } + + reader.onload = () => { + callback({ dataURL: reader.result as string }); + dispatch(hideDialog()); + }; + reader.onerror = () => { + callback({ error: 'Failed generating base64 URL!' }); + dispatch(hideDialog()); + }; + + reader.readAsDataURL(files[0]); + }, []); + + return ( + +
+
+
+ ); +}; + +export default translate(connect()(CameraCaptureDialog)); diff --git a/react/features/screenshot-capture/actionTypes.ts b/react/features/screenshot-capture/actionTypes.ts index fe26b5cc01..2eff824a6e 100644 --- a/react/features/screenshot-capture/actionTypes.ts +++ b/react/features/screenshot-capture/actionTypes.ts @@ -7,3 +7,23 @@ */ export const SET_SCREENSHOT_CAPTURE = 'SET_SCREENSHOT_CAPTURE'; + +/** + * The camera capture payload. + */ +export interface ICameraCapturePayload { + /** + * Selected camera on open. + */ + cameraFacingMode?: string; + + /** + * Custom explanatory text to show. + */ + descriptionText?: string, + + /** + * Custom dialog title text. + */ + titleText?: string +}; diff --git a/react/features/screenshot-capture/actions.ts b/react/features/screenshot-capture/actions.ts index a49400545c..453542b518 100644 --- a/react/features/screenshot-capture/actions.ts +++ b/react/features/screenshot-capture/actions.ts @@ -1,7 +1,10 @@ import { IStore } from '../app/types'; +import { openDialog } from '../base/dialog/actions'; +import { isMobileBrowser } from '../base/environment/utils'; import { getLocalJitsiDesktopTrack } from '../base/tracks/functions'; -import { SET_SCREENSHOT_CAPTURE } from './actionTypes'; +import CameraCaptureDialog from './CameraCaptureDialog'; +import { ICameraCapturePayload, SET_SCREENSHOT_CAPTURE } from './actionTypes'; import { createScreenshotCaptureSummary } from './functions'; import logger from './logger'; @@ -61,3 +64,23 @@ export function toggleScreenshotCaptureSummary(enabled: boolean) { return Promise.resolve(); }; } + +/** + * Opens {@code CameraCaptureDialog}. + * + * @param {Function} callback - The callback to execute on picture taken. + * @param {ICameraCapturePayload} componentProps - The camera capture payload. + * @returns {Function} + */ +export function openCameraCaptureDialog(callback: Function, componentProps: ICameraCapturePayload) { + return (dispatch: IStore['dispatch']) => { + if (!isMobileBrowser()) { + return; + } + + dispatch(openDialog(CameraCaptureDialog, { + callback, + componentProps + })); + }; +}