diff --git a/config.js b/config.js index 62fd60474..67127187a 100644 --- a/config.js +++ b/config.js @@ -1001,6 +1001,15 @@ var config = { // disableGrantModerator: true // }, + // Endpoint that enables support for salesforce integration with in-meeting resource linking + // This is required for: + // listing the most recent records - salesforceUrl/records/recents + // searching records - salesforceUrl/records?text=${text} + // retrieving record details - salesforceUrl/records/${id}?type=${type} + // and linking the meeting - salesforceUrl/sessions/${sessionId}/records/${id} + // + // salesforceUrl: 'https://api.example.com/', + // If set to true all muting operations of remote participants will be disabled. // disableRemoteMute: true, @@ -1247,6 +1256,7 @@ var config = { // 'notify.invitedThreePlusMembers', // shown when 3+ participants have been invited // 'notify.invitedTwoMembers', // shown when 2 participants have been invited // 'notify.kickParticipant', // shown when a participant is kicked + // 'notify.linkToSalesforce', // shown when joining a meeting with salesforce integration // 'notify.moderationStartedTitle', // shown when AV moderation is activated // 'notify.moderationStoppedTitle', // shown when AV moderation is deactivated // 'notify.moderationInEffectTitle', // shown when user attempts to unmute audio during AV moderation diff --git a/lang/main.json b/lang/main.json index 5de6a39bb..d81f8e5a6 100644 --- a/lang/main.json +++ b/lang/main.json @@ -216,6 +216,8 @@ "liveStreaming": "Live Stream" }, "add": "Add", + "addMeetingNote": "Add a note about this meeting", + "addOptionalNote": "Add a note (optional):", "allow": "Allow", "alreadySharedVideoMsg": "Another participant is already sharing a video. This conference allows only one shared video at a time.", "alreadySharedVideoTitle": "Only one shared video is allowed at a time", @@ -267,6 +269,8 @@ "kickParticipantDialog": "Are you sure you want to kick this participant?", "kickParticipantTitle": "Kick this participant?", "kickTitle": "Ouch! {{participantDisplayName}} kicked you out of the meeting", + "linkMeeting": "Link meeting", + "linkMeetingTitle": "Link meeting to Salesforce", "liveStreaming": "Live Streaming", "liveStreamingDisabledBecauseOfActiveRecordingTooltip": "Not possible while recording is active", "liveStreamingDisabledTooltip": "Start live stream disabled.", @@ -321,6 +325,7 @@ "popupError": "Your browser is blocking pop-up windows from this site. Please enable pop-ups in your browser's security settings and try again.", "popupErrorTitle": "Pop-up blocked", "readMore": "more", + "recentlyUsedObjects": "Your recently used objects", "recording": "Recording", "recordingDisabledBecauseOfActiveLiveStreamingTooltip": "Not possible while a live stream is active", "recordingDisabledTooltip": "Start recording disabled.", @@ -343,6 +348,12 @@ "screenSharingFailed": "Oops! Something went wrong, we weren’t able to start screen sharing!", "screenSharingFailedTitle": "Screen sharing failed!", "screenSharingPermissionDeniedError": "Oops! Something went wrong with your screen sharing permissions. Please reload and try again.", + "searchInSalesforce": "Search in Salesforce", + "searchResults": "Search results({{count}})", + "searchResultsDetailsError": "Something went wrong while retrieving owner data.", + "searchResultsError": "Something went wrong while retrieving data.", + "searchResultsNotFound": "No search results found.", + "searchResultsTryAgain": "Try using alternative keywords.", "sendPrivateMessage": "You recently received a private message. Did you intend to reply to that privately, or you want to send your message to the group?", "sendPrivateMessageCancel": "Send to the group", "sendPrivateMessageOk": "Send privately", @@ -624,6 +635,12 @@ "leftOneMember": "{{name}} left the meeting", "leftThreePlusMembers": "{{name}} and many others left the meeting", "leftTwoMembers": "{{first}} and {{second}} left the meeting", + "linkToSalesforce": "Link to Salesforce", + "linkToSalesforceDescription": "You can link the meeting summary to a Salesforce object.", + "linkToSalesforceError": "Failed to link meeting to Salesforce", + "linkToSalesforceKey": "Link this meeting", + "linkToSalesforceProgress": "Linking meeting to Salesforce...", + "linkToSalesforceSuccess": "The meeting was linked to Salesforce", "me": "Me", "moderationInEffectCSDescription": "Please raise hand if you want to share your screen.", "moderationInEffectCSTitle": "Screen sharing is blocked by the moderator", @@ -817,6 +834,18 @@ }, "raisedHand": "Would like to speak", "raisedHandsLabel": "Number of raised hands", + "record": { + "already": { + "linked": "Record is already linked to this session." + }, + "type": { + "account": "Account", + "contact": "Contact", + "lead": "Lead", + "opportunity": "Opportunity", + "owner": "Owner" + } + }, "recording": { "authDropboxText": "Upload to Dropbox", "availableSpace": "Available space: {{spaceLeft}} MB (approximately {{duration}} minutes of recording)", @@ -984,6 +1013,7 @@ "kick": "Kick participant", "laugh": "Laugh", "like": "Thumbs Up", + "linkToSalesforce": "Link to Salesforce", "lobbyButton": "Enable/disable lobby mode", "localRecording": "Toggle local recording controls", "lockRoom": "Toggle meeting password", @@ -1052,6 +1082,7 @@ "laugh": "Laugh", "leaveBreakoutRoom": "Leave breakout room", "like": "Thumbs Up", + "linkToSalesforce": "Link to Salesforce", "lobbyButtonDisable": "Disable lobby mode", "lobbyButtonEnable": "Enable lobby mode", "login": "Login", diff --git a/react/features/base/conference/functions.js b/react/features/base/conference/functions.js index e6d0c4286..42c822f14 100644 --- a/react/features/base/conference/functions.js +++ b/react/features/base/conference/functions.js @@ -13,7 +13,11 @@ import { participantLeft } from '../participants'; import { toState } from '../redux'; -import { getBackendSafePath, getJitsiMeetGlobalNS, safeDecodeURIComponent } from '../util'; +import { + getBackendSafePath, + getJitsiMeetGlobalNS, + safeDecodeURIComponent +} from '../util'; import { AVATAR_URL_COMMAND, diff --git a/react/features/base/conference/middleware.any.js b/react/features/base/conference/middleware.any.js index 3e33ebf35..1caa8e38c 100644 --- a/react/features/base/conference/middleware.any.js +++ b/react/features/base/conference/middleware.any.js @@ -11,7 +11,11 @@ import { import { reloadNow } from '../../app/actions'; import { removeLobbyChatParticipant } from '../../chat/actions.any'; import { openDisplayNamePrompt } from '../../display-name'; -import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../notifications'; +import { + NOTIFICATION_TIMEOUT_TYPE, + showErrorNotification +} from '../../notifications'; +import { showSalesforceNotification } from '../../salesforce'; import { CONNECTION_ESTABLISHED, CONNECTION_FAILED, connectionDisconnected } from '../connection'; import { validateJwt } from '../jwt'; import { JitsiConferenceErrors } from '../lib-jitsi-meet'; @@ -211,7 +215,10 @@ function _conferenceJoined({ dispatch, getState }, next, action) { const result = next(action); const { conference } = action; const { pendingSubjectChange } = getState()['features/base/conference']; - const { requireDisplayName, disableBeforeUnloadHandlers = false } = getState()['features/base/config']; + const { + disableBeforeUnloadHandlers = false, + requireDisplayName + } = getState()['features/base/config']; dispatch(removeLobbyChatParticipant(true)); @@ -233,6 +240,9 @@ function _conferenceJoined({ dispatch, getState }, next, action) { dispatch(openDisplayNamePrompt(undefined)); } + + dispatch(showSalesforceNotification()); + return result; } diff --git a/react/features/base/config/configWhitelist.js b/react/features/base/config/configWhitelist.js index f78cd2fdb..982adf133 100644 --- a/react/features/base/config/configWhitelist.js +++ b/react/features/base/config/configWhitelist.js @@ -201,6 +201,7 @@ export default [ 'readOnlyName', 'replaceParticipant', 'resolution', + 'salesforceUrl', 'screenshotCapture', 'startAudioMuted', 'startAudioOnly', diff --git a/react/features/base/config/constants.js b/react/features/base/config/constants.js index dc0ef3604..898071437 100644 --- a/react/features/base/config/constants.js +++ b/react/features/base/config/constants.js @@ -27,6 +27,7 @@ export const TOOLBAR_BUTTONS = [ 'hangup', 'help', 'invite', + 'linktosalesforce', 'livestreaming', 'microphone', 'mute-everyone', diff --git a/react/features/base/icons/svg/account-record.svg b/react/features/base/icons/svg/account-record.svg new file mode 100644 index 000000000..8e35ddca7 --- /dev/null +++ b/react/features/base/icons/svg/account-record.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/react/features/base/icons/svg/contact-record.svg b/react/features/base/icons/svg/contact-record.svg new file mode 100644 index 000000000..f9875a1d7 --- /dev/null +++ b/react/features/base/icons/svg/contact-record.svg @@ -0,0 +1,4 @@ + + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index 1023b2fe2..847619fbd 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -97,6 +97,10 @@ export { default as IconPresentation } from './presentation.svg'; export { default as IconRaisedHand } from './raised-hand.svg'; export { default as IconRaisedHandHollow } from './raised-hand-hollow.svg'; export { default as IconRec } from './rec.svg'; +export { default as IconRecordAccount } from './account-record.svg'; +export { default as IconRecordContact } from './contact-record.svg'; +export { default as IconRecordLead } from './lead-record.svg'; +export { default as IconRecordOpportunity } from './opportunity-record.svg'; export { default as IconRemoteControlStart } from './play.svg'; export { default as IconRemoteControlStop } from './stop.svg'; export { default as IconReply } from './reply.svg'; @@ -104,6 +108,7 @@ export { default as IconRestore } from './restore.svg'; export { default as IconRingGroup } from './icon-ring-group.svg'; export { default as IconRoomLock } from './security.svg'; export { default as IconRoomUnlock } from './security-locked.svg'; +export { default as IconSalesforce } from './salesforce.svg'; export { default as IconSecurityOff } from './security-off.svg'; export { default as IconSecurityOn } from './security-on.svg'; export { default as IconSearch } from './search.svg'; diff --git a/react/features/base/icons/svg/lead-record.svg b/react/features/base/icons/svg/lead-record.svg new file mode 100644 index 000000000..bee3d59a0 --- /dev/null +++ b/react/features/base/icons/svg/lead-record.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/react/features/base/icons/svg/opportunity-record.svg b/react/features/base/icons/svg/opportunity-record.svg new file mode 100644 index 000000000..afa78cb4c --- /dev/null +++ b/react/features/base/icons/svg/opportunity-record.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/react/features/base/icons/svg/salesforce.svg b/react/features/base/icons/svg/salesforce.svg new file mode 100644 index 000000000..5bbb2238e --- /dev/null +++ b/react/features/base/icons/svg/salesforce.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/util/httpUtils.js b/react/features/base/util/httpUtils.js index 9dfa598dd..5692d3d36 100644 --- a/react/features/base/util/httpUtils.js +++ b/react/features/base/util/httpUtils.js @@ -13,11 +13,12 @@ const RETRY_TIMEOUT = 3000; * * @param {string} url - The URL to perform a GET against. * @param {?boolean} retry - Whether the request will be retried after short timeout. + * @param {?Object} options - The request options. * @returns {Promise} The response body, in JSON format, will be * through the Promise. */ -export function doGetJSON(url, retry) { - const fetchPromise = fetch(url) +export function doGetJSON(url, retry, options) { + const fetchPromise = fetch(url, options) .then(response => { const jsonify = response.json(); diff --git a/react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.js b/react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.js index 957230e85..d61ed910a 100644 --- a/react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.js +++ b/react/features/mobile/navigation/components/conference/components/ConferenceNavigationContainer.js @@ -17,6 +17,8 @@ import { ParticipantsPane } from '../../../../../participants-pane/components/na import { StartLiveStreamDialog } from '../../../../../recording'; import { StartRecordingDialog } from '../../../../../recording/components/Recording/native'; +import SalesforceLinkDialog + from '../../../../../salesforce/components/native/SalesforceLinkDialog'; import SecurityDialog from '../../../../../security/components/security-dialog/native/SecurityDialog'; import SpeakerStats @@ -31,6 +33,7 @@ import { navigationContainerTheme, participantsScreenOptions, recordingScreenOptions, + salesforceScreenOptions, securityScreenOptions, sharedDocumentScreenOptions, speakerStatsScreenOptions @@ -115,6 +118,13 @@ const ConferenceNavigationContainer = () => { ...speakerStatsScreenOptions, title: t('speakerStats.speakerStats') }} /> + { backgroundColor: theme.palette.iconError }, + '&.success': { + backgroundColor: theme.palette.success01 + }, + '&.warning': { backgroundColor: theme.palette.warning01 } diff --git a/react/features/notifications/constants.js b/react/features/notifications/constants.js index 1381e65f2..ed4fafe8f 100644 --- a/react/features/notifications/constants.js +++ b/react/features/notifications/constants.js @@ -58,6 +58,13 @@ export const NOTIFICATION_ICON = { PARTICIPANTS: 'participants' }; +/** + * The identifier of the salesforce link notification. + * + * @type {string} + */ +export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION'; + /** * The identifier of the lobby notification. * diff --git a/react/features/salesforce/actions.js b/react/features/salesforce/actions.js new file mode 100644 index 000000000..bcc5f9959 --- /dev/null +++ b/react/features/salesforce/actions.js @@ -0,0 +1,39 @@ +// @flow + +import { openDialog } from '../base/dialog'; +import { + hideNotification, + NOTIFICATION_TIMEOUT_TYPE, + NOTIFICATION_TYPE, + SALESFORCE_LINK_NOTIFICATION_ID, + showNotification +} from '../notifications'; + +import { SalesforceLinkDialog } from './components'; + +/** + * Displays the notification for linking the meeting to Salesforce. + * + * @returns {void} + */ +export function showSalesforceNotification() { + return (dispatch: Object, getState: Function) => { + const { salesforceUrl } = getState()['features/base/config']; + + if (!salesforceUrl) { + return; + } + + dispatch(showNotification({ + descriptionKey: 'notify.linkToSalesforceDescription', + titleKey: 'notify.linkToSalesforce', + uid: SALESFORCE_LINK_NOTIFICATION_ID, + customActionNameKey: [ 'notify.linkToSalesforceKey' ], + customActionHandler: [ () => { + dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID)); + dispatch(openDialog(SalesforceLinkDialog)); + } ], + appearance: NOTIFICATION_TYPE.NORMAL + }, NOTIFICATION_TIMEOUT_TYPE.STICKY)); + }; +} diff --git a/react/features/salesforce/components/_.native.js b/react/features/salesforce/components/_.native.js new file mode 100644 index 000000000..738c4d2b8 --- /dev/null +++ b/react/features/salesforce/components/_.native.js @@ -0,0 +1 @@ +export * from './native'; diff --git a/react/features/salesforce/components/_.web.js b/react/features/salesforce/components/_.web.js new file mode 100644 index 000000000..b80c83af3 --- /dev/null +++ b/react/features/salesforce/components/_.web.js @@ -0,0 +1 @@ +export * from './web'; diff --git a/react/features/salesforce/components/index.js b/react/features/salesforce/components/index.js new file mode 100644 index 000000000..cda61441e --- /dev/null +++ b/react/features/salesforce/components/index.js @@ -0,0 +1 @@ +export * from './_'; diff --git a/react/features/salesforce/components/native/RecordItem.js b/react/features/salesforce/components/native/RecordItem.js new file mode 100644 index 000000000..ddd72dcad --- /dev/null +++ b/react/features/salesforce/components/native/RecordItem.js @@ -0,0 +1,83 @@ +// @flow + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { View, Text, TouchableHighlight } from 'react-native'; + +import { Icon } from '../../../base/icons'; +import { RECORD_TYPE } from '../../constants'; + +import styles from './styles'; + +/** + * The type of the React {@code Component} props of {@link RecordItem}. + */ +type Props = { + + /** + * The id of the record. + */ + id: String, + + /** + * The name of the record. + */ + name: String, + + /** + * The handler for the click event. + */ + onClick: Function, + + /** + * The type of the record. + */ + type: String +} + +/** + * Component to render Record data. + * + * @param {Props} props - The props of the component. + * @returns {React$Element} + */ +export const RecordItem = ({ + id, + name, + type, + /* eslint-disable-next-line no-empty-function */ + onClick = () => {} +}: Props) => { + const { t } = useTranslation(); + const IconRecord = RECORD_TYPE[type].icon; + + return ( + + + + {IconRecord && ( + + )} + + + + {name} + + + {t(RECORD_TYPE[type].label)} + + + + + ); +}; diff --git a/react/features/salesforce/components/native/SalesforceLinkDialog.js b/react/features/salesforce/components/native/SalesforceLinkDialog.js new file mode 100644 index 000000000..35f583b6f --- /dev/null +++ b/react/features/salesforce/components/native/SalesforceLinkDialog.js @@ -0,0 +1,192 @@ +// @flow + +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { View, SafeAreaView, ScrollView, Text, TextInput, Platform } from 'react-native'; +import { Button, withTheme } from 'react-native-paper'; +import { useSelector } from 'react-redux'; + +import { Icon, IconSearch } from '../../../base/icons'; +import JitsiScreen from '../../../base/modal/components/JitsiScreen'; +import { LoadingIndicator } from '../../../base/react'; +import BaseTheme from '../../../base/ui/components/BaseTheme.native'; +import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef'; +import { screen } from '../../../mobile/navigation/routes'; +import { CONTENT_HEIGHT_OFFSET, LIST_HEIGHT_OFFSET, NOTES_LINES, NOTES_MAX_LENGTH } from '../../constants'; +import { useSalesforceLinkDialog } from '../../useSalesforceLinkDialog'; + +import { RecordItem } from './RecordItem'; +import styles from './styles'; + +/** + * Component that renders the Salesforce link dialog. + * + * @returns {React$Element} + */ +const SalesforceLinkDialog = () => { + const { t } = useTranslation(); + const { clientHeight } = useSelector(state => state['features/base/responsive-ui']); + const { + hasDetailsErrors, + hasRecordsErrors, + isLoading, + linkMeeting, + notes, + records, + searchTerm, + selectedRecord, + selectedRecordOwner, + setNotes, + setSearchTerm, + setSelectedRecord, + showNoResults, + showSearchResults + } = useSalesforceLinkDialog(); + + const handlePress = useCallback(() => { + navigate(screen.conference.main); + linkMeeting(); + }, [ navigate, linkMeeting ]); + + const renderSpinner = () => ( + + + + ); + + const renderDetailsErrors = () => ( + + {t('dialog.searchResultsDetailsError')} + + ); + + const renderSelection = () => ( + + + + + {selectedRecordOwner && } + {hasDetailsErrors && renderDetailsErrors()} + + + {t('dialog.addOptionalNote')} + + setNotes(value) } + placeholder = { t('dialog.addMeetingNote') } + placeholderTextColor = { BaseTheme.palette.text03 } + style = { styles.notes } + value = { notes } /> + + + ); + + const renderRecordsSearch = () => ( + + + setSearchTerm(value) } + placeholder = { t('dialog.searchInSalesforce') } + placeholderTextColor = { BaseTheme.palette.text03 } + style = { styles.recordsSearch } + value = { searchTerm } /> + {(!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 ( + + + {!selectedRecord && renderRecordsSearch()} + {renderContent()} + + { + selectedRecord + && +