Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Chat Barge to Supervisor Barge Coach Feature #521

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/docs/feature-library/supervisor-barge-coach.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ There is a private toggle to enable/disable the agent's ability to see who is co
![Plugin Demo](/img/features/supervisor-barge-coach/Supervisor-Barge-Coach-Plugin-4.gif)

There is also a Supervisor Monitor Panel, which gives the supervisors the ability to see if other supervisors are monitoring, coaching, or have joined (barged) the call. \*\*Note that the private toggle feature does apply to this feature as well. If a Supervisor has private mode on, they will not show up in the Supervisor Monitor Panel

![Plugin Demo](/img/features/supervisor-barge-coach/Supervisor-Barge-Coach-Plugin-5.gif)

You are now able to barge into a chat/conversation by clicking the Join button when monitoring a live conversation. Click Leave to remove yourself from the conversation. You are able to type within the Monitor Task Panel and both the customer and agent will see your responses.
![Plugin Demo](/img/features/supervisor-barge-coach/Supervisor-Barge-Coach-Plugin-10.gif)

We've also included Agent Assistance into this plugin suite. This adds a button to the agent's call canvas to ask for assistance.

![Plugin Demo](/img/features/supervisor-barge-coach/Supervisor-Barge-Coach-Plugin-6.gif)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21,713 changes: 18,680 additions & 3,033 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { TaskContext, Template, templates } from '@twilio/flex-ui';
import React from 'react';
import { Badge } from '@twilio-paste/core/badge';
import { Stack } from '@twilio-paste/core/stack';
import { TaskContext, Template, templates } from '@twilio/flex-ui';

import { StringTemplates } from '../../flex-hooks/strings/ChatTransferStrings';

const ParticipantBadgeCount = ({ participantCount }: { participantCount: number }) => (
<Stack orientation="horizontal" spacing="space20">
<Template source={templates[StringTemplates.Participants]} />
<Badge as="span" variant="info">
{participantCount.toString()}
</Badge>
</Stack>
);

export const ParticipantTabLabelContainer = () => {
return (
<TaskContext.Consumer>
{(context) => {
const participantCount = context.conversation?.participants?.size as number;

return (
<Stack orientation="horizontal" spacing="space20">
<Template source={templates[StringTemplates.Participants]} />
<Badge as="span" variant="info">
{participantCount}
</Badge>
</Stack>
);
{(context: any) => {
const participantCount = context.conversation?.participants?.size as number | 0;
return <ParticipantBadgeCount participantCount={participantCount} />;
}}
</TaskContext.Consumer>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Box } from '@twilio-paste/core/box';
import { Button } from '@twilio-paste/core/button';
import { UserIcon } from '@twilio-paste/icons/esm/UserIcon';
import { AgentIcon } from '@twilio-paste/icons/esm/AgentIcon';
import { CommunityIcon } from '@twilio-paste/icons/esm/CommunityIcon';
import { CloseIcon } from '@twilio-paste/icons/esm/CloseIcon';
import { useState } from 'react';
import { templates } from '@twilio/flex-ui';
Expand All @@ -28,6 +29,8 @@ export const Participant = ({ participantType, name, allowKick, handleKickPartic
const icon =
participantType === 'agent' ? (
<AgentIcon decorative={false} title={templates[StringTemplates.Agent]()} />
) : participantType === 'supervisor' ? (
<CommunityIcon decorative={false} title={templates[StringTemplates.Supervisor]()} />
) : (
<UserIcon decorative={false} title={templates[StringTemplates.Customer]()} />
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import * as Flex from '@twilio/flex-ui';
import { useState, useEffect } from 'react';
import { Stack } from '@twilio-paste/core/stack';
import { ConversationState, styled, Actions } from '@twilio/flex-ui';
import { useDispatch } from 'react-redux';

import { Participants } from './Participants.tsx/Participants';
import { InvitedParticipants } from './InvitedParticipants/InvitedParticipants';
import { ParticipantDetails } from '../../types/ParticipantDetails';
import { InvitedParticipantDetails } from '../../types/InvitedParticipantDetails';
import { getUpdatedParticipantDetails, getUpdatedInvitedParticipantDetails } from './hooks';
import { getUpdatedParticipantDetails, getUpdatedInvitedParticipantDetails, useParticipantCountEffect } from './hooks';
import {
CancelChatParticipantInviteActionPayload,
RemoveChatParticipantActionPayload,
Expand All @@ -27,16 +28,17 @@ interface ParticipantsTabProps {
}

export const ParticipantsTab = ({ task, conversation }: ParticipantsTabProps) => {
const dispatch = useDispatch();
const [participantDetails, setParticipantDetails] = useState<ParticipantDetails[]>([]);
const [invitedParticipantDetails, setInvitedParticipantDetails] = useState<InvitedParticipantDetails[]>([]);

useParticipantCountEffect(task, dispatch);
useEffect(() => {
const updateParticipants = () => {
getUpdatedParticipantDetails(task, conversation, participantDetails).then((participantDetails) => {
if (participantDetails) setParticipantDetails(participantDetails);
});
};

updateParticipants();
setInvitedParticipantDetails(getUpdatedInvitedParticipantDetails(conversation));
}, [conversation]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as Flex from '@twilio/flex-ui';
import { ITask } from '@twilio/flex-ui';
import { useDispatch } from 'react-redux';

import { Actions } from '../../../supervisor-barge-coach/flex-hooks/states/SupervisorBargeCoach';
import { ParticipantDetails } from '../../types/ParticipantDetails';
import { InvitedParticipantDetails, InvitedParticipants } from '../../types/InvitedParticipantDetails';
import { ConversationState } from '../../../../types/conversations';
Expand Down Expand Up @@ -53,14 +55,28 @@ const getCBMParticipantsWrapper = async (task: ITask, flexInteractionChannelSid:
return [];
};

export const useParticipantCountEffect = (task: ITask, dispatch: ReturnType<typeof useDispatch>) => {
const fetchParticipants = async () => {
const flexInteractionChannelSid = task?.attributes?.flexInteractionChannelSid;
const participants = await task.getParticipants(flexInteractionChannelSid);

const count = participants.length;
dispatch(Actions.setBargeCoachStatus({ interactionParticipants: count }));
};

if (task) {
fetchParticipants(); // Call the async function
}
};

// we use a mix of conversation participants (MBxxx sids) and Interactions Participants (UTxxx) to build what we need

export const getUpdatedParticipantDetails = async (
task: Flex.ITask,
conversation: ConversationState,
participantDetails: ParticipantDetails[],
) => {
const myIdentity = manager.conversationsClient?.user?.identity;

const flexInteractionChannelSid = task?.attributes?.flexInteractionChannelSid;
if (!flexInteractionChannelSid) return [];

Expand All @@ -72,28 +88,23 @@ export const getUpdatedParticipantDetails = async (
if (participantDetailsUpToDateCheck(conversation, participantDetails)) return participantDetails;

const participants: ParticipantDetails[] = [];

const intertactionParticipants: any[] = await getCBMParticipantsWrapper(task, flexInteractionChannelSid);

if (!intertactionParticipants || !conversation?.participants) return participantDetails;

const interactionParticipants: any[] = await getCBMParticipantsWrapper(task, flexInteractionChannelSid);
const conversationParticipants = Array.from(conversation?.participants.values());

console.log('getParticipantDetails', conversationParticipants, intertactionParticipants);

// Add interaction participants to the array
conversationParticipants.forEach((conversationParticipant) => {
const intertactionParticipant = intertactionParticipants.find(
const interactionParticipant = interactionParticipants.find(
(participant) => participant.mediaProperties?.sid === conversationParticipant.source.sid,
);

if (intertactionParticipant) {
if (interactionParticipant) {
const friendlyName =
conversationParticipant.friendlyName ||
intertactionParticipant.mediaProperties?.messagingBinding?.address ||
intertactionParticipant.mediaProperties?.identity;
const participantType = intertactionParticipant.type;
interactionParticipant.mediaProperties?.messagingBinding?.address ||
interactionParticipant.mediaProperties?.identity;
const participantType = interactionParticipant.type;
const isMe = conversationParticipant.source.identity === myIdentity;
const interactionParticipantSid = intertactionParticipant.participantSid;
const interactionParticipantSid = interactionParticipant.participantSid;
const conversationMemberSid = conversationParticipant.source.sid;

participants.push({
Expand All @@ -106,6 +117,28 @@ export const getUpdatedParticipantDetails = async (
}
});

// Add only conversation participants to the array
conversationParticipants.forEach((conversationParticipant) => {
const existingParticipant = participants.find(
(participant) => participant.conversationMemberSid === conversationParticipant.source.sid,
);

if (!existingParticipant) {
const friendlyName = conversationParticipant.friendlyName || conversationParticipant.source.identity || 'null';
const participantType = 'supervisor';
const isMe = conversationParticipant.source.identity === myIdentity;
const conversationMemberSid = conversationParticipant.source.sid;

participants.push({
friendlyName,
participantType,
isMe,
interactionParticipantSid: 'null',
conversationMemberSid,
});
}
});

return participants;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as Flex from '@twilio/flex-ui';
import * as React from 'react';
import { ITask, TaskHelper, StateHelper } from '@twilio/flex-ui';

import { reduxNamespace } from '../../../../utils/state';
import { isColdTransferEnabled, isMultiParticipantEnabled } from '../../config';
import TransferButton from '../../custom-components/TransferButton';
import LeaveChatButton from '../../custom-components/LeaveChatButton';
Expand All @@ -10,9 +12,8 @@ import { FlexComponent } from '../../../../types/feature-loader';
interface Props {
task: ITask;
}

export const componentName = FlexComponent.TaskCanvasHeader;
export const componentHook = function addConvTransferButtons(flex: typeof Flex) {
export const componentHook = function addConvTransferButtons(flex: typeof Flex, props: any) {
if (!isColdTransferEnabled() && !isMultiParticipantEnabled()) return;

flex.TaskCanvasHeader.Content.add(<TransferButton key="conversation-transfer-button" />, {
Expand All @@ -24,11 +25,13 @@ export const componentHook = function addConvTransferButtons(flex: typeof Flex)

const replaceEndTaskButton = (task: ITask) => {
if (TaskHelper.isCBMTask(task) && task.taskStatus === 'assigned') {
const interactionParticipants =
props?.store?.getState()?.[reduxNamespace]?.supervisorBargeCoach?.interactionParticipants;
// more than two participants or are there any active invites?
const conversationState = StateHelper.getConversationStateForTask(task);
if (
conversationState &&
(conversationState.participants.size > 2 || countOfOutstandingInvitesForConversation(conversationState))
(interactionParticipants > 2 || countOfOutstandingInvitesForConversation(conversationState))
Comment on lines 26 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would break functionality if the supervisor-barge-coach feature is removed, but not the conversation-transfer feature. This will need to be implemented a different way to not introduce a co-dependency of features.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could maybe approach it with the following

  1. Add a config value in conversation-transfer that checks whether supervisor barge coach is enabled or not (similar example is in custom-transfer-directory with its dependency on conversation-transfer)

  2. Use that config value here to determine whether to extract the participants count from redux or use the old method

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this piece of state is only set from the conversation-transfer feature, and only read by the conversation-transfer feature, so we shouldn't need multiple implementations. This piece of state can probably just live within the conversation-transfer feature, but after I've looked into things some more, I don't think we need to keep this state at all!

I think we should remove useParticipantCountEffect and instead fetch the participant list using existing data in Redux, which seems to be up-to-date and includes supervisor participants. Its location in Redux is flex.chat.conversations[conversationSid].participants. Using this should be a lot more efficient, as the current code makes 8 or so network requests when selecting the task.

) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export enum StringTemplates {
Participants = 'PSConvTransferParticipants',
Agent = 'PSConvTransferAgent',
Customer = 'PSConvTransferCustomer',
Supervisor = 'PSConvTransferSupervisor',
Queue = 'PSConvTransferQueue',
Remove = 'PSConvTransferRemove',
InvitedParticipants = 'PSConvTransferInvitedParticipants',
Expand All @@ -47,6 +48,7 @@ export const stringHook = () => ({
[StringTemplates.Participants]: 'Participants',
[StringTemplates.Agent]: 'Agent',
[StringTemplates.Customer]: 'Customer',
[StringTemplates.Supervisor]: 'Supervisor',
[StringTemplates.Queue]: 'Queue',
[StringTemplates.Remove]: 'Remove {{name}}',
[StringTemplates.InvitedParticipants]: 'Invited Participants',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ParticipantType = 'agent' | 'worker';
export type ParticipantType = 'agent' | 'worker' | 'supervisor' | 'customer';

export interface ParticipantDetails {
friendlyName: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import * as React from 'react';
import * as Flex from '@twilio/flex-ui';
import { useFlexSelector, ITask, templates, StateHelper } from '@twilio/flex-ui';
import { useDispatch, useSelector } from 'react-redux';
import { Box } from '@twilio-paste/core/box';
import { Button } from '@twilio-paste/core/button';
import { Tooltip } from '@twilio-paste/core/tooltip';
import { Flex as FlexBox } from '@twilio-paste/core/flex';

import { AppState } from '../../../../types/manager';
import { reduxNamespace } from '../../../../utils/state';
import { Actions, SupervisorBargeCoachState } from '../../flex-hooks/states/SupervisorBargeCoach';
import BargeCoachService from '../../utils/serverless/BargeCoachService';
import { StringTemplates } from '../../flex-hooks/strings/BargeCoachAssist';

type SupervisorChatBargeProps = {
task: ITask;
};

export const SupervisorChatBargeButton = ({ task }: SupervisorChatBargeProps) => {
const dispatch = useDispatch();
const [processing, setProcessing] = React.useState(false);
const [isChecking, setIsChecking] = React.useState(false);
const { chatBarge } = useSelector(
(state: AppState) => state[reduxNamespace].supervisorBargeCoach as SupervisorBargeCoachState,
);
const teamViewTaskSID = useFlexSelector((state) => state?.flex?.view?.selectedTaskInSupervisorSid) || '';
const agentWorkerSID = useFlexSelector((state) => state?.flex?.supervisor?.stickyWorker?.worker?.sid) || '';
const myWorkerName = useFlexSelector((state) => state?.flex?.session?.identity) || '';
const myWorkerSid = useFlexSelector((state) => state?.flex?.worker?.worker?.sid) || '';
const conversationState = StateHelper.getConversationStateForTask(task) || null;
const conversationSid = task?.attributes?.conversationSid || '';
const [chatBargeStatus, setChatBargeStatus] = React.useState(false);
// Storing teamViewPath to browser cache to help if a refresh happens
// will use this in the browserRefreshHelper
if (teamViewTaskSID && agentWorkerSID) {
localStorage.setItem('teamViewTaskSID', teamViewTaskSID);
localStorage.setItem('agentWorkerSID', agentWorkerSID);
}
const bargeHandleClick = async () => {
if (!teamViewTaskSID || processing) {
return;
}
setProcessing(true);
if (chatBarge[teamViewTaskSID]) {
await BargeCoachService.removeWorkerParticipant(conversationSid, myWorkerName);
const { [teamViewTaskSID]: value, ...newChatBargeState } = chatBarge;

Check warning on line 47 in plugin-flex-ts-template-v2/src/feature-library/supervisor-barge-coach/custom-components/ChatBargeButton/SupervisorChatBargeButton.tsx

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

plugin-flex-ts-template-v2/src/feature-library/supervisor-barge-coach/custom-components/ChatBargeButton/SupervisorChatBargeButton.tsx#L47

[@typescript-eslint/no-unused-vars] 'value' is assigned a value but never used.
dispatch(Actions.setBargeCoachStatus({ chatBarge: newChatBargeState }));
localStorage.setItem('chatBarge', JSON.stringify(newChatBargeState));
} else {
await BargeCoachService.inviteWorkerParticipant(conversationSid, myWorkerName);
const newChatBargeState = { ...chatBarge, [teamViewTaskSID]: conversationSid };
dispatch(Actions.setBargeCoachStatus({ chatBarge: newChatBargeState }));
localStorage.setItem('chatBarge', JSON.stringify(newChatBargeState));
}
// Because of how the monitor panel renders, we need to close it and re-open it to show
// we are part of the conversation
console.warn('teamViewTaskSID', teamViewTaskSID);
await Flex.Actions.invokeAction('SelectTaskInSupervisor', { sid: null });
Flex.Actions.invokeAction('SelectTaskInSupervisor', { sid: teamViewTaskSID });
setProcessing(false);
};
React.useEffect(() => {
// If the supervisor closes or refreshes their browser, we need to check if they are still in the chat
// First will check if we have it from the redux value, otherwise let's check the conversation state itself
const checkSupervisorInChat = async () => {
if (teamViewTaskSID in chatBarge) {
setIsChecking(true);
let supervisorInChat = false;
if (conversationState && !conversationState.isLoadingParticipants) {
supervisorInChat = conversationState.participants.has(myWorkerName);
if (supervisorInChat) {
setChatBargeStatus(true);
} else {
const { [teamViewTaskSID]: value, ...newChatBargeState } = chatBarge;

Check warning on line 75 in plugin-flex-ts-template-v2/src/feature-library/supervisor-barge-coach/custom-components/ChatBargeButton/SupervisorChatBargeButton.tsx

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

plugin-flex-ts-template-v2/src/feature-library/supervisor-barge-coach/custom-components/ChatBargeButton/SupervisorChatBargeButton.tsx#L75

[@typescript-eslint/no-unused-vars] 'value' is assigned a value but never used.
dispatch(Actions.setBargeCoachStatus({ chatBarge: newChatBargeState }));
localStorage.setItem('chatBarge', JSON.stringify(newChatBargeState));
setChatBargeStatus(false);
}
}
setIsChecking(false);
} else {
setChatBargeStatus(false);
}
};
checkSupervisorInChat();
}, [teamViewTaskSID, chatBarge, conversationState]);

React.useEffect(() => {
if (chatBargeStatus && (task.status === 'wrapping' || task.status === 'completed')) {
BargeCoachService.removeWorkerParticipant(conversationSid, myWorkerName);
setChatBargeStatus(false);
}
}, [task]);

const isLiveConversation = (task: ITask): boolean =>
task !== null && task.status !== 'completed' && task.status !== 'wrapping' && myWorkerSid !== agentWorkerSID;

// Returning two options, if it's disabled due to the supervisor being assigned the task
// we want a hover text explaing why, otherwise don't do any Tooltip
return (
<FlexBox hAlignContent="left" vertical>
{myWorkerSid === agentWorkerSID ? (
<Tooltip placement="right" text={templates[StringTemplates.TaskAssignedToYou]()}>
<Box padding="space10" element="BARGE_COACH_BUTTON_BOX">
<Button
variant="primary"
size="small"
onClick={bargeHandleClick}
disabled={true} // always disabled when condition is met
>
{templates[StringTemplates.Join]()}
</Button>
</Box>
</Tooltip>
) : (
<Box padding="space10" element="BARGE_COACH_BUTTON_BOX">
<Button
variant="primary"
size="small"
onClick={bargeHandleClick}
disabled={processing || isChecking || !isLiveConversation(task)}
>
{processing
? templates[StringTemplates.Joining]()
: chatBarge[teamViewTaskSID]
? templates[StringTemplates.Leave]()
: templates[StringTemplates.Join]()}
</Button>
</Box>
)}
</FlexBox>
);
};
Loading