diff --git a/config.js b/config.js index ef5a54a20..a6b30e3bf 100644 --- a/config.js +++ b/config.js @@ -603,6 +603,7 @@ var config = { // 'fullscreen', // 'hangup', // 'help', + // 'highlight', // 'invite', // 'livestreaming', // 'microphone', @@ -1118,7 +1119,8 @@ var config = { // 'e2ee', // 'transcribing', // 'video-quality', - // 'insecure-room' + // 'insecure-room', + // 'highlight-moment' // ] // }, diff --git a/lang/main.json b/lang/main.json index 3ba773ba4..ab1c593d6 100644 --- a/lang/main.json +++ b/lang/main.json @@ -865,6 +865,10 @@ "expandedPending": "Recording is being started...", "failedToStart": "Recording failed to start", "fileSharingdescription": "Share the recording link with the meeting participants", + "highlightMoment": "Highlight moment", + "highlightMomentDisabled": "You can highlight moments when the recording starts", + "highlightMomentSuccess": "Moment highlighted", + "highlightMomentSucessDescription": "Your highlighted moment will be added to the meeting summary.", "inProgress": "Recording or live streaming in progress", "limitNotificationDescriptionNative": "Due to high demand your recording will be limited to {{limit}} min. For unlimited recordings try <3>{{app}}.", "limitNotificationDescriptionWeb": "Due to high demand your recording will be limited to {{limit}} min. For unlimited recordings try {{app}}.", diff --git a/react/features/base/config/constants.js b/react/features/base/config/constants.js index 898071437..0ce2cfd1d 100644 --- a/react/features/base/config/constants.js +++ b/react/features/base/config/constants.js @@ -26,6 +26,7 @@ export const TOOLBAR_BUTTONS = [ 'fullscreen', 'hangup', 'help', + 'highlight', 'invite', 'linktosalesforce', 'livestreaming', diff --git a/react/features/base/icons/svg/highlight.svg b/react/features/base/icons/svg/highlight.svg new file mode 100644 index 000000000..84496c1f2 --- /dev/null +++ b/react/features/base/icons/svg/highlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index 847619fbd..b80642975 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -60,6 +60,7 @@ export { default as IconFullScreen } from './full-screen.svg'; export { default as IconGoogle } from './google.svg'; export { default as IconHangup } from './hangup.svg'; export { default as IconHelp } from './help.svg'; +export { default as IconHighlight } from './highlight.svg'; export { default as IconHome } from './home.svg'; export { default as IconHorizontalPoints } from './horizontal-points.svg'; export { default as IconInfo } from './info.svg'; diff --git a/react/features/conference/components/constants.js b/react/features/conference/components/constants.js index 81a621db9..fd61734c8 100644 --- a/react/features/conference/components/constants.js +++ b/react/features/conference/components/constants.js @@ -1,6 +1,7 @@ export const CONFERENCE_INFO = { alwaysVisible: [ 'recording', 'local-recording', 'raised-hands-count' ], autoHide: [ + 'highlight-moment', 'subject', 'conference-timer', 'participants-count', diff --git a/react/features/conference/components/web/ConferenceInfo.js b/react/features/conference/components/web/ConferenceInfo.js index 834da37e5..cd983549f 100644 --- a/react/features/conference/components/web/ConferenceInfo.js +++ b/react/features/conference/components/web/ConferenceInfo.js @@ -9,6 +9,7 @@ import { connect } from '../../../base/redux'; import { E2EELabel } from '../../../e2ee'; import { LocalRecordingLabel } from '../../../local-recording'; import { RecordingLabel } from '../../../recording'; +import HighlightButton from '../../../recording/components/Recording/web/HighlightButton'; import { isToolboxVisible } from '../../../toolbox/functions.web'; import { TranscribingLabel } from '../../../transcribing'; import { VideoQualityLabel } from '../../../video-quality'; @@ -38,6 +39,10 @@ type Props = { }; const COMPONENTS = [ + { + Component: HighlightButton, + id: 'highlight-moment' + }, { Component: SubjectText, id: 'subject' diff --git a/react/features/recording/actionTypes.js b/react/features/recording/actionTypes.js index 593aa0831..53c84a4f9 100644 --- a/react/features/recording/actionTypes.js +++ b/react/features/recording/actionTypes.js @@ -56,3 +56,13 @@ export const SET_SELECTED_RECORDING_SERVICE = 'SET_SELECTED_RECORDING_SERVICE'; * } */ export const SET_STREAM_KEY = 'SET_STREAM_KEY'; + +/** + * Sets the enable state of the meeting highlight button. + * + * { + * type: SET_MEETING_HIGHLIGHT_BUTTON_STATE, + * disabled: boolean + * } + */ +export const SET_MEETING_HIGHLIGHT_BUTTON_STATE = 'SET_MEETING_HIGHLIGHT_BUTTON_STATE'; diff --git a/react/features/recording/actions.any.js b/react/features/recording/actions.any.js index 921d4150f..429bb3132 100644 --- a/react/features/recording/actions.any.js +++ b/react/features/recording/actions.any.js @@ -16,11 +16,12 @@ import { import { CLEAR_RECORDING_SESSIONS, RECORDING_SESSION_UPDATED, + SET_MEETING_HIGHLIGHT_BUTTON_STATE, SET_PENDING_RECORDING_NOTIFICATION_UID, SET_SELECTED_RECORDING_SERVICE, SET_STREAM_KEY } from './actionTypes'; -import { getRecordingLink, getResourceId, isSavingRecordingOnDropbox } from './functions'; +import { getRecordingLink, getResourceId, isSavingRecordingOnDropbox, sendMeetingHighlight } from './functions'; import logger from './logger'; declare var APP: Object; @@ -38,6 +39,21 @@ export function clearRecordingSessions() { }; } +/** + * Sets the meeting highlight button disable state. + * + * @param {boolean} disabled - The disabled state value. + * @returns {{ + * type: CLEAR_RECORDING_SESSIONS + * }} + */ +export function setHighlightMomentButtonState(disabled: boolean) { + return { + type: SET_MEETING_HIGHLIGHT_BUTTON_STATE, + disabled + }; +} + /** * Signals that the pending recording notification should be removed from the * screen. @@ -105,6 +121,31 @@ export function showPendingRecordingNotification(streamType: string) { }; } +/** + * Highlights a meeting moment. + * + * {@code stream}). + * + * @returns {Function} + */ +export function highlightMeetingMoment() { + return async (dispatch: Function, getState: Function) => { + dispatch(setHighlightMomentButtonState(true)); + + try { + await sendMeetingHighlight(getState()); + dispatch(showNotification({ + descriptionKey: 'recording.highlightMomentSucessDescription', + titleKey: 'recording.highlightMomentSuccess' + })); + } catch (err) { + logger.error('Could not highlight meeting moment', err); + } + + dispatch(setHighlightMomentButtonState(false)); + }; +} + /** * Signals that the recording error notification should be shown. * diff --git a/react/features/recording/components/Recording/AbstractHighlightButton.js b/react/features/recording/components/Recording/AbstractHighlightButton.js new file mode 100644 index 000000000..39acdcad2 --- /dev/null +++ b/react/features/recording/components/Recording/AbstractHighlightButton.js @@ -0,0 +1,71 @@ +// @flow + +import { Component } from 'react'; + +import { getActiveSession, isHighlightMeetingMomentDisabled } from '../..'; +import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet'; +import { highlightMeetingMoment } from '../../actions.any'; + +export type Props = { + + /** + * Whether or not the conference is in audio only mode. + */ + _audioOnly: boolean, + + /** + * Invoked to obtain translated strings. + */ + t: Function +}; + +/** + * Abstract class for the {@code AbstractHighlightButton} component. + */ +export default class AbstractHighlightButton extends Component

{ + /** + * Initializes a new AbstractVideoTrack instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props: Props) { + super(props); + + this._onClick = this._onClick.bind(this); + } + + /** + * Handles clicking / pressing the button. + * + * @override + * @protected + * @returns {void} + */ + _onClick() { + const { dispatch } = this.props; + + dispatch(highlightMeetingMoment()); + } +} + +/** + * Maps (parts of) the Redux state to the associated + * {@code AbstractVideoQualityLabel}'s props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * _audioOnly: boolean + * }} + */ +export function _abstractMapStateToProps(state: Object) { + const isRecordingRunning = getActiveSession(state, JitsiRecordingConstants.mode.FILE); + const isButtonDisabled = isHighlightMeetingMomentDisabled(state); + const { webhookProxyUrl } = state['features/base/config']; + + return { + _disabled: !isRecordingRunning || isButtonDisabled, + _visible: Boolean(webhookProxyUrl) + }; +} diff --git a/react/features/recording/components/Recording/web/HighlightButton.js b/react/features/recording/components/Recording/web/HighlightButton.js new file mode 100644 index 000000000..630a45087 --- /dev/null +++ b/react/features/recording/components/Recording/web/HighlightButton.js @@ -0,0 +1,94 @@ +// @flow + +import { withStyles } from '@material-ui/core'; +import React from 'react'; + +import { translate } from '../../../../base/i18n'; +import { IconHighlight } from '../../../../base/icons'; +import { Label } from '../../../../base/label'; +import { connect } from '../../../../base/redux'; +import { Tooltip } from '../../../../base/tooltip'; +import BaseTheme from '../../../../base/ui/components/BaseTheme'; +import AbstractHighlightButton, { + _abstractMapStateToProps, + type Props as AbstractProps +} from '../AbstractHighlightButton'; + +type Props = AbstractProps & { + _disabled: boolean, + + /** + * The message to show within the label's tooltip. + */ + _tooltipKey: string, + + /** + * Flag controlling visibility of the component. + */ + _visible: boolean, +}; + +/** + * Creates the styles for the component. + * + * @param {Object} theme - The current UI theme. + * + * @returns {Object} + */ +const styles = theme => { + return { + regular: { + background: theme.palette.field02, + margin: '0 4px 4px 4px' + }, + disabled: { + background: theme.palette.text02, + margin: '0 4px 4px 4px' + } + }; +}; + +/** + * React {@code Component} responsible for displaying an action that + * allows users to highlight a meeting moment. + */ +export class HighlightButton extends AbstractHighlightButton { + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { + _disabled, + _visible, + classes, + t + } = this.props; + + + if (!_visible) { + return null; + } + + const className = _disabled ? classes.disabled : classes.regular; + const tooltipKey = _disabled ? 'recording.highlightMomentDisabled' : 'recording.highlightMoment'; + + return ( + + + ); + } +} + +export default withStyles(styles)(translate(connect(_abstractMapStateToProps)(HighlightButton))); diff --git a/react/features/recording/components/Recording/web/index.js b/react/features/recording/components/Recording/web/index.js index c59badd89..6ea324514 100644 --- a/react/features/recording/components/Recording/web/index.js +++ b/react/features/recording/components/Recording/web/index.js @@ -1,5 +1,6 @@ // @flow +export { default as HighlightButton } from './HighlightButton'; export { default as RecordButton } from './RecordButton'; export { default as StartRecordingDialog } from './StartRecordingDialog'; export { default as StopRecordingDialog } from './StopRecordingDialog'; diff --git a/react/features/recording/functions.js b/react/features/recording/functions.js index 82592e333..aff18baee 100644 --- a/react/features/recording/functions.js +++ b/react/features/recording/functions.js @@ -1,9 +1,12 @@ // @flow import { JitsiRecordingConstants } from '../base/lib-jitsi-meet'; +import { getLocalParticipant } from '../base/participants'; import { isEnabled as isDropboxEnabled } from '../dropbox'; +import { extractFqnFromPath } from '../dynamic-branding'; import { RECORDING_STATUS_PRIORITIES, RECORDING_TYPES } from './constants'; +import logger from './logger'; /** * Searches in the passed in redux state for an active recording session of the @@ -79,6 +82,16 @@ export function isSavingRecordingOnDropbox(state: Object) { && state['features/recording'].selectedRecordingService === RECORDING_TYPES.DROPBOX; } +/** + * Selector used for determining disable state for the meeting highlight button. + * + * @param {Object} state - The redux state to search in. + * @returns {string} + */ +export function isHighlightMeetingMomentDisabled(state: Object) { + return state['features/recording'].disableHighlightMeetingMoment; +} + /** * 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 @@ -120,3 +133,51 @@ export function getResourceId(recorder: string | Object) { : recorder.getId(); } } + +/** + * Sends a meeting highlight to backend. + * + * @param {Object} state - Redux state. + * @returns {boolean} - True if sent, false otherwise. + */ +export async function sendMeetingHighlight(state: Object) { + const { webhookProxyUrl: url } = state['features/base/config']; + const { conference } = state['features/base/conference']; + const { jwt } = state['features/base/jwt']; + const { connection } = state['features/base/connection']; + const jid = connection.getJid(); + const localParticipant = getLocalParticipant(state); + + const headers = { + ...jwt ? { 'Authorization': `Bearer ${jwt}` } : {}, + 'Content-Type': 'application/json' + }; + + const reqBody = { + meetingFqn: extractFqnFromPath(), + sessionId: conference.sessionId, + submitted: Date.now(), + participantId: localParticipant.jwtId, + participantName: localParticipant.name, + participantJid: jid + }; + + if (url) { + try { + const res = await fetch(`${url}/v2/highlights`, { + method: 'POST', + headers, + body: JSON.stringify(reqBody) + }); + + if (res.ok) { + return true; + } + logger.error('Status error:', res.status); + } catch (err) { + logger.error('Could not send request', err); + } + } + + return false; +} diff --git a/react/features/recording/reducer.js b/react/features/recording/reducer.js index e63efa8a1..99bc4f535 100644 --- a/react/features/recording/reducer.js +++ b/react/features/recording/reducer.js @@ -3,12 +3,14 @@ import { ReducerRegistry } from '../base/redux'; import { CLEAR_RECORDING_SESSIONS, RECORDING_SESSION_UPDATED, + SET_MEETING_HIGHLIGHT_BUTTON_STATE, SET_PENDING_RECORDING_NOTIFICATION_UID, SET_SELECTED_RECORDING_SERVICE, SET_STREAM_KEY } from './actionTypes'; const DEFAULT_STATE = { + disableHighlightMeetingMoment: false, pendingNotificationUids: {}, selectedRecordingService: '', sessionDatas: [] @@ -65,6 +67,12 @@ ReducerRegistry.register(STORE_NAME, streamKey: action.streamKey }; + case SET_MEETING_HIGHLIGHT_BUTTON_STATE: + return { + ...state, + disableHighlightMeetingMoment: action.disabled + }; + default: return state; }