Call Quality Survey Integration
This commit is contained in:
parent
4b2f6af4ad
commit
1338eadf6f
@ -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",
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -638,6 +638,10 @@ export default {
|
||||
__dangerouslyRunAbitraryReadOnlySqlQuery: async () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
callQualitySurveyCooldownDisabled: false,
|
||||
setCallQualitySurveyCooldownDisabled: action(
|
||||
'setCallQualitySurveyCooldownDisabled'
|
||||
),
|
||||
} satisfies PropsType,
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -27,6 +27,11 @@ export const getCallLinkEditModalRoomId = createSelector(
|
||||
({ callLinkEditModalRoomId }) => callLinkEditModalRoomId
|
||||
);
|
||||
|
||||
export const getCallQualitySurveyProps = createSelector(
|
||||
getGlobalModalsState,
|
||||
({ callQualitySurveyProps }) => callQualitySurveyProps
|
||||
);
|
||||
|
||||
export const getCallLinkAddNameModalRoomId = createSelector(
|
||||
getGlobalModalsState,
|
||||
({ callLinkAddNameModalRoomId }) => callLinkAddNameModalRoomId
|
||||
|
||||
66
ts/state/smart/CallQualitySurveyDialog.preload.tsx
Normal file
66
ts/state/smart/CallQualitySurveyDialog.preload.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
3
ts/types/Storage.d.ts
vendored
3
ts/types/Storage.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 }
|
||||
|
||||
76
ts/util/callQualitySurvey.dom.ts
Normal file
76
ts/util/callQualitySurvey.dom.ts
Normal 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
3
ts/window.d.ts
vendored
@ -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 = {
|
||||
|
||||
@ -29,6 +29,7 @@ createRoot(app).render(
|
||||
i18n={i18n}
|
||||
fetchLogs={DebugLogWindowProps.fetchLogs}
|
||||
uploadLogs={DebugLogWindowProps.uploadLogs}
|
||||
mode={DebugLogWindowProps.mode}
|
||||
/>
|
||||
</FunDefaultEnglishEmojiLocalizationProvider>
|
||||
</AxoProvider>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user