Skip to content

Commit

Permalink
feat: Call quality feedback (#17969)
Browse files Browse the repository at this point in the history
* feat: Call quality feedback

* call quality feedback

* quality feedback modal improvements

* add quality feedback tests

* code improvements

* CR Improvements

* create showQualityFeedbackModal method

* CR changes

* change typo

* test fixes

* add check for countly
  • Loading branch information
przemvs authored Sep 9, 2024
1 parent 76647f6 commit 9950c43
Show file tree
Hide file tree
Showing 13 changed files with 505 additions and 1 deletion.
7 changes: 7 additions & 0 deletions src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
29 changes: 29 additions & 0 deletions src/script/calling/CallingRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/script/components/AppContainer/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -97,8 +98,10 @@ export const AppContainer: FC<AppProps> = ({config, clientType}) => {
return <AppMain app={app} selfUser={selfUser} mainView={mainView} locked={softLockEnabled} />;
}}
</AppLoader>

<StyledApp themeId={THEME_ID.DEFAULT} css={{backgroundColor: 'unset', height: '100%'}}>
<PrimaryModalComponent />
<QualityFeedbackModal />
</StyledApp>

{isDetachedCallingFeatureEnabled() && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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%',
};
Original file line number Diff line number Diff line change
@@ -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(<QualityFeedbackModal />)));
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();
});
});
});
Loading

0 comments on commit 9950c43

Please sign in to comment.