Call Quality Survey Integration

This commit is contained in:
yash-signal 2025-12-10 14:05:46 -06:00 committed by GitHub
parent 4b2f6af4ad
commit 1338eadf6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 840 additions and 54 deletions

View File

@ -735,6 +735,10 @@
"messageformat": "When you click Submit, your log will be posted online for 30 days at a unique, unpublished URL. You may Save it locally first.",
"description": "Description of what will happen with your debug log"
},
"icu:debugLogExplanation--close": {
"messageformat": "This log will be posted publicly online for contributors to view. You may download it before submitting.",
"description": "Explanation text shown in the debug log window when opened in close mode - the user can't submit, just view"
},
"icu:debugLogError": {
"messageformat": "Something went wrong with the upload! Please email support@signal.org and attach your log as a text file.",
"description": "Error message a recommendations if debug log upload fails"
@ -10022,10 +10026,18 @@
"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__SomethingElse__TextArea__ErrorText": {
"messageformat": "Please describe your issue",
"description": "Call Quality Survey Dialog > What issues did you have? > Something else > Textarea > Error text shown when field is empty"
},
"icu:CallQualitySurvey__WhatIssuesDidYouHave__ContinueButton": {
"messageformat": "Continue",
"description": "Call Quality Survey Dialog > What issues did you have? > Continue Button"
},
"icu:CallQualitySurvey__WhatIssuesDidYouHave__ContinueButton__DisabledTooltip": {
"messageformat": "Make a selection to continue",
"description": "Call Quality Survey Dialog > What issues did you have? > Tooltip shown when Continue button is disabled because no issues are selected"
},
"icu:CallQualitySurvey__ConfirmSubmission__PageTitle": {
"messageformat": "Help us improve",
"description": "Call Quality Survey Dialog > Help us improve > Page Title"
@ -10050,6 +10062,22 @@
"messageformat": "Submit",
"description": "Call Quality Survey Dialog > Help us improve > Submit Button"
},
"icu:CallQualitySurvey__ConfirmSubmission__Submitting": {
"messageformat": "Submitting survey...",
"description": "Call Quality Survey Dialog > Help us improve > Accessibility label for loading spinner while submitting"
},
"icu:CallQualitySurvey__SubmissionFailed": {
"messageformat": "Failed to submit survey",
"description": "Toast message shown when call quality survey submission fails"
},
"icu:CallQualitySurvey__SubmissionFailed__Retry": {
"messageformat": "Retry",
"description": "Button label in toast to retry submitting the call quality survey after a failure"
},
"icu:CallQualitySurvey__SubmissionSuccess": {
"messageformat": "Thanks for your feedback!",
"description": "Toast message shown when call quality survey submission succeeds"
},
"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

@ -1462,7 +1462,11 @@ async function openArtCreator() {
}
let debugLogWindow: BrowserWindow | undefined;
async function showDebugLogWindow() {
type DebugLogWindowOptions = {
mode?: 'submit' | 'close';
};
async function showDebugLogWindow(options: DebugLogWindowOptions = {}) {
if (debugLogWindow) {
doShowDebugLogWindow();
return;
@ -1483,7 +1487,7 @@ async function showDebugLogWindow() {
}
}
const options: Electron.BrowserWindowConstructorOptions = {
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 700,
height: 500,
resizable: false,
@ -1503,7 +1507,7 @@ async function showDebugLogWindow() {
parent: mainWindow,
};
debugLogWindow = new BrowserWindow(options);
debugLogWindow = new BrowserWindow(windowOptions);
await handleCommonWindowEvents(debugLogWindow);
@ -1520,10 +1524,12 @@ async function showDebugLogWindow() {
}
});
await safeLoadURL(
debugLogWindow,
await prepareFileUrl([__dirname, '../debug_log.html'])
);
const url = pathToFileURL(join(__dirname, '../debug_log.html'));
if (options.mode) {
url.searchParams.set('mode', options.mode);
}
await safeLoadURL(debugLogWindow, url.href);
}
let permissionsPopupWindow: BrowserWindow | undefined;
@ -2718,7 +2724,12 @@ ipc.on('update-tray-icon', (_event: Electron.Event, unreadCount: number) => {
// Debug Log-related IPC calls
ipc.on('show-debug-log', showDebugLogWindow);
ipc.on(
'show-debug-log',
(_event: Electron.Event, options?: DebugLogWindowOptions) => {
void showDebugLogWindow(options);
}
);
ipc.on(
'show-debug-log-save-dialog',
async (_event: Electron.Event, logText: string) => {

View File

@ -3,6 +3,8 @@
syntax = "proto3";
option java_multiple_files = true;
package signalservice;
message SubmitCallQualitySurveyRequest {
@ -20,12 +22,10 @@ message SubmitCallQualitySurveyRequest {
// 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)
// The time at which the call started in milliseconds since the epoch
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)
// The time at which the call ended in milliseconds since the epoch
int64 end_timestamp = 6;
// The type of call; note that direct voice calls can become video calls and
@ -41,18 +41,58 @@ message SubmitCallQualitySurveyRequest {
// 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 round-trip time, measured in milliseconds, for STUN/ICE packets
// (i.e. connection maintenance and establishment)
optional float connection_rtt_median = 10;
// The median jitter, measured in milliseconds, for the duration of the call
optional float jitter_median = 11;
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
// for audio streams
optional float audio_rtt_median = 11;
// The fraction of all packets lost over the duration of the call
optional float packet_loss_fraction = 12;
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
// for video streams
optional float video_rtt_median = 12;
// The median jitter for audio streams, measured in milliseconds, for the
// duration of the call as measured by the client submitting the survey
optional float audio_recv_jitter_median = 13;
// The median jitter for video streams, measured in milliseconds, for the
// duration of the call as measured by the client submitting the survey
optional float video_recv_jitter_median = 14;
// The median jitter for audio streams, measured in milliseconds, for the
// duration of the call as measured by the remote endpoint in the call (either
// the peer of the client submitting the survey in a direct call or the SFU in
// a group call)
optional float audio_send_jitter_median = 15;
// The median jitter for video streams, measured in milliseconds, for the
// duration of the call as measured by the remote endpoint in the call (either
// the peer of the client submitting the survey in a direct call or the SFU in
// a group call)
optional float video_send_jitter_median = 16;
// The fraction of audio packets lost over the duration of the call as
// measured by the client submitting the survey
optional float audio_recv_packet_loss_fraction = 17;
// The fraction of video packets lost over the duration of the call as
// measured by the client submitting the survey
optional float video_recv_packet_loss_fraction = 18;
// The fraction of audio packets lost over the duration of the call as
// measured by the remote endpoint in the call (either the peer of the client
// submitting the survey in a direct call or the SFU in a group call)
optional float audio_send_packet_loss_fraction = 19;
// The fraction of video packets lost over the duration of the call as
// measured by the remote endpoint in the call (either the peer of the client
// submitting the survey in a direct call or the SFU in a group call)
optional float video_send_packet_loss_fraction = 20;
// 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;
optional bytes call_telemetry = 21;
}

View File

@ -23,6 +23,8 @@ const log = createLogger('RemoteConfig');
// Semver flags must always be set to a valid semver (no empty enabled-only keys)
const SemverKeys = [
'desktop.callQualitySurvey.beta',
'desktop.callQualitySurvey.prod',
'desktop.plaintextExport.beta',
'desktop.plaintextExport.prod',
] as const;

View File

@ -1,6 +1,6 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useId, useState } from 'react';
import React, { useCallback, useId, useMemo, 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';
@ -10,6 +10,7 @@ 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 { Tooltip, TooltipPlacement } from './Tooltip.dom.js';
import Issue = CallQualitySurvey.Issue;
@ -17,7 +18,6 @@ enum Page {
HOW_WAS_YOUR_CALL,
WHAT_ISSUES_DID_YOU_HAVE,
CONFIRM_SUBMISSION,
PREVIEW_DEBUGLOGS,
}
export type CallQualitySurveyDialogProps = Readonly<{
@ -25,12 +25,14 @@ export type CallQualitySurveyDialogProps = Readonly<{
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (form: CallQualitySurvey.Form) => void;
onViewDebugLog?: () => void;
isSubmitting?: boolean;
}>;
export function CallQualitySurveyDialog(
props: CallQualitySurveyDialogProps
): JSX.Element {
const { i18n, onSubmit } = props;
const { i18n, onSubmit, onViewDebugLog, isSubmitting } = props;
const [page, setPage] = useState(Page.HOW_WAS_YOUR_CALL);
const [userSatisfied, setUserSatisfied] = useState<boolean | null>(null);
@ -39,9 +41,23 @@ export function CallQualitySurveyDialog(
>(() => new Set());
const [additionalIssuesDescription, setAdditionalIssuesDescription] =
useState('');
const [userHasSeenOtherForm, setUserHasSeenOtherForm] = useState(false);
const debugLogCheckboxId = useId();
const otherTextareaErrorId = useId();
const [shareDebugLog, setShareDebugLog] = useState(false);
// Validation for the issues page
const hasOtherIssue = callQualityIssues.has(Issue.OTHER);
const isOtherInputValid = useMemo(() => {
if (!hasOtherIssue) {
return true;
}
return additionalIssuesDescription.trim() !== '';
}, [hasOtherIssue, additionalIssuesDescription]);
const canContinueFromIssuesPage =
callQualityIssues.size > 0 && isOtherInputValid;
const showOtherInputError = userHasSeenOtherForm && !isOtherInputValid;
const handleSubmit = useCallback(() => {
strictAssert(userSatisfied != null, 'userSatisfied cannot be null');
@ -150,27 +166,54 @@ export function CallQualitySurveyDialog(
<IssueSelector
i18n={i18n}
issues={callQualityIssues}
onIssuesChange={setCallQualityIssues}
onIssuesChange={newIssues => {
if (!newIssues.has(Issue.OTHER)) {
setUserHasSeenOtherForm(false);
}
setCallQualityIssues(newIssues);
}}
/>
</div>
{callQualityIssues.has(Issue.OTHER) && (
{hasOtherIssue && (
<div className={tw('mb-3')}>
<textarea
aria-label={i18n(
'icu:CallQualitySurvey__WhatIssuesDidYouHave__SomethingElse__TextArea__AccessibilityLabel'
)}
aria-describedby={
showOtherInputError ? otherTextareaErrorId : undefined
}
aria-invalid={showOtherInputError}
value={additionalIssuesDescription}
onChange={event => {
setAdditionalIssuesDescription(event.currentTarget.value);
}}
onBlur={() => {
setUserHasSeenOtherForm(true);
}}
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',
'rounded-lg border-[0.5px] 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]'
'outline-offset-[-2.5px] not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]',
showOtherInputError
? 'border-border-error outline-[2.5px] outline-border-error'
: 'border-border-primary outline-border-focused'
)}
/>
{showOtherInputError && (
<p
id={otherTextareaErrorId}
className={tw(
'mt-1 mb-3 type-body-small text-color-label-destructive'
)}
>
{i18n(
'icu:CallQualitySurvey__WhatIssuesDidYouHave__SomethingElse__TextArea__ErrorText'
)}
</p>
)}
<p
className={tw('mt-3 type-body-small text-label-secondary')}
>
@ -183,16 +226,40 @@ export function CallQualitySurveyDialog(
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action
variant="primary"
onClick={() => {
setPage(Page.CONFIRM_SUBMISSION);
}}
>
{i18n(
'icu:CallQualitySurvey__WhatIssuesDidYouHave__ContinueButton'
)}
</AxoDialog.Action>
{canContinueFromIssuesPage ? (
<AxoButton.Root
variant="primary"
size="md"
width="grow"
onClick={() => {
setPage(Page.CONFIRM_SUBMISSION);
}}
>
{i18n(
'icu:CallQualitySurvey__WhatIssuesDidYouHave__ContinueButton'
)}
</AxoButton.Root>
) : (
<Tooltip
content={i18n(
!isOtherInputValid
? 'icu:CallQualitySurvey__WhatIssuesDidYouHave__SomethingElse__TextArea__ErrorText'
: 'icu:CallQualitySurvey__WhatIssuesDidYouHave__ContinueButton__DisabledTooltip'
)}
direction={TooltipPlacement.Top}
>
<AxoButton.Root
variant="primary"
size="md"
width="grow"
disabled
>
{i18n(
'icu:CallQualitySurvey__WhatIssuesDidYouHave__ContinueButton'
)}
</AxoButton.Root>
</Tooltip>
)}
</AxoDialog.Actions>
</AxoDialog.Footer>
</>
@ -248,7 +315,7 @@ export function CallQualitySurveyDialog(
variant="subtle-primary"
size="sm"
onClick={() => {
setPage(Page.PREVIEW_DEBUGLOGS);
onViewDebugLog?.();
}}
>
{i18n(
@ -264,7 +331,19 @@ export function CallQualitySurveyDialog(
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="primary" onClick={handleSubmit}>
<AxoDialog.Action
variant="primary"
onClick={handleSubmit}
experimentalSpinner={
isSubmitting
? {
'aria-label': i18n(
'icu:CallQualitySurvey__ConfirmSubmission__Submitting'
),
}
: null
}
>
{i18n(
'icu:CallQualitySurvey__ConfirmSubmission__SubmitButton'
)}
@ -502,7 +581,7 @@ function IssueToggle(props: {
<AxoButton.Root
variant={props.isSelected ? 'primary' : 'secondary'}
size="md"
symbol={ISSUE_ICONS[issue]}
symbol={isSelected ? 'check' : ISSUE_ICONS[issue]}
aria-pressed={props.isSelected}
onClick={handleClick}
>

View File

@ -32,6 +32,7 @@ export type PropsType = {
i18n: LocalizerType;
fetchLogs: () => Promise<string>;
uploadLogs: (logs: string) => Promise<string>;
mode?: 'submit' | 'close';
};
export function DebugLogWindow({
@ -40,6 +41,7 @@ export function DebugLogWindow({
i18n,
fetchLogs,
uploadLogs,
mode = 'submit',
}: PropsType): JSX.Element {
const [loadState, setLoadState] = useState<LoadState>(LoadState.NotStarted);
const [logText, setLogText] = useState<string | undefined>();
@ -157,6 +159,7 @@ export function DebugLogWindow({
i18n={i18n}
onShowDebugLog={shouldNeverBeCalled}
onUndoArchive={shouldNeverBeCalled}
retryCallQualitySurvey={shouldNeverBeCalled}
openFileInFolder={shouldNeverBeCalled}
setDidResumeDonation={shouldNeverBeCalled}
toast={toast}
@ -179,7 +182,9 @@ export function DebugLogWindow({
{i18n('icu:submitDebugLog')}
</div>
<p className="DebugLogWindow__subtitle">
{i18n('icu:debugLogExplanation')}
{mode === 'close'
? i18n('icu:debugLogExplanation--close')
: i18n('icu:debugLogExplanation')}
</p>
</div>
{isLoading ? (
@ -205,9 +210,13 @@ export function DebugLogWindow({
>
{i18n('icu:debugLogSave')}
</Button>
<Button disabled={!canSubmit} onClick={handleSubmit}>
{i18n('icu:submit')}
</Button>
{mode === 'close' ? (
<Button onClick={closeWindow}>{i18n('icu:close')}</Button>
) : (
<Button disabled={!canSubmit} onClick={handleSubmit}>
{i18n('icu:submit')}
</Button>
)}
</div>
<ToastManager
changeLocation={shouldNeverBeCalled}
@ -216,6 +225,7 @@ export function DebugLogWindow({
i18n={i18n}
onShowDebugLog={shouldNeverBeCalled}
onUndoArchive={shouldNeverBeCalled}
retryCallQualitySurvey={shouldNeverBeCalled}
openFileInFolder={shouldNeverBeCalled}
setDidResumeDonation={shouldNeverBeCalled}
toast={toast}

View File

@ -3,6 +3,7 @@
import React from 'react';
import type {
CallQualitySurveyPropsType,
ContactModalStateType,
DeleteMessagesPropsType,
EditHistoryMessagesType,
@ -49,6 +50,9 @@ export type PropsType = {
// CallLinkEditModal
callLinkEditModalRoomId: string | null;
renderCallLinkEditModal: () => JSX.Element;
// CallQualitySurvey
callQualitySurveyProps: CallQualitySurveyPropsType | null;
renderCallQualitySurvey: () => JSX.Element;
// CallLinkPendingParticipantModal
callLinkPendingParticipantContactId: string | undefined;
renderCallLinkPendingParticipantModal: () => JSX.Element;
@ -172,6 +176,9 @@ export function GlobalModalContainer({
// CallLinkEditModal
callLinkEditModalRoomId,
renderCallLinkEditModal,
// CallQualitySurvey
callQualitySurveyProps,
renderCallQualitySurvey,
// CallLinkPendingParticipantModal
callLinkPendingParticipantContactId,
renderCallLinkPendingParticipantModal,
@ -323,6 +330,10 @@ export function GlobalModalContainer({
return renderCallLinkEditModal();
}
if (callQualitySurveyProps) {
return renderCallQualitySurvey();
}
if (editHistoryMessages) {
return renderEditHistoryMessagesModal();
}

View File

@ -312,6 +312,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
i18n={i18n}
onShowDebugLog={action('onShowDebugLog')}
onUndoArchive={action('onUndoArchive')}
retryCallQualitySurvey={action('retryCallQualitySurvey')}
openFileInFolder={action('openFileInFolder')}
setDidResumeDonation={action('setDidResumeDonation')}
toast={undefined}

View File

@ -638,6 +638,10 @@ export default {
__dangerouslyRunAbitraryReadOnlySqlQuery: async () => {
return Promise.resolve([]);
},
callQualitySurveyCooldownDisabled: false,
setCallQualitySurveyCooldownDisabled: action(
'setCallQualitySurveyCooldownDisabled'
),
} satisfies PropsType,
} satisfies Meta<PropsType>;

View File

@ -329,6 +329,8 @@ type PropsFunctionType = {
__dangerouslyRunAbitraryReadOnlySqlQuery: (
readonlySqlQuery: string
) => Promise<ReadonlyArray<RowType<object>>>;
callQualitySurveyCooldownDisabled: boolean;
setCallQualitySurveyCooldownDisabled: (value: boolean) => void;
// Localization
i18n: LocalizerType;
@ -538,6 +540,8 @@ export function Preferences({
generateDonationReceiptBlob,
internalDeleteAllMegaphones,
__dangerouslyRunAbitraryReadOnlySqlQuery,
callQualitySurveyCooldownDisabled,
setCallQualitySurveyCooldownDisabled,
}: PropsType): JSX.Element {
const storiesId = useId();
const themeSelectId = useId();
@ -2289,6 +2293,12 @@ export function Preferences({
__dangerouslyRunAbitraryReadOnlySqlQuery={
__dangerouslyRunAbitraryReadOnlySqlQuery
}
callQualitySurveyCooldownDisabled={
callQualitySurveyCooldownDisabled
}
setCallQualitySurveyCooldownDisabled={
setCallQualitySurveyCooldownDisabled
}
/>
}
contentsRef={settingsPaneRef}

View File

@ -20,6 +20,7 @@ import { isStagingServer } from '../util/isStagingServer.dom.js';
import { getHumanDonationAmount } from '../util/currency.dom.js';
import { AutoSizeTextArea } from './AutoSizeTextArea.dom.js';
import { AxoButton } from '../axo/AxoButton.dom.js';
import { AxoSwitch } from '../axo/AxoSwitch.dom.js';
const log = createLogger('PreferencesInternal');
@ -35,6 +36,8 @@ export function PreferencesInternal({
generateDonationReceiptBlob,
internalDeleteAllMegaphones,
__dangerouslyRunAbitraryReadOnlySqlQuery,
callQualitySurveyCooldownDisabled,
setCallQualitySurveyCooldownDisabled,
}: {
i18n: LocalizerType;
exportLocalBackup: () => Promise<BackupValidationResultType>;
@ -58,6 +61,8 @@ export function PreferencesInternal({
__dangerouslyRunAbitraryReadOnlySqlQuery: (
readonlySqlQuery: string
) => Promise<ReadonlyArray<RowType<object>>>;
callQualitySurveyCooldownDisabled: boolean;
setCallQualitySurveyCooldownDisabled: (value: boolean) => void;
}): JSX.Element {
const [isExportPending, setIsExportPending] = useState(false);
const [exportResult, setExportResult] = useState<
@ -488,6 +493,20 @@ export function PreferencesInternal({
</SettingsRow>
)}
<SettingsRow title="Call Quality Survey Testing">
<FlowingSettingsControl>
<div className="Preferences__two-thirds-flow">
Disable 24h cooldown (show survey after every call)
</div>
<div className="Preferences__one-third-flow Preferences__one-third-flow--align-right">
<AxoSwitch.Root
checked={callQualitySurveyCooldownDisabled}
onCheckedChange={setCallQualitySurveyCooldownDisabled}
/>
</div>
</FlowingSettingsControl>
</SettingsRow>
<SettingsRow title="Readonly SQL Playground">
<FlowingSettingsControl>
<AutoSizeTextArea

View File

@ -45,6 +45,13 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.BlockedGroup };
case ToastType.CallHistoryCleared:
return { toastType: ToastType.CallHistoryCleared };
case ToastType.CallQualitySurveyFailed:
return {
toastType: ToastType.CallQualitySurveyFailed,
parameters: { canRetry: true },
};
case ToastType.CallQualitySurveySuccess:
return { toastType: ToastType.CallQualitySurveySuccess };
case ToastType.CannotEditMessage:
return { toastType: ToastType.CannotEditMessage };
case ToastType.CannotForwardEmptyMessage:
@ -304,6 +311,7 @@ export default {
openFileInFolder: action('openFileInFolder'),
onShowDebugLog: action('onShowDebugLog'),
onUndoArchive: action('onUndoArchive'),
retryCallQualitySurvey: action('retryCallQualitySurvey'),
i18n,
toastType: ToastType.AddingUserToGroup,
megaphoneType: MegaphoneType.UsernameOnboarding,

View File

@ -35,6 +35,7 @@ export type PropsType = {
conversationId: string,
options?: { wasPinned?: boolean }
) => unknown;
retryCallQualitySurvey: () => unknown;
setDidResumeDonation: (didResume: boolean) => unknown;
toast?: AnyToast;
megaphone?: AnyActionableMegaphone;
@ -53,6 +54,7 @@ export function renderToast({
openFileInFolder,
onShowDebugLog,
onUndoArchive,
retryCallQualitySurvey,
setDidResumeDonation,
OS,
toast,
@ -131,6 +133,36 @@ export function renderToast({
);
}
if (toastType === ToastType.CallQualitySurveyFailed) {
const { canRetry } = toast.parameters;
return (
<Toast
onClose={hideToast}
toastAction={
canRetry
? {
label: i18n('icu:CallQualitySurvey__SubmissionFailed__Retry'),
onClick: () => {
retryCallQualitySurvey();
},
}
: undefined
}
>
{i18n('icu:CallQualitySurvey__SubmissionFailed')}
</Toast>
);
}
if (toastType === ToastType.CallQualitySurveySuccess) {
return (
<Toast onClose={hideToast}>
{i18n('icu:CallQualitySurvey__SubmissionSuccess')}
</Toast>
);
}
if (toastType === ToastType.CannotEditMessage) {
return (
<Toast onClose={hideToast}>

View File

@ -5,6 +5,7 @@ import { ipcRenderer } from 'electron';
import type {
AudioDevice,
CallId,
CallSummary,
DeviceId,
GroupCallObserver,
PeekInfo,
@ -175,6 +176,11 @@ import OS from '../util/os/osMain.node.js';
import { sleep } from '../util/sleep.std.js';
import { signalProtocolStore } from '../SignalProtocolStore.preload.js';
import { itemStorage } from '../textsecure/Storage.preload.js';
import { CallQualitySurvey } from '../types/CallQualitySurvey.std.js';
import {
isCallFailure,
shouldShowCallQualitySurvey,
} from '../util/callQualitySurvey.dom.js';
const { i18n } = window.SignalContext;
@ -201,6 +207,7 @@ const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map<
const CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL = 10 * durations.MINUTE;
const OUTGOING_SIGNALING_WAIT = 15 * durations.SECOND;
const CALL_QUALITY_SURVEY_DELAY = 2.5 * durations.SECOND;
const ICE_SERVER_IS_IP_LIKE = /(turn|turns|stun):[.\d]+/;
@ -322,6 +329,40 @@ function cleanForLogging(settings?: MediaDeviceSettings): unknown {
};
}
function maybeShowCallQualitySurvey(
summary: CallSummary,
callType: CallQualitySurvey.CallType
): void {
const lastSurveyTime = itemStorage.get('lastCallQualitySurveyTime') ?? null;
const lastFailureSurveyTime =
itemStorage.get('lastCallQualityFailureSurveyTime') ?? null;
const bypassCooldown =
itemStorage.get('callQualitySurveyCooldownDisabled') ?? false;
if (
!shouldShowCallQualitySurvey(
summary,
lastSurveyTime,
lastFailureSurveyTime,
bypassCooldown
)
) {
return;
}
drop(itemStorage.put('lastCallQualitySurveyTime', Date.now()));
if (isCallFailure(summary.callEndReasonText)) {
drop(itemStorage.put('lastCallQualityFailureSurveyTime', Date.now()));
}
setTimeout(() => {
window.reduxActions.calling.showCallQualitySurvey({
callSummary: summary,
callType,
});
}, CALL_QUALITY_SURVEY_DELAY);
}
function protoToCallingMessage({
offer,
answer,
@ -1715,7 +1756,7 @@ export class CallingClass {
requestGroupMembers: groupCall => {
groupCall.setGroupMembers(this.#getGroupCallMembers(conversationId));
},
onEnded: (groupCall, endedReason, _summary) => {
onEnded: (groupCall, endedReason, summary) => {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo();
@ -1726,7 +1767,13 @@ export class CallingClass {
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
// TODO: handle call summary
if (summary != null) {
const callType =
callMode === CallMode.Adhoc
? CallQualitySurvey.CallType.CALL_LINK
: CallQualitySurvey.CallType.GROUP;
maybeShowCallQualitySurvey(summary, callType);
}
this.#reduxInterface?.groupCallEnded({
conversationId,
@ -3631,7 +3678,16 @@ export class CallingClass {
await updateCallHistoryFromLocalEvent(callEvent, null, null);
}
// TODO: handle CallSummary here.
if (
call.state === CallState.Ended &&
call.summary != null &&
call.endedReason != null
) {
const callType = call.isVideoCall
? CallQualitySurvey.CallType.DIRECT_VIDEO
: CallQualitySurvey.CallType.DIRECT_VOICE;
maybeShowCallQualitySurvey(call.summary, callType);
}
reduxInterface.callStateChange({
conversationId,

View File

@ -2,13 +2,16 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { ipcRenderer } from 'electron';
import lodash from 'lodash';
import Long from 'long';
import type { ReadonlyDeep } from 'type-fest';
import {
CallLinkEpoch,
CallLinkRootKey,
CallEndReason,
type Reaction as CallReaction,
type CallSummary,
} from '@signalapp/ringrtc';
import { getOwn } from '../../util/getOwn.std.js';
import * as Errors from '../../types/errors.std.js';
@ -93,13 +96,20 @@ import {
isGroupOrAdhocCallState,
} from '../../util/isGroupOrAdhocCall.std.js';
import type {
CallQualitySurveyPropsType,
HideCallQualitySurveyActionType,
ShowCallQualitySurveyActionType,
ShowErrorModalActionType,
ToggleConfirmLeaveCallModalActionType,
} from './globalModals.preload.js';
import {
HIDE_CALL_QUALITY_SURVEY,
SHOW_CALL_QUALITY_SURVEY,
SHOW_ERROR_MODAL,
toggleConfirmLeaveCallModal,
} from './globalModals.preload.js';
import { CallQualitySurvey } from '../../types/CallQualitySurvey.std.js';
import { isCallFailure } from '../../util/callQualitySurvey.dom.js';
import { ButtonVariant } from '../../components/Button.dom.js';
import { getConversationIdForLogging } from '../../util/idForLogging.preload.js';
import { DataReader, DataWriter } from '../../sql/Client.preload.js';
@ -115,7 +125,10 @@ import {
import { storageServiceUploadJob } from '../../services/storage.preload.js';
import { CallLinkFinalizeDeleteManager } from '../../jobs/CallLinkFinalizeDeleteManager.preload.js';
import { callLinkRefreshJobQueue } from '../../jobs/callLinkRefreshJobQueue.preload.js';
import { isOnline } from '../../textsecure/WebAPI.preload.js';
import {
isOnline,
submitCallQualitySurvey as submitCallQualitySurveyToServer,
} from '../../textsecure/WebAPI.preload.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
const { omit } = lodash;
@ -228,6 +241,18 @@ export type CallLinksByRoomIdType = ReadonlyDeep<{
[roomId: string]: CallLinkType;
}>;
// CQS Submission State
export type CQSSubmissionStateType = ReadonlyDeep<{
failedAttempts: number;
state:
| { status: 'idle' }
| { status: 'loading' }
| {
status: 'failed';
lastSubmissionData: SubmitCallQualitySurveyOptionsType;
};
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type CallingStateType = MediaDeviceSettings & {
callsByConversation: CallsByConversationType;
@ -235,6 +260,7 @@ export type CallingStateType = MediaDeviceSettings & {
callLinks: CallLinksByRoomIdType;
activeCallState?: ActiveCallStateType | WaitingCallStateType;
capturerBaton?: DesktopCapturerBaton;
callQualitySurveySubmission: CQSSubmissionStateType;
};
export type AcceptCallType = ReadonlyDeep<{
@ -708,6 +734,9 @@ const TOGGLE_SELF_VIEW_EXPANDED = 'calling/TOGGLE_SELF_VIEW_EXPANDED';
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
const SWITCH_TO_PRESENTATION_VIEW = 'calling/SWITCH_TO_PRESENTATION_VIEW';
const SWITCH_FROM_PRESENTATION_VIEW = 'calling/SWITCH_FROM_PRESENTATION_VIEW';
const CQS_SUBMISSION_STARTED = 'calling/CQS_SUBMISSION_STARTED';
const CQS_SUBMISSION_FAILED = 'calling/CQS_SUBMISSION_FAILED';
const RESET_CQS_SUBMISSION_STATE = 'calling/RESET_CQS_SUBMISSION_STATE';
type AcceptCallPendingActionType = ReadonlyDeep<{
type: 'calling/ACCEPT_CALL_PENDING';
@ -1023,6 +1052,21 @@ type SwitchFromPresentationViewActionType = ReadonlyDeep<{
type: 'calling/SWITCH_FROM_PRESENTATION_VIEW';
}>;
type CQSSubmissionStartedActionType = ReadonlyDeep<{
type: 'calling/CQS_SUBMISSION_STARTED';
}>;
type CQSSubmissionFailedActionType = ReadonlyDeep<{
type: 'calling/CQS_SUBMISSION_FAILED';
payload: {
lastSubmissionData: SubmitCallQualitySurveyOptionsType;
};
}>;
type ResetCQSSubmissionStateActionType = ReadonlyDeep<{
type: typeof RESET_CQS_SUBMISSION_STATE;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type CallingActionType =
| ApproveUserActionType
@ -1080,7 +1124,10 @@ export type CallingActionType =
| SwitchToPresentationViewActionType
| SwitchFromPresentationViewActionType
| WaitingForCallingLobbyActionType
| WaitingForCallLinkLobbyActionType;
| WaitingForCallLinkLobbyActionType
| CQSSubmissionStartedActionType
| CQSSubmissionFailedActionType
| ResetCQSSubmissionStateActionType;
// Action Creators
@ -2864,6 +2911,155 @@ function switchFromPresentationView(): SwitchFromPresentationViewActionType {
type: SWITCH_FROM_PRESENTATION_VIEW,
};
}
type SubmitCallQualitySurveyOptionsType = ReadonlyDeep<{
userSatisfied: boolean;
callQualityIssues: ReadonlyArray<CallQualitySurvey.Issue>;
additionalIssuesDescription: string;
shareDebugLog: boolean;
callSummary: CallSummary;
callType: CallQualitySurvey.CallType;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type CQSSubmissionActionType =
| CQSSubmissionStartedActionType
| CQSSubmissionFailedActionType
| ResetCQSSubmissionStateActionType
| HideCallQualitySurveyActionType
| ShowCallQualitySurveyActionType;
function showCallQualitySurvey(
payload: CallQualitySurveyPropsType
): ThunkAction<
void,
RootStateType,
unknown,
ResetCQSSubmissionStateActionType | ShowCallQualitySurveyActionType
> {
return dispatch => {
dispatch({ type: RESET_CQS_SUBMISSION_STATE });
dispatch({ type: SHOW_CALL_QUALITY_SURVEY, payload });
};
}
function submitCallQualitySurvey(
options: SubmitCallQualitySurveyOptionsType
): ThunkAction<
void,
RootStateType,
unknown,
CQSSubmissionActionType | ShowToastActionType
> {
return async (dispatch, getState) => {
const {
userSatisfied,
callQualityIssues,
additionalIssuesDescription,
shareDebugLog,
callSummary,
callType,
} = options;
dispatch({ type: CQS_SUBMISSION_STARTED });
try {
let debugLogUrl: string | undefined;
if (shareDebugLog) {
const logData = await ipcRenderer.invoke('fetch-log');
const logs: string = await ipcRenderer.invoke(
'DebugLogs.getLogs',
logData,
window.navigator.userAgent
);
debugLogUrl = await ipcRenderer.invoke('DebugLogs.upload', logs);
}
const { qualityStats } = callSummary;
const { audioStats, videoStats } = qualityStats;
const surveyRequest = {
userSatisfied,
callQualityIssues: userSatisfied ? [] : Array.from(callQualityIssues),
additionalIssuesDescription:
!userSatisfied &&
callQualityIssues.includes(CallQualitySurvey.Issue.OTHER)
? additionalIssuesDescription
: null,
debugLogUrl,
startTimestamp: Long.fromNumber(callSummary.startTime),
endTimestamp: Long.fromNumber(callSummary.endTime),
callType,
success: !isCallFailure(callSummary.callEndReasonText),
callEndReason: callSummary.callEndReasonText,
connectionRttMedian: qualityStats.rttMedianConnection,
audioRttMedian: audioStats.rttMedianMillis,
videoRttMedian: videoStats.rttMedianMillis,
audioRecvJitterMedian: audioStats.jitterMedianRecvMillis,
videoRecvJitterMedian: videoStats.jitterMedianRecvMillis,
audioSendJitterMedian: audioStats.jitterMedianSendMillis,
videoSendJitterMedian: videoStats.jitterMedianSendMillis,
audioRecvPacketLossFraction: audioStats.packetLossPercentageRecv,
videoRecvPacketLossFraction: videoStats.packetLossPercentageRecv,
audioSendPacketLossFraction: audioStats.packetLossPercentageSend,
videoSendPacketLossFraction: videoStats.packetLossPercentageSend,
callTelemetry: callSummary.rawStats,
};
await submitCallQualitySurveyToServer(surveyRequest);
log.info('Call quality survey submitted successfully');
dispatch({ type: RESET_CQS_SUBMISSION_STATE });
dispatch({
type: SHOW_TOAST,
payload: { toastType: ToastType.CallQualitySurveySuccess },
});
} catch (error) {
log.error(
'Failed to submit call quality survey:',
Errors.toLogFormat(error)
);
dispatch({
type: CQS_SUBMISSION_FAILED,
payload: {
lastSubmissionData: options,
},
});
const { failedAttempts } = getState().calling.callQualitySurveySubmission;
const maxRetries = 3;
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.CallQualitySurveyFailed,
parameters: { canRetry: failedAttempts < maxRetries },
},
});
} finally {
dispatch({ type: HIDE_CALL_QUALITY_SURVEY });
}
};
}
function retryCallQualitySurvey(): ThunkAction<
void,
RootStateType,
unknown,
CQSSubmissionActionType
> {
return (dispatch, getState) => {
const { state: submissionState } =
getState().calling.callQualitySurveySubmission;
if (submissionState.status !== 'failed') {
log.warn('Cannot retry CQS submission: not in failed state');
return;
}
dispatch(submitCallQualitySurvey(submissionState.lastSubmissionData));
};
}
export const actions = {
acceptCall,
approveUser,
@ -2925,6 +3121,9 @@ export const actions = {
startCallLinkLobby,
startCallLinkLobbyByRoomId,
startCallingLobby,
showCallQualitySurvey,
submitCallQualitySurvey,
retryCallQualitySurvey,
switchToPresentationView,
switchFromPresentationView,
toggleParticipants,
@ -2960,6 +3159,10 @@ export function getEmptyState(): CallingStateType {
adhocCalls: {},
activeCallState: undefined,
callLinks: {},
callQualitySurveySubmission: {
failedAttempts: 0,
state: { status: 'idle' },
},
};
}
@ -4509,5 +4712,38 @@ export function reducer(
};
}
if (action.type === RESET_CQS_SUBMISSION_STATE) {
return {
...state,
callQualitySurveySubmission: {
failedAttempts: 0,
state: { status: 'idle' },
},
};
}
if (action.type === CQS_SUBMISSION_STARTED) {
return {
...state,
callQualitySurveySubmission: {
failedAttempts: state.callQualitySurveySubmission.failedAttempts,
state: { status: 'loading' },
},
};
}
if (action.type === CQS_SUBMISSION_FAILED) {
return {
...state,
callQualitySurveySubmission: {
failedAttempts: state.callQualitySurveySubmission.failedAttempts + 1,
state: {
status: 'failed',
lastSubmissionData: action.payload.lastSubmissionData,
},
},
};
}
return state;
}

View File

@ -1,6 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CallSummary } from '@signalapp/ringrtc';
import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
import OS from '../../util/os/osMain.node.js';
@ -55,6 +56,7 @@ import type { CallLinkType } from '../../types/CallLink.std.js';
import type { LocalizerType } from '../../types/I18N.std.js';
import { linkCallRoute } from '../../util/signalRoutes.std.js';
import type { StartCallData } from '../../components/ConfirmLeaveCallModal.dom.js';
import type { CallQualitySurvey } from '../../types/CallQualitySurvey.std.js';
import { getMessageById } from '../../messages/getMessageById.preload.js';
import type { DataPropsType as TapToViewNotAvailablePropsType } from '../../components/TapToViewNotAvailableModal.dom.js';
import type { DataPropsType as BackfillFailureModalPropsType } from '../../components/BackfillFailureModal.dom.js';
@ -102,6 +104,11 @@ type MigrateToGV2PropsType = ReadonlyDeep<{
invitedMemberIds: Array<string>;
}>;
export type CallQualitySurveyPropsType = ReadonlyDeep<{
callSummary: CallSummary;
callType: CallQualitySurvey.CallType;
}>;
export type GlobalModalsStateType = ReadonlyDeep<{
addUserToAnotherGroupModalContactId?: string;
aboutContactModalContactId?: string;
@ -109,6 +116,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
callLinkAddNameModalRoomId: string | null;
callLinkEditModalRoomId: string | null;
callLinkPendingParticipantContactId: string | undefined;
callQualitySurveyProps: CallQualitySurveyPropsType | null;
confirmLeaveCallModalState: StartCallData | null;
contactModalState?: ContactModalStateType;
criticalIdlePrimaryDeviceModal: boolean;
@ -186,6 +194,8 @@ const TOGGLE_CALL_LINK_ADD_NAME_MODAL =
const TOGGLE_CALL_LINK_EDIT_MODAL = 'globalModals/TOGGLE_CALL_LINK_EDIT_MODAL';
const TOGGLE_CALL_LINK_PENDING_PARTICIPANT_MODAL =
'globalModals/TOGGLE_CALL_LINK_PENDING_PARTICIPANT_MODAL';
export const SHOW_CALL_QUALITY_SURVEY = 'globalModals/SHOW_CALL_QUALITY_SURVEY';
export const HIDE_CALL_QUALITY_SURVEY = 'globalModals/HIDE_CALL_QUALITY_SURVEY';
const TOGGLE_ABOUT_MODAL = 'globalModals/TOGGLE_ABOUT_MODAL';
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
@ -341,6 +351,15 @@ type ToggleCallLinkPendingParticipantModalActionType = ReadonlyDeep<{
payload: string | undefined;
}>;
export type ShowCallQualitySurveyActionType = ReadonlyDeep<{
type: typeof SHOW_CALL_QUALITY_SURVEY;
payload: CallQualitySurveyPropsType;
}>;
export type HideCallQualitySurveyActionType = ReadonlyDeep<{
type: typeof HIDE_CALL_QUALITY_SURVEY;
}>;
type ToggleAboutContactModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_ABOUT_MODAL;
payload: string | undefined;
@ -490,6 +509,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| CloseShortcutGuideModalActionType
| CloseStickerPackPreviewActionType
| HideBackfillFailureModalActionType
| HideCallQualitySurveyActionType
| HideContactModalActionType
| HideCriticalIdlePrimaryDeviceModalActionType
| HideLowDiskSpaceBackupImportModalActionType
@ -502,6 +522,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| MessageDeletedActionType
| MessageExpiredActionType
| ShowBackfillFailureModalActionType
| ShowCallQualitySurveyActionType
| ShowCriticalIdlePrimaryDeviceModalActionType
| ShowContactModalActionType
| ShowDebugLogErrorModalActionType
@ -549,6 +570,7 @@ export const actions = {
ensureSystemMediaPermissions,
hideBackfillFailureModal,
hideBlockingSafetyNumberChangeDialog,
hideCallQualitySurvey,
hideContactModal,
hideCriticalIdlePrimaryDeviceModal,
hideLowDiskSpaceBackupImportModal,
@ -558,6 +580,7 @@ export const actions = {
hideWhatsNewModal,
showBackfillFailureModal,
showBlockingSafetyNumberChangeDialog,
showCallQualitySurvey,
showContactModal,
showCriticalIdlePrimaryDeviceModal,
showDebugLogErrorModal,
@ -625,6 +648,21 @@ function hideBackfillFailureModal(): HideBackfillFailureModalActionType {
};
}
function showCallQualitySurvey(
payload: CallQualitySurveyPropsType
): ShowCallQualitySurveyActionType {
return {
type: SHOW_CALL_QUALITY_SURVEY,
payload,
};
}
function hideCallQualitySurvey(): HideCallQualitySurveyActionType {
return {
type: HIDE_CALL_QUALITY_SURVEY,
};
}
function hideContactModal(): HideContactModalActionType {
return {
type: HIDE_CONTACT_MODAL,
@ -1306,6 +1344,7 @@ export function getEmptyState(): GlobalModalsStateType {
callLinkAddNameModalRoomId: null,
callLinkEditModalRoomId: null,
callLinkPendingParticipantContactId: undefined,
callQualitySurveyProps: null,
confirmLeaveCallModalState: null,
criticalIdlePrimaryDeviceModal: false,
draftGifMessageSendModalProps: null,
@ -1342,6 +1381,20 @@ export function reducer(
};
}
if (action.type === SHOW_CALL_QUALITY_SURVEY) {
return {
...state,
callQualitySurveyProps: action.payload,
};
}
if (action.type === HIDE_CALL_QUALITY_SURVEY) {
return {
...state,
callQualitySurveyProps: null,
};
}
if (action.type === TOGGLE_NOTE_PREVIEW_MODAL) {
return {
...state,

View File

@ -27,6 +27,11 @@ export const getCallLinkEditModalRoomId = createSelector(
({ callLinkEditModalRoomId }) => callLinkEditModalRoomId
);
export const getCallQualitySurveyProps = createSelector(
getGlobalModalsState,
({ callQualitySurveyProps }) => callQualitySurveyProps
);
export const getCallLinkAddNameModalRoomId = createSelector(
getGlobalModalsState,
({ callLinkAddNameModalRoomId }) => callLinkAddNameModalRoomId

View File

@ -0,0 +1,66 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { CallQualitySurveyDialog } from '../../components/CallQualitySurveyDialog.dom.js';
import { useCallingActions } from '../ducks/calling.preload.js';
import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
import { getCallQualitySurveyProps } from '../selectors/globalModals.std.js';
import { getIntl } from '../selectors/user.std.js';
import type { CallQualitySurvey } from '../../types/CallQualitySurvey.std.js';
import { strictAssert } from '../../util/assert.std.js';
import type { StateType } from '../reducer.preload.js';
const getCallQualitySurveySubmission = (state: StateType) =>
state.calling.callQualitySurveySubmission;
export const SmartCallQualitySurveyDialog = memo(
function SmartCallQualitySurveyDialog(): JSX.Element | null {
const i18n = useSelector(getIntl);
const props = useSelector(getCallQualitySurveyProps);
strictAssert(props, 'Expected callQualitySurveyProps to be set');
const { callSummary, callType } = props;
const submissionState = useSelector(getCallQualitySurveySubmission);
const isSubmitting = submissionState.state.status === 'loading';
const { hideCallQualitySurvey } = useGlobalModalActions();
const { submitCallQualitySurvey } = useCallingActions();
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
hideCallQualitySurvey();
}
},
[hideCallQualitySurvey]
);
const handleSubmit = useCallback(
(form: CallQualitySurvey.Form) => {
submitCallQualitySurvey({
userSatisfied: form.userSatisfied,
callQualityIssues: Array.from(form.callQualityIssues),
additionalIssuesDescription: form.additionalIssuesDescription,
shareDebugLog: form.shareDebugLog,
callSummary,
callType,
});
},
[submitCallQualitySurvey, callSummary, callType]
);
return (
<CallQualitySurveyDialog
i18n={i18n}
open
onOpenChange={handleOpenChange}
onSubmit={handleSubmit}
onViewDebugLog={() => window.IPC.showDebugLog({ mode: 'close' })}
isSubmitting={isSubmitting}
/>
);
}
);

View File

@ -26,6 +26,7 @@ import { getGlobalModalsState } from '../selectors/globalModals.std.js';
import { SmartEditNicknameAndNoteModal } from './EditNicknameAndNoteModal.preload.js';
import { SmartNotePreviewModal } from './NotePreviewModal.preload.js';
import { SmartCallLinkEditModal } from './CallLinkEditModal.preload.js';
import { SmartCallQualitySurveyDialog } from './CallQualitySurveyDialog.preload.js';
import { SmartCallLinkAddNameModal } from './CallLinkAddNameModal.preload.js';
import { SmartConfirmLeaveCallModal } from './ConfirmLeaveCallModal.preload.js';
import { SmartCallLinkPendingParticipantModal } from './CallLinkPendingParticipantModal.preload.js';
@ -43,6 +44,10 @@ function renderCallLinkEditModal(): JSX.Element {
return <SmartCallLinkEditModal />;
}
function renderCallQualitySurvey(): JSX.Element {
return <SmartCallQualitySurveyDialog />;
}
function renderCallLinkPendingParticipantModal(): JSX.Element {
return <SmartCallLinkPendingParticipantModal />;
}
@ -128,6 +133,7 @@ export const SmartGlobalModalContainer = memo(
backfillFailureModalProps,
callLinkAddNameModalRoomId,
callLinkEditModalRoomId,
callQualitySurveyProps,
callLinkPendingParticipantContactId,
confirmLeaveCallModalState,
contactModalState,
@ -236,6 +242,8 @@ export const SmartGlobalModalContainer = memo(
backfillFailureModalProps={backfillFailureModalProps}
callLinkAddNameModalRoomId={callLinkAddNameModalRoomId}
callLinkEditModalRoomId={callLinkEditModalRoomId}
callQualitySurveyProps={callQualitySurveyProps}
renderCallQualitySurvey={renderCallQualitySurvey}
callLinkPendingParticipantContactId={
callLinkPendingParticipantContactId
}

View File

@ -765,6 +765,13 @@ export function SmartPreferences(): JSX.Element | null {
[]
);
const callQualitySurveyCooldownDisabled =
items.callQualitySurveyCooldownDisabled ?? false;
const setCallQualitySurveyCooldownDisabled = useCallback((value: boolean) => {
drop(itemStorage.put('callQualitySurveyCooldownDisabled', value));
}, []);
if (currentLocation.tab !== NavTab.Settings) {
return null;
}
@ -969,6 +976,10 @@ export function SmartPreferences(): JSX.Element | null {
__dangerouslyRunAbitraryReadOnlySqlQuery={
__dangerouslyRunAbitraryReadOnlySqlQuery
}
callQualitySurveyCooldownDisabled={callQualitySurveyCooldownDisabled}
setCallQualitySurveyCooldownDisabled={
setCallQualitySurveyCooldownDisabled
}
/>
</AxoProvider>
</StrictMode>

View File

@ -22,6 +22,7 @@ import {
getSelectedConversationId,
} from '../selectors/conversations.dom.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { useCallingActions } from '../ducks/calling.preload.js';
import { useToastActions } from '../ducks/toast.preload.js';
import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
import { useNavActions } from '../ducks/nav.std.js';
@ -63,6 +64,7 @@ export const SmartToastManager = memo(function SmartToastManager({
const { setDidResume } = useDonationsActions();
const { onUndoArchive } = useConversationsActions();
const { retryCallQualitySurvey } = useCallingActions();
const { openFileInFolder, hideToast } = useToastActions();
const { toggleUsernameOnboarding } = useGlobalModalActions();
@ -100,6 +102,7 @@ export const SmartToastManager = memo(function SmartToastManager({
megaphone={disableMegaphone ? undefined : megaphone}
onShowDebugLog={handleShowDebugLog}
onUndoArchive={onUndoArchive}
retryCallQualitySurvey={retryCallQualitySurvey}
openFileInFolder={openFileInFolder}
hideToast={hideToast}
setDidResumeDonation={setDidResume}

View File

@ -3390,10 +3390,11 @@ export async function submitCallQualitySurvey(
const data = Proto.SubmitCallQualitySurveyRequest.encode(survey).finish();
await _ajax({
call: 'callQualitySurvey',
contentType: 'application/x-protobuf',
contentType: 'application/octet-stream',
data,
host: 'chatService',
httpType: 'PUT',
unauthenticated: true,
});
}

View File

@ -66,6 +66,9 @@ export type StorageAccessType = {
'blocked-uuids': ReadonlyArray<ServiceIdString>;
'call-ringtone-notification': boolean;
'call-system-notification': boolean;
lastCallQualitySurveyTime: number;
lastCallQualityFailureSurveyTime: number;
callQualitySurveyCooldownDisabled: boolean;
'hide-menu-bar': boolean;
'incoming-call-notification': boolean;
'notification-draw-attention': boolean;

View File

@ -11,6 +11,8 @@ export enum ToastType {
Blocked = 'Blocked',
BlockedGroup = 'BlockedGroup',
CallHistoryCleared = 'CallHistoryCleared',
CallQualitySurveyFailed = 'CallQualitySurveyFailed',
CallQualitySurveySuccess = 'CallQualitySurveySuccess',
CaptchaFailed = 'CaptchaFailed',
CaptchaSolved = 'CaptchaSolved',
CannotEditMessage = 'CannotEditMessage',
@ -114,6 +116,11 @@ export type AnyToast =
| { toastType: ToastType.Blocked }
| { toastType: ToastType.BlockedGroup }
| { toastType: ToastType.CallHistoryCleared }
| {
toastType: ToastType.CallQualitySurveyFailed;
parameters: { canRetry: boolean };
}
| { toastType: ToastType.CallQualitySurveySuccess }
| { toastType: ToastType.CannotEditMessage }
| { toastType: ToastType.CannotForwardEmptyMessage }
| { toastType: ToastType.CannotMixMultiAndNonMultiAttachments }

View File

@ -0,0 +1,76 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CallSummary } from '@signalapp/ringrtc';
import { DAY, MINUTE } from './durations/index.std.js';
import { isFeaturedEnabledNoRedux } from './isFeatureEnabled.dom.js';
import { isMockEnvironment } from '../environment.std.js';
const FAILURE_END_REASONS: ReadonlySet<string> = new Set([
'internalFailure',
'signalingFailure',
'connectionFailure',
'iceFailedAfterConnected',
]);
const SURVEY_COOLDOWN = DAY;
const SHORT_CALL_THRESHOLD = MINUTE;
const LONG_CALL_THRESHOLD = 25 * MINUTE;
const RANDOM_SAMPLE_RATE = 0.01; // 1%
export function isCallFailure(callEndReasonText: string): boolean {
return FAILURE_END_REASONS.has(callEndReasonText);
}
export function isCallQualitySurveyEnabled(): boolean {
return isFeaturedEnabledNoRedux({
betaKey: 'desktop.callQualitySurvey.beta',
prodKey: 'desktop.callQualitySurvey.prod',
});
}
export function shouldShowCallQualitySurvey(
callSummary: CallSummary,
lastSurveyTime: number | null,
lastFailureSurveyTime: number | null,
bypassCooldown?: boolean
): boolean {
if (
isMockEnvironment() ||
!isCallQualitySurveyEnabled() ||
!callSummary.isSurveyCandidate
) {
return false;
}
const now = Date.now();
const isFailure = isCallFailure(callSummary.callEndReasonText);
const canShowFailureSurvey =
bypassCooldown ||
lastFailureSurveyTime == null ||
now - lastFailureSurveyTime > SURVEY_COOLDOWN;
if (isFailure && canShowFailureSurvey) {
return true;
}
const canShowGeneralSurvey =
bypassCooldown ||
lastSurveyTime == null ||
now - lastSurveyTime > SURVEY_COOLDOWN;
if (!canShowGeneralSurvey) {
return false;
}
const callDuration = callSummary.endTime - callSummary.startTime;
if (callDuration < SHORT_CALL_THRESHOLD) {
return true;
}
if (callDuration > LONG_CALL_THRESHOLD) {
return true;
}
return Math.random() < RANDOM_SAMPLE_RATE;
}

3
ts/window.d.ts vendored
View File

@ -54,7 +54,7 @@ export type IPCType = {
setMediaPermissions: (value: boolean) => Promise<void>;
setMediaCameraPermissions: (value: boolean) => Promise<void>;
setMenuBarVisibility: (value: boolean) => void;
showDebugLog: () => void;
showDebugLog: (options?: { mode?: 'submit' | 'close' }) => void;
showPermissionsPopup: (
forCalling: boolean,
forCamera: boolean
@ -88,6 +88,7 @@ type DebugLogWindowPropsType = {
downloadLog: (text: string) => unknown;
fetchLogs: () => Promise<string>;
uploadLogs: (text: string) => Promise<string>;
mode: 'submit' | 'close';
};
type PermissionsWindowPropsType = {

View File

@ -29,6 +29,7 @@ createRoot(app).render(
i18n={i18n}
fetchLogs={DebugLogWindowProps.fetchLogs}
uploadLogs={DebugLogWindowProps.uploadLogs}
mode={DebugLogWindowProps.mode}
/>
</FunDefaultEnglishEmojiLocalizationProvider>
</AxoProvider>

View File

@ -21,11 +21,15 @@ function uploadLogs(logs: string) {
return ipcRenderer.invoke('DebugLogs.upload', logs);
}
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get('mode') === 'close' ? 'close' : 'submit';
const Signal = {
DebugLogWindowProps: {
downloadLog,
fetchLogs,
uploadLogs,
mode,
},
};
contextBridge.exposeInMainWorld('Signal', Signal);

View File

@ -129,9 +129,9 @@ const IPC: IPCType = {
setBadge: badge => ipc.send('set-badge', badge),
setMenuBarVisibility: visibility =>
ipc.send('set-menu-bar-visibility', visibility),
showDebugLog: () => {
log.info('showDebugLog');
ipc.send('show-debug-log');
showDebugLog: (options?: { mode?: 'submit' | 'close' }) => {
log.info('showDebugLog', options);
ipc.send('show-debug-log', options);
},
showPermissionsPopup: (forCalling, forCamera) =>
ipc.invoke('show-permissions-popup', forCalling, forCamera),