diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index d5333cc9478..92c44734108 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -1342,6 +1342,13 @@ "proteusNotVerified": "Not Verified", "proteusVerified": "Verified", "proteusVerifiedDetails": "Verified (Proteus)", + "qualityFeedback.heading": "Call Quality Feedback", + "qualityFeedback.description": "How do you rate the overall quality of the call?", + "qualityFeedback.bad": "Bad", + "qualityFeedback.excellent": "Excellent", + "qualityFeedback.fair": "Fair", + "qualityFeedback.skip": "Skip", + "qualityFeedback.doNotAskAgain": "Don't ask again", "readReceiptsToggleInfo": "When this is on, people can see when their messages in this conversation are read.", "readReceiptsToggleName": "Read receipts", "receiptToggleInfo": "When this is on, people can see when their messages in this conversation are read.", diff --git a/src/script/calling/CallingRepository.ts b/src/script/calling/CallingRepository.ts index 4b1c60db4ec..35229a16058 100644 --- a/src/script/calling/CallingRepository.ts +++ b/src/script/calling/CallingRepository.ts @@ -52,6 +52,8 @@ import { import {Runtime} from '@wireapp/commons'; import {WebAppEvents} from '@wireapp/webapp-events'; +import {useCallAlertState} from 'Components/calling/useCallAlertState'; +import {CALL_QUALITY_FEEDBACK_KEY} from 'Components/Modals/QualityFeedbackModal/constants'; import {flatten} from 'Util/ArrayUtil'; import {t} from 'Util/LocalizerUtil'; import {getLogger, Logger} from 'Util/Logger'; @@ -87,6 +89,7 @@ import {APIClient} from '../service/APIClientSingleton'; import {Core} from '../service/CoreSingleton'; import {TeamState} from '../team/TeamState'; import type {ServerTimeHandler} from '../time/serverTimeHandler'; +import {isCountlyEnabledAtCurrentEnvironment} from '../tracking/Countly.helpers'; import {EventName} from '../tracking/EventName'; import * as trackingHelpers from '../tracking/Helpers'; import {Segmentation} from '../tracking/Segmentation'; @@ -1165,11 +1168,37 @@ export class CallingRepository { this.wCall?.requestVideoStreams(this.wUser, convId, VSTREAMS.LIST, JSON.stringify(payload)); } + readonly showCallQualityFeedbackModal = () => { + if (!this.selfUser) { + return; + } + + const {setQualityFeedbackModalShown} = useCallAlertState.getState(); + + try { + const qualityFeedbackStorage = localStorage.getItem(CALL_QUALITY_FEEDBACK_KEY); + const currentStorageData = qualityFeedbackStorage ? JSON.parse(qualityFeedbackStorage) : {}; + const currentUserDate = currentStorageData?.[this.selfUser.id]; + const currentDate = new Date().getTime(); + + if (currentUserDate === undefined || (currentUserDate !== null && currentDate >= currentUserDate)) { + setQualityFeedbackModalShown(true); + } + } catch (error) { + this.logger.warn(`Storage data can't found: ${(error as Error).message}`); + setQualityFeedbackModalShown(true); + } + }; + readonly leaveCall = (conversationId: QualifiedId, reason: LEAVE_CALL_REASON): void => { this.logger.info(`Ending call with reason ${reason} \n Stack trace: `, new Error().stack); const conversationIdStr = this.serializeQualifiedId(conversationId); delete this.poorCallQualityUsers[conversationIdStr]; this.wCall?.end(this.wUser, conversationIdStr); + + if (isCountlyEnabledAtCurrentEnvironment()) { + this.showCallQualityFeedbackModal(); + } }; muteCall(call: Call, shouldMute: boolean, reason?: MuteState): void { diff --git a/src/script/components/AppContainer/AppContainer.tsx b/src/script/components/AppContainer/AppContainer.tsx index e38c3c7b553..77bd87a2999 100644 --- a/src/script/components/AppContainer/AppContainer.tsx +++ b/src/script/components/AppContainer/AppContainer.tsx @@ -26,6 +26,7 @@ import {StyledApp, THEME_ID} from '@wireapp/react-ui-kit'; import {DetachedCallingCell} from 'Components/calling/DetachedCallingCell'; import {PrimaryModalComponent} from 'Components/Modals/PrimaryModal/PrimaryModal'; +import {QualityFeedbackModal} from 'Components/Modals/QualityFeedbackModal'; import {SIGN_OUT_REASON} from 'src/script/auth/SignOutReason'; import {useAppSoftLock} from 'src/script/hooks/useAppSoftLock'; import {useSingleInstance} from 'src/script/hooks/useSingleInstance'; @@ -97,8 +98,10 @@ export const AppContainer: FC = ({config, clientType}) => { return ; }} + + {isDetachedCallingFeatureEnabled() && ( diff --git a/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.styles.ts b/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.styles.ts new file mode 100644 index 00000000000..161fffe03f9 --- /dev/null +++ b/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.styles.ts @@ -0,0 +1,76 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react'; + +export const wrapper: CSSObject = { + padding: '32px 24px', +}; + +export const title: CSSObject = { + fontSize: 'var(--line-height-lg)', + marginBottom: '16px', + textAlign: 'center', +}; + +export const description: CSSObject = { + fontSize: 'var(--font-size-base)', + marginBottom: '32px', + textAlign: 'center', +}; + +export const ratingList: CSSObject = { + display: 'flex', + alignItems: 'flex-end', + justifyContent: 'space-between', + gap: '10px', + listStyle: 'none', + margin: '0 0 24px', + padding: 0, +}; + +export const ratingItemHeading: CSSObject = { + color: 'var(--foreground)', + fontSize: 'var(--font-size-small)', + marginBottom: '8px', + textAlign: 'center', +}; + +export const ratingItemBubble: CSSObject = { + display: 'grid', + placeContent: 'center', + height: '56px', + width: '56px', + + borderRadius: '50%', + + fontSize: 'var(--font-size-base)', + fontWeight: 'var(--font-weight-semibold)', +}; + +export const buttonWrapper: CSSObject = { + marginBottom: '32px', +}; + +export const buttonStyle: CSSObject = { + fontSize: 'var(--font-size-base)', + fontWeight: 'var(--font-weight-semibold)', + height: '56px', + width: '100%', +}; diff --git a/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.test.tsx b/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.test.tsx new file mode 100644 index 00000000000..dd1e906a575 --- /dev/null +++ b/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.test.tsx @@ -0,0 +1,137 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {render, fireEvent, act} from '@testing-library/react'; +import {amplify} from 'amplify'; +import {container} from 'tsyringe'; + +import {WebAppEvents} from '@wireapp/webapp-events'; + +import {useCallAlertState} from 'Components/calling/useCallAlertState'; +import {CALL_QUALITY_FEEDBACK_KEY} from 'Components/Modals/QualityFeedbackModal/constants'; +import {RatingListLabel} from 'Components/Modals/QualityFeedbackModal/typings'; + +import {QualityFeedbackModal} from './QualityFeedbackModal'; + +import {withIntl, withTheme} from '../../../auth/util/test/TestUtil'; +import {User} from '../../../entity/User'; +import {EventName} from '../../../tracking/EventName'; +import {Segmentation} from '../../../tracking/Segmentation'; +import {UserState} from '../../../user/UserState'; + +jest.mock('../../../tracking/Countly.helpers', () => ({ + isCountlyEnabledAtCurrentEnvironment: () => true, +})); + +describe('QualityFeedbackModal', () => { + const renderQualityFeedbackModal = () => render(withTheme(withIntl())); + const user = new User('userId', 'domain'); + + beforeEach(() => { + jest.clearAllMocks(); + spyOn(container.resolve(UserState), 'self').and.returnValue(user); + }); + + it('should not render if qualityFeedbackModalShown is false', () => { + renderQualityFeedbackModal(); + + act(() => { + useCallAlertState.getState().setQualityFeedbackModalShown(false); + }); + + expect(useCallAlertState.getState().qualityFeedbackModalShown).toBe(false); + }); + + it('should render correctly when qualityFeedbackModalShown is true', () => { + renderQualityFeedbackModal(); + + act(() => { + useCallAlertState.getState().setQualityFeedbackModalShown(true); + }); + + expect(useCallAlertState.getState().qualityFeedbackModalShown).toBe(true); + }); + + it('should close modal on skip', () => { + const {getByText} = renderQualityFeedbackModal(); + + act(() => { + useCallAlertState.getState().setQualityFeedbackModalShown(true); + }); + + spyOn(amplify, 'publish').and.returnValue({ + eventKey: WebAppEvents.ANALYTICS.EVENT, + type: EventName.CALLING.QUALITY_REVIEW, + value: { + [Segmentation.CALL.QUALITY_REVIEW_LABEL]: RatingListLabel.DISMISSED, + }, + }); + + fireEvent.click(getByText('qualityFeedback.skip')); + + expect(amplify.publish).toHaveBeenCalledWith(WebAppEvents.ANALYTICS.EVENT, EventName.CALLING.QUALITY_REVIEW, { + [Segmentation.CALL.QUALITY_REVIEW_LABEL]: RatingListLabel.DISMISSED, + }); + + expect(useCallAlertState.getState().qualityFeedbackModalShown).toBe(false); + }); + + it('should send quality feedback and close modal on rating click', () => { + const {getByText} = renderQualityFeedbackModal(); + + act(() => { + useCallAlertState.getState().setQualityFeedbackModalShown(true); + }); + + spyOn(amplify, 'publish').and.returnValue({ + eventKey: WebAppEvents.ANALYTICS.EVENT, + type: EventName.CALLING.QUALITY_REVIEW, + value: { + [Segmentation.CALL.SCORE]: 5, + [Segmentation.CALL.QUALITY_REVIEW_LABEL]: RatingListLabel.ANSWERED, + }, + }); + + fireEvent.click(getByText('5')); + + expect(amplify.publish).toHaveBeenCalledWith(WebAppEvents.ANALYTICS.EVENT, EventName.CALLING.QUALITY_REVIEW, { + [Segmentation.CALL.SCORE]: 5, + [Segmentation.CALL.QUALITY_REVIEW_LABEL]: RatingListLabel.ANSWERED, + }); + + expect(useCallAlertState.getState().qualityFeedbackModalShown).toBe(false); + }); + + it('should store the doNotAskAgain state in localStorage', () => { + const {getByText} = renderQualityFeedbackModal(); + + act(() => { + useCallAlertState.getState().setQualityFeedbackModalShown(true); + }); + + const checkbox = getByText('qualityFeedback.doNotAskAgain'); + fireEvent.click(checkbox); + fireEvent.click(getByText('5')); + + act(() => { + const storedData = JSON.parse(localStorage.getItem(CALL_QUALITY_FEEDBACK_KEY) || '{}'); + expect(storedData['userId']).toBeNull(); + }); + }); +}); diff --git a/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.tsx b/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.tsx new file mode 100644 index 00000000000..2d09698bcf5 --- /dev/null +++ b/src/script/components/Modals/QualityFeedbackModal/QualityFeedbackModal.tsx @@ -0,0 +1,156 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import React, {useState} from 'react'; + +import {amplify} from 'amplify'; +import {container} from 'tsyringe'; + +import {Button, ButtonVariant, Checkbox, CheckboxLabel} from '@wireapp/react-ui-kit'; +import {WebAppEvents} from '@wireapp/webapp-events'; + +import {useCallAlertState} from 'Components/calling/useCallAlertState'; +import {ModalComponent} from 'Components/ModalComponent'; +import {RatingListLabel} from 'Components/Modals/QualityFeedbackModal/typings'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {t} from 'Util/LocalizerUtil'; +import {getLogger} from 'Util/Logger'; + +import {CALL_QUALITY_FEEDBACK_KEY, CALL_SURVEY_MUTE_INTERVAL, ratingListItems} from './constants'; +import { + buttonStyle, + buttonWrapper, + description, + ratingItemBubble, + ratingItemHeading, + ratingList, + title, + wrapper, +} from './QualityFeedbackModal.styles'; + +import {EventName} from '../../../tracking/EventName'; +import {Segmentation} from '../../../tracking/Segmentation'; +import {UserState} from '../../../user/UserState'; + +const logger = getLogger('CallQualityFeedback'); + +export const QualityFeedbackModal = () => { + const userState = container.resolve(UserState); + + const [isChecked, setIsChecked] = useState(false); + const {setQualityFeedbackModalShown, qualityFeedbackModalShown} = useCallAlertState(); + const {self: selfUser} = useKoSubscribableChildren(userState, ['self']); + + if (!qualityFeedbackModalShown) { + return null; + } + + const handleCloseModal = () => { + if (!selfUser) { + setQualityFeedbackModalShown(false); + return; + } + + try { + const qualityFeedbackStorage = localStorage.getItem(CALL_QUALITY_FEEDBACK_KEY); + const currentStorageData = qualityFeedbackStorage ? JSON.parse(qualityFeedbackStorage) : {}; + const currentDate = new Date(); + const dateUntilShowModal = new Date(currentDate.getTime() + CALL_SURVEY_MUTE_INTERVAL); + + currentStorageData[selfUser.id] = isChecked ? null : dateUntilShowModal.getTime(); + localStorage.setItem(CALL_QUALITY_FEEDBACK_KEY, JSON.stringify(currentStorageData)); + } catch (error) { + logger.warn(`No labels were loaded: ${(error as Error).message}`); + } finally { + setQualityFeedbackModalShown(false); + } + }; + + const sendQualityFeedback = (score: number) => { + amplify.publish(WebAppEvents.ANALYTICS.EVENT, EventName.CALLING.QUALITY_REVIEW, { + [Segmentation.CALL.SCORE]: score, + [Segmentation.CALL.QUALITY_REVIEW_LABEL]: RatingListLabel.ANSWERED, + }); + + handleCloseModal(); + }; + + const skipQualityFeedback = () => { + amplify.publish(WebAppEvents.ANALYTICS.EVENT, EventName.CALLING.QUALITY_REVIEW, { + [Segmentation.CALL.QUALITY_REVIEW_LABEL]: RatingListLabel.DISMISSED, + }); + + handleCloseModal(); + }; + + return ( + +
+

{t('qualityFeedback.heading')}

+ +

{t('qualityFeedback.description')}

+ +
    + {ratingListItems.map(ratingItem => ( +
  • + {ratingItem?.headingTranslationKey && ( +
    {t(ratingItem.headingTranslationKey)}
    + )} + +
  • + ))} +
+ +
+ +
+ +
+ ) => setIsChecked(event.target.checked)} + > + + {t('qualityFeedback.doNotAskAgain')} + + +
+
+
+ ); +}; diff --git a/src/script/components/Modals/QualityFeedbackModal/constants.ts b/src/script/components/Modals/QualityFeedbackModal/constants.ts new file mode 100644 index 00000000000..6e1b355a767 --- /dev/null +++ b/src/script/components/Modals/QualityFeedbackModal/constants.ts @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {TIME_IN_MILLIS} from 'Util/TimeUtil'; + +import {RatingListItem} from './typings'; + +export const ratingListItems: RatingListItem[] = [ + {value: 1, headingTranslationKey: 'qualityFeedback.bad'}, + {value: 2}, + {value: 3, headingTranslationKey: 'qualityFeedback.fair'}, + {value: 4}, + {value: 5, headingTranslationKey: 'qualityFeedback.excellent'}, +]; + +const MUTE_INTERVAL_DAYS = 3; +export const CALL_SURVEY_MUTE_INTERVAL = TIME_IN_MILLIS.DAY * MUTE_INTERVAL_DAYS; + +export const CALL_QUALITY_FEEDBACK_KEY = 'CALL_QUALITY_FEEDBACK'; diff --git a/src/script/components/Modals/QualityFeedbackModal/index.ts b/src/script/components/Modals/QualityFeedbackModal/index.ts new file mode 100644 index 00000000000..285eceb0b26 --- /dev/null +++ b/src/script/components/Modals/QualityFeedbackModal/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './QualityFeedbackModal'; diff --git a/src/script/components/Modals/QualityFeedbackModal/typings.tsx b/src/script/components/Modals/QualityFeedbackModal/typings.tsx new file mode 100644 index 00000000000..f0d99499322 --- /dev/null +++ b/src/script/components/Modals/QualityFeedbackModal/typings.tsx @@ -0,0 +1,30 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {StringIdentifer} from 'Util/LocalizerUtil'; + +export type RatingListItem = { + value: number; + headingTranslationKey?: StringIdentifer; +}; + +export enum RatingListLabel { + ANSWERED = 'answered', + DISMISSED = 'dismissed', +} diff --git a/src/script/components/calling/useCallAlertState.ts b/src/script/components/calling/useCallAlertState.ts index 438b5af2e3b..7a966dca311 100644 --- a/src/script/components/calling/useCallAlertState.ts +++ b/src/script/components/calling/useCallAlertState.ts @@ -24,11 +24,19 @@ type CallAlertState = { isGroupCall: boolean; showStartedCallAlert: (isGroupCall: boolean, isVideoCall?: boolean) => void; clearShowAlert: () => void; + qualityFeedbackModalShown: boolean; + setQualityFeedbackModalShown: (isVisible: boolean) => void; }; const useCallAlertState = create((set, get) => ({ showAlert: false, isGroupCall: false, + qualityFeedbackModalShown: false, + setQualityFeedbackModalShown: isVisible => + set(state => ({ + ...state, + qualityFeedbackModalShown: isVisible, + })), showStartedCallAlert: (isGroupCall = false, isVideoCall = false) => set(state => ({ ...state, diff --git a/src/script/tracking/Countly.helpers.ts b/src/script/tracking/Countly.helpers.ts index 50ad9f9bc26..26ad531c420 100644 --- a/src/script/tracking/Countly.helpers.ts +++ b/src/script/tracking/Countly.helpers.ts @@ -50,7 +50,7 @@ export function isCountlyEnabledAtCurrentEnvironment(): boolean { const {COUNTLY_API_KEY, COUNTLY_ALLOWED_BACKEND, BACKEND_REST} = Config.getConfig(); - const allowedBackendUrls = COUNTLY_ALLOWED_BACKEND.split(',').map(url => url.trim()) || []; + const allowedBackendUrls = COUNTLY_ALLOWED_BACKEND?.split(',').map(url => url.trim()) || []; const isCountlyEnabled = !!COUNTLY_API_KEY && allowedBackendUrls.length > 0 && allowedBackendUrls.includes(BACKEND_REST); diff --git a/src/script/tracking/EventName.ts b/src/script/tracking/EventName.ts index 956c49172de..cb950461f1f 100644 --- a/src/script/tracking/EventName.ts +++ b/src/script/tracking/EventName.ts @@ -29,6 +29,7 @@ export const EventName = { JOINED_CALL: 'calling.joined_call', RECEIVED_CALL: 'calling.received_call', SCREEN_SHARE: 'calling.screen_share', + QUALITY_REVIEW: 'calling.call_quality_review', }, CONTRIBUTED: 'contributed', E2EE: { diff --git a/src/script/tracking/Segmentation.ts b/src/script/tracking/Segmentation.ts index 9c431a3e36a..8e0f4215a1c 100644 --- a/src/script/tracking/Segmentation.ts +++ b/src/script/tracking/Segmentation.ts @@ -26,6 +26,8 @@ export const Segmentation = { PARTICIPANTS: 'call_participants', REASON: 'reason', // This has to be in sync with ios SCREEN_SHARE: 'call_screen_share', + SCORE: 'score', + QUALITY_REVIEW_LABEL: 'label', SETUP_TIME: 'call_setup_time', VIDEO: 'call_video', },