diff --git a/images/icon-cloud.png b/images/icon-cloud.png new file mode 100644 index 000000000..6ea1b9572 Binary files /dev/null and b/images/icon-cloud.png differ diff --git a/lang/main.json b/lang/main.json index 5c5640f7f..c4f9cdc43 100644 --- a/lang/main.json +++ b/lang/main.json @@ -655,12 +655,15 @@ "beta": "BETA", "busy": "We're working on freeing recording resources. Please try again in a few minutes.", "busyTitle": "All recorders are currently busy", + "copyLink": "Copy Link", "error": "Recording failed. Please try again.", + "errorFetchingLink": "Error fetching recording link.", "expandedOff": "Recording has stopped", "expandedOn": "The meeting is currently being recorded.", "expandedPending": "Recording is being started...", "failedToStart": "Recording failed to start", "fileSharingdescription": "Share recording with meeting participants", + "linkGenerated": "We have generated a link to your recording.", "live": "LIVE", "loggedIn": "Logged in as {{userName}}", "off": "Recording stopped", @@ -675,7 +678,8 @@ "signIn": "Sign in", "signOut": "Sign out", "unavailable": "Oops! The {{serviceName}} is currently unavailable. We're working on resolving the issue. Please try again later.", - "unavailableTitle": "Recording unavailable" + "unavailableTitle": "Recording unavailable", + "uploadToCloud": "Upload to the cloud" }, "sectionList": { "pullToRefresh": "Pull to refresh" diff --git a/react/features/base/config/functions.any.js b/react/features/base/config/functions.any.js index 09d657894..dbc61cd79 100644 --- a/react/features/base/config/functions.any.js +++ b/react/features/base/config/functions.any.js @@ -40,6 +40,26 @@ export function createFakeConfig(baseURL: string) { }; } +/** + * Selector used to get the meeting region. + * + * @param {Object} state - The global state. + * @returns {string} + */ +export function getMeetingRegion(state: Object) { + return state['features/base/config']?.deploymentInfo?.region || ''; +} + +/** + * Selector used to get the endpoint used for fetching the recording. + * + * @param {Object} state - The global state. + * @returns {string} + */ +export function getRecordingSharingUrl(state: Object) { + return state['features/base/config'].recordingSharingUrl; +} + /* eslint-disable max-params, no-shadow */ /** diff --git a/react/features/billing-counter/functions.js b/react/features/billing-counter/functions.js index af4579c21..54dcb8369 100644 --- a/react/features/billing-counter/functions.js +++ b/react/features/billing-counter/functions.js @@ -22,6 +22,16 @@ export function extractVpaasTenantFromPath(path: string) { return ''; } +/** + * Returns the vpaas tenant. + * + * @param {Object} state - The global state. + * @returns {string} + */ +export function getVpaasTenant(state: Object) { + return extractVpaasTenantFromPath(state['features/base/connection'].locationURL.pathname); +} + /** * Returns true if the current meeting is a vpaas one. * diff --git a/react/features/recording/actions.any.js b/react/features/recording/actions.any.js index 01e6bdfd6..3ec2c27e0 100644 --- a/react/features/recording/actions.any.js +++ b/react/features/recording/actions.any.js @@ -1,6 +1,10 @@ // @flow +import { getMeetingRegion, getRecordingSharingUrl } from '../base/config'; import JitsiMeetJS, { JitsiRecordingConstants } from '../base/lib-jitsi-meet'; +import { getLocalParticipant, getParticipantDisplayName } from '../base/participants'; +import { copyText } from '../base/util/helpers'; +import { getVpaasTenant, isVpaasMeeting } from '../billing-counter/functions'; import { NOTIFICATION_TIMEOUT, hideNotification, @@ -14,6 +18,8 @@ import { SET_PENDING_RECORDING_NOTIFICATION_UID, SET_STREAM_KEY } from './actionTypes'; +import { getRecordingLink, getResourceId } from './functions'; +import logger from './logger'; /** * Clears the data of every recording sessions. @@ -136,26 +142,68 @@ export function showStoppedRecordingNotification(streamType: string, participant * Signals that a started recording notification should be shown on the * screen for a given period. * - * @param {string} streamType - The type of the stream ({@code file} or - * {@code stream}). - * @param {string} participantName - The participant name that started the recording. - * @returns {showNotification} + * @param {string} mode - The type of the recording: Stream of File. + * @param {string | Object } initiator - The participant who started recording. + * @param {string} sessionId - The recording session id. + * @returns {Function} */ -export function showStartedRecordingNotification(streamType: string, participantName: string) { - const isLiveStreaming - = streamType === JitsiMeetJS.constants.recording.mode.STREAM; - const descriptionArguments = { name: participantName }; - const dialogProps = isLiveStreaming ? { - descriptionKey: participantName ? 'liveStreaming.onBy' : 'liveStreaming.on', - descriptionArguments, - titleKey: 'dialog.liveStreaming' - } : { - descriptionKey: participantName ? 'recording.onBy' : 'recording.on', - descriptionArguments, - titleKey: 'dialog.recording' - }; +export function showStartedRecordingNotification( + mode: string, + initiator: Object | string, + sessionId: string) { + return async (dispatch: Function, getState: Function) => { + const state = getState(); + const initiatorId = getResourceId(initiator); + const participantName = getParticipantDisplayName(state, initiatorId); + let dialogProps = { + customActionNameKey: undefined, + descriptionKey: participantName ? 'liveStreaming.onBy' : 'liveStreaming.on', + descriptionArguments: { name: participantName }, + isDismissAllowed: true, + titleKey: 'dialog.liveStreaming' + }; - return showNotification(dialogProps, NOTIFICATION_TIMEOUT); + if (mode !== JitsiMeetJS.constants.recording.mode.STREAM) { + const recordingSharingUrl = getRecordingSharingUrl(state); + const iAmRecordingInitiator = getLocalParticipant(state).id === initiatorId; + + dialogProps = { + customActionHandler: undefined, + customActionNameKey: undefined, + descriptionKey: participantName ? 'recording.onBy' : 'recording.on', + descriptionArguments: { name: participantName }, + isDismissAllowed: true, + titleKey: 'dialog.recording' + }; + + // fetch the recording link from the server for recording initiators in jaas meetings + if (recordingSharingUrl + && isVpaasMeeting(state) + && iAmRecordingInitiator) { + const region = getMeetingRegion(state); + const tenant = getVpaasTenant(state); + + try { + const link = await getRecordingLink(recordingSharingUrl, sessionId, region, tenant); + + // add the option to copy recording link + dialogProps.customActionNameKey = 'recording.copyLink'; + dialogProps.customActionHandler = () => copyText(link); + dialogProps.titleKey = 'recording.on'; + dialogProps.descriptionKey = 'recording.linkGenerated'; + dialogProps.isDismissAllowed = false; + } catch (err) { + dispatch(showErrorNotification({ + titleKey: 'recording.errorFetchingLink' + })); + + return logger.error('Could not fetch recording link', err); + } + } + } + + dispatch(showNotification(dialogProps)); + }; } /** diff --git a/react/features/recording/components/Recording/StartRecordingDialogContent.js b/react/features/recording/components/Recording/StartRecordingDialogContent.js index dc1fe623b..1e5fef9bb 100644 --- a/react/features/recording/components/Recording/StartRecordingDialogContent.js +++ b/react/features/recording/components/Recording/StartRecordingDialogContent.js @@ -26,8 +26,7 @@ import { authorizeDropbox, updateDropboxToken } from '../../../dropbox'; import { RECORDING_TYPES } from '../../constants'; import { getRecordingDurationEstimation } from '../../functions'; -import { DROPBOX_LOGO, ICON_SHARE, JITSI_LOGO } from './styles'; - +import { DROPBOX_LOGO, ICON_CLOUD, JITSI_LOGO } from './styles'; type Props = { @@ -162,9 +161,11 @@ class StartRecordingDialogContent extends Component { * @returns {React$Component} */ _renderFileSharingContent() { - const { fileRecordingsServiceSharingEnabled, isVpaas } = this.props; + const { fileRecordingsServiceSharingEnabled, isVpaas, selectedRecordingService } = this.props; - if (!fileRecordingsServiceSharingEnabled || isVpaas) { + if (!fileRecordingsServiceSharingEnabled + || isVpaas + || selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE) { return null; } @@ -173,31 +174,22 @@ class StartRecordingDialogContent extends Component { _styles: styles, isValidating, onSharingSettingChanged, - selectedRecordingService, sharingSetting, t } = this.props; - const controlDisabled = selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE; - let mainContainerClasses = 'recording-header recording-header-line'; - - if (controlDisabled) { - mainContainerClasses += ' recording-switch-disabled'; - } - return ( { + value = { sharingSetting } /> ); } @@ -248,7 +240,7 @@ class StartRecordingDialogContent extends Component { value = { this.props.selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE } /> ) : null; - const icon = isVpaas ? ICON_SHARE : JITSI_LOGO; + const icon = isVpaas ? ICON_CLOUD : JITSI_LOGO; const label = isVpaas ? t('recording.serviceDescriptionCloud') : t('recording.serviceDescription'); return ( @@ -334,7 +326,7 @@ class StartRecordingDialogContent extends Component { return ( diff --git a/react/features/recording/components/Recording/styles.native.js b/react/features/recording/components/Recording/styles.native.js index 442f8cf20..3d49191b7 100644 --- a/react/features/recording/components/Recording/styles.native.js +++ b/react/features/recording/components/Recording/styles.native.js @@ -4,7 +4,7 @@ import { ColorSchemeRegistry, schemeColor } from '../../../base/color-scheme'; import { BoxModel, ColorPalette } from '../../../base/styles'; export const DROPBOX_LOGO = require('../../../../../images/dropboxLogo_square.png'); -export const ICON_SHARE = require('../../../../../images/icon-users.png'); +export const ICON_CLOUD = require('../../../../../images/icon-cloud.png'); export const JITSI_LOGO = require('../../../../../images/jitsiLogo_square.png'); // XXX The "standard" {@code BoxModel.padding} has been deemed insufficient in diff --git a/react/features/recording/components/Recording/styles.web.js b/react/features/recording/components/Recording/styles.web.js index 1b8318589..4a0d76ae1 100644 --- a/react/features/recording/components/Recording/styles.web.js +++ b/react/features/recording/components/Recording/styles.web.js @@ -4,6 +4,6 @@ export default {}; export const DROPBOX_LOGO = 'images/dropboxLogo_square.png'; -export const ICON_SHARE = 'images/icon-users.png'; +export const ICON_CLOUD = 'images/icon-cloud.png'; export const JITSI_LOGO = 'images/jitsiLogo_square.png'; diff --git a/react/features/recording/functions.js b/react/features/recording/functions.js index 363132b88..678d254ab 100644 --- a/react/features/recording/functions.js +++ b/react/features/recording/functions.js @@ -46,6 +46,27 @@ export function getSessionById(state: Object, id: string) { sessionData => sessionData.id === id); } +/** + * Fetches the recording link from the server. + * + * @param {string} url - The base url. + * @param {string} recordingSessionId - The ID of the recording session to find. + * @param {string} region - The meeting region. + * @param {string} tenant - The meeting tenant. + * @returns {Promise} + */ +export async function getRecordingLink(url: string, recordingSessionId: string, region: string, tenant: string) { + const fullUrl = `${url}?recordingSessionId=${recordingSessionId}®ion=${region}&tenant=${tenant}`; + const res = await fetch(fullUrl, { + headers: { + 'Content-Type': 'application/json' + } + }); + const json = await res.json(); + + return res.ok ? json.url : Promise.reject(json); +} + /** * Returns the recording session status that is to be shown in a label. E.g. If * there is a session with the status OFF and one with PENDING, then the PENDING @@ -72,3 +93,18 @@ export function getSessionStatusToShow(state: Object, mode: string): ?string { return status; } + + +/** + * Returns the resource id. + * + * @param {Object | string} recorder - A participant or it's resource. + * @returns {string|undefined} + */ +export function getResourceId(recorder: string | Object) { + if (recorder) { + return typeof recorder === 'string' + ? recorder + : recorder.getId(); + } +} diff --git a/react/features/recording/middleware.js b/react/features/recording/middleware.js index bccc491bd..9ec0a6fb4 100644 --- a/react/features/recording/middleware.js +++ b/react/features/recording/middleware.js @@ -37,7 +37,7 @@ import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from './constants'; -import { getSessionById } from './functions'; +import { getSessionById, getResourceId } from './functions'; import { LIVE_STREAMING_OFF_SOUND_FILE, LIVE_STREAMING_ON_SOUND_FILE, @@ -160,11 +160,9 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { // Show notification with additional information to the initiator. dispatch(showRecordingLimitNotification(mode)); } else { - dispatch(showStartedRecordingNotification( - mode, initiator && getParticipantDisplayName(getState, initiator.getId()))); + dispatch(showStartedRecordingNotification(mode, initiator, action.sessionData.id)); } - sendAnalytics(createRecordingEvent('start', mode)); if (disableRecordAudioNotification) { @@ -188,8 +186,12 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { } } else if (updatedSessionData.status === OFF && (!oldSessionData || oldSessionData.status !== OFF)) { - dispatch(showStoppedRecordingNotification( - mode, terminator && getParticipantDisplayName(getState, terminator.getId()))); + if (terminator) { + dispatch( + showStoppedRecordingNotification( + mode, getParticipantDisplayName(getState, getResourceId(terminator)))); + } + let duration = 0, soundOff, soundOn; if (oldSessionData && oldSessionData.timestamp) {