mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import React, { useCallback, useId, useState } from 'react';
|
|
import type { LocalizerType } from '../types/I18N.std.js';
|
|
import { AxoSymbol } from '../axo/AxoSymbol.dom.js';
|
|
import { AxoButton } from '../axo/AxoButton.dom.js';
|
|
import { AxoDialog } from '../axo/AxoDialog.dom.js';
|
|
import { CallQualitySurvey } from '../types/CallQualitySurvey.std.js';
|
|
import { tw } from '../axo/tw.dom.js';
|
|
import { missingCaseError } from '../util/missingCaseError.std.js';
|
|
import { AxoCheckbox } from '../axo/AxoCheckbox.dom.js';
|
|
import { strictAssert } from '../util/assert.std.js';
|
|
|
|
import Issue = CallQualitySurvey.Issue;
|
|
|
|
enum Page {
|
|
HOW_WAS_YOUR_CALL,
|
|
WHAT_ISSUES_DID_YOU_HAVE,
|
|
CONFIRM_SUBMISSION,
|
|
PREVIEW_DEBUGLOGS,
|
|
}
|
|
|
|
export type CallQualitySurveyDialogProps = Readonly<{
|
|
i18n: LocalizerType;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (form: CallQualitySurvey.Form) => void;
|
|
}>;
|
|
|
|
export function CallQualitySurveyDialog(
|
|
props: CallQualitySurveyDialogProps
|
|
): JSX.Element {
|
|
const { i18n, onSubmit } = props;
|
|
|
|
const [page, setPage] = useState(Page.HOW_WAS_YOUR_CALL);
|
|
const [userSatisfied, setUserSatisfied] = useState<boolean | null>(null);
|
|
const [callQualityIssues, setCallQualityIssues] = useState<
|
|
ReadonlySet<Issue>
|
|
>(() => new Set());
|
|
const [additionalIssuesDescription, setAdditionalIssuesDescription] =
|
|
useState('');
|
|
const debugLogCheckboxId = useId();
|
|
const [shareDebugLog, setShareDebugLog] = useState(false);
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
strictAssert(userSatisfied != null, 'userSatisfied cannot be null');
|
|
|
|
const form: CallQualitySurvey.Form = {
|
|
userSatisfied,
|
|
// TODO: Only include if `!userSatisfied`
|
|
callQualityIssues,
|
|
// TODO: Only include if `callQualityIssues.has(Issue.OTHER)`
|
|
additionalIssuesDescription,
|
|
shareDebugLog,
|
|
};
|
|
|
|
onSubmit(form);
|
|
}, [
|
|
onSubmit,
|
|
userSatisfied,
|
|
callQualityIssues,
|
|
additionalIssuesDescription,
|
|
shareDebugLog,
|
|
]);
|
|
|
|
return (
|
|
<AxoDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
|
|
<AxoDialog.Content escape="cancel-is-destructive" size="md">
|
|
{page === Page.HOW_WAS_YOUR_CALL && (
|
|
<>
|
|
<AxoDialog.Header>
|
|
<AxoDialog.Title>
|
|
{i18n('icu:CallQualitySurvey__HowWasYourCall__PageTitle')}
|
|
</AxoDialog.Title>
|
|
<AxoDialog.Close
|
|
aria-label={i18n(
|
|
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
|
|
)}
|
|
/>
|
|
</AxoDialog.Header>
|
|
<AxoDialog.Body>
|
|
<p className={tw('mb-3 type-body-medium text-label-primary')}>
|
|
<AxoDialog.Description>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__HowWasYourCall__PageDescription'
|
|
)}
|
|
</AxoDialog.Description>
|
|
</p>
|
|
<div className={tw('mb-6 flex justify-center gap-10')}>
|
|
<BigCircleButton
|
|
symbol="thumbsdown"
|
|
className={tw(
|
|
'bg-color-fill-destructive/10 text-color-fill-destructive group-hovered:bg-color-fill-destructive/15'
|
|
)}
|
|
onClick={() => {
|
|
setUserSatisfied(false);
|
|
setPage(Page.WHAT_ISSUES_DID_YOU_HAVE);
|
|
}}
|
|
label={i18n(
|
|
'icu:CallQualitySurvey__HowWasYourCall__Response__HadIssues'
|
|
)}
|
|
/>
|
|
|
|
<BigCircleButton
|
|
symbol="thumbsup"
|
|
className={tw(
|
|
'bg-color-fill-primary/10 text-color-fill-primary group-hovered:bg-color-fill-primary/15'
|
|
)}
|
|
onClick={() => {
|
|
setUserSatisfied(true);
|
|
setPage(Page.CONFIRM_SUBMISSION);
|
|
}}
|
|
label={i18n(
|
|
'icu:CallQualitySurvey__HowWasYourCall__Response__Great'
|
|
)}
|
|
/>
|
|
</div>
|
|
</AxoDialog.Body>
|
|
</>
|
|
)}
|
|
{page === Page.WHAT_ISSUES_DID_YOU_HAVE && (
|
|
<>
|
|
<AxoDialog.Header>
|
|
<AxoDialog.Back
|
|
aria-label={i18n(
|
|
'icu:CallQualitySurvey__BackButton__AccessibilityLabel'
|
|
)}
|
|
onClick={() => {
|
|
setPage(Page.HOW_WAS_YOUR_CALL);
|
|
}}
|
|
/>
|
|
<AxoDialog.Title>
|
|
{i18n('icu:CallQualitySurvey__WhatIssuesDidYouHave__PageTitle')}
|
|
</AxoDialog.Title>
|
|
<AxoDialog.Close
|
|
aria-label={i18n(
|
|
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
|
|
)}
|
|
/>
|
|
</AxoDialog.Header>
|
|
<AxoDialog.Body>
|
|
<p className={tw('mb-3 type-body-medium text-label-primary')}>
|
|
<AxoDialog.Description>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__WhatIssuesDidYouHave__IssuesList__Heading'
|
|
)}
|
|
</AxoDialog.Description>
|
|
</p>
|
|
<div className={tw('mb-3')}>
|
|
<IssueSelector
|
|
i18n={i18n}
|
|
issues={callQualityIssues}
|
|
onIssuesChange={setCallQualityIssues}
|
|
/>
|
|
</div>
|
|
{callQualityIssues.has(Issue.OTHER) && (
|
|
<div className={tw('mb-3')}>
|
|
<textarea
|
|
aria-label={i18n(
|
|
'icu:CallQualitySurvey__WhatIssuesDidYouHave__SomethingElse__TextArea__AccessibilityLabel'
|
|
)}
|
|
value={additionalIssuesDescription}
|
|
onChange={event => {
|
|
setAdditionalIssuesDescription(event.currentTarget.value);
|
|
}}
|
|
placeholder="Describe your issue"
|
|
className={tw(
|
|
'field-sizing-content max-h-50 min-h-20 w-full resize-none',
|
|
'rounded-lg border-[0.5px] border-border-primary px-3 py-2 shadow-elevation-1',
|
|
'text-label-primary placeholder:text-label-placeholder disabled:text-label-disabled',
|
|
'outline-border-focused not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]'
|
|
)}
|
|
/>
|
|
<p
|
|
className={tw('mt-3 type-body-small text-label-secondary')}
|
|
>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__WhatIssuesDidYouHave__SomethingElse__TextArea__HelpText'
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</AxoDialog.Body>
|
|
<AxoDialog.Footer>
|
|
<AxoDialog.Actions>
|
|
<AxoDialog.Action
|
|
variant="primary"
|
|
onClick={() => {
|
|
setPage(Page.CONFIRM_SUBMISSION);
|
|
}}
|
|
>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__WhatIssuesDidYouHave__ContinueButton'
|
|
)}
|
|
</AxoDialog.Action>
|
|
</AxoDialog.Actions>
|
|
</AxoDialog.Footer>
|
|
</>
|
|
)}
|
|
{page === Page.CONFIRM_SUBMISSION && (
|
|
<>
|
|
<AxoDialog.Header>
|
|
<AxoDialog.Back
|
|
aria-label={i18n(
|
|
'icu:CallQualitySurvey__BackButton__AccessibilityLabel'
|
|
)}
|
|
onClick={() => {
|
|
if (!userSatisfied) {
|
|
setPage(Page.WHAT_ISSUES_DID_YOU_HAVE);
|
|
} else {
|
|
setPage(Page.HOW_WAS_YOUR_CALL);
|
|
}
|
|
}}
|
|
/>
|
|
<AxoDialog.Title>
|
|
{i18n('icu:CallQualitySurvey__ConfirmSubmission__PageTitle')}
|
|
</AxoDialog.Title>
|
|
<AxoDialog.Close
|
|
aria-label={i18n(
|
|
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
|
|
)}
|
|
/>
|
|
</AxoDialog.Header>
|
|
<AxoDialog.Body>
|
|
<p className={tw('mb-3 type-body-medium text-label-primary')}>
|
|
<AxoDialog.Description>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__ConfirmSubmission__PageDescription'
|
|
)}
|
|
</AxoDialog.Description>
|
|
</p>
|
|
<div className={tw('my-1.5 flex items-center gap-3')}>
|
|
<AxoCheckbox.Root
|
|
variant="square"
|
|
id={debugLogCheckboxId}
|
|
checked={shareDebugLog}
|
|
onCheckedChange={setShareDebugLog}
|
|
/>
|
|
<label
|
|
htmlFor={debugLogCheckboxId}
|
|
className={tw('grow truncate')}
|
|
>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__Label'
|
|
)}
|
|
</label>
|
|
<AxoButton.Root
|
|
variant="subtle-primary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setPage(Page.PREVIEW_DEBUGLOGS);
|
|
}}
|
|
>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__ViewButton'
|
|
)}
|
|
</AxoButton.Root>
|
|
</div>
|
|
<p className={tw('mt-3 type-body-small text-label-secondary')}>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__HelpText'
|
|
)}
|
|
</p>
|
|
</AxoDialog.Body>
|
|
<AxoDialog.Footer>
|
|
<AxoDialog.Actions>
|
|
<AxoDialog.Action variant="primary" onClick={handleSubmit}>
|
|
{i18n(
|
|
'icu:CallQualitySurvey__ConfirmSubmission__SubmitButton'
|
|
)}
|
|
</AxoDialog.Action>
|
|
</AxoDialog.Actions>
|
|
</AxoDialog.Footer>
|
|
</>
|
|
)}
|
|
</AxoDialog.Content>
|
|
</AxoDialog.Root>
|
|
);
|
|
}
|
|
|
|
function BigCircleButton(props: {
|
|
symbol: AxoSymbol.IconName;
|
|
className: string;
|
|
label: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={tw(
|
|
'group flex w-24 flex-col items-center gap-3 rounded-lg p-3',
|
|
'outline-border-focused not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]'
|
|
)}
|
|
onClick={props.onClick}
|
|
>
|
|
<span
|
|
className={tw(
|
|
'flex size-10 items-center justify-center rounded-full',
|
|
props.className
|
|
)}
|
|
>
|
|
<AxoSymbol.Icon size={24} symbol={props.symbol} label={null} />
|
|
</span>
|
|
<span className={tw('type-body-medium text-label-primary')}>
|
|
{props.label}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const ISSUE_ICONS: Record<Issue, AxoSymbol.InlineGlyphName> = {
|
|
[Issue.AUDIO]: 'speaker',
|
|
[Issue.AUDIO_STUTTERING]: 'speaker',
|
|
[Issue.AUDIO_LOCAL_ECHO]: 'speaker',
|
|
[Issue.AUDIO_REMOTE_ECHO]: 'speaker',
|
|
[Issue.AUDIO_DROP]: 'speaker',
|
|
[Issue.VIDEO]: 'videocamera',
|
|
[Issue.VIDEO_NO_CAMERA]: 'videocamera',
|
|
[Issue.VIDEO_LOW_QUALITY]: 'videocamera',
|
|
[Issue.VIDEO_LOW_RESOLUTION]: 'videocamera',
|
|
[Issue.CALL_DROPPED]: 'x-circle',
|
|
[Issue.OTHER]: 'error',
|
|
};
|
|
|
|
function getIssueLabel(i18n: LocalizerType, issue: Issue): string {
|
|
switch (issue) {
|
|
case Issue.AUDIO:
|
|
return i18n('icu:CallQualitySurvey__Issue--AUDIO');
|
|
case Issue.AUDIO_STUTTERING:
|
|
return i18n('icu:CallQualitySurvey__Issue--AUDIO_STUTTERING');
|
|
case Issue.AUDIO_LOCAL_ECHO:
|
|
return i18n('icu:CallQualitySurvey__Issue--AUDIO_LOCAL_ECHO');
|
|
case Issue.AUDIO_REMOTE_ECHO:
|
|
return i18n('icu:CallQualitySurvey__Issue--AUDIO_REMOTE_ECHO');
|
|
case Issue.AUDIO_DROP:
|
|
return i18n('icu:CallQualitySurvey__Issue--AUDIO_DROP');
|
|
case Issue.VIDEO:
|
|
return i18n('icu:CallQualitySurvey__Issue--VIDEO');
|
|
case Issue.VIDEO_NO_CAMERA:
|
|
return i18n('icu:CallQualitySurvey__Issue--VIDEO_NO_CAMERA');
|
|
case Issue.VIDEO_LOW_QUALITY:
|
|
return i18n('icu:CallQualitySurvey__Issue--VIDEO_LOW_QUALITY');
|
|
case Issue.VIDEO_LOW_RESOLUTION:
|
|
return i18n('icu:CallQualitySurvey__Issue--VIDEO_LOW_RESOLUTION');
|
|
case Issue.CALL_DROPPED:
|
|
return i18n('icu:CallQualitySurvey__Issue--CALL_DROPPED');
|
|
case Issue.OTHER:
|
|
return i18n('icu:CallQualitySurvey__Issue--OTHER');
|
|
default:
|
|
throw missingCaseError(issue);
|
|
}
|
|
}
|
|
|
|
type IssueGroup = Readonly<{
|
|
parent: Issue;
|
|
children: ReadonlyArray<Issue>;
|
|
}>;
|
|
|
|
const IssueGroups: ReadonlyArray<IssueGroup> = [
|
|
{
|
|
parent: Issue.AUDIO,
|
|
children: [
|
|
Issue.AUDIO_STUTTERING,
|
|
Issue.AUDIO_LOCAL_ECHO,
|
|
Issue.AUDIO_REMOTE_ECHO,
|
|
Issue.AUDIO_DROP,
|
|
],
|
|
},
|
|
{
|
|
parent: Issue.VIDEO,
|
|
children: [
|
|
Issue.VIDEO_NO_CAMERA,
|
|
Issue.VIDEO_LOW_QUALITY,
|
|
Issue.VIDEO_LOW_RESOLUTION,
|
|
],
|
|
},
|
|
{
|
|
parent: Issue.CALL_DROPPED,
|
|
children: [],
|
|
},
|
|
{
|
|
parent: Issue.OTHER,
|
|
children: [],
|
|
},
|
|
];
|
|
|
|
function IssueSelector(props: {
|
|
i18n: LocalizerType;
|
|
issues: ReadonlySet<Issue>;
|
|
onIssuesChange: (issues: ReadonlySet<Issue>) => void;
|
|
}): JSX.Element {
|
|
const { i18n, issues, onIssuesChange } = props;
|
|
|
|
return (
|
|
<div className={tw('flex flex-wrap justify-center-safe gap-2 px-4')}>
|
|
{IssueGroups.map(group => {
|
|
return (
|
|
<IssueToggleGroup
|
|
key={group.parent}
|
|
i18n={i18n}
|
|
group={group}
|
|
issues={issues}
|
|
onIssuesChange={onIssuesChange}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IssueToggleGroup(props: {
|
|
i18n: LocalizerType;
|
|
group: IssueGroup;
|
|
issues: ReadonlySet<Issue>;
|
|
onIssuesChange: (issues: ReadonlySet<Issue>) => void;
|
|
}) {
|
|
const { i18n, group, issues, onIssuesChange } = props;
|
|
|
|
const selected = issues.has(group.parent);
|
|
const [stored, setStored] = useState<ReadonlySet<Issue>>(() => new Set());
|
|
|
|
const handleParentToggle = useCallback(
|
|
(_issue: Issue, toggle: boolean) => {
|
|
const newIssues = new Set(issues);
|
|
if (toggle) {
|
|
newIssues.add(group.parent);
|
|
for (const child of stored) {
|
|
newIssues.add(child);
|
|
}
|
|
} else {
|
|
newIssues.delete(group.parent);
|
|
for (const child of group.children) {
|
|
newIssues.delete(child);
|
|
}
|
|
}
|
|
|
|
onIssuesChange(newIssues);
|
|
},
|
|
[issues, stored, group, onIssuesChange]
|
|
);
|
|
|
|
const handleChildToggle = useCallback(
|
|
(issue: Issue, toggle: boolean) => {
|
|
const newIssues = new Set(issues);
|
|
const newStored = new Set(stored);
|
|
if (toggle) {
|
|
newIssues.add(issue);
|
|
newStored.add(issue);
|
|
} else {
|
|
newIssues.delete(issue);
|
|
newStored.delete(issue);
|
|
}
|
|
setStored(newStored);
|
|
onIssuesChange(newIssues);
|
|
},
|
|
[issues, stored, onIssuesChange]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<IssueToggle
|
|
i18n={i18n}
|
|
issue={group.parent}
|
|
isParent
|
|
isSelected={selected}
|
|
onToggle={handleParentToggle}
|
|
/>
|
|
{selected && (
|
|
<>
|
|
{group.children.map(child => {
|
|
return (
|
|
<IssueToggle
|
|
key={child}
|
|
i18n={i18n}
|
|
issue={child}
|
|
isParent={false}
|
|
isSelected={issues.has(child)}
|
|
onToggle={handleChildToggle}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function IssueToggle(props: {
|
|
i18n: LocalizerType;
|
|
issue: Issue;
|
|
isParent: boolean;
|
|
isSelected: boolean;
|
|
onToggle: (issue: Issue, toggle: boolean) => void;
|
|
}) {
|
|
const { i18n, issue, isSelected, onToggle } = props;
|
|
|
|
const handleClick = useCallback(() => {
|
|
onToggle(issue, !isSelected);
|
|
}, [issue, isSelected, onToggle]);
|
|
|
|
return (
|
|
<AxoButton.Root
|
|
variant={props.isSelected ? 'primary' : 'secondary'}
|
|
size="md"
|
|
symbol={ISSUE_ICONS[issue]}
|
|
aria-pressed={props.isSelected}
|
|
onClick={handleClick}
|
|
>
|
|
{getIssueLabel(i18n, issue)}
|
|
</AxoButton.Root>
|
|
);
|
|
}
|