Support for GV1 -> GV2 migration
This commit is contained in:
parent
a0baa3e03f
commit
2c69f2c367
32 changed files with 2626 additions and 341 deletions
|
@ -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) {
|
||||
|
|
|
@ -337,8 +337,9 @@ export const CompositionArea = ({
|
|||
}, [setLarge]);
|
||||
|
||||
if (
|
||||
messageRequestsEnabled &&
|
||||
(!acceptedMessageRequest || isBlocked || areWePending)
|
||||
isBlocked ||
|
||||
areWePending ||
|
||||
(messageRequestsEnabled && !acceptedMessageRequest)
|
||||
) {
|
||||
return (
|
||||
<MessageRequestActions
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
97
ts/components/GroupV1MigrationDialog.stories.tsx
Normal file
97
ts/components/GroupV1MigrationDialog.stories.tsx
Normal 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: [],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
180
ts/components/GroupV1MigrationDialog.tsx
Normal file
180
ts/components/GroupV1MigrationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
65
ts/components/ModalHost.tsx
Normal file
65
ts/components/ModalHost.tsx
Normal 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;
|
||||
});
|
78
ts/components/conversation/GroupV1Migration.stories.tsx
Normal file
78
ts/components/conversation/GroupV1Migration.stories.tsx
Normal 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: [],
|
||||
})}
|
||||
/>
|
||||
));
|
100
ts/components/conversation/GroupV1Migration.tsx
Normal file
100
ts/components/conversation/GroupV1Migration.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue