UpdateDialog on InstallScreen

This commit is contained in:
Fedor Indutny 2023-03-20 13:42:00 -07:00 committed by GitHub
parent 28adb58c69
commit 1d1b124a92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 443 additions and 26 deletions

View file

@ -5459,6 +5459,26 @@
"messageformat": "Update Downloaded",
"description": "The title of update dialog when update download is completed."
},
"icu:InstallScreenUpdateDialog--unsupported-os__title": {
"messageformat": "Update Required",
"description": "The title of update dialog on install screen when user OS is unsupported"
},
"icu:InstallScreenUpdateDialog--auto-update__body": {
"messageformat": "To continue using Signal, you must update to the latest version.",
"description": "The body of update dialog on install screen when auto update is downloaded and available."
},
"icu:InstallScreenUpdateDialog--manual-update__action": {
"messageformat": "Download {downloadSize}",
"description": "The text of a confirmation button in update dialog on install screen when manual update is ready to be downloaded."
},
"icu:InstallScreenUpdateDialog--downloaded__body": {
"messageformat": "Restart Signal to install the update.",
"description": "The body of the update dialog on install screen when manual update was downloaded."
},
"icu:InstallScreenUpdateDialog--cannot-update__body": {
"messageformat": "Signal Desktop failed to update, but there is a new version available. Go to {downloadUrl} and install the new version manually, then either contact support or file a bug about this problem.",
"description": "The body of the update dialog on install screen when update cannot be installed."
},
"NSIS__retry-dialog--first-line": {
"message": "Signal cannot be closed.",
"description": "First line of the dialog displayed when Windows installer can't close application automatically and needs user intervention to complete the installation."

View file

@ -0,0 +1,39 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.InstallScreenUpdateDialog {
&__download-size {
font-weight: 400;
}
&__progress {
&--container {
@include light-theme() {
background-color: $color-gray-15;
}
@include dark-theme() {
background-color: $color-gray-65;
}
border-radius: 2px;
height: 4px;
overflow: hidden;
width: 100%;
margin: 16px 0;
}
&--bar {
background-color: $color-ultramarine;
border-radius: 2px;
display: block;
height: 100%;
width: 100%;
transform: translateX(-100%);
transition: transform 500ms ease-out;
}
}
a {
// Prevent breaking the text
display: inline-block;
}
}

View file

@ -88,6 +88,7 @@
@import './components/InstallScreenLinkInProgressStep.scss';
@import './components/InstallScreenQrCodeNotScannedStep.scss';
@import './components/InstallScreenSignalLogo.scss';
@import './components/InstallScreenUpdateDialog.scss';
@import './components/LeftPaneDialog.scss';
@import './components/LeftPaneSearchInput.scss';
@import './components/Lightbox.scss';

View file

@ -1781,6 +1781,7 @@ export async function startApp(): Promise<void> {
}
window.Whisper.events.on('setupAsNewDevice', () => {
window.IPC.readyForUpdates();
window.reduxActions.app.openInstaller();
});
@ -1967,6 +1968,7 @@ export async function startApp(): Promise<void> {
void connect();
window.reduxActions.app.openInbox();
} else {
window.IPC.readyForUpdates();
window.reduxActions.app.openInstaller();
}

View file

@ -13,11 +13,19 @@ import { useAnimated } from '../hooks/useAnimated';
import { Spinner } from './Spinner';
export type ActionSpec = {
text: string;
action: () => unknown;
style?: 'affirmative' | 'negative';
autoClose?: boolean;
};
} & (
| {
text: string;
id?: string;
}
| {
text: string | JSX.Element;
id: string;
}
);
export type OwnProps = Readonly<{
actions?: Array<ActionSpec>;
@ -117,7 +125,11 @@ export const ConfirmationDialog = React.memo(function ConfirmationDialogInner({
) : null}
{actions.map((action, i) => (
<Button
key={action.text}
key={
typeof action.text === 'string'
? action.id ?? action.text
: action.id
}
disabled={isSpinning}
onClick={() => {
action.action();

View file

@ -7,6 +7,7 @@ import formatFileSize from 'filesize';
import { isBeta } from '../util/version';
import { DialogType } from '../types/Dialogs';
import type { LocalizerType } from '../types/Util';
import { PRODUCTION_DOWNLOAD_URL, BETA_DOWNLOAD_URL } from '../types/support';
import { Intl } from './Intl';
import { LeftPaneDialog } from './LeftPaneDialog';
import type { WidthBreakpoint } from './_util';
@ -24,9 +25,6 @@ export type PropsType = {
currentVersion: string;
};
const PRODUCTION_DOWNLOAD_URL = 'https://signal.org/download/';
const BETA_DOWNLOAD_URL = 'https://support.signal.org/beta';
export function DialogUpdate({
containerWidthBreakpoint,
dialogType,

View file

@ -6,6 +6,7 @@ import moment from 'moment';
import type { FormatXMLElementFn } from 'intl-messageformat';
import type { LocalizerType } from '../types/Util';
import { UNSUPPORTED_OS_URL } from '../types/support';
import { missingCaseError } from '../util/missingCaseError';
import type { WidthBreakpoint } from './_util';
import { Intl } from './Intl';
@ -20,8 +21,6 @@ export type PropsType = {
OS: string;
};
const SUPPORT_URL = 'https://support.signal.org/hc/articles/5109141421850';
export function UnsupportedOSDialog({
containerWidthBreakpoint,
expirationTimestamp,
@ -30,7 +29,12 @@ export function UnsupportedOSDialog({
OS,
}: PropsType): JSX.Element | null {
const learnMoreLink: FormatXMLElementFn<JSX.Element | string> = children => (
<a key="signal-support" href={SUPPORT_URL} rel="noreferrer" target="_blank">
<a
key="signal-support"
href={UNSUPPORTED_OS_URL}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);

View file

@ -2,8 +2,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../util/setupI18n';
import { DialogType } from '../../types/Dialogs';
import enMessages from '../../../_locales/en/messages.json';
import type { Loadable } from '../../util/loadable';
@ -12,8 +14,24 @@ import { InstallScreenQrCodeNotScannedStep } from './InstallScreenQrCodeNotScann
const i18n = setupI18n('en', enMessages);
const LOADED_URL = {
loadingState: LoadingState.Loaded as const,
value:
'sgnl://linkdevice?uuid=b33f6338-aaf1-4853-9aff-6652369f6b52&pub_key=BTpRKRtFeJGga1M3Na4PzZevMvVIWmTWQIpn0BJI3x10',
};
const DEFAULT_UPDATES = {
dialogType: DialogType.None,
didSnooze: false,
showEventsCount: 0,
downloadSize: 67 * 1024 * 1024,
downloadedSize: 15 * 1024 * 1024,
version: 'v7.7.7',
};
export default {
title: 'Components/InstallScreen/InstallScreenQrCodeNotScannedStep',
argTypes: {},
};
function Simulation({ finalResult }: { finalResult: Loadable<string> }) {
@ -34,6 +52,10 @@ function Simulation({ finalResult }: { finalResult: Loadable<string> }) {
<InstallScreenQrCodeNotScannedStep
i18n={i18n}
provisioningUrl={provisioningUrl}
updates={DEFAULT_UPDATES}
OS="macOS"
startUpdate={action('startUpdate')}
currentVersion="v6.0.0"
/>
);
}
@ -45,6 +67,10 @@ export function QrCodeLoading(): JSX.Element {
provisioningUrl={{
loadingState: LoadingState.Loading,
}}
updates={DEFAULT_UPDATES}
OS="macOS"
startUpdate={action('startUpdate')}
currentVersion="v6.0.0"
/>
);
}
@ -61,6 +87,10 @@ export function QrCodeFailedToLoad(): JSX.Element {
loadingState: LoadingState.LoadFailed,
error: new Error('uh oh'),
}}
updates={DEFAULT_UPDATES}
OS="macOS"
startUpdate={action('startUpdate')}
currentVersion="v6.0.0"
/>
);
}
@ -73,11 +103,11 @@ export function QrCodeLoaded(): JSX.Element {
return (
<InstallScreenQrCodeNotScannedStep
i18n={i18n}
provisioningUrl={{
loadingState: LoadingState.Loaded,
value:
'https://example.com/fake-signal-link?uuid=56cdd548-e595-4962-9a27-3f1e8210a959&pub_key=SW4gdGhlIHZhc3QsIGRlZXAgZm9yZXN0IG9mIEh5cnVsZS4uLg%3D%3D',
}}
provisioningUrl={LOADED_URL}
updates={DEFAULT_UPDATES}
OS="macOS"
startUpdate={action('startUpdate')}
currentVersion="v6.0.0"
/>
);
}
@ -87,15 +117,7 @@ QrCodeLoaded.story = {
};
export function SimulatedLoading(): JSX.Element {
return (
<Simulation
finalResult={{
loadingState: LoadingState.Loaded,
value:
'https://example.com/fake-signal-link?uuid=56cdd548-e595-4962-9a27-3f1e8210a959&pub_key=SW4gdGhlIHZhc3QsIGRlZXAgZm9yZXN0IG9mIEh5cnVsZS4uLg%3D%3D',
}}
/>
);
return <Simulation finalResult={LOADED_URL} />;
}
SimulatedLoading.story = {
@ -116,3 +138,42 @@ export function SimulatedFailure(): JSX.Element {
SimulatedFailure.story = {
name: 'Simulated failure',
};
export function WithUpdateKnobs({
dialogType,
currentVersion,
}: {
dialogType: DialogType;
currentVersion: string;
}): JSX.Element {
return (
<InstallScreenQrCodeNotScannedStep
i18n={i18n}
provisioningUrl={LOADED_URL}
hasExpired
updates={{
...DEFAULT_UPDATES,
dialogType,
}}
OS="macOS"
startUpdate={action('startUpdate')}
currentVersion={currentVersion}
/>
);
}
WithUpdateKnobs.story = {
name: 'With Update Knobs',
argTypes: {
dialogType: {
control: { type: 'select' },
defaultValue: DialogType.AutoUpdate,
options: Object.values(DialogType),
},
currentVersion: {
control: { type: 'select' },
defaultValue: 'v6.0.0',
options: ['v6.0.0', 'v6.1.0-beta.1'],
},
},
};

View file

@ -15,15 +15,22 @@ import { Spinner } from '../Spinner';
import { QrCode } from '../QrCode';
import { TitlebarDragArea } from '../TitlebarDragArea';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
import { InstallScreenUpdateDialog } from './InstallScreenUpdateDialog';
import { getClassNamesFor } from '../../util/getClassNamesFor';
import type { UpdatesStateType } from '../../state/ducks/updates';
// We can't always use destructuring assignment because of the complexity of this props
// type.
type PropsType = {
type PropsType = Readonly<{
i18n: LocalizerType;
provisioningUrl: Loadable<string>;
};
hasExpired?: boolean;
updates: UpdatesStateType;
currentVersion: string;
OS: string;
startUpdate: () => void;
}>;
const QR_CODE_FAILED_LINK =
'https://support.signal.org/hc/articles/360007320451#desktop_multiple_device';
@ -35,6 +42,11 @@ const getQrCodeClassName = getClassNamesFor(
export function InstallScreenQrCodeNotScannedStep({
i18n,
provisioningUrl,
hasExpired,
updates,
startUpdate,
currentVersion,
OS,
}: Readonly<PropsType>): ReactElement {
return (
<div className="module-InstallScreenQrCodeNotScannedStep">
@ -42,6 +54,16 @@ export function InstallScreenQrCodeNotScannedStep({
<InstallScreenSignalLogo />
{hasExpired && (
<InstallScreenUpdateDialog
i18n={i18n}
{...updates}
startUpdate={startUpdate}
currentVersion={currentVersion}
OS={OS}
/>
)}
<div className="module-InstallScreenQrCodeNotScannedStep__contents">
<InstallScreenQrCode i18n={i18n} {...provisioningUrl} />
<div className="module-InstallScreenQrCodeNotScannedStep__instructions">

View file

@ -0,0 +1,233 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { noop } from 'lodash';
import type { FormatXMLElementFn } from 'intl-messageformat';
import formatFileSize from 'filesize';
import { DialogType } from '../../types/Dialogs';
import type { LocalizerType } from '../../types/Util';
import {
PRODUCTION_DOWNLOAD_URL,
BETA_DOWNLOAD_URL,
UNSUPPORTED_OS_URL,
} from '../../types/support';
import type { UpdatesStateType } from '../../state/ducks/updates';
import { isBeta } from '../../util/version';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Modal } from '../Modal';
import { Intl } from '../Intl';
export type PropsType = UpdatesStateType &
Readonly<{
i18n: LocalizerType;
startUpdate: () => void;
currentVersion: string;
OS: string;
}>;
export function InstallScreenUpdateDialog({
i18n,
dialogType,
downloadSize,
downloadedSize,
startUpdate,
currentVersion,
OS,
}: PropsType): JSX.Element | null {
const learnMoreLink: FormatXMLElementFn<JSX.Element | string> = children => (
<a
key="signal-support"
href={UNSUPPORTED_OS_URL}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
const dialogName = `InstallScreenUpdateDialog.${dialogType}`;
if (dialogType === DialogType.UnsupportedOS) {
return (
<Modal
i18n={i18n}
modalName={dialogName}
noMouseClose
title={i18n('icu:InstallScreenUpdateDialog--unsupported-os__title')}
>
<Intl
id="icu:UnsupportedOSErrorDialog__body"
i18n={i18n}
components={{
OS,
learnMoreLink,
}}
/>
</Modal>
);
}
if (
dialogType === DialogType.AutoUpdate ||
// Manual update with an action button
dialogType === DialogType.DownloadReady ||
dialogType === DialogType.FullDownloadReady ||
dialogType === DialogType.DownloadedUpdate
) {
let title = i18n('autoUpdateNewVersionTitle');
let actionText: string | JSX.Element = i18n('autoUpdateRestartButtonLabel');
let bodyText = i18n('icu:InstallScreenUpdateDialog--auto-update__body');
if (
dialogType === DialogType.DownloadReady ||
dialogType === DialogType.FullDownloadReady
) {
actionText = (
<Intl
id="icu:InstallScreenUpdateDialog--manual-update__action"
i18n={i18n}
components={{
downloadSize: (
<span className="InstallScreenUpdateDialog__download-size">
({formatFileSize(downloadSize ?? 0, { round: 0 })})
</span>
),
}}
/>
);
}
if (dialogType === DialogType.DownloadedUpdate) {
title = i18n('icu:DialogUpdate__downloaded');
bodyText = i18n('icu:InstallScreenUpdateDialog--downloaded__body');
}
return (
<ConfirmationDialog
i18n={i18n}
dialogName={dialogName}
title={title}
noDefaultCancelButton
actions={[
{
id: 'ok',
text: actionText,
action: startUpdate,
style: 'affirmative',
autoClose: false,
},
]}
onClose={noop}
>
{bodyText}
</ConfirmationDialog>
);
}
if (dialogType === DialogType.Downloading) {
// Focus trap can't be used because there are no elements that can be
// focused within the modal.
const width = Math.ceil(
((downloadedSize || 1) / (downloadSize || 1)) * 100
);
return (
<Modal
i18n={i18n}
modalName={dialogName}
noMouseClose
useFocusTrap={false}
title={i18n('icu:DialogUpdate__downloading')}
>
<div className="InstallScreenUpdateDialog__progress--container">
<div
className="InstallScreenUpdateDialog__progress--bar"
style={{ transform: `translateX(${width - 100}%)` }}
/>
</div>
</Modal>
);
}
if (
dialogType === DialogType.Cannot_Update ||
dialogType === DialogType.Cannot_Update_Require_Manual
) {
const url = isBeta(currentVersion)
? BETA_DOWNLOAD_URL
: PRODUCTION_DOWNLOAD_URL;
const title = i18n('cannotUpdate');
const body = (
<Intl
i18n={i18n}
id="icu:InstallScreenUpdateDialog--cannot-update__body"
components={{
downloadUrl: (
<a href={url} target="_blank" rel="noreferrer">
{url}
</a>
),
}}
/>
);
if (dialogType === DialogType.Cannot_Update) {
return (
<ConfirmationDialog
i18n={i18n}
dialogName={dialogName}
moduleClassName="InstallScreenUpdateDialog"
title={title}
noDefaultCancelButton
actions={[
{
text: i18n('autoUpdateRetry'),
action: startUpdate,
style: 'affirmative',
autoClose: false,
},
]}
onClose={noop}
>
{body}
</ConfirmationDialog>
);
}
return (
<Modal
i18n={i18n}
modalName={dialogName}
noMouseClose
title={title}
moduleClassName="InstallScreenUpdateDialog"
>
{body}
</Modal>
);
}
if (dialogType === DialogType.MacOS_Read_Only) {
// No focus trap, because there are no focusable elements.
return (
<Modal
i18n={i18n}
modalName={dialogName}
noMouseClose
useFocusTrap={false}
title={i18n('cannotUpdate')}
>
<Intl
components={{
app: <strong key="app">Signal.app</strong>,
folder: <strong key="folder">/Applications</strong>,
}}
i18n={i18n}
id="readOnlyVolume"
/>
</Modal>
);
}
return null;
}

View file

@ -3,6 +3,8 @@
import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import * as updateIpc from '../../shims/updateIpc';
import { DialogType } from '../../types/Dialogs';
import { DAY } from '../../util/durations';
@ -140,6 +142,10 @@ export const actions = {
startUpdate,
};
export const useUpdatesActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
// Reducer
export function getEmptyState(): UpdatesStateType {

View file

@ -8,7 +8,7 @@ import { DialogType } from '../../types/Dialogs';
import type { StateType } from '../reducer';
import type { UpdatesStateType } from '../ducks/updates';
const getUpdatesState = (state: Readonly<StateType>): UpdatesStateType =>
export const getUpdatesState = (state: Readonly<StateType>): UpdatesStateType =>
state.updates;
export const isUpdateDialogVisible = createSelector(

View file

@ -6,6 +6,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { getIntl } from '../selectors/user';
import { getUpdatesState } from '../selectors/updates';
import { useUpdatesActions } from '../ducks/updates';
import { hasExpired as hasExpiredSelector } from '../selectors/expiration';
import * as log from '../../logging/log';
import type { Loadable } from '../../util/loadable';
@ -23,6 +26,7 @@ import { HTTPError } from '../../textsecure/Errors';
import { isRecord } from '../../util/isRecord';
import * as Errors from '../../types/errors';
import { normalizeDeviceName } from '../../util/normalizeDeviceName';
import { getName as getOSName } from '../../OS';
type PropsType = ComponentProps<typeof InstallScreen>;
@ -71,6 +75,9 @@ function getInstallError(err: unknown): InstallError {
export function SmartInstallScreen(): ReactElement {
const i18n = useSelector(getIntl);
const updates = useSelector(getUpdatesState);
const { startUpdate } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector);
const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise<string>());
@ -246,6 +253,11 @@ export function SmartInstallScreen(): ReactElement {
screenSpecificProps: {
i18n,
provisioningUrl: state.provisioningUrl,
hasExpired,
updates,
currentVersion: window.getVersion(),
startUpdate,
OS: getOSName(),
},
};
break;

7
ts/types/support.ts Normal file
View file

@ -0,0 +1,7 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const PRODUCTION_DOWNLOAD_URL = 'https://signal.org/download/';
export const BETA_DOWNLOAD_URL = 'https://support.signal.org/beta';
export const UNSUPPORTED_OS_URL =
'https://support.signal.org/hc/articles/5109141421850';