diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a0d0eb5bc..e2798bff7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ instance while you make changes - they'll run until you stop them: ``` pnpm run dev:transpile # recompiles when you change .ts files -pnpm run dev:sass # recompiles when you change .scss files +pnpm run dev:styles # recompiles when you change .scss files ``` #### Known issues diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 51684ebf2d..ee361d63ff 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4684,6 +4684,18 @@ "messageformat": "Please try again or contact support.", "description": "Description text in popup dialog when user-initiated task has gone wrong" }, + "icu:DebugLogErrorModal__UnexpectedError": { + "messageformat": "An unexpected error occurred", + "description": "Title of the dialog shown when an unexpected user interface or coding bug occurs. The dialog's description text requests the user to submit debug logs." + }, + "icu:DebugLogErrorModal__SubmitDebugLog": { + "messageformat": "Submit debug log", + "description": "Primary button text in the dialog shown when an unexpected user interface or coding bug occurs. Clicking the button will open the Debug Log submission dialog." + }, + "icu:DebugLogErrorModal__SubmitDebugLog__Cancel": { + "messageformat": "No thanks", + "description": "Secondary button text in the dialog shown when an unexpected user interface or coding bug occurs. Clicking the button will dismiss the error dialog." + }, "icu:Confirmation--confirm": { "messageformat": "Okay", "description": "Button to dismiss popup dialog when user-initiated task has gone wrong" @@ -9024,12 +9036,16 @@ }, "icu:Donations__GenericError": { "messageformat": "An error occurred with your donation", - "description": "Title of the dialog shown when some unknown error has happened during a user's attempted donation" + "description": "Title of the dialog shown when some unknown error has happened during a user's attempted donation. This will show when we detect an error but haven't designed a string for the error type." }, "icu:Donations__GenericError__Description": { "messageformat": "Your donation might not have been processed. Click on “Donate to Signal” and then “Donation Receipts” to check your receipts and confirm.", "description": "An explanation for the 'error occurred' dialog" }, + "icu:DonationsErrorBoundary__DonationUnexpectedError": { + "messageformat": "Try again or submit a debug log to Support for help completing your donation. Debug logs helps us diagnose and fix the issue, and do not contain identifying information.", + "description": "Description of the dialog shown when an unexpected user interface or coding bug occurs while using a donations-related part of the app." + }, "icu:Donations__Processing": { "messageformat": "Processing donation...", "description": "Explainer text for donation progress dialog" diff --git a/stylesheets/components/DonationProgressModal.scss b/stylesheets/components/DonationProgressModal.scss index f3bfc6f1f0..3209c181e4 100644 --- a/stylesheets/components/DonationProgressModal.scss +++ b/stylesheets/components/DonationProgressModal.scss @@ -21,6 +21,10 @@ @include mixins.font-body-2; } +.DonationProgressModal .SpinnerV2 { + margin-inline: auto; +} + .DonationProgressModal .SpinnerV2__Path { color: variables.$color-ultramarine; } diff --git a/ts/components/DebugLogErrorModal.stories.tsx b/ts/components/DebugLogErrorModal.stories.tsx new file mode 100644 index 0000000000..4db48fe116 --- /dev/null +++ b/ts/components/DebugLogErrorModal.stories.tsx @@ -0,0 +1,39 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; + +import type { Meta } from '@storybook/react'; +import type { PropsType } from './DebugLogErrorModal'; +import { DebugLogErrorModal } from './DebugLogErrorModal'; + +const { i18n } = window.SignalContext; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + description: overrideProps.description ?? '', + i18n, + onClose: action('onClick'), + onSubmitDebugLog: action('onSubmitDebugLog'), +}); + +export default { + title: 'Components/DebugLogErrorModal', + argTypes: {}, + args: {}, +} satisfies Meta; + +export function Default(): JSX.Element { + return ; +} + +export function Donations(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/DebugLogErrorModal.tsx b/ts/components/DebugLogErrorModal.tsx new file mode 100644 index 0000000000..47ab5719b6 --- /dev/null +++ b/ts/components/DebugLogErrorModal.tsx @@ -0,0 +1,57 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import type { LocalizerType } from '../types/Util'; +import { Modal } from './Modal'; +import { Button, ButtonVariant } from './Button'; + +export type PropsType = { + description?: string; + i18n: LocalizerType; + onClose: () => void; + onSubmitDebugLog: () => void; +}; + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +export function DebugLogErrorModal(props: PropsType): JSX.Element { + const { description, i18n, onClose, onSubmitDebugLog } = props; + + const footer = ( + <> + + + + ); + + return ( + +
+ {description || i18n('icu:ErrorModal--description')} +
+
+ ); +} diff --git a/ts/components/DonationProgressModal.tsx b/ts/components/DonationProgressModal.tsx index 9e99e2a803..087a40fa16 100644 --- a/ts/components/DonationProgressModal.tsx +++ b/ts/components/DonationProgressModal.tsx @@ -34,6 +34,7 @@ export function DonationProgressModal(props: PropsType): JSX.Element { i18n={i18n} moduleClassName="DonationProgressModal" modalName="DonationProgressModal" + noEscapeClose noMouseClose onClose={() => undefined} > diff --git a/ts/components/DonationsErrorBoundary.tsx b/ts/components/DonationsErrorBoundary.tsx new file mode 100644 index 0000000000..99b69594a6 --- /dev/null +++ b/ts/components/DonationsErrorBoundary.tsx @@ -0,0 +1,73 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ReactNode, ErrorInfo } from 'react'; +import React, { Component, useCallback } from 'react'; +import { createLogger } from '../logging/log'; +import * as Errors from '../types/errors'; + +const log = createLogger('DonationsErrorBoundary'); + +type ErrorBoundaryProps = Readonly<{ + children: ReactNode; + onError: (error: unknown, info: ErrorInfo) => void; + fallback: (error: unknown) => ReactNode; +}>; + +type ErrorBoundaryState = { + caught?: { error: unknown }; +}; + +class ErrorBoundary extends Component { + // eslint-disable-next-line react/state-in-constructor + override state: ErrorBoundaryState = {}; + + static getDerivedStateFromError(error: unknown) { + return { caught: { error } }; + } + + override componentDidCatch(error: unknown, info: ErrorInfo) { + this.props.onError(error, info); + } + + override render() { + if (this.state.caught != null) { + return this.props.fallback(this.state.caught.error); + } + + return this.props.children; + } +} + +export type DonationsErrorBoundaryProps = Readonly<{ + children: ReactNode; +}>; + +export function DonationsErrorBoundary( + props: DonationsErrorBoundaryProps +): JSX.Element { + const fallback = useCallback(() => { + return
; + }, []); + + const handleError = useCallback((error: unknown, info: ErrorInfo) => { + log.error( + 'DonationsErrorBoundary: Caught error', + Errors.toLogFormat(error), + info.componentStack + ); + + if (window.reduxActions) { + window.reduxActions.globalModals.showDebugLogErrorModal({ + description: window.i18n( + 'icu:DonationsErrorBoundary__DonationUnexpectedError' + ), + }); + } + }, []); + + return ( + + {props.children} + + ); +} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 3ad1fcce79..4ce0880ef9 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -80,6 +80,13 @@ export type PropsType = { description?: string; title?: string | null; }) => JSX.Element; + // DebugLogErrorModal + debugLogErrorModalProps: + | { + description?: string; + } + | undefined; + renderDebugLogErrorModal: (opts: { description?: string }) => JSX.Element; // DeleteMessageModal deleteMessagesProps: DeleteMessagesPropsType | undefined; renderDeleteMessagesModal: () => JSX.Element; @@ -186,6 +193,9 @@ export function GlobalModalContainer({ // ErrorModal errorModalProps, renderErrorModal, + // DebugLogErrorModal + debugLogErrorModalProps, + renderDebugLogErrorModal, // DeleteMessageModal deleteMessagesProps, renderDeleteMessagesModal, @@ -263,6 +273,11 @@ export function GlobalModalContainer({ return renderErrorModal(errorModalProps); } + // Errors where we want them to submit a debug log + if (debugLogErrorModalProps) { + return renderDebugLogErrorModal(debugLogErrorModalProps); + } + // Safety Number if (hasSafetyNumberChangeModal || safetyNumberChangedBlockingData) { return renderSendAnywayDialog(); diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index c518a9f0a0..4c1aa5d242 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -116,6 +116,9 @@ export type GlobalModalsStateType = ReadonlyDeep<{ criticalIdlePrimaryDeviceModal: boolean; deleteMessagesProps?: DeleteMessagesPropsType; draftGifMessageSendModalProps: SmartDraftGifMessageSendModalProps | null; + debugLogErrorModalProps?: { + description?: string; + }; editHistoryMessages?: EditHistoryMessagesType; editNicknameAndNoteModalProps: EditNicknameAndNoteModalPropsType | null; errorModalProps?: { @@ -200,6 +203,8 @@ const SHOW_STICKER_PACK_PREVIEW = 'globalModals/SHOW_STICKER_PACK_PREVIEW'; const CLOSE_STICKER_PACK_PREVIEW = 'globalModals/CLOSE_STICKER_PACK_PREVIEW'; const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL'; export const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL'; +const CLOSE_DEBUG_LOG_ERROR_MODAL = 'globalModals/CLOSE_DEBUG_LOG_ERROR_MODAL'; +const SHOW_DEBUG_LOG_ERROR_MODAL = 'globalModals/SHOW_DEBUG_LOG_ERROR_MODAL'; const TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL = 'globalModals/TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL'; const TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION = @@ -419,6 +424,17 @@ export type ShowErrorModalActionType = ReadonlyDeep<{ }; }>; +type CloseDebugLogErrorModalActionType = ReadonlyDeep<{ + type: typeof CLOSE_DEBUG_LOG_ERROR_MODAL; +}>; + +type ShowDebugLogErrorModalActionType = ReadonlyDeep<{ + type: typeof SHOW_DEBUG_LOG_ERROR_MODAL; + payload: { + description?: string; + }; +}>; + type CloseMediaPermissionsModalActionType = ReadonlyDeep<{ type: typeof CLOSE_MEDIA_PERMISSIONS_MODAL; }>; @@ -482,6 +498,7 @@ type CloseEditHistoryModalActionType = ReadonlyDeep<{ export type GlobalModalsActionType = ReadonlyDeep< | CloseEditHistoryModalActionType + | CloseDebugLogErrorModalActionType | CloseErrorModalActionType | CloseMediaPermissionsModalActionType | CloseGV2MigrationDialogActionType @@ -504,6 +521,7 @@ export type GlobalModalsActionType = ReadonlyDeep< | ShowBackfillFailureModalActionType | ShowCriticalIdlePrimaryDeviceModalActionType | ShowContactModalActionType + | ShowDebugLogErrorModalActionType | ShowEditHistoryModalActionType | ShowErrorModalActionType | ShowLowDiskSpaceBackupImportModalActionType @@ -538,6 +556,7 @@ export type GlobalModalsActionType = ReadonlyDeep< // Action Creators export const actions = { + closeDebugLogErrorModal, closeEditHistoryModal, closeErrorModal, closeGV2MigrationDialog, @@ -560,6 +579,7 @@ export const actions = { showBlockingSafetyNumberChangeDialog, showContactModal, showCriticalIdlePrimaryDeviceModal, + showDebugLogErrorModal, showEditHistoryModal, showErrorModal, showGV2MigrationDialog, @@ -1095,6 +1115,25 @@ function showErrorModal({ }; } +function closeDebugLogErrorModal(): CloseDebugLogErrorModalActionType { + return { + type: CLOSE_DEBUG_LOG_ERROR_MODAL, + }; +} + +function showDebugLogErrorModal({ + description, +}: { + description?: string; +}): ShowDebugLogErrorModalActionType { + return { + type: SHOW_DEBUG_LOG_ERROR_MODAL, + payload: { + description, + }, + }; +} + function closeMediaPermissionsModal(): CloseMediaPermissionsModalActionType { return { type: CLOSE_MEDIA_PERMISSIONS_MODAL, @@ -1591,6 +1630,20 @@ export function reducer( }; } + if (action.type === CLOSE_DEBUG_LOG_ERROR_MODAL) { + return { + ...state, + debugLogErrorModalProps: undefined, + }; + } + + if (action.type === SHOW_DEBUG_LOG_ERROR_MODAL) { + return { + ...state, + debugLogErrorModalProps: action.payload, + }; + } + if (action.type === TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL) { return { ...state, diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index 50ab3ada37..6a9bbfbafc 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -32,6 +32,7 @@ import { SmartCallLinkPendingParticipantModal } from './CallLinkPendingParticipa import { SmartAttachmentNotAvailableModal } from './AttachmentNotAvailableModal'; import { SmartProfileNameWarningModal } from './ProfileNameWarningModal'; import { SmartDraftGifMessageSendModal } from './DraftGifMessageSendModal'; +import { DebugLogErrorModal } from '../../components/DebugLogErrorModal'; function renderCallLinkAddNameModal(): JSX.Element { return ; @@ -128,6 +129,7 @@ export const SmartGlobalModalContainer = memo( confirmLeaveCallModalState, contactModalState, criticalIdlePrimaryDeviceModal, + debugLogErrorModalProps, deleteMessagesProps, draftGifMessageSendModalProps, editHistoryMessages, @@ -153,6 +155,7 @@ export const SmartGlobalModalContainer = memo( } = useSelector(getGlobalModalsState); const { + closeDebugLogErrorModal, closeErrorModal, closeMediaPermissionsModal, hideCriticalIdlePrimaryDeviceModal, @@ -210,6 +213,18 @@ export const SmartGlobalModalContainer = memo( [closeErrorModal, i18n] ); + const renderDebugLogErrorModal = useCallback( + ({ description }: { description?: string }) => ( + window.IPC.showDebugLog()} + /> + ), + [closeDebugLogErrorModal, i18n] + ); + return ( void; }): JSX.Element { return ( - + + + ); }