From 57c465176a8ea0aa286edfb3504f52bb51465a99 Mon Sep 17 00:00:00 2001 From: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com> Date: Sat, 5 Aug 2023 07:52:59 +0800 Subject: [PATCH] Add support chat (#1066) * Add support chat Co-authored-by: v1b3m * Refactor the chat logic Co-authored-by: v1b3m * Add HMAC signing Co-authored-by: v1b3m * Update the button styles Co-authored-by: v1b3m * Update the button styles Co-authored-by: v1b3m * Refactor the chat logic Co-authored-by: v1b3m * Fix the chat not loading Co-authored-by: v1b3m * Fix the chat not loading Co-authored-by: v1b3m * Add requested changes Co-authored-by: v1b3m * Add requested changes Co-authored-by: v1b3m * Add requested changes Co-authored-by: v1b3m * Add requested changes Co-authored-by: v1b3m * Add requested changes Co-authored-by: v1b3m --------- Co-authored-by: v1b3m --- front/src/generated/graphql.tsx | 17 ++- .../components/ClientConfigProvider.tsx | 6 +- .../modules/client-config/queries/index.tsx | 4 + .../client-config/states/supportChatState.ts | 11 ++ front/src/modules/types/custom.d.ts | 3 + .../ui/navbar/components/MainNavbar.tsx | 13 +- .../ui/navbar/components/SupportChat.tsx | 115 ++++++++++++++++++ front/src/modules/users/queries/index.ts | 1 + front/src/testing/graphqlMocks.ts | 4 + server/.env.example | 5 +- .../client-config/client-config.entity.ts | 12 ++ .../client-config/client-config.resolver.ts | 4 + server/src/core/user/dto/user-with-HMAC.ts | 9 ++ server/src/core/user/user.module.ts | 3 +- server/src/core/user/user.resolver.spec.ts | 5 + server/src/core/user/user.resolver.ts | 26 +++- .../environment/environment.service.ts | 12 ++ 17 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 front/src/modules/client-config/states/supportChatState.ts create mode 100644 front/src/modules/types/custom.d.ts create mode 100644 front/src/modules/ui/navbar/components/SupportChat.tsx create mode 100644 server/src/core/user/dto/user-with-HMAC.ts diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 4f38c08f1..e4cafdde7 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -548,6 +548,7 @@ export type ClientConfig = { authProviders: AuthProviders; debugMode: Scalars['Boolean']; signInPrefilled: Scalars['Boolean']; + supportChat: SupportChat; telemetry: Telemetry; }; @@ -1906,6 +1907,12 @@ export type StringNullableFilter = { startsWith?: InputMaybe; }; +export type SupportChat = { + __typename?: 'SupportChat'; + supportDriver: Scalars['String']; + supportFrontendKey?: Maybe; +}; + export type Telemetry = { __typename?: 'Telemetry'; anonymizationEnabled: Scalars['Boolean']; @@ -1935,6 +1942,7 @@ export type User = { phoneNumber?: Maybe; settings: UserSettings; settingsId: Scalars['String']; + supportHMACKey?: Maybe; updatedAt: Scalars['DateTime']; workspaceMember?: Maybe; }; @@ -2448,7 +2456,7 @@ export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __ty export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, supportChat: { __typename?: 'SupportChat', supportDriver: string, supportFrontendKey?: string | null } } }; export type GetCompaniesQueryVariables = Exact<{ orderBy?: InputMaybe | CompanyOrderByWithRelationInput>; @@ -2677,7 +2685,7 @@ export type SearchActivityQuery = { __typename?: 'Query', searchResults: Array<{ export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, canImpersonate: boolean, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, allowImpersonation: boolean, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, canImpersonate: boolean, supportHMACKey?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, allowImpersonation: boolean, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } }; export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>; @@ -3648,6 +3656,10 @@ export const GetClientConfigDocument = gql` enabled anonymizationEnabled } + supportChat { + supportDriver + supportFrontendKey + } } } `; @@ -4895,6 +4907,7 @@ export const GetCurrentUserDocument = gql` locale colorScheme } + supportHMACKey } } `; diff --git a/front/src/modules/client-config/components/ClientConfigProvider.tsx b/front/src/modules/client-config/components/ClientConfigProvider.tsx index 5963ac59c..8ddf943af 100644 --- a/front/src/modules/client-config/components/ClientConfigProvider.tsx +++ b/front/src/modules/client-config/components/ClientConfigProvider.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; +import { supportChatState } from '@/client-config/states/supportChatState'; import { telemetryState } from '@/client-config/states/telemetryState'; import { useGetClientConfigQuery } from '~/generated/graphql'; @@ -15,6 +16,7 @@ export const ClientConfigProvider: React.FC = ({ const [, setSignInPrefilled] = useRecoilState(isSignInPrefilledState); const [, setTelemetry] = useRecoilState(telemetryState); const [isLoading, setIsLoading] = useState(true); + const setSupportChat = useSetRecoilState(supportChatState); const { data, loading } = useGetClientConfigQuery(); @@ -31,6 +33,7 @@ export const ClientConfigProvider: React.FC = ({ setDebugMode(data?.clientConfig.debugMode); setSignInPrefilled(data?.clientConfig.signInPrefilled); setTelemetry(data?.clientConfig.telemetry); + setSupportChat(data?.clientConfig.supportChat); } }, [ data, @@ -40,6 +43,7 @@ export const ClientConfigProvider: React.FC = ({ setTelemetry, setIsLoading, loading, + setSupportChat, ]); return isLoading ? <> : <>{children}; diff --git a/front/src/modules/client-config/queries/index.tsx b/front/src/modules/client-config/queries/index.tsx index d53e84c65..420962f27 100644 --- a/front/src/modules/client-config/queries/index.tsx +++ b/front/src/modules/client-config/queries/index.tsx @@ -13,6 +13,10 @@ export const GET_CLIENT_CONFIG = gql` enabled anonymizationEnabled } + supportChat { + supportDriver + supportFrontendKey + } } } `; diff --git a/front/src/modules/client-config/states/supportChatState.ts b/front/src/modules/client-config/states/supportChatState.ts new file mode 100644 index 000000000..5da5c79c0 --- /dev/null +++ b/front/src/modules/client-config/states/supportChatState.ts @@ -0,0 +1,11 @@ +import { atom } from 'recoil'; + +import { SupportChat } from '~/generated/graphql'; + +export const supportChatState = atom({ + key: 'supportChatState', + default: { + supportDriver: 'front', + supportFrontendKey: null, + }, +}); diff --git a/front/src/modules/types/custom.d.ts b/front/src/modules/types/custom.d.ts new file mode 100644 index 000000000..bc54aabeb --- /dev/null +++ b/front/src/modules/types/custom.d.ts @@ -0,0 +1,3 @@ +declare interface Window { + FrontChat?: (method: string, ...args: any[]) => void; +} diff --git a/front/src/modules/ui/navbar/components/MainNavbar.tsx b/front/src/modules/ui/navbar/components/MainNavbar.tsx index 57b30a191..80394c1b1 100644 --- a/front/src/modules/ui/navbar/components/MainNavbar.tsx +++ b/front/src/modules/ui/navbar/components/MainNavbar.tsx @@ -2,20 +2,29 @@ import styled from '@emotion/styled'; import NavItemsContainer from './NavItemsContainer'; import NavWorkspaceButton from './NavWorkspaceButton'; +import SupportChat from './SupportChat'; type OwnProps = { children: React.ReactNode; }; const StyledContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + margin-bottom: ${({ theme }) => theme.spacing(2.5)}; width: 100%; `; export default function MainNavbar({ children }: OwnProps) { return ( - - {children} +
+ + {children} +
+
); } diff --git a/front/src/modules/ui/navbar/components/SupportChat.tsx b/front/src/modules/ui/navbar/components/SupportChat.tsx new file mode 100644 index 000000000..e77d4f244 --- /dev/null +++ b/front/src/modules/ui/navbar/components/SupportChat.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { supportChatState } from '@/client-config/states/supportChatState'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@/ui/button/components/Button'; + +const StyledButtonContainer = styled.div` + display: flex; +`; + +const StyledQuestionMark = styled.div` + align-items: center; + border-radius: 50%; + border-style: solid; + border-width: ${({ theme }) => theme.spacing(0.25)}; + display: flex; + height: ${({ theme }) => theme.spacing(3.5)}; + justify-content: center; + margin-right: ${({ theme }) => theme.spacing(1)}; + width: ${({ theme }) => theme.spacing(3.5)}; +`; + +// insert a script tag into the DOM right before the closing body tag +function insertScript({ + src, + innerHTML, + onLoad, +}: { + src?: string; + innerHTML?: string; + onLoad?: (...args: any[]) => void; +}) { + const script = document.createElement('script'); + if (src) script.src = src; + if (innerHTML) script.innerHTML = innerHTML; + if (onLoad) script.onload = onLoad; + document.body.appendChild(script); +} + +function configureFront(chatId: string) { + const url = 'https://chat-assets.frontapp.com/v1/chat.bundle.js'; + // check if Front Chat script is already loaded + const script = document.querySelector(`script[src="${url}"]`); + + if (!script) { + // insert script and initialize Front Chat when it loads + insertScript({ + src: url, + onLoad: () => { + window.FrontChat?.('init', { + chatId, + useDefaultLauncher: false, + }); + }, + }); + } +} + +export default function SupportChat() { + const user = useRecoilValue(currentUserState); + const supportChatConfig = useRecoilValue(supportChatState); + const [isFrontChatLoaded, setIsFrontChatLoaded] = useState(false); + const [isChatShowing, setIsChatShowing] = useState(false); + + useEffect(() => { + if ( + supportChatConfig?.supportDriver === 'front' && + supportChatConfig.supportFrontendKey && + !isFrontChatLoaded + ) { + configureFront(supportChatConfig.supportFrontendKey); + setIsFrontChatLoaded(true); + } + if (user?.email && isFrontChatLoaded) { + window.FrontChat?.('identity', { + email: user.email, + name: user.displayName, + userHash: user?.supportHMACKey, + }); + } + }, [ + isFrontChatLoaded, + supportChatConfig?.supportDriver, + supportChatConfig.supportFrontendKey, + user?.displayName, + user?.email, + user?.supportHMACKey, + ]); + + function handleSupportClick() { + if (supportChatConfig?.supportDriver === 'front') { + const action = isChatShowing ? 'hide' : 'show'; + setIsChatShowing(!isChatShowing); + window.FrontChat?.(action); + } + } + + return isFrontChatLoaded ? ( + +