diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9b2b40c730..8c2d30ef84 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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 diagnostic information about your call. 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" diff --git a/app/main.main.ts b/app/main.main.ts index d3ccf9f504..10ae8d8295 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -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( diff --git a/call_diagnostic.html b/call_diagnostic.html new file mode 100644 index 0000000000..fa9b2da47b --- /dev/null +++ b/call_diagnostic.html @@ -0,0 +1,25 @@ + + + + + + + + + + +
+ + + diff --git a/scripts/esbuild.js b/scripts/esbuild.js index 4ed3b2ae9e..8b02581be4 100644 --- a/scripts/esbuild.js +++ b/scripts/esbuild.js @@ -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( diff --git a/ts/components/CallDiagnosticWindow.dom.tsx b/ts/components/CallDiagnosticWindow.dom.tsx new file mode 100644 index 0000000000..33d75c069f --- /dev/null +++ b/ts/components/CallDiagnosticWindow.dom.tsx @@ -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 ( +
+
+

+ {i18n('icu:CallDiagnosticWindow__title')} +

+
+
+
+          {formattedData}
+        
+
+
+ + {i18n('icu:close')} + +
+
+ ); +} diff --git a/ts/components/CallQualitySurvey.dom.stories.tsx b/ts/components/CallQualitySurvey.dom.stories.tsx index 53ee5880c8..82a352a5e5 100644 --- a/ts/components/CallQualitySurvey.dom.stories.tsx +++ b/ts/components/CallQualitySurvey.dom.stories.tsx @@ -19,6 +19,8 @@ export function Default(): React.JSX.Element { open={open} onOpenChange={setOpen} onSubmit={action('onSubmit')} + onViewDebugLog={action('onViewDebugLog')} + onViewDiagnosticInfo={action('onViewDiagnosticInfo')} /> ); } diff --git a/ts/components/CallQualitySurveyDialog.dom.tsx b/ts/components/CallQualitySurveyDialog.dom.tsx index 7a9c4dc743..bc90713ef3 100644 --- a/ts/components/CallQualitySurveyDialog.dom.tsx +++ b/ts/components/CallQualitySurveyDialog.dom.tsx @@ -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; + onClick: () => void; +}): React.JSX.Element { + return ( + + ); +} + 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(null); @@ -79,6 +100,20 @@ export function CallQualitySurveyDialog( shareDebugLog, ]); + const renderDiagnosticInfoLink = useCallback( + (parts: Array) => ( + + ), + [onViewDiagnosticInfo] + ); + + const diagnosticInfoLinkComponents = useMemo( + () => ({ + diagnosticInfoLink: renderDiagnosticInfoLink, + }), + [renderDiagnosticInfoLink] + ); + return ( @@ -291,9 +326,11 @@ export function CallQualitySurveyDialog(

- {i18n( - 'icu:CallQualitySurvey__ConfirmSubmission__PageDescription' - )} +

@@ -314,9 +351,7 @@ export function CallQualitySurveyDialog( { - onViewDebugLog?.(); - }} + onClick={onViewDebugLog} > {i18n( 'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__ViewButton' @@ -324,9 +359,7 @@ export function CallQualitySurveyDialog(

- {i18n( - 'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__HelpText' - )} + {i18n('icu:CallQualitySurvey__ConfirmSubmission__PrivacyNote')}

diff --git a/ts/state/ducks/calling.preload.ts b/ts/state/ducks/calling.preload.ts index 93cc5cabef..7eb44cc586 100644 --- a/ts/state/ducks/calling.preload.ts +++ b/ts/state/ducks/calling.preload.ts @@ -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()); } }; } diff --git a/ts/state/ducks/globalModals.preload.ts b/ts/state/ducks/globalModals.preload.ts index 200cabdc70..14f1f66ea0 100644 --- a/ts/state/ducks/globalModals.preload.ts +++ b/ts/state/ducks/globalModals.preload.ts @@ -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 }); }; } diff --git a/ts/state/smart/CallQualitySurveyDialog.preload.tsx b/ts/state/smart/CallQualitySurveyDialog.preload.tsx index fe6e7418b0..fc3dcbf490 100644 --- a/ts/state/smart/CallQualitySurveyDialog.preload.tsx +++ b/ts/state/smart/CallQualitySurveyDialog.preload.tsx @@ -52,6 +52,10 @@ export const SmartCallQualitySurveyDialog = memo( [submitCallQualitySurvey, callSummary, callType] ); + const handleViewDiagnosticInfo = useCallback(() => { + window.IPC.showCallDiagnostic(); + }, []); + return ( window.IPC.showDebugLog({ mode: 'close' })} + onViewDiagnosticInfo={handleViewDiagnosticInfo} isSubmitting={isSubmitting} /> ); diff --git a/ts/window.d.ts b/ts/window.d.ts index bbd2ea4de7..1c1d00b0fc 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -55,6 +55,10 @@ export type IPCType = { setMediaCameraPermissions: (value: boolean) => Promise; 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; diff --git a/ts/windows/calldiagnostic/app.dom.tsx b/ts/windows/calldiagnostic/app.dom.tsx new file mode 100644 index 0000000000..d564b13dd2 --- /dev/null +++ b/ts/windows/calldiagnostic/app.dom.tsx @@ -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 ( + window.SignalContext.executeMenuRole('close')} + i18n={i18n} + diagnosticData={diagnosticData} + /> + ); +} + +const app = document.getElementById('app'); +strictAssert(app != null, 'No #app'); + +createRoot(app).render( + + + + + + + +); diff --git a/ts/windows/calldiagnostic/preload.preload.ts b/ts/windows/calldiagnostic/preload.preload.ts new file mode 100644 index 0000000000..d7c9bd8783 --- /dev/null +++ b/ts/windows/calldiagnostic/preload.preload.ts @@ -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 { + 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); diff --git a/ts/windows/main/phase1-ipc.preload.ts b/ts/windows/main/phase1-ipc.preload.ts index 2eed5c118f..ab09211a97 100644 --- a/ts/windows/main/phase1-ipc.preload.ts +++ b/ts/windows/main/phase1-ipc.preload.ts @@ -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) =>