New feature flag with ability to migrate GV1 groups

This commit is contained in:
Scott Nonnenberg 2020-12-01 08:42:35 -08:00 committed by GitHub
parent 089a6fb5a2
commit 2b8ae412e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 608 additions and 189 deletions

View file

@ -30,7 +30,7 @@ jobs:
- run: yarn generate - run: yarn generate
- run: yarn lint - run: yarn lint
- run: yarn lint-deps - run: yarn lint-deps
- run: git diff --quiet --exit-code - run: git diff --exit-code
macos: macos:
needs: lint needs: lint

View file

@ -3968,6 +3968,16 @@
} }
} }
}, },
"GroupV1--Migration--disabled": {
"message": "Upgrade this group to activate new features like @mentions and admins. Members who have not shared their name or photo in this group will be invited to join. $learnMore$",
"description": "Shown instead of composition area when user is forced to migrate a legacy group (GV1).",
"placeholders": {
"learnMore": {
"content": "$1",
"example": "Learn more."
}
}
},
"GroupV1--Migration--was-upgraded": { "GroupV1--Migration--was-upgraded": {
"message": "This group was upgraded to a New Group.", "message": "This group was upgraded to a New Group.",
"description": "Shown in timeline when a legacy group (GV1) is upgraded to a new group (GV2)" "description": "Shown in timeline when a legacy group (GV1) is upgraded to a new group (GV2)"

View file

@ -73,6 +73,9 @@ const {
createConversationHeader, createConversationHeader,
} = require('../../ts/state/roots/createConversationHeader'); } = require('../../ts/state/roots/createConversationHeader');
const { createCallManager } = require('../../ts/state/roots/createCallManager'); const { createCallManager } = require('../../ts/state/roots/createCallManager');
const {
createGroupV1MigrationModal,
} = require('../../ts/state/roots/createGroupV1MigrationModal');
const { createLeftPane } = require('../../ts/state/roots/createLeftPane'); const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
const { const {
createSafetyNumberViewer, createSafetyNumberViewer,
@ -326,6 +329,7 @@ exports.setup = (options = {}) => {
createCompositionArea, createCompositionArea,
createContactModal, createContactModal,
createConversationHeader, createConversationHeader,
createGroupV1MigrationModal,
createLeftPane, createLeftPane,
createSafetyNumberViewer, createSafetyNumberViewer,
createShortcutGuideModal, createShortcutGuideModal,

View file

@ -6983,6 +6983,10 @@ button.module-image__border-overlay:focus {
overflow: hidden; overflow: hidden;
} }
.module-timeline--disabled {
user-select: none;
}
.module-timeline__message-container { .module-timeline__message-container {
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
@ -9834,6 +9838,59 @@ button.module-image__border-overlay:focus {
@include button-secondary-blue-text; @include button-secondary-blue-text;
} }
// Module: GroupV1 Disabled Actions
.module-group-v1-disabled-actions {
padding: 8px 16px 12px 16px;
max-width: 650px;
margin-left: auto;
margin-right: auto;
@include light-theme {
background: $color-white;
}
@include dark-theme {
background: $color-gray-95;
}
}
.module-group-v1-disabled-actions__message {
@include font-body-2;
text-align: center;
margin-bottom: 12px;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
.module-group-v1-disabled-actions__message__learn-more {
text-decoration: none;
}
.module-group-v1-disabled-actions__buttons {
display: flex;
flex-direction: row;
justify-content: center;
}
.module-group-v1-disabled-actions__buttons__button {
@include button-reset;
@include font-body-1-bold;
border-radius: 4px;
padding: 8px;
padding-left: 30px;
padding-right: 30px;
@include button-primary;
}
// Module: Modal Host // Module: Modal Host
.module-modal-host__overlay { .module-modal-host__overlay {

View file

@ -7,11 +7,14 @@ import { WebAPIType } from './textsecure/WebAPI';
type ConfigKeyType = type ConfigKeyType =
| 'desktop.cds' | 'desktop.cds'
| 'desktop.clientExpiration' | 'desktop.clientExpiration'
| 'desktop.disableGV1'
| 'desktop.gv2' | 'desktop.gv2'
| 'desktop.mandatoryProfileSharing' | 'desktop.mandatoryProfileSharing'
| 'desktop.messageRequests' | 'desktop.messageRequests'
| 'desktop.storage' | 'desktop.storage'
| 'desktop.storageWrite'; | 'desktop.storageWrite'
| 'global.groupsv2.maxGroupSize'
| 'global.groupsv2.groupSizeHardLimit';
type ConfigValueType = { type ConfigValueType = {
name: ConfigKeyType; name: ConfigKeyType;
enabled: boolean; enabled: boolean;
@ -112,3 +115,7 @@ export const maybeRefreshRemoteConfig = throttle(
export function isEnabled(name: ConfigKeyType): boolean { export function isEnabled(name: ConfigKeyType): boolean {
return get(config, [name, 'enabled'], false); return get(config, [name, 'enabled'], false);
} }
export function getValue(name: ConfigKeyType): string | undefined {
return get(config, [name, 'value'], undefined);
}

View file

@ -69,6 +69,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
overrideProps.messageRequestsEnabled || false overrideProps.messageRequestsEnabled || false
), ),
title: '', title: '',
// GroupV1 Disabled Actions
onStartGroupMigration: action('onStartGroupMigration'),
}); });
story.add('Default', () => { story.add('Default', () => {

View file

@ -18,6 +18,10 @@ import {
MessageRequestActions, MessageRequestActions,
Props as MessageRequestActionsProps, Props as MessageRequestActionsProps,
} from './conversation/MessageRequestActions'; } from './conversation/MessageRequestActions';
import {
GroupV1DisabledActions,
PropsType as GroupV1DisabledActionsPropsType,
} from './conversation/GroupV1DisabledActions';
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions'; import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
import { countStickers } from './stickers/lib'; import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
@ -27,6 +31,7 @@ export type OwnProps = {
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly areWePending?: boolean; readonly areWePending?: boolean;
readonly groupVersion?: 1 | 2; readonly groupVersion?: 1 | 2;
readonly isGroupV1AndDisabled?: boolean;
readonly isMissingMandatoryProfileSharing?: boolean; readonly isMissingMandatoryProfileSharing?: boolean;
readonly messageRequestsEnabled?: boolean; readonly messageRequestsEnabled?: boolean;
readonly acceptedMessageRequest?: boolean; readonly acceptedMessageRequest?: boolean;
@ -77,6 +82,7 @@ export type Props = Pick<
| 'clearShowPickerHint' | 'clearShowPickerHint'
> & > &
MessageRequestActionsProps & MessageRequestActionsProps &
Pick<GroupV1DisabledActionsPropsType, 'onStartGroupMigration'> &
OwnProps; OwnProps;
const emptyElement = (el: HTMLElement) => { const emptyElement = (el: HTMLElement) => {
@ -135,6 +141,9 @@ export const CompositionArea = ({
phoneNumber, phoneNumber,
profileName, profileName,
title, title,
// GroupV1 Disabled Actions
isGroupV1AndDisabled,
onStartGroupMigration,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [disabled, setDisabled] = React.useState(false); const [disabled, setDisabled] = React.useState(false);
const [showMic, setShowMic] = React.useState(!draftText); const [showMic, setShowMic] = React.useState(!draftText);
@ -381,6 +390,16 @@ export const CompositionArea = ({
); );
} }
// If this is a V1 group, now disabled entirely, we show UI to help them upgrade
if (isGroupV1AndDisabled) {
return (
<GroupV1DisabledActions
i18n={i18n}
onStartGroupMigration={onStartGroupMigration}
/>
);
}
return ( return (
<div className="module-composition-area"> <div className="module-composition-area">
<div className="module-composition-area__toggle-large"> <div className="module-composition-area__toggle-large">

View file

@ -570,6 +570,13 @@ export const CompositionInput: React.ComponentType<Props> = props => {
[] []
); );
// The onClick handler below is only to make it easier for mouse users to focus the
// message box. In 'large' mode, the actual Quill text box can be one line while the
// visual text box is much larger. Clicking that should allow you to start typing,
// hence the click handler.
// eslint-disable-next-line max-len
/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
return ( return (
<Manager> <Manager>
<Reference> <Reference>
@ -577,6 +584,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
<div className="module-composition-input__input" ref={ref}> <div className="module-composition-input__input" ref={ref}>
<div <div
ref={scrollerRef} ref={scrollerRef}
onClick={focus}
className={classNames( className={classNames(
'module-composition-input__input__scroller', 'module-composition-input__input__scroller',
large large

View file

@ -43,7 +43,6 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
), ),
i18n, i18n,
invitedMembers: overrideProps.invitedMembers || [contact2], invitedMembers: overrideProps.invitedMembers || [contact2],
learnMore: action('learnMore'),
migrate: action('migrate'), migrate: action('migrate'),
onClose: action('onClose'), onClose: action('onClose'),
}); });

View file

@ -19,7 +19,6 @@ export type DataPropsType = {
readonly droppedMembers: Array<ConversationType>; readonly droppedMembers: Array<ConversationType>;
readonly hasMigrated: boolean; readonly hasMigrated: boolean;
readonly invitedMembers: Array<ConversationType>; readonly invitedMembers: Array<ConversationType>;
readonly learnMore: CallbackType;
readonly migrate: CallbackType; readonly migrate: CallbackType;
readonly onClose: CallbackType; readonly onClose: CallbackType;
}; };
@ -42,7 +41,6 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
hasMigrated, hasMigrated,
i18n, i18n,
invitedMembers, invitedMembers,
learnMore,
migrate, migrate,
onClose, onClose,
} = props; } = props;
@ -85,7 +83,7 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
)} )}
{renderMembers(droppedMembers, droppedMembersKey, i18n)} {renderMembers(droppedMembers, droppedMembersKey, i18n)}
</div> </div>
{renderButtons(hasMigrated, onClose, learnMore, migrate, i18n)} {renderButtons(hasMigrated, onClose, migrate, i18n)}
</div> </div>
); );
}); });
@ -93,7 +91,6 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
function renderButtons( function renderButtons(
hasMigrated: boolean, hasMigrated: boolean,
onClose: CallbackType, onClose: CallbackType,
learnMore: CallbackType,
migrate: CallbackType, migrate: CallbackType,
i18n: LocalizerType i18n: LocalizerType
) { ) {
@ -125,9 +122,9 @@ function renderButtons(
'module-group-v2-migration-dialog__button--secondary' 'module-group-v2-migration-dialog__button--secondary'
)} )}
type="button" type="button"
onClick={learnMore} onClick={onClose}
> >
{i18n('GroupV1--Migration--learn-more')} {i18n('cancel')}
</button> </button>
<button <button
className="module-group-v2-migration-dialog__button" className="module-group-v2-migration-dialog__button"

View file

@ -0,0 +1,29 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import {
GroupV1DisabledActions,
PropsType as GroupV1DisabledActionsPropsType,
} from './GroupV1DisabledActions';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const createProps = (): GroupV1DisabledActionsPropsType => ({
i18n,
onStartGroupMigration: action('onStartGroupMigration'),
});
const stories = storiesOf(
'Components/Conversation/GroupV1DisabledActions',
module
);
stories.add('Default', () => {
return <GroupV1DisabledActions {...createProps()} />;
});

View file

@ -0,0 +1,49 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
export type PropsType = {
i18n: LocalizerType;
onStartGroupMigration: () => unknown;
};
export const GroupV1DisabledActions = ({
i18n,
onStartGroupMigration,
}: PropsType): JSX.Element => {
return (
<div className="module-group-v1-disabled-actions">
<p className="module-group-v1-disabled-actions__message">
<Intl
i18n={i18n}
id="GroupV1--Migration--disabled"
components={{
learnMore: (
<a
href="https://support.signal.org/hc/articles/360007319331"
target="_blank"
rel="noreferrer"
className="module-group-v1-disabled-actions__message__learn-more"
>
{i18n('MessageRequests--learn-more')}
</a>
),
}}
/>
</p>
<div className="module-group-v1-disabled-actions__buttons">
<button
type="button"
onClick={onStartGroupMigration}
tabIndex={0}
className="module-group-v1-disabled-actions__buttons__button"
>
{i18n('MessageRequests--continue')}
</button>
</div>
</div>
);
};

View file

@ -55,9 +55,6 @@ export function GroupV1Migration(props: PropsType): React.ReactElement {
hasMigrated hasMigrated
i18n={i18n} i18n={i18n}
invitedMembers={invitedMembers} invitedMembers={invitedMembers}
learnMore={() =>
window.log.warn('GroupV1Migration: Modal called learnMore()')
}
migrate={() => migrate={() =>
window.log.warn('GroupV1Migration: Modal called migrate()') window.log.warn('GroupV1Migration: Modal called migrate()')
} }

View file

@ -8,7 +8,7 @@ import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import { Props, Timeline } from './Timeline'; import { PropsType, Timeline } from './Timeline';
import { TimelineItem, TimelineItemType } from './TimelineItem'; import { TimelineItem, TimelineItemType } from './TimelineItem';
import { LastSeenIndicator } from './LastSeenIndicator'; import { LastSeenIndicator } from './LastSeenIndicator';
import { TimelineLoadingRow } from './TimelineLoadingRow'; import { TimelineLoadingRow } from './TimelineLoadingRow';
@ -278,7 +278,7 @@ const renderTypingBubble = () => (
/> />
); );
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n, i18n,
haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false), haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false),

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { debounce, get, isNumber } from 'lodash'; import { debounce, get, isNumber } from 'lodash';
import classNames from 'classnames';
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { import {
AutoSizer, AutoSizer,
@ -44,6 +45,8 @@ type PropsHousekeepingType = {
id: string; id: string;
unreadCount?: number; unreadCount?: number;
typingContact?: unknown; typingContact?: unknown;
isGroupV1AndDisabled?: boolean;
selectedMessageId?: string; selectedMessageId?: string;
i18n: LocalizerType; i18n: LocalizerType;
@ -82,7 +85,9 @@ type PropsActionsType = {
} & MessageActionsType & } & MessageActionsType &
SafetyNumberActionsType; SafetyNumberActionsType;
export type Props = PropsDataType & PropsHousekeepingType & PropsActionsType; export type PropsType = PropsDataType &
PropsHousekeepingType &
PropsActionsType;
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 // from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
type RowRendererParamsType = { type RowRendererParamsType = {
@ -120,7 +125,7 @@ type VisibleRowsType = {
}; };
}; };
type State = { type StateType = {
atBottom: boolean; atBottom: boolean;
atTop: boolean; atTop: boolean;
oneTimeScrollRow?: number; oneTimeScrollRow?: number;
@ -133,7 +138,7 @@ type State = {
areUnreadBelowCurrentPosition: boolean; areUnreadBelowCurrentPosition: boolean;
}; };
export class Timeline extends React.PureComponent<Props, State> { export class Timeline extends React.PureComponent<PropsType, StateType> {
public cellSizeCache = new CellMeasurerCache({ public cellSizeCache = new CellMeasurerCache({
defaultHeight: 64, defaultHeight: 64,
fixedWidth: true, fixedWidth: true,
@ -153,7 +158,7 @@ export class Timeline extends React.PureComponent<Props, State> {
public loadCountdownTimeout: NodeJS.Timeout | null = null; public loadCountdownTimeout: NodeJS.Timeout | null = null;
constructor(props: Props) { constructor(props: PropsType) {
super(props); super(props);
const { scrollToIndex } = this.props; const { scrollToIndex } = this.props;
@ -170,7 +175,10 @@ export class Timeline extends React.PureComponent<Props, State> {
}; };
} }
public static getDerivedStateFromProps(props: Props, state: State): State { public static getDerivedStateFromProps(
props: PropsType,
state: StateType
): StateType {
if ( if (
isNumber(props.scrollToIndex) && isNumber(props.scrollToIndex) &&
(props.scrollToIndex !== state.prevPropScrollToIndex || (props.scrollToIndex !== state.prevPropScrollToIndex ||
@ -646,7 +654,10 @@ export class Timeline extends React.PureComponent<Props, State> {
return itemsCount + extraRows; return itemsCount + extraRows;
} }
public fromRowToItemIndex(row: number, props?: Props): number | undefined { public fromRowToItemIndex(
row: number,
props?: PropsType
): number | undefined {
const { items } = props || this.props; const { items } = props || this.props;
// We will always render either the hero row or the loading row // We will always render either the hero row or the loading row
@ -666,7 +677,7 @@ export class Timeline extends React.PureComponent<Props, State> {
return index; return index;
} }
public getLastSeenIndicatorRow(props?: Props): number | undefined { public getLastSeenIndicatorRow(props?: PropsType): number | undefined {
const { oldestUnreadIndex } = props || this.props; const { oldestUnreadIndex } = props || this.props;
if (!isNumber(oldestUnreadIndex)) { if (!isNumber(oldestUnreadIndex)) {
return; return;
@ -785,7 +796,7 @@ export class Timeline extends React.PureComponent<Props, State> {
window.unregisterForActive(this.updateWithVisibleRows); window.unregisterForActive(this.updateWithVisibleRows);
} }
public componentDidUpdate(prevProps: Props): void { public componentDidUpdate(prevProps: PropsType): void {
const { const {
id, id,
clearChangedMessages, clearChangedMessages,
@ -1052,7 +1063,7 @@ export class Timeline extends React.PureComponent<Props, State> {
}; };
public render(): JSX.Element | null { public render(): JSX.Element | null {
const { i18n, id, items } = this.props; const { i18n, id, items, isGroupV1AndDisabled } = this.props;
const { const {
shouldShowScrollDownButton, shouldShowScrollDownButton,
areUnreadBelowCurrentPosition, areUnreadBelowCurrentPosition,
@ -1067,7 +1078,10 @@ export class Timeline extends React.PureComponent<Props, State> {
return ( return (
<div <div
className="module-timeline" className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
)}
role="presentation" role="presentation"
tabIndex={-1} tabIndex={-1}
onBlur={this.handleBlur} onBlur={this.handleBlur}

View file

@ -7,6 +7,7 @@ import {
difference, difference,
flatten, flatten,
fromPairs, fromPairs,
isFinite,
isNumber, isNumber,
values, values,
} from 'lodash'; } from 'lodash';
@ -722,6 +723,168 @@ export async function isGroupEligibleToMigrate(
return true; return true;
} }
export async function getGroupMigrationMembers(
conversation: ConversationModel
): Promise<{
areWeInvited: boolean;
areWeMember: boolean;
droppedGV2MemberIds: Array<string>;
membersV2: Array<GroupV2MemberType>;
pendingMembersV2: Array<GroupV2PendingMemberType>;
previousGroupV1Members: Array<string>;
}> {
const logId = conversation.idForLogging();
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const ourConversationId = window.ConversationController.getOurConversationId();
if (!ourConversationId) {
throw new Error(
`getGroupMigrationMembers/${logId}: Couldn't fetch our own conversationId!`
);
}
let areWeMember = false;
let areWeInvited = false;
const previousGroupV1Members = conversation.get('members') || [];
const now = Date.now();
const memberLookup: Record<string, boolean> = {};
const membersV2: Array<GroupV2MemberType> = compact(
await Promise.all(
previousGroupV1Members.map(async e164 => {
const contact = window.ConversationController.get(e164);
if (!contact) {
throw new Error(
`getGroupMigrationMembers/${logId}: membersV2 - missing local contact for ${e164}, skipping.`
);
}
if (!contact.get('uuid')) {
window.log.warn(
`getGroupMigrationMembers/${logId}: membersV2 - missing uuid for ${e164}, skipping.`
);
return null;
}
if (!contact.get('profileKey')) {
window.log.warn(
`getGroupMigrationMembers/${logId}: membersV2 - missing profileKey for member ${e164}, skipping.`
);
return null;
}
let capabilities = contact.get('capabilities');
// Refresh our local data to be sure
if (
!capabilities ||
!capabilities.gv2 ||
!capabilities['gv1-migration'] ||
!contact.get('profileKeyCredential')
) {
await contact.getProfiles();
}
capabilities = contact.get('capabilities');
if (!capabilities || !capabilities.gv2) {
window.log.warn(
`getGroupMigrationMembers/${logId}: membersV2 - member ${e164} is missing gv2 capability, skipping.`
);
return null;
}
if (!capabilities || !capabilities['gv1-migration']) {
window.log.warn(
`getGroupMigrationMembers/${logId}: membersV2 - member ${e164} is missing gv1-migration capability, skipping.`
);
return null;
}
if (!contact.get('profileKeyCredential')) {
window.log.warn(
`getGroupMigrationMembers/${logId}: membersV2 - no profileKeyCredential for ${e164}, skipping.`
);
return null;
}
const conversationId = contact.id;
if (conversationId === ourConversationId) {
areWeMember = true;
}
memberLookup[conversationId] = true;
return {
conversationId,
role: MEMBER_ROLE_ENUM.ADMINISTRATOR,
joinedAtVersion: 0,
};
})
)
);
const droppedGV2MemberIds: Array<string> = [];
const pendingMembersV2: Array<GroupV2PendingMemberType> = compact(
(previousGroupV1Members || []).map(e164 => {
const contact = window.ConversationController.get(e164);
if (!contact) {
throw new Error(
`getGroupMigrationMembers/${logId}: pendingMembersV2 - missing local contact for ${e164}, skipping.`
);
}
const conversationId = contact.id;
// If we've already added this contact above, we'll skip here
if (memberLookup[conversationId]) {
return null;
}
if (!contact.get('uuid')) {
window.log.warn(
`getGroupMigrationMembers/${logId}: pendingMembersV2 - missing uuid for ${e164}, skipping.`
);
droppedGV2MemberIds.push(conversationId);
return null;
}
const capabilities = contact.get('capabilities');
if (!capabilities || !capabilities.gv2) {
window.log.warn(
`getGroupMigrationMembers/${logId}: pendingMembersV2 - member ${e164} is missing gv2 capability, skipping.`
);
droppedGV2MemberIds.push(conversationId);
return null;
}
if (!capabilities || !capabilities['gv1-migration']) {
window.log.warn(
`getGroupMigrationMembers/${logId}: pendingMembersV2 - member ${e164} is missing gv1-migration capability, skipping.`
);
droppedGV2MemberIds.push(conversationId);
return null;
}
if (conversationId === ourConversationId) {
areWeInvited = true;
}
return {
conversationId,
timestamp: now,
addedByUserId: ourConversationId,
};
})
);
return {
areWeInvited,
areWeMember,
droppedGV2MemberIds,
membersV2,
pendingMembersV2,
previousGroupV1Members,
};
}
// This is called when the user chooses to migrate a GroupV1. It will update the server, // This is called when the user chooses to migrate a GroupV1. It will update the server,
// then let all members know about the new group. // then let all members know about the new group.
export async function initiateMigrationToGroupV2( export async function initiateMigrationToGroupV2(
@ -732,7 +895,6 @@ export async function initiateMigrationToGroupV2(
try { try {
await conversation.queueJob(async () => { await conversation.queueJob(async () => {
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const ACCESS_ENUM = const ACCESS_ENUM =
window.textsecure.protobuf.AccessControl.AccessRequired; window.textsecure.protobuf.AccessControl.AccessRequired;
@ -766,138 +928,14 @@ export async function initiateMigrationToGroupV2(
); );
} }
let areWeMember = false; const {
let areWeInvited = false; areWeMember,
areWeInvited,
const now = Date.now(); membersV2,
pendingMembersV2,
const previousGroupV1Members = conversation.get('members') || []; droppedGV2MemberIds,
const memberLookup: Record<string, boolean> = {}; previousGroupV1Members,
const membersV2: Array<GroupV2MemberType> = compact( } = await getGroupMigrationMembers(conversation);
await Promise.all(
previousGroupV1Members.map(async e164 => {
const contact = window.ConversationController.get(e164);
if (!contact) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: membersV2 - missing local contact for ${e164}, skipping.`
);
}
if (!contact.get('uuid')) {
window.log.warn(
`initiateMigrationToGroupV2/${logId}: membersV2 - missing uuid for ${e164}, skipping.`
);
return null;
}
if (!contact.get('profileKey')) {
window.log.warn(
`initiateMigrationToGroupV2/${logId}: membersV2 - missing profileKey for member ${e164}, skipping.`
);
return null;
}
let capabilities = contact.get('capabilities');
// Refresh our local data to be sure
if (
!capabilities ||
!capabilities.gv2 ||
!capabilities['gv1-migration'] ||
!contact.get('profileKeyCredential')
) {
await contact.getProfiles();
}
capabilities = contact.get('capabilities');
if (!capabilities || !capabilities.gv2) {
window.log.warn(
`initiateMigrationToGroupV2/${logId}: membersV2 - member ${e164} is missing gv2 capability, skipping.`
);
return null;
}
if (!capabilities || !capabilities['gv1-migration']) {
window.log.warn(
`initiateMigrationToGroupV2/${logId}: membersV2 - member ${e164} is missing gv1-migration capability, skipping.`
);
return null;
}
if (!contact.get('profileKeyCredential')) {
window.log.warn(
`initiateMigrationToGroupV2/${logId}: membersV2 - no profileKeyCredential for ${e164}, skipping.`
);
return null;
}
const conversationId = contact.id;
if (conversationId === ourConversationId) {
areWeMember = true;
}
memberLookup[conversationId] = true;
return {
conversationId,
role: MEMBER_ROLE_ENUM.ADMINISTRATOR,
joinedAtVersion: 0,
};
})
)
);
const droppedGV2MemberIds: Array<string> = [];
const pendingMembersV2: Array<GroupV2PendingMemberType> = compact(
(previousGroupV1Members || []).map(e164 => {
const contact = window.ConversationController.get(e164);
if (!contact) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: pendingMembersV2 - missing local contact for ${e164}, skipping.`
);
}
const conversationId = contact.id;
// If we've already added this contact above, we'll skip here
if (memberLookup[conversationId]) {
return null;
}
if (!contact.get('uuid')) {
window.log.warn(
`initiateMigrationToGroupV2/${logId}: pendingMembersV2 - missing uuid for ${e164}, skipping.`
);
droppedGV2MemberIds.push(conversationId);
return null;
}
const capabilities = contact.get('capabilities');
if (!capabilities || !capabilities.gv2) {
window.log.warn(
`initiateMigrationToGroupV2/${logId}: pendingMembersV2 - member ${e164} is missing gv2 capability, skipping.`
);
droppedGV2MemberIds.push(conversationId);
return null;
}
if (!capabilities || !capabilities['gv1-migration']) {
window.log.warn(
`initiateMigrationToGroupV2/${logId}: pendingMembersV2 - member ${e164} is missing gv1-migration capability, skipping.`
);
droppedGV2MemberIds.push(conversationId);
return null;
}
if (conversationId === ourConversationId) {
areWeInvited = true;
}
return {
conversationId,
timestamp: now,
addedByUserId: ourConversationId,
};
})
);
if (!areWeMember) { if (!areWeMember) {
throw new Error( throw new Error(
@ -910,6 +948,26 @@ export async function initiateMigrationToGroupV2(
); );
} }
const rawSizeLimit = window.Signal.RemoteConfig.getValue(
'global.groupsv2.groupSizeHardLimit'
);
if (!rawSizeLimit) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: Failed to fetch group size limit`
);
}
const sizeLimit = parseInt(rawSizeLimit, 10);
if (!isFinite(sizeLimit)) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: Failed to parse group size limit`
);
}
if (membersV2.length + pendingMembersV2.length > sizeLimit) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
);
}
// Note: A few group elements don't need to change here: // Note: A few group elements don't need to change here:
// - avatar // - avatar
// - name // - name
@ -2004,7 +2062,7 @@ async function integrateGroupChange({
}; };
} }
export async function getCurrentGroupState({ async function getCurrentGroupState({
authCredentialBase64, authCredentialBase64,
dropInitialJoinMessage, dropInitialJoinMessage,
group, group,

View file

@ -632,6 +632,13 @@ export class ConversationModel extends window.Backbone.Model<
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
} }
isGroupV1AndDisabled(): boolean {
return (
this.isGroupV1() &&
window.Signal.RemoteConfig.isEnabled('desktop.disableGV1')
);
}
isBlocked(): boolean { isBlocked(): boolean {
const uuid = this.get('uuid'); const uuid = this.get('uuid');
if (uuid) { if (uuid) {
@ -1181,6 +1188,7 @@ export class ConversationModel extends window.Backbone.Model<
isArchived: this.get('isArchived')!, isArchived: this.get('isArchived')!,
isBlocked: this.isBlocked(), isBlocked: this.isBlocked(),
isMe: this.isMe(), isMe: this.isMe(),
isGroupV1AndDisabled: this.isGroupV1AndDisabled(),
isPinned: this.get('isPinned'), isPinned: this.get('isPinned'),
isMissingMandatoryProfileSharing: this.isMissingRequiredProfileSharing(), isMissingMandatoryProfileSharing: this.isMissingRequiredProfileSharing(),
isVerified: this.isVerified(), isVerified: this.isVerified(),
@ -4063,6 +4071,10 @@ export class ConversationModel extends window.Backbone.Model<
return true; return true;
} }
if (this.isGroupV1AndDisabled()) {
return false;
}
if (!this.isGroupV2()) { if (!this.isGroupV2()) {
return true; return true;
} }

View file

@ -2079,33 +2079,42 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const isOutgoing = this.get('type') === 'outgoing'; const isOutgoing = this.get('type') === 'outgoing';
const numDelivered = this.get('delivered'); const numDelivered = this.get('delivered');
// Case 1: If mandatory profile sharing is enabled, and we haven't shared yet, then if (!conversation) {
return false;
}
// If GroupV1 groups have been disabled, we can't reply.
if (conversation.isGroupV1AndDisabled()) {
return false;
}
// If mandatory profile sharing is enabled, and we haven't shared yet, then
// we can't reply. // we can't reply.
if (conversation?.isMissingRequiredProfileSharing()) { if (conversation.isMissingRequiredProfileSharing()) {
return false; return false;
} }
// Case 2: We cannot reply if we have accepted the message request // We cannot reply if we haven't accepted the message request
if (!conversation?.getAccepted()) { if (!conversation.getAccepted()) {
return false; return false;
} }
// Case 3: We cannot reply if this message is deleted for everyone // We cannot reply if this message is deleted for everyone
if (this.get('deletedForEveryone')) { if (this.get('deletedForEveryone')) {
return false; return false;
} }
// Case 4: We can reply if this is outgoing and delievered to at least one recipient // We can reply if this is outgoing and delievered to at least one recipient
if (isOutgoing && numDelivered > 0) { if (isOutgoing && numDelivered > 0) {
return true; return true;
} }
// Case 5: We can reply if there are no errors // We can reply if there are no errors
if (!errors || (errors && errors.length === 0)) { if (!errors || (errors && errors.length === 0)) {
return true; return true;
} }
// Case 6: default // Fail safe.
return false; return false;
} }

View file

@ -54,6 +54,7 @@ export type ConversationType = {
isAccepted?: boolean; isAccepted?: boolean;
isArchived?: boolean; isArchived?: boolean;
isBlocked?: boolean; isBlocked?: boolean;
isGroupV1AndDisabled?: boolean;
isPinned?: boolean; isPinned?: boolean;
isVerified?: boolean; isVerified?: boolean;
activeAt?: number; activeAt?: number;

View file

@ -0,0 +1,28 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { ModalHost } from '../../components/ModalHost';
import {
SmartGroupV1MigrationDialog,
PropsType,
} from '../smart/GroupV1MigrationDialog';
export const createGroupV1MigrationModal = (
store: Store,
props: PropsType
): React.ReactElement => {
const { onClose } = props;
return (
<Provider store={store}>
<ModalHost onClose={onClose}>
<SmartGroupV1MigrationDialog {...props} />
</ModalHost>
</Provider>
);
};

View file

@ -0,0 +1,59 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import {
GroupV1MigrationDialog,
PropsType as GroupV1MigrationDialogPropsType,
} from '../../components/GroupV1MigrationDialog';
import { ConversationType } from '../ducks/conversations';
import { StateType } from '../reducer';
import { getConversationSelector } from '../selectors/conversations';
import { getIntl } from '../selectors/user';
export type PropsType = {
readonly droppedMemberIds: Array<string>;
readonly invitedMemberIds: Array<string>;
} & Omit<
GroupV1MigrationDialogPropsType,
'i18n' | 'droppedMembers' | 'invitedMembers'
>;
const mapStateToProps = (
state: StateType,
props: PropsType
): GroupV1MigrationDialogPropsType => {
const getConversation = getConversationSelector(state);
const { droppedMemberIds, invitedMemberIds } = props;
const droppedMembers = droppedMemberIds
.map(getConversation)
.filter(Boolean) as Array<ConversationType>;
if (droppedMembers.length !== droppedMemberIds.length) {
window.log.warn(
'smart/GroupV1MigrationDialog: droppedMembers length changed'
);
}
const invitedMembers = invitedMemberIds
.map(getConversation)
.filter(Boolean) as Array<ConversationType>;
if (invitedMembers.length !== invitedMemberIds.length) {
window.log.warn(
'smart/GroupV1MigrationDialog: invitedMembers length changed'
);
}
return {
...props,
droppedMembers,
invitedMembers,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartGroupV1MigrationDialog = smart(GroupV1MigrationDialog);

View file

@ -101,7 +101,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return { return {
id, id,
...pick(conversation, ['unreadCount', 'typingContact']), ...pick(conversation, [
'unreadCount',
'typingContact',
'isGroupV1AndDisabled',
]),
...conversationMessages, ...conversationMessages,
selectedMessageId: selectedMessage ? selectedMessage.id : undefined, selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
i18n: getIntl(state), i18n: getIntl(state),

View file

@ -1077,8 +1077,9 @@ export function initialize({
responseType: 'json', responseType: 'json',
}); });
return res.config.filter(({ name }: { name: string }) => return res.config.filter(
name.startsWith('desktop.') ({ name }: { name: string }) =>
name.startsWith('desktop.') || name.startsWith('global.')
); );
} }

View file

@ -14463,7 +14463,7 @@
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.js", "path": "ts/components/CompositionArea.js",
"line": " el.innerHTML = '';", "line": " el.innerHTML = '';",
"lineNumber": 27, "lineNumber": 28,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z", "updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Our code, no user input, only clearing out the dom" "reasonDetail": "Our code, no user input, only clearing out the dom"
@ -14472,7 +14472,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CompositionArea.js", "path": "ts/components/CompositionArea.js",
"line": " const inputApiRef = React.useRef();", "line": " const inputApiRef = React.useRef();",
"lineNumber": 43, "lineNumber": 46,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element." "reasonDetail": "Doesn't refer to a DOM element."
@ -14481,7 +14481,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CompositionArea.js", "path": "ts/components/CompositionArea.js",
"line": " const attSlotRef = React.useRef(null);", "line": " const attSlotRef = React.useRef(null);",
"lineNumber": 66, "lineNumber": 69,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Needed for the composition area." "reasonDetail": "Needed for the composition area."
@ -14490,7 +14490,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CompositionArea.js", "path": "ts/components/CompositionArea.js",
"line": " const micCellRef = React.useRef(null);", "line": " const micCellRef = React.useRef(null);",
"lineNumber": 100, "lineNumber": 103,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Needed for the composition area." "reasonDetail": "Needed for the composition area."
@ -14499,7 +14499,7 @@
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx", "path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';", "line": " el.innerHTML = '';",
"lineNumber": 85, "lineNumber": 91,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-06-03T19:23:21.195Z", "updated": "2020-06-03T19:23:21.195Z",
"reasonDetail": "Our code, no user input, only clearing out the dom" "reasonDetail": "Our code, no user input, only clearing out the dom"
@ -14859,7 +14859,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/Timeline.js", "path": "ts/components/conversation/Timeline.js",
"line": " this.listRef = react_1.default.createRef();", "line": " this.listRef = react_1.default.createRef();",
"lineNumber": 29, "lineNumber": 30,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-07-31T00:19:18.696Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Timeline needs to interact with its child List directly" "reasonDetail": "Timeline needs to interact with its child List directly"
@ -15172,7 +15172,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.ts", "path": "ts/textsecure/WebAPI.ts",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
"lineNumber": 2171, "lineNumber": 2172,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z" "updated": "2020-09-08T23:07:22.682Z"
} }

View file

@ -3,9 +3,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
// Note: because this file is pulled in directly from background.html, we can't use any // This allows us to pull in types despite the fact that this is not a module. We can't
// imports here aside from types. That means everything will have to be references via // use normal import syntax, nor can we use 'import type' syntax, or this will be turned
// globals right on window. // into a module, and we'll get the dreaded 'exports is not defined' error.
// see https://github.com/microsoft/TypeScript/issues/41562
type GroupV2PendingMemberType = import('../model-types.d').GroupV2PendingMemberType;
interface GetLinkPreviewResult { interface GetLinkPreviewResult {
title: string; title: string;
@ -404,8 +406,6 @@ Whisper.ConversationView = Whisper.View.extend({
}, },
events: { events: {
'click .composition-area-placeholder': 'onClickPlaceholder',
'click .bottom-bar': 'focusMessageField',
'click .capture-audio .microphone': 'captureAudio', 'click .capture-audio .microphone': 'captureAudio',
'change input.file-input': 'onChoseAttachment', 'change input.file-input': 'onChoseAttachment',
@ -647,6 +647,7 @@ Whisper.ConversationView = Whisper.View.extend({
), ),
}); });
}, },
onStartGroupMigration: () => this.startMigrationToGV2(),
}; };
this.compositionAreaView = new Whisper.ReactWrapperView({ this.compositionAreaView = new Whisper.ReactWrapperView({
@ -661,13 +662,13 @@ Whisper.ConversationView = Whisper.View.extend({
this.$('.composition-area-placeholder').append(this.compositionAreaView.el); this.$('.composition-area-placeholder').append(this.compositionAreaView.el);
}, },
async longRunningTaskWrapper({ async longRunningTaskWrapper<T>({
name, name,
task, task,
}: { }: {
name: string; name: string;
task: () => Promise<void>; task: () => Promise<T>;
}): Promise<void> { }): Promise<T> {
const idLog = `${name}/${this.model.idForLogging()}`; const idLog = `${name}/${this.model.idForLogging()}`;
const ONE_SECOND = 1000; const ONE_SECOND = 1000;
const TWO_SECONDS = 2000; const TWO_SECONDS = 2000;
@ -690,7 +691,7 @@ Whisper.ConversationView = Whisper.View.extend({
// show a spinner until it's done // show a spinner until it's done
try { try {
window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`); window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`);
await task(); const result = await task();
window.log.info( window.log.info(
`longRunningTaskWrapper/${idLog}: Task completed successfully` `longRunningTaskWrapper/${idLog}: Task completed successfully`
); );
@ -710,6 +711,8 @@ Whisper.ConversationView = Whisper.View.extend({
progressView.remove(); progressView.remove();
progressView = undefined; progressView = undefined;
} }
return result;
} catch (error) { } catch (error) {
window.log.error( window.log.error(
`longRunningTaskWrapper/${idLog}: Error!`, `longRunningTaskWrapper/${idLog}: Error!`,
@ -736,6 +739,8 @@ Whisper.ConversationView = Whisper.View.extend({
onClose: () => errorView.remove(), onClose: () => errorView.remove(),
}, },
}); });
throw error;
} }
}, },
@ -1170,10 +1175,58 @@ Whisper.ConversationView = Whisper.View.extend({
} }
}, },
// We need this, or clicking the reactified buttons will submit the form and send any async startMigrationToGV2(): Promise<void> {
// mid-composition message content. const logId = this.model.idForLogging();
onClickPlaceholder(e: any) {
e.preventDefault(); if (!this.model.isGroupV1()) {
throw new Error(
`startMigrationToGV2/${logId}: Cannot start, not a GroupV1 group`
);
}
const onClose = () => {
if (this.migrationDialog) {
this.migrationDialog.remove();
this.migrationDialog = undefined;
}
};
onClose();
const migrate = () => {
onClose();
this.longRunningTaskWrapper({
name: 'initiateMigrationToGroupV2',
task: () => window.Signal.Groups.initiateMigrationToGroupV2(this.model),
});
};
// Grab the dropped/invited user set
const {
droppedGV2MemberIds,
pendingMembersV2,
} = await this.longRunningTaskWrapper({
name: 'getGroupMigrationMembers',
task: () => window.Signal.Groups.getGroupMigrationMembers(this.model),
});
const invitedMemberIds = pendingMembersV2.map(
(item: GroupV2PendingMemberType) => item.conversationId
);
this.migrationDialog = new Whisper.ReactWrapperView({
className: 'group-v1-migration-wrapper',
JSX: window.Signal.State.Roots.createGroupV1MigrationModal(
window.reduxStore,
{
droppedMemberIds: droppedGV2MemberIds,
hasMigrated: false,
invitedMemberIds,
migrate,
onClose,
}
),
});
}, },
onChooseAttachment() { onChooseAttachment() {

2
ts/window.d.ts vendored
View file

@ -36,6 +36,7 @@ import { createCallManager } from './state/roots/createCallManager';
import { createCompositionArea } from './state/roots/createCompositionArea'; import { createCompositionArea } from './state/roots/createCompositionArea';
import { createContactModal } from './state/roots/createContactModal'; import { createContactModal } from './state/roots/createContactModal';
import { createConversationHeader } from './state/roots/createConversationHeader'; import { createConversationHeader } from './state/roots/createConversationHeader';
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
import { createLeftPane } from './state/roots/createLeftPane'; import { createLeftPane } from './state/roots/createLeftPane';
import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer'; import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer';
import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal'; import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal';
@ -430,6 +431,7 @@ declare global {
createCompositionArea: typeof createCompositionArea; createCompositionArea: typeof createCompositionArea;
createContactModal: typeof createContactModal; createContactModal: typeof createContactModal;
createConversationHeader: typeof createConversationHeader; createConversationHeader: typeof createConversationHeader;
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
createLeftPane: typeof createLeftPane; createLeftPane: typeof createLeftPane;
createSafetyNumberViewer: typeof createSafetyNumberViewer; createSafetyNumberViewer: typeof createSafetyNumberViewer;
createShortcutGuideModal: typeof createShortcutGuideModal; createShortcutGuideModal: typeof createShortcutGuideModal;