}
+ */
+function SalesforceLinkDialog() {
+ const { t } = useTranslation();
+ const classes = useStyles();
+ const dispatch = useDispatch();
+ const {
+ hasDetailsErrors,
+ hasRecordsErrors,
+ isLoading,
+ linkMeeting,
+ notes,
+ records,
+ searchTerm,
+ selectedRecord,
+ selectedRecordOwner,
+ setNotes,
+ setSearchTerm,
+ setSelectedRecord,
+ showNoResults,
+ showSearchResults
+ } = useSalesforceLinkDialog();
+
+ const handleChange = useCallback((event: Event) => {
+ const value = getFieldValue(event);
+
+ setSearchTerm(value);
+ }, [ getFieldValue ]);
+
+ const handleSubmit = useCallback(() => {
+ dispatch(hideDialog());
+ linkMeeting();
+ }, [ hideDialog, linkMeeting ]);
+
+ const renderSpinner = () => (
+
+
+
+ );
+
+ const renderDetailsErrors = () => (
+
+ {t('dialog.searchResultsDetailsError')}
+
+ );
+
+ const renderSelection = () => (
+
+
+
+ {selectedRecordOwner && }
+ {hasDetailsErrors && renderDetailsErrors()}
+
+
{t('dialog.addOptionalNote')}
+
+ );
+
+ const renderRecordsSearch = () => !selectedRecord && (
+
+
+
+ {(!isLoading && !hasRecordsErrors) && (
+
+ {showSearchResults
+ ? t('dialog.searchResults', { count: records.length })
+ : t('dialog.recentlyUsedObjects')
+ }
+
+ )}
+
+ );
+
+ const renderNoRecords = () => showNoResults && (
+
+
{t('dialog.searchResultsNotFound')}
+
{t('dialog.searchResultsTryAgain')}
+
+ );
+
+ const renderRecordsError = () => (
+
+ {t('dialog.searchResultsError')}
+
+ );
+
+ const renderContent = () => {
+ if (isLoading) {
+ return renderSpinner();
+ }
+ if (hasRecordsErrors) {
+ return renderRecordsError();
+ }
+ if (showNoResults) {
+ return renderNoRecords();
+ }
+ if (selectedRecord) {
+ return renderSelection();
+ }
+
+ return (
+
+ {records.map(item => (
+ setSelectedRecord(item) }
+ { ...item } />
+ ))}
+
+ );
+ };
+
+ return (
+
+ );
+}
+
+export default SalesforceLinkDialog;
diff --git a/react/features/salesforce/components/web/index.js b/react/features/salesforce/components/web/index.js
new file mode 100644
index 000000000..6a7e3dac0
--- /dev/null
+++ b/react/features/salesforce/components/web/index.js
@@ -0,0 +1 @@
+export { default as SalesforceLinkDialog } from './SalesforceLinkDialog';
diff --git a/react/features/salesforce/constants.js b/react/features/salesforce/constants.js
new file mode 100644
index 000000000..22bc1271c
--- /dev/null
+++ b/react/features/salesforce/constants.js
@@ -0,0 +1,36 @@
+import {
+ IconRecordAccount,
+ IconRecordContact,
+ IconRecordLead,
+ IconRecordOpportunity
+} from '../base/icons';
+
+export const NOTES_MAX_LENGTH = 255;
+
+export const NOTES_LINES = 4;
+
+export const CONTENT_HEIGHT_OFFSET = 200;
+
+export const LIST_HEIGHT_OFFSET = 250;
+
+export const RECORD_TYPE = {
+ ACCOUNT: {
+ label: 'record.type.account',
+ icon: IconRecordAccount
+ },
+ CONTACT: {
+ label: 'record.type.contact',
+ icon: IconRecordContact
+ },
+ LEAD: {
+ label: 'record.type.lead',
+ icon: IconRecordLead
+ },
+ OPPORTUNITY: {
+ label: 'record.type.opportunity',
+ icon: IconRecordOpportunity
+ },
+ OWNER: {
+ label: 'record.type.owner'
+ }
+};
diff --git a/react/features/salesforce/functions.js b/react/features/salesforce/functions.js
new file mode 100644
index 000000000..fa77d0991
--- /dev/null
+++ b/react/features/salesforce/functions.js
@@ -0,0 +1,93 @@
+// @flow
+
+import { doGetJSON } from '../base/util';
+
+/**
+ * Fetches the Salesforce records that were most recently interacted with.
+ *
+ * @param {string} url - The endpoint for the session records.
+ * @param {string} jwt - The JWT needed for authentication.
+ * @returns {Promise}
+ */
+export async function getRecentSessionRecords(
+ url: string,
+ jwt: string
+) {
+ return doGetJSON(`${url}/records/recents`, true, {
+ headers: {
+ 'Authorization': `Bearer ${jwt}`
+ }
+ });
+}
+
+/**
+ * Fetches the Salesforce records that match the search criteria.
+ *
+ * @param {string} url - The endpoint for the session records.
+ * @param {string} jwt - The JWT needed for authentication.
+ * @param {string} text - The search term for the session record to find.
+ * @returns {Promise}
+ */
+export async function searchSessionRecords(
+ url: string,
+ jwt: string,
+ text: string
+) {
+ return doGetJSON(`${url}/records?text=${text}`, true, {
+ headers: {
+ 'Authorization': `Bearer ${jwt}`
+ }
+ });
+}
+
+/**
+* Fetches the Salesforce record details from the server.
+*
+* @param {string} url - The endpoint for the record details.
+* @param {string} jwt - The JWT needed for authentication.
+* @param {Object} item - The item for which details are being retrieved.
+* @returns {Promise}
+*/
+export async function getSessionRecordDetails(
+ url: string,
+ jwt: string,
+ item: Object
+) {
+ const fullUrl = `${url}/records/${item.id}?type=${item.type}`;
+
+ return doGetJSON(fullUrl, true, {
+ headers: {
+ 'Authorization': `Bearer ${jwt}`
+ }
+ });
+}
+
+/**
+* Executes the meeting linking.
+*
+* @param {string} url - The endpoint for meeting linking.
+* @param {string} jwt - The JWT needed for authentication.
+* @param {string} sessionId - The ID of the meeting session.
+* @param {Object} body - The body of the request.
+* @returns {Object}
+*/
+export async function executeLinkMeetingRequest(
+ url: string,
+ jwt: string,
+ sessionId: String,
+ body: Object
+) {
+ const fullUrl = `${url}/sessions/${sessionId}/records/${body.id}`;
+ const res = await fetch(fullUrl, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${jwt}`
+ },
+ body: JSON.stringify(body)
+ });
+
+ const json = await res.json();
+
+ return res.ok ? json : Promise.reject(json);
+}
diff --git a/react/features/salesforce/index.js b/react/features/salesforce/index.js
new file mode 100644
index 000000000..a6be21126
--- /dev/null
+++ b/react/features/salesforce/index.js
@@ -0,0 +1,2 @@
+export * from './components';
+export * from './actions';
diff --git a/react/features/salesforce/useSalesforceLinkDialog.js b/react/features/salesforce/useSalesforceLinkDialog.js
new file mode 100644
index 000000000..1f66a82ef
--- /dev/null
+++ b/react/features/salesforce/useSalesforceLinkDialog.js
@@ -0,0 +1,146 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { getCurrentConference } from '../base/conference';
+import {
+ hideNotification,
+ NOTIFICATION_TIMEOUT_TYPE,
+ NOTIFICATION_TYPE,
+ SALESFORCE_LINK_NOTIFICATION_ID,
+ showNotification
+} from '../notifications';
+
+import {
+ executeLinkMeetingRequest,
+ getRecentSessionRecords,
+ getSessionRecordDetails,
+ searchSessionRecords
+} from './functions';
+
+export const useSalesforceLinkDialog = () => {
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
+ const [ selectedRecord, setSelectedRecord ] = useState(null);
+ const [ selectedRecordOwner, setSelectedRecordOwner ] = useState(null);
+ const [ records, setRecords ] = useState([]);
+ const [ isLoading, setLoading ] = useState(false);
+ const [ searchTerm, setSearchTerm ] = useState(null);
+ const [ notes, setNotes ] = useState(null);
+ const [ hasRecordsErrors, setRecordsErrors ] = useState(false);
+ const [ hasDetailsErrors, setDetailsErrors ] = useState(false);
+ const conference = useSelector(getCurrentConference);
+ const sessionId = conference.getMeetingUniqueId();
+ const { salesforceUrl } = useSelector(state => state['features/base/config']);
+ const { jwt } = useSelector(state => state['features/base/jwt']);
+ const showSearchResults = searchTerm && searchTerm.length > 1;
+ const showNoResults = showSearchResults && records.length === 0;
+
+ useEffect(() => {
+ const fetchRecords = async () => {
+ setRecordsErrors(false);
+ setLoading(true);
+
+ try {
+ const text = showSearchResults ? searchTerm : null;
+ const result = text
+ ? await searchSessionRecords(salesforceUrl, jwt, text)
+ : await getRecentSessionRecords(salesforceUrl, jwt);
+
+ setRecords(result);
+ } catch (error) {
+ setRecordsErrors(true);
+ }
+
+ setLoading(false);
+ };
+
+ fetchRecords();
+ }, [
+ getRecentSessionRecords,
+ jwt,
+ salesforceUrl,
+ searchSessionRecords,
+ searchTerm
+ ]);
+
+ useEffect(() => {
+ const fetchRecordDetails = async () => {
+ setDetailsErrors(false);
+ setSelectedRecordOwner(null);
+ try {
+ const result = await getSessionRecordDetails(salesforceUrl, jwt, selectedRecord);
+
+ setSelectedRecordOwner({
+ id: result.id,
+ name: result.ownerName,
+ type: 'OWNER'
+ });
+ } catch (error) {
+ setDetailsErrors(true);
+ }
+ };
+
+ fetchRecordDetails();
+ }, [
+ jwt,
+ getSessionRecordDetails,
+ salesforceUrl,
+ selectedRecord
+ ]);
+
+ const linkMeeting = useCallback(async () => {
+ dispatch(showNotification({
+ titleKey: 'notify.linkToSalesforceProgress',
+ uid: SALESFORCE_LINK_NOTIFICATION_ID,
+ appearance: NOTIFICATION_TYPE.NORMAL
+ }, NOTIFICATION_TIMEOUT_TYPE.STICKY));
+
+ try {
+ await executeLinkMeetingRequest(salesforceUrl, jwt, sessionId, {
+ id: selectedRecord.id,
+ type: selectedRecord.type,
+ notes
+ });
+ dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
+ dispatch(showNotification({
+ titleKey: 'notify.linkToSalesforceSuccess',
+ uid: SALESFORCE_LINK_NOTIFICATION_ID,
+ appearance: NOTIFICATION_TYPE.SUCCESS
+ }, NOTIFICATION_TIMEOUT_TYPE.LONG));
+ } catch (error) {
+ dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
+ dispatch(showNotification({
+ titleKey: 'notify.linkToSalesforceError',
+ descriptionKey: error?.messageKey && t(error.messageKey),
+ uid: SALESFORCE_LINK_NOTIFICATION_ID,
+ appearance: NOTIFICATION_TYPE.ERROR
+ }, NOTIFICATION_TIMEOUT_TYPE.LONG));
+ }
+
+ }, [
+ executeLinkMeetingRequest,
+ hideNotification,
+ jwt,
+ salesforceUrl,
+ selectedRecord,
+ showNotification
+ ]);
+
+ return {
+ hasDetailsErrors,
+ hasRecordsErrors,
+ isLoading,
+ linkMeeting,
+ notes,
+ records,
+ searchTerm,
+ selectedRecord,
+ selectedRecordOwner,
+ setNotes,
+ setSearchTerm,
+ setSelectedRecord,
+ showNoResults,
+ showSearchResults
+ };
+};
diff --git a/react/features/toolbox/components/native/LinkToSalesforceButton.js b/react/features/toolbox/components/native/LinkToSalesforceButton.js
new file mode 100644
index 000000000..4cc81c1bd
--- /dev/null
+++ b/react/features/toolbox/components/native/LinkToSalesforceButton.js
@@ -0,0 +1,46 @@
+// @flow
+
+import { createToolbarEvent, sendAnalytics } from '../../../analytics';
+import { translate } from '../../../base/i18n';
+import { IconSalesforce } 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';
+
+/**
+ * Implementation of a button for opening the Salesforce link dialog.
+ */
+class LinkToSalesforceButton extends AbstractButton {
+ accessibilityLabel = 'toolbar.accessibilityLabel.linkToSalesforce';
+ icon = IconSalesforce;
+ label = 'toolbar.linkToSalesforce';
+
+ /**
+ * Handles clicking / pressing the button, and opens the Salesforce link dialog.
+ *
+ * @protected
+ * @returns {void}
+ */
+ _handleClick() {
+ sendAnalytics(createToolbarEvent('link.to.salesforce'));
+
+ return navigate(screen.conference.salesforce);
+ }
+}
+
+/**
+ * Function that maps parts of Redux state tree into component props.
+ *
+ * @param {Object} state - Redux state.
+ * @private
+ * @returns {Props}
+ */
+function mapStateToProps(state) {
+ return {
+ visible: Boolean(state['features/base/config'].salesforceUrl)
+ };
+}
+
+export default translate(connect(mapStateToProps)(LinkToSalesforceButton));
diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js
index be084ed34..cf095e0b3 100644
--- a/react/features/toolbox/components/native/OverflowMenu.js
+++ b/react/features/toolbox/components/native/OverflowMenu.js
@@ -23,6 +23,7 @@ import { getMovableButtons } from '../../functions.native';
import HelpButton from '../HelpButton';
import AudioOnlyButton from './AudioOnlyButton';
+import LinkToSalesforceButton from './LinkToSalesforceButton';
import RaiseHandButton from './RaiseHandButton';
import ScreenSharingButton from './ScreenSharingButton.js';
import ToggleCameraButton from './ToggleCameraButton';
@@ -109,7 +110,11 @@ class OverflowMenu extends PureComponent {
* @returns {ReactElement}
*/
render() {
- const { _bottomSheetStyles, _width, _reactionsEnabled } = this.props;
+ const {
+ _bottomSheetStyles,
+ _width,
+ _reactionsEnabled
+ } = this.props;
const toolbarButtons = getMovableButtons(_width);
const buttonProps = {
@@ -144,6 +149,7 @@ class OverflowMenu extends PureComponent {
+
diff --git a/react/features/toolbox/components/web/LinkToSalesforceButton.js b/react/features/toolbox/components/web/LinkToSalesforceButton.js
new file mode 100644
index 000000000..93e67b44e
--- /dev/null
+++ b/react/features/toolbox/components/web/LinkToSalesforceButton.js
@@ -0,0 +1,45 @@
+// @flow
+
+import { createToolbarEvent, sendAnalytics } from '../../../analytics';
+import { openDialog } from '../../../base/dialog';
+import { translate } from '../../../base/i18n';
+import { IconSalesforce } from '../../../base/icons';
+import { connect } from '../../../base/redux';
+import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
+import { SalesforceLinkDialog } from '../../../salesforce';
+
+/**
+ * The type of the React {@code Component} props of {@link LinkToSalesforce}.
+ */
+ type Props = AbstractButtonProps & {
+
+ /**
+ * The redux {@code dispatch} function.
+ */
+ dispatch: Function
+};
+
+/**
+ * Implementation of a button for opening the Salesforce link dialog.
+ */
+class LinkToSalesforce extends AbstractButton {
+ accessibilityLabel = 'toolbar.accessibilityLabel.linkToSalesforce';
+ icon = IconSalesforce;
+ label = 'toolbar.linkToSalesforce';
+ tooltip = 'toolbar.linkToSalesforce';
+
+ /**
+ * Handles clicking / pressing the button, and opens the Salesforce link dialog.
+ *
+ * @protected
+ * @returns {void}
+ */
+ _handleClick() {
+ const { dispatch } = this.props;
+
+ sendAnalytics(createToolbarEvent('link.to.salesforce'));
+ dispatch(openDialog(SalesforceLinkDialog));
+ }
+}
+
+export default translate(connect()(LinkToSalesforce));
diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js
index 0c31c2c91..433b95b13 100644
--- a/react/features/toolbox/components/web/Toolbox.js
+++ b/react/features/toolbox/components/web/Toolbox.js
@@ -85,6 +85,7 @@ import MuteEveryonesVideoButton from '../MuteEveryonesVideoButton';
import AudioSettingsButton from './AudioSettingsButton';
import FullscreenButton from './FullscreenButton';
+import LinkToSalesforceButton from './LinkToSalesforceButton';
import OverflowMenuButton from './OverflowMenuButton';
import ProfileButton from './ProfileButton';
import Separator from './Separator';
@@ -158,6 +159,11 @@ type Props = {
*/
_fullScreen: boolean,
+ /**
+ * Whether the app has Salesforce integration.
+ */
+ _hasSalesforce: boolean,
+
/**
* Whether or not the app is running in an ios mobile browser.
*/
@@ -603,6 +609,7 @@ class Toolbox extends Component {
_feedbackConfigured,
_isIosMobile,
_isMobile,
+ _hasSalesforce,
_screenSharing
} = this.props;
@@ -715,6 +722,12 @@ class Toolbox extends Component {
group: 2
};
+ const linkToSalesforce = _hasSalesforce && {
+ key: 'linktosalesforce',
+ Content: LinkToSalesforceButton,
+ group: 2
+ };
+
const muteEveryone = {
key: 'mute-everyone',
Content: MuteEveryoneButton,
@@ -811,6 +824,7 @@ class Toolbox extends Component {
recording,
localRecording,
livestreaming,
+ linkToSalesforce,
muteEveryone,
muteVideoEveryone,
shareVideo,
@@ -1351,7 +1365,8 @@ function _mapStateToProps(state, ownProps) {
disableProfile,
enableFeaturesBasedOnToken,
iAmRecorder,
- iAmSipGateway
+ iAmSipGateway,
+ salesforceUrl
} = state['features/base/config'];
const {
fullScreen,
@@ -1399,6 +1414,7 @@ function _mapStateToProps(state, ownProps) {
_isIosMobile: isIosMobileBrowser(),
_isMobile: isMobileBrowser(),
_isVpaasMeeting: isVpaasMeeting(state),
+ _hasSalesforce: Boolean(salesforceUrl),
_localParticipantID: localParticipant?.id,
_localVideo: localVideo,
_overflowMenuVisible: overflowMenuVisible,