Add diagnostic information window to Call Quality Survey

This commit is contained in:
yash-signal
2026-01-16 16:25:19 -06:00
committed by GitHub
parent d6fc5ac6e3
commit 680304f9d2
14 changed files with 395 additions and 16 deletions

View File

@@ -10122,6 +10122,10 @@
"messageformat": "This helps us learn more about calls and what is working or not working. You can view your debug log before submitting.",
"description": "Call Quality Survey Dialog > Help us improve > Page Description"
},
"icu:CallQualitySurvey__ConfirmSubmission__PageDescriptionWithDiagnosticLink": {
"messageformat": "Submitting will share your feedback along with <diagnosticInfoLink>diagnostic information about your call</diagnosticInfoLink>. You can optionally share a debug log to help us improve call quality.",
"description": "Call Quality Survey Dialog > Help us improve > Page Description with link to view diagnostic info"
},
"icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__Label": {
"messageformat": "Share debug log",
"description": "Call Quality Survey Dialog > Help us improve > Share debug log > Label"
@@ -10134,6 +10138,14 @@
"messageformat": "Debug logs contain low level app information and do not reveal any of your message contents.",
"description": "Call Quality Survey Dialog > Help us improve > Share debug log > Help Text"
},
"icu:CallQualitySurvey__ConfirmSubmission__PrivacyNote": {
"messageformat": "Information shared with us contains low level app information and does not include the contents of your calls.",
"description": "Call Quality Survey Dialog > Help us improve > Privacy note about what information is shared"
},
"icu:CallDiagnosticWindow__title": {
"messageformat": "Diagnostic information",
"description": "Title of the call diagnostic information window"
},
"icu:CallQualitySurvey__ConfirmSubmission__SubmitButton": {
"messageformat": "Submit",
"description": "Call Quality Survey Dialog > Help us improve > Submit Button"

View File

@@ -1401,16 +1401,28 @@ async function openArtCreator() {
}
let debugLogWindow: BrowserWindow | undefined;
let debugLogCurrentMode: 'submit' | 'close' | undefined;
type DebugLogWindowOptions = {
mode?: 'submit' | 'close';
};
async function showDebugLogWindow(options: DebugLogWindowOptions = {}) {
const newMode = options.mode ?? 'submit';
if (debugLogWindow) {
if (debugLogCurrentMode !== newMode) {
debugLogCurrentMode = newMode;
const url = pathToFileURL(join(__dirname, '../debug_log.html'));
url.searchParams.set('mode', newMode);
await safeLoadURL(debugLogWindow, url.href);
}
doShowDebugLogWindow();
return;
}
debugLogCurrentMode = newMode;
function doShowDebugLogWindow() {
if (debugLogWindow) {
// Electron has [a macOS bug][0] that causes parent windows to become unresponsive
@@ -1452,6 +1464,7 @@ async function showDebugLogWindow(options: DebugLogWindowOptions = {}) {
debugLogWindow.on('closed', () => {
debugLogWindow = undefined;
debugLogCurrentMode = undefined;
});
debugLogWindow.once('ready-to-show', () => {
@@ -1471,6 +1484,71 @@ async function showDebugLogWindow(options: DebugLogWindowOptions = {}) {
await safeLoadURL(debugLogWindow, url.href);
}
let callDiagnosticWindow: BrowserWindow | undefined;
let storedCallDiagnosticData: string | undefined;
async function showCallDiagnosticWindow() {
if (callDiagnosticWindow) {
doShowCallDiagnosticWindow();
return;
}
function doShowCallDiagnosticWindow() {
if (callDiagnosticWindow) {
// Electron has [a macOS bug][0] that causes parent windows to become unresponsive
// if it's fullscreen and opens a fullscreen child window. Until that's fixed, we
// only set the parent on MacOS is if the mainWindow is not fullscreen
// [0]: https://github.com/electron/electron/issues/32374
if (OS.isMacOS() && mainWindow?.isFullScreen()) {
callDiagnosticWindow.setParentWindow(null);
} else {
callDiagnosticWindow.setParentWindow(mainWindow ?? null);
}
callDiagnosticWindow.show();
}
}
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 700,
height: 500,
resizable: false,
title: getResolvedMessagesLocale().i18n('icu:CallDiagnosticWindow__title'),
titleBarStyle: nonMainTitleBarStyle,
autoHideMenuBar: true,
backgroundColor: await getBackgroundColor(),
show: false,
webPreferences: {
...defaultWebPrefs,
nodeIntegration: false,
nodeIntegrationInWorker: false,
sandbox: true,
contextIsolation: true,
preload: join(__dirname, '../bundles/calldiagnostic/preload.preload.js'),
},
parent: mainWindow,
};
callDiagnosticWindow = new BrowserWindow(windowOptions);
await handleCommonWindowEvents(callDiagnosticWindow);
callDiagnosticWindow.on('closed', () => {
callDiagnosticWindow = undefined;
});
callDiagnosticWindow.once('ready-to-show', () => {
if (callDiagnosticWindow) {
doShowCallDiagnosticWindow();
// Electron sometimes puts the window in a strange spot until it's shown.
callDiagnosticWindow.center();
}
});
const url = pathToFileURL(join(__dirname, '../call_diagnostic.html'));
await safeLoadURL(callDiagnosticWindow, url.href);
}
let permissionsPopupWindow: BrowserWindow | undefined;
function showPermissionsPopupWindow(forCalling: boolean, forCamera: boolean) {
// eslint-disable-next-line no-async-promise-executor
@@ -2686,6 +2764,35 @@ ipc.on(
}
);
// Call Diagnostic Window-related IPC calls
ipc.on('show-call-diagnostic', () => {
void showCallDiagnosticWindow();
});
ipc.handle('get-call-diagnostic-data', () => {
return storedCallDiagnosticData ?? '';
});
ipc.on('close-call-diagnostic', () => {
storedCallDiagnosticData = undefined;
callDiagnosticWindow?.close();
});
ipc.on('close-debug-log', () => {
if (debugLogCurrentMode === 'close') {
debugLogWindow?.close();
}
});
ipc.on('update-call-diagnostic-data', (_event, diagnosticData: string) => {
storedCallDiagnosticData = diagnosticData;
if (callDiagnosticWindow && !callDiagnosticWindow.isDestroyed()) {
callDiagnosticWindow.webContents.send('call-diagnostic-data-updated');
}
});
// Permissions Popup-related IPC calls
ipc.handle(

25
call_diagnostic.html Normal file
View File

@@ -0,0 +1,25 @@
<!-- Copyright 2026 Signal Messenger, LLC -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<html>
<head>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none';
frame-src 'none';
form-action 'none';
font-src 'self';
img-src 'self' blob: data:;
media-src 'self' blob:;
object-src 'none';
script-src 'self';
style-src 'self' 'unsafe-inline';"
/>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/tailwind.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="bundles/calldiagnostic/app.dom.js"></script>
</body>
</html>

View File

@@ -178,6 +178,7 @@ async function sandboxedEnv() {
mainFields: ['browser', 'main'],
entryPoints: [
path.join(ROOT_DIR, 'ts', 'windows', 'about', 'app.dom.tsx'),
path.join(ROOT_DIR, 'ts', 'windows', 'calldiagnostic', 'app.dom.tsx'),
path.join(ROOT_DIR, 'ts', 'windows', 'debuglog', 'app.dom.tsx'),
path.join(ROOT_DIR, 'ts', 'windows', 'loading', 'start.dom.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'permissions', 'app.dom.tsx'),
@@ -189,6 +190,13 @@ async function sandboxedEnv() {
mainFields: ['browser', 'main'],
entryPoints: [
path.join(ROOT_DIR, 'ts', 'windows', 'about', 'preload.preload.ts'),
path.join(
ROOT_DIR,
'ts',
'windows',
'calldiagnostic',
'preload.preload.ts'
),
path.join(ROOT_DIR, 'ts', 'windows', 'debuglog', 'preload.preload.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'loading', 'preload.preload.ts'),
path.join(

View File

@@ -0,0 +1,70 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useMemo } from 'react';
import type { LocalizerType } from '../types/Util.std.js';
import { tw } from '../axo/tw.dom.js';
import { AxoButton } from '../axo/AxoButton.dom.js';
import { useEscapeHandling } from '../hooks/useEscapeHandling.dom.js';
export type PropsType = {
closeWindow: () => unknown;
i18n: LocalizerType;
diagnosticData: string;
};
export function CallDiagnosticWindow({
closeWindow,
i18n,
diagnosticData,
}: PropsType): React.JSX.Element {
useEscapeHandling(closeWindow);
const formattedData = useMemo(() => {
try {
const parsed = JSON.parse(diagnosticData);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { rawStats, rawStatsText, ...rest } = parsed;
const pretty = {
...rest,
rawStats: JSON.parse(rawStatsText),
};
return JSON.stringify(pretty, null, 2);
} catch {
return diagnosticData;
}
}, [diagnosticData]);
return (
<div
className={tw(
'flex h-screen flex-col bg-background-primary p-4 text-label-primary'
)}
>
<div className={tw('mb-4')}>
<h1 className={tw('type-title-medium font-semibold')}>
{i18n('icu:CallDiagnosticWindow__title')}
</h1>
</div>
<div
className={tw(
'min-h-0 flex-1 overflow-auto border border-border-secondary bg-background-secondary p-4'
)}
>
<pre
className={tw(
'font-mono type-body-small whitespace-pre-wrap text-label-primary'
)}
>
{formattedData}
</pre>
</div>
<div className={tw('mt-4 flex justify-end')}>
<AxoButton.Root onClick={closeWindow} variant="primary" size="md">
{i18n('icu:close')}
</AxoButton.Root>
</div>
</div>
);
}

View File

@@ -19,6 +19,8 @@ export function Default(): React.JSX.Element {
open={open}
onOpenChange={setOpen}
onSubmit={action('onSubmit')}
onViewDebugLog={action('onViewDebugLog')}
onViewDiagnosticInfo={action('onViewDiagnosticInfo')}
/>
);
}

View File

@@ -11,9 +11,28 @@ 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 { I18n } from './I18n.dom.js';
import Issue = CallQualitySurvey.Issue;
function DiagnosticInfoLink({
parts,
onClick,
}: {
parts: Array<string | React.JSX.Element>;
onClick: () => void;
}): React.JSX.Element {
return (
<button
type="button"
className={tw('text-color-label-primary hover:underline')}
onClick={onClick}
>
{parts}
</button>
);
}
enum Page {
HOW_WAS_YOUR_CALL,
WHAT_ISSUES_DID_YOU_HAVE,
@@ -25,14 +44,16 @@ export type CallQualitySurveyDialogProps = Readonly<{
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (form: CallQualitySurvey.Form) => void;
onViewDebugLog?: () => void;
onViewDebugLog: () => void;
onViewDiagnosticInfo: () => void;
isSubmitting?: boolean;
}>;
export function CallQualitySurveyDialog(
props: CallQualitySurveyDialogProps
): React.JSX.Element {
const { i18n, onSubmit, onViewDebugLog, isSubmitting } = props;
const { i18n, onSubmit, onViewDebugLog, onViewDiagnosticInfo, isSubmitting } =
props;
const [page, setPage] = useState(Page.HOW_WAS_YOUR_CALL);
const [userSatisfied, setUserSatisfied] = useState<boolean | null>(null);
@@ -79,6 +100,20 @@ export function CallQualitySurveyDialog(
shareDebugLog,
]);
const renderDiagnosticInfoLink = useCallback(
(parts: Array<string | React.JSX.Element>) => (
<DiagnosticInfoLink parts={parts} onClick={onViewDiagnosticInfo} />
),
[onViewDiagnosticInfo]
);
const diagnosticInfoLinkComponents = useMemo(
() => ({
diagnosticInfoLink: renderDiagnosticInfoLink,
}),
[renderDiagnosticInfoLink]
);
return (
<AxoDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
<AxoDialog.Content escape="cancel-is-destructive" size="md">
@@ -291,9 +326,11 @@ export function CallQualitySurveyDialog(
<AxoDialog.Body>
<p className={tw('mb-3 type-body-medium text-label-primary')}>
<AxoDialog.Description>
{i18n(
'icu:CallQualitySurvey__ConfirmSubmission__PageDescription'
)}
<I18n
i18n={i18n}
id="icu:CallQualitySurvey__ConfirmSubmission__PageDescriptionWithDiagnosticLink"
components={diagnosticInfoLinkComponents}
/>
</AxoDialog.Description>
</p>
<div className={tw('my-1.5 flex items-center gap-3')}>
@@ -314,9 +351,7 @@ export function CallQualitySurveyDialog(
<AxoButton.Root
variant="subtle-primary"
size="sm"
onClick={() => {
onViewDebugLog?.();
}}
onClick={onViewDebugLog}
>
{i18n(
'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__ViewButton'
@@ -324,9 +359,7 @@ export function CallQualitySurveyDialog(
</AxoButton.Root>
</div>
<p className={tw('mt-3 type-body-small text-label-secondary')}>
{i18n(
'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__HelpText'
)}
{i18n('icu:CallQualitySurvey__ConfirmSubmission__PrivacyNote')}
</p>
</AxoDialog.Body>
<AxoDialog.Footer>

View File

@@ -103,10 +103,10 @@ import type {
ToggleConfirmLeaveCallModalActionType,
} from './globalModals.preload.js';
import {
HIDE_CALL_QUALITY_SURVEY,
SHOW_CALL_QUALITY_SURVEY,
SHOW_ERROR_MODAL,
toggleConfirmLeaveCallModal,
hideCallQualitySurvey,
} from './globalModals.preload.js';
import { CallQualitySurvey } from '../../types/CallQualitySurvey.std.js';
import { isCallFailure } from '../../util/callQualitySurvey.dom.js';
@@ -2935,6 +2935,9 @@ function showCallQualitySurvey(
return dispatch => {
dispatch({ type: RESET_CQS_SUBMISSION_STATE });
dispatch({ type: SHOW_CALL_QUALITY_SURVEY, payload });
const diagnosticData = JSON.stringify(payload.callSummary);
window.IPC.updateCallDiagnosticData(diagnosticData);
};
}
@@ -3031,7 +3034,7 @@ function submitCallQualitySurvey(
},
});
} finally {
dispatch({ type: HIDE_CALL_QUALITY_SURVEY });
dispatch(hideCallQualitySurvey());
}
};
}

View File

@@ -657,9 +657,16 @@ function showCallQualitySurvey(
};
}
function hideCallQualitySurvey(): HideCallQualitySurveyActionType {
return {
type: HIDE_CALL_QUALITY_SURVEY,
export function hideCallQualitySurvey(): ThunkAction<
void,
RootStateType,
unknown,
HideCallQualitySurveyActionType
> {
return dispatch => {
window.IPC.closeDebugLog();
window.IPC.closeCallDiagnostic();
dispatch({ type: HIDE_CALL_QUALITY_SURVEY });
};
}

View File

@@ -52,6 +52,10 @@ export const SmartCallQualitySurveyDialog = memo(
[submitCallQualitySurvey, callSummary, callType]
);
const handleViewDiagnosticInfo = useCallback(() => {
window.IPC.showCallDiagnostic();
}, []);
return (
<CallQualitySurveyDialog
i18n={i18n}
@@ -59,6 +63,7 @@ export const SmartCallQualitySurveyDialog = memo(
onOpenChange={handleOpenChange}
onSubmit={handleSubmit}
onViewDebugLog={() => window.IPC.showDebugLog({ mode: 'close' })}
onViewDiagnosticInfo={handleViewDiagnosticInfo}
isSubmitting={isSubmitting}
/>
);

10
ts/window.d.ts vendored
View File

@@ -55,6 +55,10 @@ export type IPCType = {
setMediaCameraPermissions: (value: boolean) => Promise<void>;
setMenuBarVisibility: (value: boolean) => void;
showDebugLog: (options?: { mode?: 'submit' | 'close' }) => void;
showCallDiagnostic: () => void;
closeCallDiagnostic: () => void;
closeDebugLog: () => void;
updateCallDiagnosticData: (data: string) => void;
showPermissionsPopup: (
forCalling: boolean,
forCamera: boolean
@@ -91,6 +95,11 @@ type DebugLogWindowPropsType = {
mode: 'submit' | 'close';
};
type CallDiagnosticWindowPropsType = {
subscribe: (listener: () => void) => () => void;
getSnapshot: () => string | null;
};
type PermissionsWindowPropsType = {
forCamera: boolean;
forCalling: boolean;
@@ -113,6 +122,7 @@ type SettingsWindowPropsType = {
export type SignalCoreType = {
AboutWindowProps?: AboutWindowPropsType;
CallDiagnosticWindowProps?: CallDiagnosticWindowPropsType;
DebugLogWindowProps?: DebugLogWindowPropsType;
PermissionsWindowProps?: PermissionsWindowPropsType;
ScreenShareWindowProps?: ScreenShareWindowPropsType;

View File

@@ -0,0 +1,46 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { StrictMode, useSyncExternalStore } from 'react';
import { createRoot } from 'react-dom/client';
import '../sandboxedInit.dom.js';
import { CallDiagnosticWindow } from '../../components/CallDiagnosticWindow.dom.js';
import { FunDefaultEnglishEmojiLocalizationProvider } from '../../components/fun/FunEmojiLocalizationProvider.dom.js';
import { strictAssert } from '../../util/assert.std.js';
import { AxoProvider } from '../../axo/AxoProvider.dom.js';
const { CallDiagnosticWindowProps } = window.Signal;
strictAssert(CallDiagnosticWindowProps, 'window values not provided');
const { subscribe, getSnapshot } = CallDiagnosticWindowProps;
const { i18n } = window.SignalContext;
function App(): React.JSX.Element | null {
const diagnosticData = useSyncExternalStore(subscribe, getSnapshot);
if (diagnosticData == null) {
return null;
}
return (
<CallDiagnosticWindow
closeWindow={() => window.SignalContext.executeMenuRole('close')}
i18n={i18n}
diagnosticData={diagnosticData}
/>
);
}
const app = document.getElementById('app');
strictAssert(app != null, 'No #app');
createRoot(app).render(
<StrictMode>
<AxoProvider
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
>
<FunDefaultEnglishEmojiLocalizationProvider>
<App />
</FunDefaultEnglishEmojiLocalizationProvider>
</AxoProvider>
</StrictMode>
);

View File

@@ -0,0 +1,38 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { contextBridge, ipcRenderer } from 'electron';
import { MinimalSignalContext } from '../minimalContext.preload.js';
// External store for useSyncExternalStore
let currentData: string | null = null;
const listeners = new Set<() => void>();
async function fetchData(): Promise<void> {
currentData = await ipcRenderer.invoke('get-call-diagnostic-data');
listeners.forEach(listener => listener());
}
ipcRenderer.on('call-diagnostic-data-updated', () => {
void fetchData();
});
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): string | null {
return currentData;
}
void fetchData();
const Signal = {
CallDiagnosticWindowProps: {
subscribe,
getSnapshot,
},
};
contextBridge.exposeInMainWorld('Signal', Signal);
contextBridge.exposeInMainWorld('SignalContext', MinimalSignalContext);

View File

@@ -133,6 +133,19 @@ const IPC: IPCType = {
log.info('showDebugLog', options);
ipc.send('show-debug-log', options);
},
showCallDiagnostic: () => {
log.info('showCallDiagnostic');
ipc.send('show-call-diagnostic');
},
closeCallDiagnostic: () => {
ipc.send('close-call-diagnostic');
},
closeDebugLog: () => {
ipc.send('close-debug-log');
},
updateCallDiagnosticData: (data: string) => {
ipc.send('update-call-diagnostic-data', data);
},
showPermissionsPopup: (forCalling, forCamera) =>
ipc.invoke('show-permissions-popup', forCalling, forCamera),
setMediaPermissions: (value: boolean) =>