Add support for ACI safety numbers behind a feature flag

This commit is contained in:
Fedor Indutny 2023-07-13 21:06:42 +02:00 committed by Fedor Indutnyy
parent 42cd8ce792
commit c1580a5eb3
38 changed files with 1392 additions and 204 deletions

View file

@ -12,7 +12,7 @@ const ERROR_CORRECTION_LEVEL = 'L';
type PropsType = Readonly<{
alt: string;
className?: string;
data: string;
data: string | Uint8Array;
}>;
export function QrCode(props: PropsType): ReactElement {
@ -37,6 +37,9 @@ export function QrCode(props: PropsType): ReactElement {
if (getEnvironment() === Environment.Production) {
return;
}
if (data instanceof Uint8Array) {
return;
}
void navigator.clipboard.writeText(data);

View file

@ -1,34 +1,81 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useState, useCallback } from 'react';
import { SafetyNumberMode } from '../types/safetyNumber';
import { isSafetyNumberNotAvailable } from '../util/isSafetyNumberNotAvailable';
import { Modal } from './Modal';
import type { PropsType as SafetyNumberViewerPropsType } from './SafetyNumberViewer';
import { SafetyNumberViewer } from './SafetyNumberViewer';
import { SafetyNumberOnboarding } from './SafetyNumberOnboarding';
import { SafetyNumberNotReady } from './SafetyNumberNotReady';
type PropsType = {
toggleSafetyNumberModal: () => unknown;
hasCompletedSafetyNumberOnboarding: boolean;
markHasCompletedSafetyNumberOnboarding: () => unknown;
} & Omit<SafetyNumberViewerPropsType, 'onClose'>;
export function SafetyNumberModal({
i18n,
toggleSafetyNumberModal,
hasCompletedSafetyNumberOnboarding,
markHasCompletedSafetyNumberOnboarding,
...safetyNumberViewerProps
}: PropsType): JSX.Element | null {
return (
<Modal
modalName="SafetyNumberModal"
hasXButton
i18n={i18n}
moduleClassName="module-SafetyNumberViewer__modal"
onClose={toggleSafetyNumberModal}
title={i18n('icu:SafetyNumberModal__title')}
>
const { contact, safetyNumberMode } = safetyNumberViewerProps;
const [isOnboarding, setIsOnboarding] = useState(
safetyNumberMode === SafetyNumberMode.ACIAndE164 &&
!hasCompletedSafetyNumberOnboarding
);
const showOnboarding = useCallback(() => {
setIsOnboarding(true);
}, [setIsOnboarding]);
const hideOnboarding = useCallback(() => {
setIsOnboarding(false);
markHasCompletedSafetyNumberOnboarding();
}, [setIsOnboarding, markHasCompletedSafetyNumberOnboarding]);
let title: string | undefined;
let content: JSX.Element;
let hasXButton = true;
if (isSafetyNumberNotAvailable(contact)) {
content = (
<SafetyNumberNotReady
i18n={i18n}
onClose={() => toggleSafetyNumberModal()}
/>
);
hasXButton = false;
} else if (isOnboarding) {
content = <SafetyNumberOnboarding i18n={i18n} onClose={hideOnboarding} />;
} else {
title = i18n('icu:SafetyNumberModal__title');
content = (
<SafetyNumberViewer
i18n={i18n}
onClose={toggleSafetyNumberModal}
showOnboarding={showOnboarding}
{...safetyNumberViewerProps}
/>
);
}
return (
<Modal
modalName="SafetyNumberModal"
hasXButton={hasXButton}
i18n={i18n}
moduleClassName="module-SafetyNumberViewer__modal"
onClose={toggleSafetyNumberModal}
title={title}
>
{content}
</Modal>
);
}

View file

@ -0,0 +1,23 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { SafetyNumberNotReady } from './SafetyNumberNotReady';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/SafetyNumberNotReady',
};
export function Default(): JSX.Element {
return <SafetyNumberNotReady i18n={i18n} onClose={action('close')} />;
}
Default.story = {
name: 'Safety Number Not Ready',
};

View file

@ -0,0 +1,42 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Button, ButtonVariant } from './Button';
import { Modal } from './Modal';
import { Intl } from './Intl';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
import type { LocalizerType } from '../types/Util';
import { SAFETY_NUMBER_MIGRATION_URL } from '../types/support';
export type PropsType = {
i18n: LocalizerType;
onClose: () => void;
};
function onLearnMore() {
openLinkInWebBrowser(SAFETY_NUMBER_MIGRATION_URL);
}
export function SafetyNumberNotReady({
i18n,
onClose,
}: PropsType): JSX.Element | null {
return (
<div className="module-SafetyNumberNotReady">
<div>
<Intl i18n={i18n} id="icu:SafetyNumberNotReady__body" />
</div>
<Modal.ButtonFooter>
<Button onClick={onLearnMore} variant={ButtonVariant.Secondary}>
<Intl i18n={i18n} id="icu:SafetyNumberNotReady__learn-more" />
</Button>
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
<Intl i18n={i18n} id="icu:ok" />
</Button>
</Modal.ButtonFooter>
</div>
);
}

View file

@ -0,0 +1,23 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { SafetyNumberOnboarding } from './SafetyNumberOnboarding';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/SafetyNumberOnboarding',
};
export function Default(): JSX.Element {
return <SafetyNumberOnboarding i18n={i18n} onClose={action('close')} />;
}
Default.story = {
name: 'Safety Number Onboarding',
};

View file

@ -0,0 +1,78 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useRef } from 'react';
import Lottie from 'lottie-react';
import type { LottieRefCurrentProps } from 'lottie-react';
import { Button, ButtonVariant } from './Button';
import { Intl } from './Intl';
import type { LocalizerType } from '../types/Util';
import { SAFETY_NUMBER_MIGRATION_URL } from '../types/support';
import { useReducedMotion } from '../hooks/useReducedMotion';
import animationData from '../../images/safety-number-onboarding.json';
import reducedAnimationData from '../../images/safety-number-onboarding-reduced-motion.json';
export type PropsType = {
i18n: LocalizerType;
onClose: () => void;
};
export function SafetyNumberOnboarding({
i18n,
onClose,
}: PropsType): JSX.Element | null {
const isMotionReduced = useReducedMotion();
const lottieRef = useRef<LottieRefCurrentProps | null>(null);
const onDOMLoaded = useCallback(() => {
if (isMotionReduced) {
lottieRef.current?.goToAndPlay(0);
return;
}
lottieRef.current?.playSegments(
[
[0, 360],
[60, 360],
],
true
);
}, [isMotionReduced]);
return (
<div className="module-SafetyNumberOnboarding">
<h2>
<Intl i18n={i18n} id="icu:SafetyNumberOnboarding__title" />
</h2>
<p>
<Intl i18n={i18n} id="icu:SafetyNumberOnboarding__p1" />
</p>
<p>
<Intl i18n={i18n} id="icu:SafetyNumberOnboarding__p2" />
</p>
<Lottie
lottieRef={lottieRef}
animationData={isMotionReduced ? reducedAnimationData : animationData}
onDOMLoaded={onDOMLoaded}
/>
<div className="module-SafetyNumberOnboarding__help">
<a
key="signal-support"
href={SAFETY_NUMBER_MIGRATION_URL}
rel="noreferrer"
target="_blank"
>
<Intl i18n={i18n} id="icu:SafetyNumberOnboarding__help" />
</a>
</div>
<Button
className="module-SafetyNumberOnboarding__close"
onClick={onClose}
variant={ButtonVariant.Primary}
>
<Intl i18n={i18n} id="icu:SafetyNumberOnboarding__close" />
</Button>
</div>
);
}

View file

@ -8,9 +8,33 @@ import { boolean, text } from '@storybook/addon-knobs';
import type { PropsType } from './SafetyNumberViewer';
import { SafetyNumberViewer } from './SafetyNumberViewer';
import { setupI18n } from '../util/setupI18n';
import {
SafetyNumberIdentifierType,
SafetyNumberMode,
} from '../types/safetyNumber';
import enMessages from '../../_locales/en/messages.json';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
function generateQRData() {
const data = new Uint8Array(128);
for (let i = 0; i < data.length; i += 1) {
data[i] = Math.floor(Math.random() * 256);
}
return data;
}
function generateNumberBlocks() {
const result = new Array<string>();
for (let i = 0; i < 12; i += 1) {
let digits = '';
for (let j = 0; j < 5; j += 1) {
digits += Math.floor(Math.random() * 10);
}
result.push(digits);
}
return result;
}
const i18n = setupI18n('en', enMessages);
const contactWithAllData = getDefaultConversation({
@ -49,7 +73,17 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
contact: overrideProps.contact || contactWithAllData,
generateSafetyNumber: action('generate-safety-number'),
i18n,
safetyNumber: text('safetyNumber', overrideProps.safetyNumber || 'XXX'),
safetyNumberMode: overrideProps.safetyNumberMode ?? SafetyNumberMode.ACI,
safetyNumbers: overrideProps.safetyNumbers ?? [
{
identifierType: SafetyNumberIdentifierType.ACIIdentifier,
numberBlocks: text(
'safetyNumber',
generateNumberBlocks().join(' ')
).split(' '),
qrData: generateQRData(),
},
],
toggleVerified: action('toggle-verified'),
verificationDisabled: boolean(
'verificationDisabled',
@ -68,6 +102,56 @@ export function SafetyNumber(): JSX.Element {
return <SafetyNumberViewer {...createProps({})} />;
}
export function SafetyNumberBeforeE164Transition(): JSX.Element {
return (
<SafetyNumberViewer
{...createProps({
safetyNumberMode: SafetyNumberMode.E164,
safetyNumbers: [
{
identifierType: SafetyNumberIdentifierType.E164Identifier,
numberBlocks: text(
'safetyNumber',
generateNumberBlocks().join(' ')
).split(' '),
qrData: generateQRData(),
},
],
})}
/>
);
}
SafetyNumberBeforeE164Transition.story = {
name: 'Safety Number (before e164 transition)',
};
export function SafetyNumberE164Transition(): JSX.Element {
return (
<SafetyNumberViewer
{...createProps({
safetyNumberMode: SafetyNumberMode.ACIAndE164,
safetyNumbers: [
{
identifierType: SafetyNumberIdentifierType.E164Identifier,
numberBlocks: generateNumberBlocks(),
qrData: generateQRData(),
},
{
identifierType: SafetyNumberIdentifierType.ACIIdentifier,
numberBlocks: generateNumberBlocks(),
qrData: generateQRData(),
},
],
})}
/>
);
}
SafetyNumberE164Transition.story = {
name: 'Safety Number (e164 transition)',
};
export function SafetyNumberNotVerified(): JSX.Element {
return (
<SafetyNumberViewer

View file

@ -1,19 +1,31 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import { Button, ButtonVariant } from './Button';
import { QrCode } from './QrCode';
import type { ConversationType } from '../state/ducks/conversations';
import { Intl } from './Intl';
import { Emojify } from './conversation/Emojify';
import type { LocalizerType } from '../types/Util';
import type { SafetyNumberType } from '../types/safetyNumber';
import { SAFETY_NUMBER_MIGRATION_URL } from '../types/support';
import {
SafetyNumberIdentifierType,
SafetyNumberMode,
} from '../types/safetyNumber';
export type PropsType = {
contact: ConversationType;
generateSafetyNumber: (contact: ConversationType) => void;
i18n: LocalizerType;
onClose: () => void;
safetyNumber: string;
safetyNumberMode: SafetyNumberMode;
safetyNumbers?: ReadonlyArray<SafetyNumberType>;
toggleVerified: (contact: ConversationType) => void;
showOnboarding?: () => void;
verificationDisabled: boolean;
};
@ -22,23 +34,28 @@ export function SafetyNumberViewer({
generateSafetyNumber,
i18n,
onClose,
safetyNumber,
safetyNumberMode,
safetyNumbers,
toggleVerified,
showOnboarding,
verificationDisabled,
}: PropsType): JSX.Element | null {
const hasSafetyNumbers = safetyNumbers != null;
React.useEffect(() => {
if (!contact) {
return;
}
generateSafetyNumber(contact);
}, [contact, generateSafetyNumber, safetyNumber]);
}, [contact, generateSafetyNumber]);
if (!contact) {
const [selectedIndex, setSelectedIndex] = useState(0);
if (!contact || !hasSafetyNumbers) {
return null;
}
if (!contact.phoneNumber) {
if (!safetyNumbers.length) {
return (
<div className="module-SafetyNumberViewer">
<div>{i18n('icu:cannotGenerateSafetyNumber')}</div>
@ -55,12 +72,10 @@ export function SafetyNumberViewer({
);
}
const showNumber = Boolean(contact.name || contact.profileName);
const numberFragment =
showNumber && contact.phoneNumber ? ` · ${contact.phoneNumber}` : '';
const name = `${contact.title}${numberFragment}`;
const boldName = (
<span className="module-SafetyNumberViewer__bold-name">{name}</span>
<span className="module-SafetyNumberViewer__bold-name">
<Emojify text={contact.title} />
</span>
);
const { isVerified } = contact;
@ -68,32 +83,136 @@ export function SafetyNumberViewer({
? i18n('icu:SafetyNumberViewer__clearVerification')
: i18n('icu:SafetyNumberViewer__markAsVerified');
const isMigrationVisible = safetyNumberMode === SafetyNumberMode.ACIAndE164;
const visibleSafetyNumber = safetyNumbers.at(selectedIndex);
if (!visibleSafetyNumber) {
return null;
}
const cardClassName = classNames('module-SafetyNumberViewer__card', {
'module-SafetyNumberViewer__card--aci':
visibleSafetyNumber.identifierType ===
SafetyNumberIdentifierType.ACIIdentifier,
'module-SafetyNumberViewer__card--e164':
visibleSafetyNumber.identifierType ===
SafetyNumberIdentifierType.E164Identifier,
});
const numberBlocks = visibleSafetyNumber.numberBlocks.join(' ');
const safetyNumberCard = (
<div className="module-SafetyNumberViewer__card-container">
<div className={cardClassName}>
<QrCode
className="module-SafetyNumberViewer__card__qr"
data={visibleSafetyNumber.qrData}
alt={i18n('icu:Install__scan-this-code')}
/>
<div className="module-SafetyNumberViewer__card__number">
{numberBlocks}
</div>
{selectedIndex > 0 && (
<button
type="button"
aria-label={i18n('icu:SafetyNumberViewer__card__prev')}
className="module-SafetyNumberViewer__card__prev"
onClick={() => setSelectedIndex(x => x - 1)}
/>
)}
{selectedIndex < safetyNumbers.length - 1 && (
<button
type="button"
aria-label={i18n('icu:SafetyNumberViewer__card__next')}
className="module-SafetyNumberViewer__card__next"
onClick={() => setSelectedIndex(x => x + 1)}
/>
)}
</div>
</div>
);
const carousel = (
<div className="module-SafetyNumberViewer__carousel">
{safetyNumbers.map(({ identifierType }, index) => {
return (
<button
type="button"
aria-label={i18n('icu:SafetyNumberViewer__carousel__dot', {
index: index + 1,
total: safetyNumbers.length,
})}
aria-pressed={index === selectedIndex}
key={identifierType}
className="module-SafetyNumberViewer__carousel__dot"
onClick={() => setSelectedIndex(index)}
/>
);
})}
</div>
);
return (
<div className="module-SafetyNumberViewer">
<div className="module-SafetyNumberViewer__number">
{safetyNumber || getPlaceholder()}
</div>
<Intl i18n={i18n} id="icu:verifyHelp" components={{ name: boldName }} />
<div className="module-SafetyNumberViewer__verification-status">
{isVerified ? (
<span className="module-SafetyNumberViewer__icon--verified" />
) : (
<span className="module-SafetyNumberViewer__icon--shield" />
)}
{isVerified ? (
{isMigrationVisible && (
<div className="module-SafetyNumberViewer__migration">
<div className="module-SafetyNumberViewer__migration__icon" />
<div className="module-SafetyNumberViewer__migration__text">
<p>
<Intl i18n={i18n} id="icu:SafetyNumberViewer__migration__text" />
</p>
<p>
<a
href={SAFETY_NUMBER_MIGRATION_URL}
rel="noreferrer"
target="_blank"
onClick={e => {
if (showOnboarding) {
e.preventDefault();
showOnboarding();
}
}}
>
<Intl
i18n={i18n}
id="icu:SafetyNumberViewer__migration__learn_more"
/>
</a>
</p>
</div>
</div>
)}
{safetyNumberCard}
{safetyNumbers.length > 1 && carousel}
<div className="module-SafetyNumberViewer__help">
{isMigrationVisible ? (
<Intl
i18n={i18n}
id="icu:isVerified"
id="icu:SafetyNumberViewer__hint--migration"
components={{ name: boldName }}
/>
) : (
<Intl
i18n={i18n}
id="icu:isNotVerified"
id="icu:SafetyNumberViewer__hint--normal"
components={{ name: boldName }}
/>
)}
<br />
<a href={SAFETY_NUMBER_MIGRATION_URL} rel="noreferrer" target="_blank">
<Intl
i18n={i18n}
id="icu:SafetyNumberViewer__migration__learn_more"
/>
</a>
</div>
<div className="module-SafetyNumberViewer__button">
<Button
disabled={verificationDisabled}
@ -108,9 +227,3 @@ export function SafetyNumberViewer({
</div>
);
}
function getPlaceholder(): string {
return Array.from(Array(12))
.map(() => 'XXXXX')
.join(' ');
}