feat(salesforce) - link resources to the current session (#10992)

This commit is contained in:
Mihaela Dumitru 2022-03-08 11:10:30 +02:00 committed by GitHub
parent 9d8ae922a9
commit 98256a8d5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1368 additions and 7 deletions

View File

@ -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

View File

@ -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 werent 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",

View File

@ -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,

View File

@ -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;
}

View File

@ -201,6 +201,7 @@ export default [
'readOnlyName',
'replaceParticipant',
'resolution',
'salesforceUrl',
'screenshotCapture',
'startAudioMuted',
'startAudioOnly',

View File

@ -27,6 +27,7 @@ export const TOOLBAR_BUTTONS = [
'hangup',
'help',
'invite',
'linktosalesforce',
'livestreaming',
'microphone',
'mute-everyone',

View File

@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M0 3C0 1.34315 1.34315 0 3 0H37C38.6569 0 40 1.34315 40 3V37C40 38.6569 38.6569 40 37 40H3C1.34315 40 0 38.6569 0 37V3Z" fill="#818DDB"/>
<path d="M30.9976 20.1427C31.0414 19.399 30.4727 19.1803 30.2539 19.1803H22.3798C21.6799 19.1803 21.5924 19.924 21.5924 19.9677V28.498H30.9976V20.1427ZM25.5732 26.267C25.5732 26.6607 25.267 27.0107 24.8296 27.0107H24.0859C23.6922 27.0107 23.3422 26.6607 23.3422 26.267V25.5233C23.3422 25.1296 23.6484 24.7797 24.0859 24.7797H24.8296C25.2233 24.7797 25.5732 25.1296 25.5732 25.5233V26.267ZM25.5732 22.5487C25.5732 22.9424 25.267 23.2923 24.8296 23.2923H24.0859C23.6922 23.2923 23.3422 22.9424 23.3422 22.5487V21.805C23.3422 21.4113 23.6484 21.0614 24.0859 21.0614H24.8296C25.2233 21.0614 25.5732 21.4113 25.5732 21.805V22.5487ZM29.2041 26.267C29.2041 26.6607 28.8978 27.0107 28.4604 27.0107H27.7167C27.323 27.0107 26.9731 26.6607 26.9731 26.267V25.5233C26.9731 25.1296 27.2793 24.7797 27.7167 24.7797H28.4604C28.8541 24.7797 29.2041 25.1296 29.2041 25.5233V26.267ZM29.2041 22.5487C29.2041 22.9424 28.8978 23.2923 28.4604 23.2923H27.7167C27.323 23.2923 26.9731 22.9424 26.9731 22.5487V21.805C26.9731 21.4113 27.2793 21.0614 27.7167 21.0614H28.4604C28.8541 21.0614 29.2041 21.4113 29.2041 21.805V22.5487Z" fill="white"/>
<path d="M23.7359 16.2057C23.7359 15.8994 23.7359 11.9624 23.7359 11.9624C23.7797 11.2187 23.2547 11 23.036 11H10.7874C10.0875 11 10 11.7437 10 11.7874V28.498H19.4052V17.8242C19.4052 17.8242 19.4052 16.9493 20.1926 16.9493C20.1926 16.9493 22.6423 16.9493 23.036 16.9493C23.4735 16.9493 23.7359 16.5119 23.7359 16.2057ZM13.9808 25.9171C13.9808 26.3108 13.6746 26.6607 13.2371 26.6607H12.5372C12.1435 26.6607 11.7935 26.3108 11.7935 25.9171V25.1734C11.7935 24.7797 12.0998 24.4297 12.5372 24.4297H13.2809C13.6746 24.4297 14.0245 24.7797 14.0245 25.1734V25.9171H13.9808ZM13.9808 22.155C13.9808 22.5487 13.6746 22.8986 13.2371 22.8986H12.5372C12.1435 22.8986 11.7935 22.5487 11.7935 22.155V21.4113C11.7935 21.0176 12.0998 20.6677 12.5372 20.6677H13.2809C13.6746 20.6677 14.0245 21.0176 14.0245 21.4113V22.155H13.9808ZM13.9808 18.4367C13.9808 18.8304 13.6746 19.1803 13.2371 19.1803H12.5372C12.1435 19.1803 11.7935 18.8304 11.7935 18.4367V17.693C11.7935 17.2993 12.0998 16.9493 12.5372 16.9493H13.2809C13.6746 16.9493 14.0245 17.2993 14.0245 17.693V18.4367H13.9808ZM13.9808 14.7183C13.9808 15.112 13.6746 15.462 13.2371 15.462H12.5372C12.1435 15.462 11.7935 15.112 11.7935 14.7183V13.9747C11.7935 13.581 12.0998 13.231 12.5372 13.231H13.2809C13.6746 13.231 14.0245 13.581 14.0245 13.9747V14.7183H13.9808ZM17.9616 25.9171C17.9616 26.3108 17.6554 26.6607 17.2179 26.6607H16.4743C16.0806 26.6607 15.7306 26.3108 15.7306 25.9171V25.1734C15.7306 24.7797 16.0368 24.4297 16.4743 24.4297H17.2179C17.6116 24.4297 17.9616 24.7797 17.9616 25.1734V25.9171ZM17.9616 22.155C17.9616 22.5487 17.6554 22.8986 17.2179 22.8986H16.4743C16.0806 22.8986 15.7306 22.5487 15.7306 22.155V21.4113C15.7306 21.0176 16.0368 20.6677 16.4743 20.6677H17.2179C17.6116 20.6677 17.9616 21.0176 17.9616 21.4113V22.155ZM17.9616 18.4367C17.9616 18.8304 17.6554 19.1803 17.2179 19.1803H16.4743C16.0806 19.1803 15.7306 18.8304 15.7306 18.4367V17.693C15.7306 17.2993 16.0368 16.9493 16.4743 16.9493H17.2179C17.6116 16.9493 17.9616 17.2993 17.9616 17.693V18.4367ZM17.9616 14.7183C17.9616 15.112 17.6554 15.462 17.2179 15.462H16.4743C16.0806 15.462 15.7306 15.112 15.7306 14.7183V13.9747C15.7306 13.581 16.0368 13.231 16.4743 13.231H17.2179C17.6116 13.231 17.9616 13.581 17.9616 13.9747V14.7183ZM21.9424 14.7183C21.9424 15.112 21.6362 15.462 21.1987 15.462H20.4988C20.1051 15.462 19.7551 15.112 19.7551 14.7183V13.9747C19.7551 13.581 20.0614 13.231 20.4988 13.231H21.2425C21.6362 13.231 21.9861 13.581 21.9861 13.9747V14.7183H21.9424Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,4 @@
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M0 3C0 1.34315 1.34315 0 3 0H37C38.6569 0 40 1.34315 40 3V37C40 38.6569 38.6569 40 37 40H3C1.34315 40 0 38.6569 0 37V3Z" fill="#A094ED"/>
<path d="M29.12 12.02H10.88C9.626 12.02 8.6 13.046 8.6 14.3V25.32C8.6 26.574 9.626 27.6 10.88 27.6H29.12C30.374 27.6 31.4 26.574 31.4 25.32V14.3C31.4 13.046 30.374 12.02 29.12 12.02ZM19.468 24.94H12.932C12.21 24.94 11.64 24.142 11.64 23.382C11.678 22.242 12.856 21.558 14.11 20.988C14.984 20.608 15.098 20.266 15.098 19.886C15.098 19.506 14.87 19.164 14.566 18.898C14.072 18.442 13.768 17.758 13.768 16.998C13.768 15.554 14.642 14.338 16.162 14.338C17.682 14.338 18.556 15.554 18.556 16.998C18.556 17.758 18.29 18.442 17.758 18.898C17.454 19.164 17.226 19.506 17.226 19.886C17.226 20.266 17.34 20.608 18.214 20.95C19.468 21.482 20.646 22.242 20.684 23.382C20.76 24.142 20.19 24.94 19.468 24.94ZM28.36 22.28C28.36 22.698 28.018 23.04 27.6 23.04H24.18C23.762 23.04 23.42 22.698 23.42 22.28V21.14C23.42 20.722 23.762 20.38 24.18 20.38H27.6C28.018 20.38 28.36 20.722 28.36 21.14V22.28ZM28.36 18.1C28.36 18.518 28.018 18.86 27.6 18.86H21.9C21.482 18.86 21.14 18.518 21.14 18.1V16.96C21.14 16.542 21.482 16.2 21.9 16.2H27.6C28.018 16.2 28.36 16.542 28.36 16.96V18.1Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -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';

View File

@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M0 3C0 1.34315 1.34315 0 3 0H37C38.6569 0 40 1.34315 40 3V37C40 38.6569 38.6569 40 37 40H3C1.34315 40 0 38.6569 0 37V3Z" fill="#EA8F6A"/>
<path d="M20.0186 16.0059C21.6771 16.0059 23.0216 14.6614 23.0216 13.0029C23.0216 11.3445 21.6771 10 20.0186 10C18.3601 10 17.0157 11.3445 17.0157 13.0029C17.0157 14.6614 18.3601 16.0059 20.0186 16.0059Z" fill="white"/>
<path d="M29.3611 18.0079H10.6761C10.0088 18.0079 9.74188 18.842 10.3091 19.209L15.1805 22.3454C15.4141 22.5123 15.5476 22.8126 15.4475 23.0795L13.6123 29.1855C13.4121 29.8528 14.2797 30.3199 14.7802 29.8194L19.5181 24.8145C19.7851 24.5142 20.2522 24.5142 20.5191 24.8145L25.2571 29.8194C25.7242 30.3199 26.5917 29.8528 26.4249 29.1855L24.5898 23.0795C24.523 22.8126 24.6231 22.5123 24.8567 22.3454L29.7281 19.209C30.2954 18.842 30.0284 18.0079 29.3611 18.0079Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 936 B

View File

@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M0 3C0 1.34315 1.34315 0 3 0H37C38.6569 0 40 1.34315 40 3V37C40 38.6569 38.6569 40 37 40H3C1.34315 40 0 38.6569 0 37V3Z" fill="#F3BB6B"/>
<path d="M25.8802 26.355H13.2598C12.9607 26.355 12.6916 26.6242 12.6916 26.9232V26.9531C12.6916 27.94 13.499 28.7475 14.4859 28.7475H24.6541C25.641 28.7475 26.4484 27.94 26.4484 26.9531V26.9232C26.4484 26.6242 26.1793 26.355 25.8802 26.355Z" fill="white"/>
<path d="M27.3456 14.0934C26.3587 14.0934 25.5513 14.9009 25.5513 15.8878C25.5513 16.4261 25.7905 16.9345 26.1793 17.2635C25.6709 18.4298 24.5045 19.2373 23.1288 19.1775C21.5438 19.0878 20.2578 17.8018 20.1681 16.2168C20.1681 15.9476 20.1681 15.7084 20.2279 15.4691C20.8859 15.2 21.3644 14.5719 21.3644 13.7944C21.3644 12.8075 20.5569 12 19.57 12C18.5831 12 17.7756 12.8075 17.7756 13.7944C17.7756 14.542 18.2541 15.2 18.9121 15.4691C18.9719 15.7084 18.9719 15.9476 18.9719 16.2168C18.8822 17.8018 17.5962 19.0878 16.0112 19.1775C14.6355 19.2672 13.4392 18.4298 12.9607 17.2635C13.3495 16.9345 13.5887 16.4261 13.5887 15.8878C13.5887 14.9009 12.7813 14.0934 11.7944 14.0934C10.8075 14.0934 10 14.9009 10 15.8878C10 16.8747 10.8075 17.6822 11.7944 17.6822L12.6318 24.0821C12.6617 24.3513 12.9009 24.5606 13.2 24.5606H25.94C26.2092 24.5606 26.4484 24.3513 26.5082 24.0821L27.3456 17.6822C28.3325 17.6822 29.14 16.8747 29.14 15.8878C29.14 14.9009 28.3325 14.0934 27.3456 14.0934Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.833344 11.6667C0.833344 9.76981 1.88966 8.11962 3.44616 7.27229C3.93274 5.02055 5.93601 3.33337 8.33334 3.33337C10.5537 3.33337 12.436 4.78064 13.0884 6.78327L13.1803 6.75332C13.4357 6.70694 13.7965 6.66671 14.1667 6.66671C16.9281 6.66671 19.1667 8.90528 19.1667 11.6667C19.1667 14.4281 16.9281 16.6667 14.1667 16.6667H5.83334C3.07192 16.6667 0.833344 14.4281 0.833344 11.6667ZM11.9714 8.73523L11.5037 7.29952C11.0607 5.9396 9.78725 5.00004 8.33334 5.00004C6.75638 5.00004 5.40363 6.10457 5.07522 7.62432L4.91389 8.37091L4.24304 8.73611C3.17664 9.31664 2.50001 10.4313 2.50001 11.6667C2.50001 13.5077 3.99239 15 5.83334 15H14.1667C16.0076 15 17.5 13.5077 17.5 11.6667C17.5 9.82576 16.0076 8.33337 14.1667 8.33337C13.9212 8.33337 13.6803 8.35962 13.4462 8.41108L11.9714 8.73523Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 949 B

View File

@ -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<Object>} 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();

View File

@ -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')
}} />
<ConferenceStack.Screen
component = { SalesforceLinkDialog }
name = { screen.conference.salesforce }
options = {{
...salesforceScreenOptions,
title: t('notify.linkToSalesforce')
}} />
<ConferenceStack.Screen
component = { LobbyScreen }
name = { screen.lobby }

View File

@ -27,6 +27,7 @@ export const screen = {
recording: 'Recording',
liveStream: 'Live stream',
speakerStats: 'Speaker Stats',
salesforce: 'Link to Salesforce',
participants: 'Participants',
invite: 'Invite',
sharedDocument: 'Shared document'

View File

@ -273,6 +273,11 @@ export const liveStreamScreenOptions = {
...presentationScreenOptions
};
/**
* Screen options for salesforce link modal.
*/
export const salesforceScreenOptions = presentationScreenOptions;
/**
* Screen options for shared document.
*/

View File

@ -28,6 +28,7 @@ declare var interfaceConfig: Object;
const ICON_COLOR = {
error: colors.error06,
normal: colors.primary06,
success: colors.success05,
warning: colors.warning05
};

View File

@ -106,6 +106,10 @@ const useStyles = theme => {
backgroundColor: theme.palette.iconError
},
'&.success': {
backgroundColor: theme.palette.success01
},
'&.warning': {
backgroundColor: theme.palette.warning01
}

View File

@ -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.
*

View File

@ -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));
};
}

View File

@ -0,0 +1 @@
export * from './native';

View File

@ -0,0 +1 @@
export * from './web';

View File

@ -0,0 +1 @@
export * from './_';

View File

@ -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<any>}
*/
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 (
<TouchableHighlight onPress = { onClick }>
<View
key = { `record-${id}` }
style = { styles.recordItem }
title = { name }>
<View style = { styles.recordTypeIcon }>
{IconRecord && (
<Icon
src = { IconRecord }
style = { styles.recordIcon } />
)}
</View>
<View style = { styles.recordDetails }>
<Text
key = { name }
numberOfLines = { 1 }
style = { styles.recordName }>
{name}
</Text>
<Text
key = { type }
style = { styles.recordType }>
{t(RECORD_TYPE[type].label)}
</Text>
</View>
</View>
</TouchableHighlight>
);
};

View File

@ -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<any>}
*/
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 = () => (
<View style = { [ styles.recordsSpinner, { height: clientHeight - CONTENT_HEIGHT_OFFSET } ] }>
<LoadingIndicator />
</View>
);
const renderDetailsErrors = () => (
<Text style = { styles.detailsError }>
{t('dialog.searchResultsDetailsError')}
</Text>
);
const renderSelection = () => (
<SafeAreaView>
<ScrollView
bounces = { false }
style = { [ styles.selectedRecord, { height: clientHeight - CONTENT_HEIGHT_OFFSET } ] }>
<View style = { styles.recordInfo }>
<RecordItem { ...selectedRecord } />
{selectedRecordOwner && <RecordItem { ...selectedRecordOwner } />}
{hasDetailsErrors && renderDetailsErrors()}
</View>
<Text style = { styles.addNote }>
{t('dialog.addOptionalNote')}
</Text>
<TextInput
maxLength = { NOTES_MAX_LENGTH }
minHeight = { Platform.OS === 'ios' && NOTES_LINES ? 20 * NOTES_LINES : null }
multiline = { true }
numberOfLines = { Platform.OS === 'ios' ? null : NOTES_LINES }
/* eslint-disable-next-line react/jsx-no-bind */
onChangeText = { value => setNotes(value) }
placeholder = { t('dialog.addMeetingNote') }
placeholderTextColor = { BaseTheme.palette.text03 }
style = { styles.notes }
value = { notes } />
</ScrollView>
</SafeAreaView>
);
const renderRecordsSearch = () => (
<View style = { styles.recordsSearchContainer }>
<Icon
color = { BaseTheme.palette.icon03 }
src = { IconSearch }
style = { styles.searchIcon } />
<TextInput
maxLength = { NOTES_MAX_LENGTH }
/* eslint-disable-next-line react/jsx-no-bind */
onChangeText = { value => setSearchTerm(value) }
placeholder = { t('dialog.searchInSalesforce') }
placeholderTextColor = { BaseTheme.palette.text03 }
style = { styles.recordsSearch }
value = { searchTerm } />
{(!isLoading && !hasRecordsErrors) && (
<Text style = { styles.resultLabel }>
{showSearchResults
? t('dialog.searchResults', { count: records.length })
: t('dialog.recentlyUsedObjects')
}
</Text>
)}
</View>
);
const renderNoRecords = () => showNoResults && (
<View style = { [ styles.noRecords, { height: clientHeight - CONTENT_HEIGHT_OFFSET } ] }>
<Text style = { styles.noRecordsText }>
{t('dialog.searchResultsNotFound')}
</Text>
<Text style = { styles.noRecordsText }>
{t('dialog.searchResultsTryAgain')}
</Text>
</View>
);
const renderRecordsError = () => (
<View style = { [ styles.recordsError, { height: clientHeight - CONTENT_HEIGHT_OFFSET } ] }>
<Text style = { styles.recordsErrorText }>
{t('dialog.searchResultsError')}
</Text>
</View>
);
const renderContent = () => {
if (isLoading) {
return renderSpinner();
}
if (hasRecordsErrors) {
return renderRecordsError();
}
if (showNoResults) {
return renderNoRecords();
}
if (selectedRecord) {
return renderSelection();
}
return (
<SafeAreaView>
<ScrollView
bounces = { false }
style = { [ styles.recordList, { height: clientHeight - LIST_HEIGHT_OFFSET } ] }>
{records.map(item => (
<RecordItem
key = { `record-${item.id}` }
/* eslint-disable-next-line react/jsx-no-bind */
onClick = { () => setSelectedRecord(item) }
{ ...item } />
))}
</ScrollView>
</SafeAreaView>
);
};
return (
<JitsiScreen style = { styles.salesforceDialogContainer }>
<View>
{!selectedRecord && renderRecordsSearch()}
{renderContent()}
</View>
{
selectedRecord
&& <View style = { styles.footer }>
<Button
children = { t('dialog.Cancel') }
mode = 'contained'
/* eslint-disable-next-line react/jsx-no-bind */
onPress = { () => setSelectedRecord(null) }
style = { styles.cancelButton } />
<Button
children = { t('dialog.linkMeeting') }
mode = 'contained'
onPress = { handlePress }
style = { styles.linkButton } />
</View>
}
</JitsiScreen>
);
};
export default withTheme(SalesforceLinkDialog);

View File

@ -0,0 +1 @@
export { default as SalesforceLinkDialog } from './SalesforceLinkDialog';

View File

@ -0,0 +1,161 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
export default {
salesforceDialogContainer: {
position: 'relative',
flexDirection: 'column',
flex: 1,
display: 'flex',
backgroundColor: BaseTheme.palette.ui01
},
recordsSearchContainer: {
paddingHorizontal: BaseTheme.spacing[3],
paddingTop: BaseTheme.spacing[3],
backgroundColor: BaseTheme.palette.ui01,
alignSelf: 'stretch',
position: 'relative',
marginTop: BaseTheme.spacing[3]
},
searchIcon: {
color: BaseTheme.palette.text03,
fontSize: 30,
left: 22,
position: 'absolute',
top: 22,
zIndex: 2
},
resultLabel: {
backgroundColor: BaseTheme.palette.ui01,
color: BaseTheme.palette.text03,
fontSize: 15,
margin: 0,
paddingBottom: 8,
paddingTop: 16
},
recordsSearch: {
backgroundColor: BaseTheme.palette.field01,
borderColor: BaseTheme.palette.border02,
borderRadius: BaseTheme.shape.borderRadius,
borderWidth: 1,
color: BaseTheme.palette.text01,
paddingLeft: 44,
paddingRight: 16,
paddingVertical: 10,
width: '100%'
},
recordsSpinner: {
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
width: '100%'
},
noRecords: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui01,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: BaseTheme.spacing[3]
},
noRecordsText: {
color: BaseTheme.palette.text03
},
recordsError: {
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
paddingBottom: 30
},
recordsErrorText: {
color: BaseTheme.palette.text03
},
recordList: {
alignSelf: 'stretch',
display: 'flex',
listStyle: 'none',
paddingVertical: BaseTheme.spacing[3],
position: 'relative'
},
selectedRecord: {
alignSelf: 'stretch',
display: 'flex',
paddingTop: BaseTheme.spacing[3],
position: 'relative'
},
recordInfo: {
backgroundColor: BaseTheme.palette.ui03,
borderRadius: BaseTheme.shape.borderRadius,
display: 'flex',
margin: BaseTheme.spacing[3],
position: 'relative'
},
detailsError: {
color: BaseTheme.palette.text03,
padding: BaseTheme.spacing[3]
},
addNote: {
color: BaseTheme.palette.field02,
margin: BaseTheme.spacing[3]
},
notes: {
alignItems: 'flex-start',
backgroundColor: BaseTheme.palette.field01,
borderColor: BaseTheme.palette.border02,
borderRadius: BaseTheme.shape.borderRadius,
borderWidth: 1,
color: BaseTheme.palette.field02,
lineHeight: 18,
marginHorizontal: BaseTheme.spacing[3],
overflow: 'hidden',
padding: BaseTheme.spacing[2],
textAlignVertical: 'top'
},
cancelButton: {
backgroundColor: BaseTheme.palette.action02,
margin: BaseTheme.spacing[2]
},
linkButton: {
backgroundColor: BaseTheme.palette.action01,
marginBottom: BaseTheme.spacing[2],
marginHorizontal: BaseTheme.spacing[2]
},
recordItem: {
alignItems: 'center',
display: 'flex',
flex: 1,
flexDirection: 'row',
paddingHorizontal: BaseTheme.spacing[3]
},
recordTypeIcon: {
alignItems: 'center',
borderRadius: BaseTheme.shape.borderRadius,
display: 'flex',
height: 40,
justifyContent: 'center',
marginRight: BaseTheme.spacing[3],
width: 40
},
recordIcon: {
alignItems: 'center',
display: 'flex',
justifyContent: 'center'
},
recordDetails: {
display: 'flex',
flex: 1,
flexDirection: 'column',
justifyContent: 'space-around',
overflow: 'hidden',
paddingVertical: BaseTheme.spacing[3]
},
recordName: {
color: BaseTheme.palette.text01,
fontSize: 15,
overflow: 'hidden'
},
recordType: {
color: BaseTheme.palette.text01,
fontSize: 13
}
};

View File

@ -0,0 +1,108 @@
// @flow
import { makeStyles } from '@material-ui/core';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { RECORD_TYPE } from '../../constants';
/**
* 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 type of the record.
*/
type: String,
/**
* The handler for the click event.
*/
onClick: Function
}
const useStyles = makeStyles(theme => {
return {
recordItem: {
display: 'flex',
alignItems: 'center'
},
recordTypeIcon: {
borderRadius: theme.shape.borderRadius,
height: '40px',
marginRight: '16px',
width: '40px'
},
recordDetails: {
display: 'flex',
flex: 1,
flexDirection: 'column',
justifyContent: 'space-around',
overflow: 'hidden',
padding: '12px 0',
textOverflow: 'ellipsis'
},
recordName: {
fontSize: '15px',
fontWeight: 400,
lineHeight: '20px',
overflow: 'hidden',
textOverflow: 'ellipsis'
},
recordType: {
fontSize: '13px',
fontWeight: 400,
lineHeight: '18px'
}
};
});
/**
* Component to render Record data.
*
* @param {Props} props - The props of the component.
* @returns {React$Element<any>}
*/
export const RecordItem = ({
id,
name,
/* eslint-disable-next-line no-empty-function */
onClick = () => {},
type
}: Props) => {
const { t } = useTranslation();
const classes = useStyles();
const Icon = RECORD_TYPE[type].icon;
return (
<li
className = { classes.recordItem }
key = { `record-${id}` }
onClick = { onClick }
title = { name }>
<div className = { classes.recordTypeIcon }>{Icon && <Icon />}</div>
<div className = { classes.recordDetails }>
<div
className = { classes.recordName }
key = { name }>
{name}
</div>
<div
className = { classes.recordType }
key = { type }>
{t(RECORD_TYPE[type].label)}
</div>
</div>
</li>
);
};

View File

@ -0,0 +1,270 @@
// @flow
import Spinner from '@atlaskit/spinner';
import { makeStyles } from '@material-ui/core';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { Dialog, hideDialog } from '../../../base/dialog';
import { Icon, IconSearch } from '../../../base/icons';
import { getFieldValue } from '../../../base/react';
import BaseTheme from '../../../base/ui/components/BaseTheme';
import { NOTES_MAX_LENGTH } from '../../constants';
import { useSalesforceLinkDialog } from '../../useSalesforceLinkDialog';
import { RecordItem } from './RecordItem';
const useStyles = makeStyles(theme => {
return {
container: {
minHeight: '450px',
overflowY: 'auto',
position: 'relative'
},
recordsSearchContainer: {
position: 'relative',
padding: '1px'
},
searchIcon: {
display: 'block',
position: 'absolute',
color: theme.palette.text03,
left: 16,
top: 10,
width: 20,
height: 20
},
resultLabel: {
fontSize: '15px',
margin: '16px 0 8px'
},
recordsSearch: {
backgroundColor: theme.palette.field01,
border: '1px solid',
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.border02,
color: theme.palette.text01,
padding: '10px 16px 10px 44px',
width: '100%',
height: 40,
'&::placeholder': {
color: theme.palette.text03,
...theme.typography.bodyShortRegular,
lineHeight: `${theme.typography.bodyShortRegular.lineHeight}px`
}
},
spinner: {
alignItems: 'center',
display: 'flex',
height: 'calc(100% - 100px)',
justifyContent: 'center',
width: '100%'
},
noRecords: {
height: 'calc(100% - 150px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
},
recordsError: {
height: 'calc(100% - 80px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
},
recordList: {
listStyle: 'none',
margin: '10px 0',
padding: 0
},
recordInfo: {
backgroundColor: theme.palette.ui03,
padding: '0 16px',
borderRadius: theme.shape.borderRadius,
marginBottom: '28px'
},
detailsError: {
padding: '10px 0'
},
addNote: {
padding: '10px 0'
},
notes: {
lineHeight: '18px',
minHeight: '130px',
resize: 'vertical',
width: '100%',
boxSizing: 'borderBox',
overflow: 'hidden',
border: '1px solid',
borderColor: theme.palette.border02,
backgroundColor: theme.palette.field01,
color: theme.palette.field02,
borderRadius: theme.shape.borderRadius,
padding: '10px 16px'
}
};
});
/**
* Component that renders the Salesforce link dialog.
*
* @returns {React$Element<any>}
*/
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 = () => (
<div className = { classes.spinner }>
<Spinner
isCompleting = { false }
size = 'medium' />
</div>
);
const renderDetailsErrors = () => (
<div className = { classes.detailsError }>
{t('dialog.searchResultsDetailsError')}
</div>
);
const renderSelection = () => (
<div>
<div className = { classes.recordInfo }>
<RecordItem { ...selectedRecord } />
{selectedRecordOwner && <RecordItem { ...selectedRecordOwner } />}
{hasDetailsErrors && renderDetailsErrors()}
</div>
<div className = { classes.addNote }>{t('dialog.addOptionalNote')}</div>
<textarea
autoFocus = { true }
className = { classes.notes }
maxLength = { NOTES_MAX_LENGTH }
/* eslint-disable-next-line react/jsx-no-bind */
onChange = { e => setNotes(e.target.value) }
placeholder = { t('dialog.addMeetingNote') }
row = '4'
value = { notes } />
</div>
);
const renderRecordsSearch = () => !selectedRecord && (
<div className = { classes.recordsSearchContainer }>
<Icon
className = { classes.searchIcon }
color = { BaseTheme.palette.icon03 }
src = { IconSearch } />
<input
autoComplete = 'off'
autoFocus = { false }
className = { classes.recordsSearch }
name = 'recordsSearch'
onChange = { handleChange }
placeholder = { t('dialog.searchInSalesforce') }
tabIndex = { 0 }
value = { searchTerm } />
{(!isLoading && !hasRecordsErrors) && (
<div className = { classes.resultLabel }>
{showSearchResults
? t('dialog.searchResults', { count: records.length })
: t('dialog.recentlyUsedObjects')
}
</div>
)}
</div>
);
const renderNoRecords = () => showNoResults && (
<div className = { classes.noRecords }>
<div>{t('dialog.searchResultsNotFound')}</div>
<div>{t('dialog.searchResultsTryAgain')}</div>
</div>
);
const renderRecordsError = () => (
<div className = { classes.recordsError }>
{t('dialog.searchResultsError')}
</div>
);
const renderContent = () => {
if (isLoading) {
return renderSpinner();
}
if (hasRecordsErrors) {
return renderRecordsError();
}
if (showNoResults) {
return renderNoRecords();
}
if (selectedRecord) {
return renderSelection();
}
return (
<ul className = { classes.recordList }>
{records.map(item => (
<RecordItem
key = { `record-${item.id}` }
/* eslint-disable-next-line react/jsx-no-bind */
onClick = { () => setSelectedRecord(item) }
{ ...item } />
))}
</ul>
);
};
return (
<Dialog
disableEnter = { true }
disableFooter = { !selectedRecord }
height = { 'medium' }
okDisabled = { !selectedRecord }
okKey = 'dialog.linkMeeting'
/* eslint-disable-next-line react/jsx-no-bind */
onDecline = { () => setSelectedRecord(null) }
onSubmit = { handleSubmit }
titleKey = 'dialog.linkMeetingTitle'
width = { 'small' }>
<div className = { classes.container } >
{renderRecordsSearch()}
{renderContent()}
</div>
</Dialog>
);
}
export default SalesforceLinkDialog;

View File

@ -0,0 +1 @@
export { default as SalesforceLinkDialog } from './SalesforceLinkDialog';

View File

@ -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'
}
};

View File

@ -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<any>}
*/
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<any>}
*/
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<any>}
*/
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);
}

View File

@ -0,0 +1,2 @@
export * from './components';
export * from './actions';

View File

@ -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
};
};

View File

@ -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<AbstractButtonProps, *> {
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));

View File

@ -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<Props, State> {
* @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<Props, State> {
<SecurityDialogButton { ...buttonProps } />
<RecordButton { ...buttonProps } />
<LiveStreamButton { ...buttonProps } />
<LinkToSalesforceButton { ...buttonProps } />
<Divider style = { styles.divider } />
<SharedVideoButton { ...buttonProps } />
<ScreenSharingButton { ...buttonProps } />

View File

@ -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<Props, *> {
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));

View File

@ -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<Props> {
_feedbackConfigured,
_isIosMobile,
_isMobile,
_hasSalesforce,
_screenSharing
} = this.props;
@ -715,6 +722,12 @@ class Toolbox extends Component<Props> {
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<Props> {
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,