Redesign device link screens

This commit is contained in:
Evan Hahn 2021-12-16 09:02:22 -06:00 committed by GitHub
parent a023fc1bb0
commit 364f00f37a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1358 additions and 803 deletions

View file

@ -8,7 +8,7 @@ import classNames from 'classnames';
import { AppViewType } from '../state/ducks/app';
import { Inbox } from './Inbox';
import { Install } from './Install';
import { SmartInstallScreen } from '../state/smart/InstallScreen';
import { StandaloneRegistration } from './StandaloneRegistration';
import { ThemeType } from '../types/Util';
import { usePageVisibility } from '../hooks/usePageVisibility';
@ -50,7 +50,7 @@ export const App = ({
let contents;
if (appView === AppViewType.Installer) {
contents = <Install />;
contents = <SmartInstallScreen />;
} else if (appView === AppViewType.Standalone) {
const onComplete = () => {
window.removeSetupMenuItems();

View file

@ -1,14 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { BackboneHost } from './BackboneHost';
export const Install = (): JSX.Element => {
return (
<BackboneHost
className="full-screen-flow"
View={window.Whisper.InstallView}
/>
);
};

View file

@ -0,0 +1,64 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps, ReactElement } from 'react';
import React from 'react';
import { missingCaseError } from '../util/missingCaseError';
import { InstallScreenErrorStep } from './installScreen/InstallScreenErrorStep';
import { InstallScreenChoosingDeviceNameStep } from './installScreen/InstallScreenChoosingDeviceNameStep';
import { InstallScreenLinkInProgressStep } from './installScreen/InstallScreenLinkInProgressStep';
import { InstallScreenQrCodeNotScannedStep } from './installScreen/InstallScreenQrCodeNotScannedStep';
export enum InstallScreenStep {
Error,
QrCodeNotScanned,
ChoosingDeviceName,
LinkInProgress,
}
// We can't always use destructuring assignment because of the complexity of this props
// type.
/* eslint-disable react/destructuring-assignment */
type PropsType =
| {
step: InstallScreenStep.Error;
screenSpecificProps: ComponentProps<typeof InstallScreenErrorStep>;
}
| {
step: InstallScreenStep.QrCodeNotScanned;
screenSpecificProps: ComponentProps<
typeof InstallScreenQrCodeNotScannedStep
>;
}
| {
step: InstallScreenStep.ChoosingDeviceName;
screenSpecificProps: ComponentProps<
typeof InstallScreenChoosingDeviceNameStep
>;
}
| {
step: InstallScreenStep.LinkInProgress;
screenSpecificProps: ComponentProps<
typeof InstallScreenLinkInProgressStep
>;
};
export function InstallScreen(props: Readonly<PropsType>): ReactElement {
switch (props.step) {
case InstallScreenStep.Error:
return <InstallScreenErrorStep {...props.screenSpecificProps} />;
case InstallScreenStep.QrCodeNotScanned:
return (
<InstallScreenQrCodeNotScannedStep {...props.screenSpecificProps} />
);
case InstallScreenStep.ChoosingDeviceName:
return (
<InstallScreenChoosingDeviceNameStep {...props.screenSpecificProps} />
);
case InstallScreenStep.LinkInProgress:
return <InstallScreenLinkInProgressStep {...props.screenSpecificProps} />;
default:
throw missingCaseError(props);
}
}

View file

@ -0,0 +1,9 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
export const TitlebarDragArea = (): ReactElement => (
<div className="module-title-bar-drag-area" />
);

View file

@ -0,0 +1,36 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { InstallScreenChoosingDeviceNameStep } from './InstallScreenChoosingDeviceNameStep';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/InstallScreen/InstallScreenChoosingDeviceNameStep',
module
);
story.add('Default', () => {
const Wrapper = () => {
const [deviceName, setDeviceName] = useState<string>('Default value');
return (
<InstallScreenChoosingDeviceNameStep
i18n={i18n}
deviceName={deviceName}
setDeviceName={setDeviceName}
onSubmit={action('onSubmit')}
/>
);
};
return <Wrapper />;
});

View file

@ -0,0 +1,85 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React, { useRef } from 'react';
import type { LocalizerType } from '../../types/Util';
import { normalizeDeviceName } from '../../util/normalizeDeviceName';
import { Button, ButtonVariant } from '../Button';
import { TitlebarDragArea } from '../TitlebarDragArea';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
// This is the string's `.length`, which is the number of UTF-16 code points. Instead, we
// want this to be either 50 graphemes or 256 encrypted bytes, whichever is smaller. See
// DESKTOP-2844.
export const MAX_DEVICE_NAME_LENGTH = 50;
type PropsType = {
deviceName: string;
i18n: LocalizerType;
onSubmit: () => void;
setDeviceName: (value: string) => void;
};
export function InstallScreenChoosingDeviceNameStep({
deviceName,
i18n,
onSubmit,
setDeviceName,
}: Readonly<PropsType>): ReactElement {
const hasFocusedRef = useRef<boolean>(false);
const focusRef = (el: null | HTMLElement) => {
if (el) {
el.focus();
hasFocusedRef.current = true;
}
};
const normalizedName = normalizeDeviceName(deviceName);
const canSubmit =
normalizedName.length > 0 &&
normalizedName.length <= MAX_DEVICE_NAME_LENGTH;
return (
<form
className="module-InstallScreenChoosingDeviceNameStep"
onSubmit={event => {
event.preventDefault();
onSubmit();
}}
>
<TitlebarDragArea />
<InstallScreenSignalLogo />
<div className="module-InstallScreenChoosingDeviceNameStep__contents">
<div className="module-InstallScreenChoosingDeviceNameStep__header">
<h1>{i18n('chooseDeviceName')}</h1>
<h2>{i18n('Install__choose-device-name__description')}</h2>
</div>
<div className="module-InstallScreenChoosingDeviceNameStep__inputs">
<input
className="module-InstallScreenChoosingDeviceNameStep__input"
maxLength={MAX_DEVICE_NAME_LENGTH}
onChange={event => {
setDeviceName(event.target.value);
}}
placeholder={i18n('Install__choose-device-name__placeholder')}
ref={focusRef}
spellCheck={false}
value={deviceName}
/>
<Button
disabled={!canSubmit}
variant={ButtonVariant.Primary}
type="submit"
>
{i18n('finishLinkingPhone')}
</Button>
</div>
</div>
</form>
);
}

View file

@ -0,0 +1,51 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { InstallScreenErrorStep, InstallError } from './InstallScreenErrorStep';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/InstallScreen/InstallScreenErrorStep',
module
);
const defaultProps = {
i18n,
quit: action('quit'),
tryAgain: action('tryAgain'),
};
story.add('Too many devices', () => (
<InstallScreenErrorStep
{...defaultProps}
error={InstallError.TooManyDevices}
/>
));
story.add('Too old', () => (
<InstallScreenErrorStep {...defaultProps} error={InstallError.TooOld} />
));
story.add('Too old', () => (
<InstallScreenErrorStep {...defaultProps} error={InstallError.TooOld} />
));
story.add('Connection failed', () => (
<InstallScreenErrorStep
{...defaultProps}
error={InstallError.ConnectionFailed}
/>
));
story.add('Unknown error', () => (
<InstallScreenErrorStep {...defaultProps} error={InstallError.UnknownError} />
));

View file

@ -0,0 +1,77 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
import type { LocalizerType } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
import { Button, ButtonVariant } from '../Button';
import { TitlebarDragArea } from '../TitlebarDragArea';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
export enum InstallError {
TooManyDevices,
TooOld,
ConnectionFailed,
UnknownError,
}
export function InstallScreenErrorStep({
error,
i18n,
quit,
tryAgain,
}: Readonly<{
error: InstallError;
i18n: LocalizerType;
quit: () => unknown;
tryAgain: () => unknown;
}>): ReactElement {
let errorMessage: string;
let buttonText = i18n('installTryAgain');
let onClickButton = () => tryAgain();
let shouldShowQuitButton = false;
switch (error) {
case InstallError.TooManyDevices:
errorMessage = i18n('installTooManyDevices');
break;
case InstallError.TooOld:
errorMessage = i18n('installTooOld');
buttonText = i18n('upgrade');
onClickButton = () => {
openLinkInWebBrowser('https://signal.org/download');
};
shouldShowQuitButton = true;
break;
case InstallError.ConnectionFailed:
errorMessage = i18n('installConnectionFailed');
break;
case InstallError.UnknownError:
errorMessage = i18n('installUnknownError');
break;
default:
throw missingCaseError(error);
}
return (
<div className="module-InstallScreenErrorStep">
<TitlebarDragArea />
<InstallScreenSignalLogo />
<h1>{i18n('installErrorHeader')}</h1>
<h2>{errorMessage}</h2>
<div className="module-InstallScreenErrorStep__buttons">
<Button onClick={onClickButton}>{buttonText}</Button>
{shouldShowQuitButton && (
<Button onClick={() => quit()} variant={ButtonVariant.Secondary}>
{i18n('quit')}
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { InstallScreenLinkInProgressStep } from './InstallScreenLinkInProgressStep';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/InstallScreen/InstallScreenLinkInProgressStep',
module
);
story.add('Default', () => <InstallScreenLinkInProgressStep i18n={i18n} />);

View file

@ -0,0 +1,25 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
import type { LocalizerType } from '../../types/Util';
import { Spinner } from '../Spinner';
import { TitlebarDragArea } from '../TitlebarDragArea';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
export const InstallScreenLinkInProgressStep = ({
i18n,
}: Readonly<{ i18n: LocalizerType }>): ReactElement => (
<div className="module-InstallScreenLinkInProgressStep">
<TitlebarDragArea />
<InstallScreenSignalLogo />
<Spinner size="50px" svgSize="normal" />
<h1>{i18n('initialSync')}</h1>
<h2>{i18n('initialSync__subtitle')}</h2>
</div>
);

View file

@ -0,0 +1,91 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import { storiesOf } from '@storybook/react';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable';
import { InstallScreenQrCodeNotScannedStep } from './InstallScreenQrCodeNotScannedStep';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/InstallScreen/InstallScreenQrCodeNotScannedStep',
module
);
const Simulation = ({ finalResult }: { finalResult: Loadable<string> }) => {
const [provisioningUrl, setProvisioningUrl] = useState<Loadable<string>>({
loadingState: LoadingState.Loading,
});
useEffect(() => {
const timeout = setTimeout(() => {
setProvisioningUrl(finalResult);
}, 2000);
return () => {
clearTimeout(timeout);
};
}, [finalResult]);
return (
<InstallScreenQrCodeNotScannedStep
i18n={i18n}
provisioningUrl={provisioningUrl}
/>
);
};
story.add('QR code loading', () => (
<InstallScreenQrCodeNotScannedStep
i18n={i18n}
provisioningUrl={{
loadingState: LoadingState.Loading,
}}
/>
));
story.add('QR code failed to load', () => (
<InstallScreenQrCodeNotScannedStep
i18n={i18n}
provisioningUrl={{
loadingState: LoadingState.LoadFailed,
error: new Error('uh oh'),
}}
/>
));
story.add('QR code loaded', () => (
<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',
}}
/>
));
story.add('Simulated loading', () => (
<Simulation
finalResult={{
loadingState: LoadingState.Loaded,
value:
'https://example.com/fake-signal-link?uuid=56cdd548-e595-4962-9a27-3f1e8210a959&pub_key=SW4gdGhlIHZhc3QsIGRlZXAgZm9yZXN0IG9mIEh5cnVsZS4uLg%3D%3D',
}}
/>
));
story.add('Simulated failure', () => (
<Simulation
finalResult={{
loadingState: LoadingState.LoadFailed,
error: new Error('uh oh'),
}}
/>
));

View file

@ -0,0 +1,152 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement, ReactNode } from 'react';
import React, { useEffect, useRef } from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
import type { LocalizerType } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable';
import { Intl } from '../Intl';
import { Spinner } from '../Spinner';
import { TitlebarDragArea } from '../TitlebarDragArea';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
import { getClassNamesFor } from '../../util/getClassNamesFor';
// We can't always use destructuring assignment because of the complexity of this props
// type.
/* eslint-disable react/destructuring-assignment */
type PropsType = {
i18n: LocalizerType;
provisioningUrl: Loadable<string>;
};
// This should match the size in the CSS.
const QR_CODE_SIZE = 256;
const QR_CODE_FAILED_LINK =
'https://support.signal.org/hc/articles/360007320451#desktop_multiple_device';
const getQrCodeClassName = getClassNamesFor(
'module-InstallScreenQrCodeNotScannedStep__qr-code'
);
export const InstallScreenQrCodeNotScannedStep = ({
i18n,
provisioningUrl,
}: Readonly<PropsType>): ReactElement => (
<div className="module-InstallScreenQrCodeNotScannedStep">
<TitlebarDragArea />
<InstallScreenSignalLogo />
<div className="module-InstallScreenQrCodeNotScannedStep__contents">
<QrCode i18n={i18n} {...provisioningUrl} />
<div className="module-InstallScreenQrCodeNotScannedStep__instructions">
<h1>{i18n('Install__scan-this-code')}</h1>
<ol>
<li>{i18n('Install__instructions__1')}</li>
<li>
<Intl
i18n={i18n}
id="Install__instructions__2"
components={{
settings: (
<strong>{i18n('Install__instructions__2__settings')}</strong>
),
linkedDevices: <strong>{i18n('linkedDevices')}</strong>,
}}
/>
</li>
<li>
<Intl
i18n={i18n}
id="Install__instructions__3"
components={{
plusButton: (
<div
className="module-InstallScreenQrCodeNotScannedStep__android-plus"
aria-label="+"
/>
),
linkNewDevice: <strong>{i18n('linkNewDevice')}</strong>,
}}
/>
</li>
</ol>
</div>
</div>
</div>
);
function QrCode(
props: Loadable<string> & { i18n: LocalizerType }
): ReactElement {
const { i18n } = props;
const qrCodeElRef = useRef<null | HTMLDivElement>(null);
const valueToRender =
props.loadingState === LoadingState.Loaded ? props.value : undefined;
useEffect(() => {
const qrCodeEl = qrCodeElRef.current;
if (!qrCodeEl || !valueToRender) {
return noop;
}
const qrCode = new window.QRCode(qrCodeEl, {
text: valueToRender,
width: QR_CODE_SIZE * window.devicePixelRatio,
height: QR_CODE_SIZE * window.devicePixelRatio,
});
return qrCode.clear.bind(qrCode);
}, [valueToRender]);
let contents: ReactNode;
switch (props.loadingState) {
case LoadingState.Loading:
contents = <Spinner size="24px" svgSize="small" />;
break;
case LoadingState.LoadFailed:
contents = (
<span className={classNames(getQrCodeClassName('__error-message'))}>
<Intl
i18n={i18n}
id="Install__qr-failed"
components={[
<a href={QR_CODE_FAILED_LINK}>
{i18n('Install__qr-failed__learn-more')}
</a>,
]}
/>
</span>
);
break;
case LoadingState.Loaded:
contents = (
<div className={getQrCodeClassName('__code')} ref={qrCodeElRef} />
);
break;
default:
throw missingCaseError(props);
}
return (
<div
className={classNames(
getQrCodeClassName(''),
props.loadingState === LoadingState.Loaded &&
getQrCodeClassName('--loaded'),
props.loadingState === LoadingState.LoadFailed &&
getQrCodeClassName('--load-failed')
)}
>
{contents}
</div>
);
}

View file

@ -0,0 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
export const InstallScreenSignalLogo = (): ReactElement => (
// Because "Signal" should be the same in every language, this is not localized.
<div className="InstallScreenSignalLogo">Signal</div>
);