Support for GV1 -> GV2 migration

This commit is contained in:
Scott Nonnenberg 2020-11-20 09:30:45 -08:00 committed by Josh Perez
parent a0baa3e03f
commit 2c69f2c367
32 changed files with 2626 additions and 341 deletions

View file

@ -86,15 +86,9 @@ export class Avatar extends React.Component<Props, State> {
}
public renderNoImage(): JSX.Element {
const {
conversationType,
name,
noteToSelf,
profileName,
size,
} = this.props;
const { conversationType, noteToSelf, size, title } = this.props;
const initials = getInitials(name || profileName);
const initials = getInitials(title);
const isGroup = conversationType === 'group';
if (noteToSelf) {

View file

@ -337,8 +337,9 @@ export const CompositionArea = ({
}, [setLarge]);
if (
messageRequestsEnabled &&
(!acceptedMessageRequest || isBlocked || areWePending)
isBlocked ||
areWePending ||
(messageRequestsEnabled && !acceptedMessageRequest)
) {
return (
<MessageRequestActions

View file

@ -38,9 +38,9 @@ export type PropsData = {
isMe?: boolean;
muteExpiresAt?: number;
lastUpdated: number;
lastUpdated?: number;
unreadCount?: number;
markedUnread: boolean;
markedUnread?: boolean;
isSelected: boolean;
acceptedMessageRequest?: boolean;
@ -100,7 +100,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
isUnread(): boolean {
const { markedUnread, unreadCount } = this.props;
return (isNumber(unreadCount) && unreadCount > 0) || markedUnread;
return Boolean((isNumber(unreadCount) && unreadCount > 0) || markedUnread);
}
public renderUnread(): JSX.Element | null {

View file

@ -41,7 +41,7 @@ export const ErrorModal = (props: PropsType): JSX.Element => {
onClick={onClose}
ref={focusRef}
>
{buttonText || i18n('ErrorModal--buttonText')}
{buttonText || i18n('Confirmation--confirm')}
</button>
</div>
</ConfirmationModal>

View file

@ -0,0 +1,97 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { isBoolean } from 'lodash';
import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { GroupV1MigrationDialog, PropsType } from './GroupV1MigrationDialog';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const contact1 = {
title: 'Alice',
number: '+1 (300) 555-000',
id: 'guid-1',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
const contact2 = {
title: 'Bob',
number: '+1 (300) 555-000',
id: 'guid-1',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
function booleanOr(value: boolean | undefined, defaultValue: boolean): boolean {
return isBoolean(value) ? value : defaultValue;
}
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
droppedMembers: overrideProps.droppedMembers || [contact1],
hasMigrated: boolean(
'hasMigrated',
booleanOr(overrideProps.hasMigrated, false)
),
i18n,
invitedMembers: overrideProps.invitedMembers || [contact2],
learnMore: action('learnMore'),
migrate: action('migrate'),
onClose: action('onClose'),
});
const stories = storiesOf('Components/GroupV1MigrationDialog', module);
stories.add('Not yet migrated, basic', () => {
return <GroupV1MigrationDialog {...createProps()} />;
});
stories.add('Migrated, basic', () => {
return (
<GroupV1MigrationDialog
{...createProps({
hasMigrated: true,
})}
/>
);
});
stories.add('Not yet migrated, multiple dropped and invited members', () => {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMembers: [contact1, contact2],
invitedMembers: [contact1, contact2],
})}
/>
);
});
stories.add('Not yet migrated, no members', () => {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMembers: [],
invitedMembers: [],
})}
/>
);
});
stories.add('Not yet migrated, just dropped member', () => {
return (
<GroupV1MigrationDialog
{...createProps({
invitedMembers: [],
})}
/>
);
});

View file

@ -0,0 +1,180 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { Avatar } from './Avatar';
export type ActionSpec = {
text: string;
action: () => unknown;
style?: 'affirmative' | 'negative';
};
type CallbackType = () => unknown;
export type DataPropsType = {
readonly droppedMembers: Array<ConversationType>;
readonly hasMigrated: boolean;
readonly invitedMembers: Array<ConversationType>;
readonly learnMore: CallbackType;
readonly migrate: CallbackType;
readonly onClose: CallbackType;
};
export type HousekeepingPropsType = {
readonly i18n: LocalizerType;
};
export type PropsType = DataPropsType & HousekeepingPropsType;
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
const {
droppedMembers,
hasMigrated,
i18n,
invitedMembers,
learnMore,
migrate,
onClose,
} = props;
const title = hasMigrated
? i18n('GroupV1--Migration--info--title')
: i18n('GroupV1--Migration--migrate--title');
const keepHistory = hasMigrated
? i18n('GroupV1--Migration--info--keep-history')
: i18n('GroupV1--Migration--migrate--keep-history');
const migrationKey = hasMigrated ? 'after' : 'before';
const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`;
return (
<div className="module-group-v2-migration-dialog">
<button
aria-label={i18n('close')}
type="button"
className="module-group-v2-migration-dialog__close-button"
onClick={onClose}
/>
<div className="module-group-v2-migration-dialog__title">{title}</div>
<div className="module-group-v2-migration-dialog__scrollable">
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{i18n('GroupV1--Migration--info--summary')}
</div>
</div>
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{keepHistory}
</div>
</div>
{renderMembers(
invitedMembers,
'GroupV1--Migration--info--invited',
i18n
)}
{renderMembers(droppedMembers, droppedMembersKey, i18n)}
</div>
{renderButtons(hasMigrated, onClose, learnMore, migrate, i18n)}
</div>
);
});
function renderButtons(
hasMigrated: boolean,
onClose: CallbackType,
learnMore: CallbackType,
migrate: CallbackType,
i18n: LocalizerType
) {
if (hasMigrated) {
return (
<div
className={classNames(
'module-group-v2-migration-dialog__buttons',
'module-group-v2-migration-dialog__buttons--narrow'
)}
>
<button
className="module-group-v2-migration-dialog__button"
ref={focusRef}
type="button"
onClick={onClose}
>
{i18n('Confirmation--confirm')}
</button>
</div>
);
}
return (
<div className="module-group-v2-migration-dialog__buttons">
<button
className={classNames(
'module-group-v2-migration-dialog__button',
'module-group-v2-migration-dialog__button--secondary'
)}
type="button"
onClick={learnMore}
>
{i18n('GroupV1--Migration--learn-more')}
</button>
<button
className="module-group-v2-migration-dialog__button"
ref={focusRef}
type="button"
onClick={migrate}
>
{i18n('GroupV1--Migration--migrate')}
</button>
</div>
);
}
function renderMembers(
members: Array<ConversationType>,
prefix: string,
i18n: LocalizerType
): React.ReactElement | null {
if (!members.length) {
return null;
}
const postfix = members.length === 1 ? '--one' : '--many';
const key = `${prefix}${postfix}`;
return (
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
<div>{i18n(key)}</div>
{members.map(member => (
<div
key={member.id}
className="module-group-v2-migration-dialog__member"
>
<Avatar
{...member}
conversationType={member.type}
size={28}
i18n={i18n}
/>{' '}
<span className="module-group-v2-migration-dialog__member__name">
{member.title}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { createPortal } from 'react-dom';
export type PropsType = {
readonly onClose: () => unknown;
readonly children: React.ReactElement;
};
export const ModalHost = React.memo(({ onClose, children }: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return () => {
document.body.removeChild(div);
setRoot(null);
};
}, []);
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
event.preventDefault();
event.stopPropagation();
}
};
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler);
};
}, [onClose]);
// This makes it easier to write dialogs to be hosted here; they won't have to worry
// as much about preventing propagation of mouse events.
const handleCancel = React.useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
return root
? createPortal(
<div
role="presentation"
className="module-modal-host__overlay"
onClick={handleCancel}
>
{children}
</div>,
root
)
: null;
});

View file

@ -0,0 +1,78 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable-next-line max-classes-per-file */
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { GroupV1Migration, PropsType } from './GroupV1Migration';
const i18n = setupI18n('en', enMessages);
const contact1 = {
title: 'Alice',
number: '+1 (300) 555-000',
id: 'guid-1',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
const contact2 = {
title: 'Bob',
number: '+1 (300) 555-000',
id: 'guid-2',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
droppedMembers: overrideProps.droppedMembers || [contact1],
i18n,
invitedMembers: overrideProps.invitedMembers || [contact2],
});
const stories = storiesOf('Components/Conversation/GroupV1Migration', module);
stories.add('Single dropped and single invited member', () => (
<GroupV1Migration {...createProps()} />
));
stories.add('Multiple dropped and invited members', () => (
<GroupV1Migration
{...createProps({
invitedMembers: [contact1, contact2],
droppedMembers: [contact1, contact2],
})}
/>
));
stories.add('Just invited members', () => (
<GroupV1Migration
{...createProps({
invitedMembers: [contact1, contact1, contact2, contact2],
droppedMembers: [],
})}
/>
));
stories.add('Just dropped members', () => (
<GroupV1Migration
{...createProps({
invitedMembers: [],
droppedMembers: [contact1, contact1, contact2, contact2],
})}
/>
));
stories.add('No dropped or invited members', () => (
<GroupV1Migration
{...createProps({
invitedMembers: [],
droppedMembers: [],
})}
/>
));

View file

@ -0,0 +1,100 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { Intl } from '../Intl';
import { ContactName } from './ContactName';
import { ModalHost } from '../ModalHost';
import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog';
export type PropsDataType = {
droppedMembers: Array<ConversationType>;
invitedMembers: Array<ConversationType>;
};
export type PropsHousekeepingType = {
i18n: LocalizerType;
};
export type PropsType = PropsDataType & PropsHousekeepingType;
export function GroupV1Migration(props: PropsType): React.ReactElement {
const { droppedMembers, i18n, invitedMembers } = props;
const [showingDialog, setShowingDialog] = React.useState(false);
const showDialog = React.useCallback(() => {
setShowingDialog(true);
}, [setShowingDialog]);
const dismissDialog = React.useCallback(() => {
setShowingDialog(false);
}, [setShowingDialog]);
return (
<div className="module-group-v1-migration">
<div className="module-group-v1-migration--icon" />
<div className="module-group-v1-migration--text">
{i18n('GroupV1--Migration--was-upgraded')}
</div>
{renderUsers(invitedMembers, i18n, 'GroupV1--Migration--invited')}
{renderUsers(droppedMembers, i18n, 'GroupV1--Migration--removed')}
<button
type="button"
className="module-group-v1-migration--button"
onClick={showDialog}
>
{i18n('GroupV1--Migration--learn-more')}
</button>
{showingDialog ? (
<ModalHost onClose={dismissDialog}>
<GroupV1MigrationDialog
droppedMembers={droppedMembers}
hasMigrated
i18n={i18n}
invitedMembers={invitedMembers}
learnMore={() =>
window.log.warn('GroupV1Migration: Modal called learnMore()')
}
migrate={() =>
window.log.warn('GroupV1Migration: Modal called migrate()')
}
onClose={dismissDialog}
/>
</ModalHost>
) : null}
</div>
);
}
function renderUsers(
members: Array<ConversationType>,
i18n: LocalizerType,
keyPrefix: string
): React.ReactElement | null {
if (!members || members.length === 0) {
return null;
}
const className = 'module-group-v1-migration--text';
if (members.length === 1) {
return (
<div className={className}>
<Intl
i18n={i18n}
id={`${keyPrefix}--one`}
components={[<ContactName title={members[0].title} i18n={i18n} />]}
/>
</div>
);
}
return (
<div className={className}>
{i18n(`${keyPrefix}--many`, [members.length.toString()])}
</div>
);
}

View file

@ -42,6 +42,10 @@ import {
GroupV2Change,
PropsDataType as GroupV2ChangeProps,
} from './GroupV2Change';
import {
GroupV1Migration,
PropsDataType as GroupV1MigrationProps,
} from './GroupV1Migration';
import { SmartContactRendererType } from '../../groupChange';
import { ResetSessionNotification } from './ResetSessionNotification';
import {
@ -85,6 +89,10 @@ type GroupV2ChangeType = {
type: 'groupV2Change';
data: GroupV2ChangeProps;
};
type GroupV1MigrationType = {
type: 'groupV1Migration';
data: GroupV1MigrationProps;
};
type ResetSessionNotificationType = {
type: 'resetSessionNotification';
data: null;
@ -97,6 +105,7 @@ type ProfileChangeNotificationType = {
export type TimelineItemType =
| CallHistoryType
| GroupNotificationType
| GroupV1MigrationType
| GroupV2ChangeType
| LinkNotificationType
| MessageType
@ -187,6 +196,10 @@ export class TimelineItem extends React.PureComponent<PropsType> {
i18n={i18n}
/>
);
} else if (item.type === 'groupV1Migration') {
notification = (
<GroupV1Migration {...this.props} {...item.data} i18n={i18n} />
);
} else if (item.type === 'resetSessionNotification') {
notification = (
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />