Init Call Quality Survey UI

This commit is contained in:
Jamie
2025-11-26 12:55:42 -08:00
committed by GitHub
parent e0000ab520
commit f9fb9a2839
7 changed files with 773 additions and 0 deletions

View File

@@ -9938,6 +9938,118 @@
"messageformat": "You can adjust this on your mobile device under Settings → Donate to Signal → Badges",
"description": "Help text below the toggle in donation thank you modal"
},
"icu:CallQualitySurvey__CloseButton__AccessibilityLabel": {
"messageformat": "Close",
"description": "Call Quality Survey Dialog > Dialog Close Button (Accessibility Label)"
},
"icu:CallQualitySurvey__BackButton__AccessibilityLabel": {
"messageformat": "Back",
"description": "Call Quality Survey Dialog > Dialog Back Button (Accessibility Label)"
},
"icu:CallQualitySurvey__HowWasYourCall__PageTitle": {
"messageformat": "How was your call?",
"description": "Call Quality Survey Dialog > How was your call? > Page Title"
},
"icu:CallQualitySurvey__HowWasYourCall__PageDescription": {
"messageformat": "This helps us improve calls in Signal. No personally identifiable information will be stored.",
"description": "Call Quality Survey Dialog > How was your call? > Page Description"
},
"icu:CallQualitySurvey__HowWasYourCall__Response__HadIssues": {
"messageformat": "Had issues",
"description": "Call Quality Survey Dialog > How was your call? > Response > Had Issues"
},
"icu:CallQualitySurvey__HowWasYourCall__Response__Great": {
"messageformat": "Great",
"description": "Call Quality Survey Dialog > How was your call? > Response > Great"
},
"icu:CallQualitySurvey__WhatIssuesDidYouHave__PageTitle": {
"messageformat": "What issues did you have?",
"description": "Call Quality Survey Dialog > What issues did you have? > Page Title"
},
"icu:CallQualitySurvey__WhatIssuesDidYouHave__IssuesList__Heading": {
"messageformat": "Select all that apply",
"description": "Call Quality Survey Dialog > What issues did you have? > Issues list > Heading"
},
"icu:CallQualitySurvey__Issue--AUDIO": {
"messageformat": "Audio issue",
"description": "Call Quality Survey Dialog > Select issues > AUDIO"
},
"icu:CallQualitySurvey__Issue--AUDIO_STUTTERING": {
"messageformat": "Audio stuttering",
"description": "Call Quality Survey Dialog > Select issues > AUDIO_STUTTERING"
},
"icu:CallQualitySurvey__Issue--AUDIO_LOCAL_ECHO": {
"messageformat": "I heard echo",
"description": "Call Quality Survey Dialog > Select issues > AUDIO_LOCAL_ECHO"
},
"icu:CallQualitySurvey__Issue--AUDIO_REMOTE_ECHO": {
"messageformat": "Others heard echo",
"description": "Call Quality Survey Dialog > Select issues > AUDIO_REMOTE_ECHO"
},
"icu:CallQualitySurvey__Issue--AUDIO_DROP": {
"messageformat": "Audio cut out",
"description": "Call Quality Survey Dialog > Select issues > AUDIO_DROP"
},
"icu:CallQualitySurvey__Issue--VIDEO": {
"messageformat": "Video issue",
"description": "Call Quality Survey Dialog > Select issues > VIDEO"
},
"icu:CallQualitySurvey__Issue--VIDEO_NO_CAMERA": {
"messageformat": "Camera didn't work",
"description": "Call Quality Survey Dialog > Select issues > VIDEO_NO_CAMERA"
},
"icu:CallQualitySurvey__Issue--VIDEO_LOW_QUALITY": {
"messageformat": "Poor video quality",
"description": "Call Quality Survey Dialog > Select issues > VIDEO_LOW_QUALITY"
},
"icu:CallQualitySurvey__Issue--VIDEO_LOW_RESOLUTION": {
"messageformat": "Low resolution",
"description": "Call Quality Survey Dialog > Select issues > VIDEO_LOW_RESOLUTION"
},
"icu:CallQualitySurvey__Issue--CALL_DROPPED": {
"messageformat": "Call dropped",
"description": "Call Quality Survey Dialog > Select issues > CALL_DROPPED"
},
"icu:CallQualitySurvey__Issue--OTHER": {
"messageformat": "Something else",
"description": "Call Quality Survey Dialog > Select issues > OTHER"
},
"icu:CallQualitySurvey__WhatIssuesDidYouHave__SomethingElse__TextArea__AccessibilityLabel": {
"messageformat": "Issues description",
"description": "Call Quality Survey Dialog > What issues did you have? > Something else > Textarea > Accessibility Label"
},
"icu:CallQualitySurvey__WhatIssuesDidYouHave__SomethingElse__TextArea__HelpText": {
"messageformat": "Please include any details relevant to the issue. Anything you share here will be kept private and will only be used to help improve calls in Signal.",
"description": "Call Quality Survey Dialog > What issues did you have? > Something else > Textarea > Help Text"
},
"icu:CallQualitySurvey__WhatIssuesDidYouHave__ContinueButton": {
"messageformat": "Continue",
"description": "Call Quality Survey Dialog > What issues did you have? > Continue Button"
},
"icu:CallQualitySurvey__ConfirmSubmission__PageTitle": {
"messageformat": "Help us improve",
"description": "Call Quality Survey Dialog > Help us improve > Page Title"
},
"icu:CallQualitySurvey__ConfirmSubmission__PageDescription": {
"messageformat": "This helps us learn more about calls and what is working or not working. You can view your debug log before submitting.",
"description": "Call Quality Survey Dialog > Help us improve > Page Description"
},
"icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__Label": {
"messageformat": "Share debug log",
"description": "Call Quality Survey Dialog > Help us improve > Share debug log > Label"
},
"icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__ViewButton": {
"messageformat": "View",
"description": "Call Quality Survey Dialog > Help us improve > Share debug log > View Button"
},
"icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__HelpText": {
"messageformat": "Debug logs contain low level app information and do not reveal any of your message contents.",
"description": "Call Quality Survey Dialog > Help us improve > Share debug log > Help Text"
},
"icu:CallQualitySurvey__ConfirmSubmission__SubmitButton": {
"messageformat": "Submit",
"description": "Call Quality Survey Dialog > Help us improve > Submit Button"
},
"icu:WhatsNew__bugfixes": {
"messageformat": "This version contains a number of small tweaks and bug fixes to keep Signal running smoothly.",
"description": "Release notes for releases that only include bug fixes",

View File

@@ -0,0 +1,58 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
syntax = "proto3";
package signalservice;
message SubmitCallQualitySurveyRequest {
// Indicates whether the caller was generally satisfied with the quality of
// the call
bool user_satisfied = 1;
// A list of call quality issues selected by the caller
repeated string call_quality_issues = 2;
// A free-form description of any additional issues as written by the caller
optional string additional_issues_description = 3;
// A URL for a set of debug logs associated with the call if the caller chose
// to submit debug logs
optional string debug_log_url = 4;
// The time at which the call started in microseconds since the epoch (see
// https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)
int64 start_timestamp = 5;
// The time at which the call ended in microseconds since the epoch (see
// https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)
int64 end_timestamp = 6;
// The type of call; note that direct voice calls can become video calls and
// vice versa, and this field indicates which mode was selected at call
// initiation time. At the time of writing, expected call types are
// "direct_voice", "direct_video", "group", and "call_link".
string call_type = 7;
// Indicates whether the call completed without error or if it terminated
// abnormally
bool success = 8;
// A client-defined, but human-readable reason for call termination
string call_end_reason = 9;
// The median round-trip time, measured in milliseconds, for packets over the
// duration of the call
optional float rtt_median = 10;
// The median jitter, measured in milliseconds, for the duration of the call
optional float jitter_median = 11;
// The fraction of all packets lost over the duration of the call
optional float packet_loss_fraction = 12;
// Machine-generated telemetry from the call; this is a serialized protobuf
// entity generated (and, critically, explained to the user!) by the calling
// library
optional bytes call_telemetry = 13;
}

View File

@@ -168,6 +168,7 @@ export namespace AxoDialog {
size="sm"
variant="borderless-secondary"
symbol="chevron-[start]"
onClick={props.onClick}
aria-label={props['aria-label']}
onClick={props.onClick}
/>
@@ -378,6 +379,7 @@ export namespace AxoDialog {
onClick={props.onClick}
size="md"
width="grow"
onClick={props.onClick}
>
{props.children}
</AxoButton.Root>

View File

@@ -0,0 +1,24 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta } from '@storybook/react';
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import { CallQualitySurveyDialog } from './CallQualitySurveyDialog.dom.js';
const { i18n } = window.SignalContext;
export default {
title: 'Components/CallQualitySurveyDialog',
} satisfies Meta;
export function Default(): JSX.Element {
const [open, setOpen] = useState(true);
return (
<CallQualitySurveyDialog
i18n={i18n}
open={open}
onOpenChange={setOpen}
onSubmit={action('onSubmit')}
/>
);
}

View File

@@ -0,0 +1,511 @@
// 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
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>
);
}

View File

@@ -738,6 +738,7 @@ const CHAT_CALLS = {
backupMediaBatch: 'v1/archives/media/batch',
backupMediaDelete: 'v1/archives/media/delete',
callLinkCreateAuth: 'v1/call-link/create-auth',
callQualitySurvey: 'v1/call_quality_survey',
redeemReceipt: 'v1/donation/redeem-receipt',
registration: 'v1/registration',
registerCapabilities: 'v1/devices/capabilities',
@@ -3337,6 +3338,19 @@ export async function callLinkCreateAuth(
});
}
export async function submitCallQualitySurvey(
survey: Proto.ISubmitCallQualitySurveyRequest
): Promise<void> {
const data = Proto.SubmitCallQualitySurveyRequest.encode(survey).finish();
await _ajax({
call: 'callQualitySurvey',
contentType: 'application/x-protobuf',
data,
host: 'chatService',
httpType: 'PUT',
});
}
export async function setPhoneNumberDiscoverability(
newValue: boolean
): Promise<void> {

View File

@@ -0,0 +1,52 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CallEndedReason } from '@signalapp/ringrtc';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CallQualitySurvey {
// IMPORTANT: These strings need to be the same across clients
export enum Issue {
AUDIO = 'audio',
AUDIO_STUTTERING = 'audio_stuttering',
AUDIO_LOCAL_ECHO = 'audio_local_echo',
AUDIO_REMOTE_ECHO = 'audio_remote_echo',
AUDIO_DROP = 'audio_drop',
VIDEO = 'video',
VIDEO_NO_CAMERA = 'video_no_camera',
VIDEO_LOW_QUALITY = 'video_low_quality',
VIDEO_LOW_RESOLUTION = 'video_low_resolution',
CALL_DROPPED = 'call_dropped',
OTHER = 'other',
}
export enum CallType {
DIRECT_VOICE = 'direct_voice',
DIRECT_VIDEO = 'direct_video',
GROUP = 'group',
CALL_LINK = 'call_link',
}
export type Request = Readonly<{
userSatisfied: boolean;
callQualityIssues: ReadonlyArray<Issue>;
additionalIssuesDescription: string;
debugLogUrl: string | null;
startTimestamp: number;
endTimestamp: number;
callType: CallType;
success: boolean;
callEndReason: CallEndedReason;
rttMedian: number | null;
jitterMedian: number | null;
packetLossFraction: number | null;
callTelemetry: Uint8Array | null;
}>;
export type Form = Readonly<{
userSatisfied: boolean;
callQualityIssues: ReadonlySet<Issue>;
additionalIssuesDescription: string;
shareDebugLog: boolean;
}>;
}