From 2b8ae412e0101bcc74e98c61ff2b06d2565c02c0 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 1 Dec 2020 08:42:35 -0800 Subject: [PATCH] New feature flag with ability to migrate GV1 groups --- .github/workflows/ci.yml | 2 +- _locales/en/messages.json | 10 + js/modules/signal.js | 4 + stylesheets/_modules.scss | 57 +++ ts/RemoteConfig.ts | 9 +- ts/components/CompositionArea.stories.tsx | 2 + ts/components/CompositionArea.tsx | 19 + ts/components/CompositionInput.tsx | 8 + .../GroupV1MigrationDialog.stories.tsx | 1 - ts/components/GroupV1MigrationDialog.tsx | 9 +- .../GroupV1DisabledActions.stories.tsx | 29 ++ .../conversation/GroupV1DisabledActions.tsx | 49 +++ .../conversation/GroupV1Migration.tsx | 3 - .../conversation/Timeline.stories.tsx | 4 +- ts/components/conversation/Timeline.tsx | 34 +- ts/groups.ts | 326 +++++++++++------- ts/models/conversations.ts | 12 + ts/models/messages.ts | 25 +- ts/state/ducks/conversations.ts | 1 + .../roots/createGroupV1MigrationModal.tsx | 28 ++ ts/state/smart/GroupV1MigrationDialog.tsx | 59 ++++ ts/state/smart/Timeline.tsx | 6 +- ts/textsecure/WebAPI.ts | 5 +- ts/util/lint/exceptions.json | 14 +- ts/views/conversation_view.ts | 79 ++++- ts/window.d.ts | 2 + 26 files changed, 608 insertions(+), 189 deletions(-) create mode 100644 ts/components/conversation/GroupV1DisabledActions.stories.tsx create mode 100644 ts/components/conversation/GroupV1DisabledActions.tsx create mode 100644 ts/state/roots/createGroupV1MigrationModal.tsx create mode 100644 ts/state/smart/GroupV1MigrationDialog.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eb3460cb..ca515aa36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - run: yarn generate - run: yarn lint - run: yarn lint-deps - - run: git diff --quiet --exit-code + - run: git diff --exit-code macos: needs: lint diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 483fd4979..12c458298 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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": { "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)" diff --git a/js/modules/signal.js b/js/modules/signal.js index 031ea520f..1e281ae28 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -73,6 +73,9 @@ const { createConversationHeader, } = require('../../ts/state/roots/createConversationHeader'); const { createCallManager } = require('../../ts/state/roots/createCallManager'); +const { + createGroupV1MigrationModal, +} = require('../../ts/state/roots/createGroupV1MigrationModal'); const { createLeftPane } = require('../../ts/state/roots/createLeftPane'); const { createSafetyNumberViewer, @@ -326,6 +329,7 @@ exports.setup = (options = {}) => { createCompositionArea, createContactModal, createConversationHeader, + createGroupV1MigrationModal, createLeftPane, createSafetyNumberViewer, createShortcutGuideModal, diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 18099542d..4b5318463 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -6983,6 +6983,10 @@ button.module-image__border-overlay:focus { overflow: hidden; } +.module-timeline--disabled { + user-select: none; +} + .module-timeline__message-container { padding-top: 4px; padding-bottom: 4px; @@ -9834,6 +9838,59 @@ button.module-image__border-overlay:focus { @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__overlay { diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 28b97eca9..4fd7e2405 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -7,11 +7,14 @@ import { WebAPIType } from './textsecure/WebAPI'; type ConfigKeyType = | 'desktop.cds' | 'desktop.clientExpiration' + | 'desktop.disableGV1' | 'desktop.gv2' | 'desktop.mandatoryProfileSharing' | 'desktop.messageRequests' | 'desktop.storage' - | 'desktop.storageWrite'; + | 'desktop.storageWrite' + | 'global.groupsv2.maxGroupSize' + | 'global.groupsv2.groupSizeHardLimit'; type ConfigValueType = { name: ConfigKeyType; enabled: boolean; @@ -112,3 +115,7 @@ export const maybeRefreshRemoteConfig = throttle( export function isEnabled(name: ConfigKeyType): boolean { return get(config, [name, 'enabled'], false); } + +export function getValue(name: ConfigKeyType): string | undefined { + return get(config, [name, 'value'], undefined); +} diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index 022a87c1e..63817cebc 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -69,6 +69,8 @@ const createProps = (overrideProps: Partial = {}): Props => ({ overrideProps.messageRequestsEnabled || false ), title: '', + // GroupV1 Disabled Actions + onStartGroupMigration: action('onStartGroupMigration'), }); story.add('Default', () => { diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 7c1079e03..7dc68e988 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -18,6 +18,10 @@ import { MessageRequestActions, Props as MessageRequestActionsProps, } from './conversation/MessageRequestActions'; +import { + GroupV1DisabledActions, + PropsType as GroupV1DisabledActionsPropsType, +} from './conversation/GroupV1DisabledActions'; import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions'; import { countStickers } from './stickers/lib'; import { LocalizerType } from '../types/Util'; @@ -27,6 +31,7 @@ export type OwnProps = { readonly i18n: LocalizerType; readonly areWePending?: boolean; readonly groupVersion?: 1 | 2; + readonly isGroupV1AndDisabled?: boolean; readonly isMissingMandatoryProfileSharing?: boolean; readonly messageRequestsEnabled?: boolean; readonly acceptedMessageRequest?: boolean; @@ -77,6 +82,7 @@ export type Props = Pick< | 'clearShowPickerHint' > & MessageRequestActionsProps & + Pick & OwnProps; const emptyElement = (el: HTMLElement) => { @@ -135,6 +141,9 @@ export const CompositionArea = ({ phoneNumber, profileName, title, + // GroupV1 Disabled Actions + isGroupV1AndDisabled, + onStartGroupMigration, }: Props): JSX.Element => { const [disabled, setDisabled] = React.useState(false); 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 ( + + ); + } + return (
diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 21d7427d5..e17e03179 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -570,6 +570,13 @@ export const CompositionInput: React.ComponentType = 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 ( @@ -577,6 +584,7 @@ export const CompositionInput: React.ComponentType = props => {
= {}): PropsType => ({ ), i18n, invitedMembers: overrideProps.invitedMembers || [contact2], - learnMore: action('learnMore'), migrate: action('migrate'), onClose: action('onClose'), }); diff --git a/ts/components/GroupV1MigrationDialog.tsx b/ts/components/GroupV1MigrationDialog.tsx index 5ec8c137e..0aeb3efee 100644 --- a/ts/components/GroupV1MigrationDialog.tsx +++ b/ts/components/GroupV1MigrationDialog.tsx @@ -19,7 +19,6 @@ export type DataPropsType = { readonly droppedMembers: Array; readonly hasMigrated: boolean; readonly invitedMembers: Array; - readonly learnMore: CallbackType; readonly migrate: CallbackType; readonly onClose: CallbackType; }; @@ -42,7 +41,6 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => { hasMigrated, i18n, invitedMembers, - learnMore, migrate, onClose, } = props; @@ -85,7 +83,7 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => { )} {renderMembers(droppedMembers, droppedMembersKey, i18n)}
- {renderButtons(hasMigrated, onClose, learnMore, migrate, i18n)} + {renderButtons(hasMigrated, onClose, migrate, i18n)}
); }); @@ -93,7 +91,6 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => { function renderButtons( hasMigrated: boolean, onClose: CallbackType, - learnMore: CallbackType, migrate: CallbackType, i18n: LocalizerType ) { @@ -125,9 +122,9 @@ function renderButtons( 'module-group-v2-migration-dialog__button--secondary' )} type="button" - onClick={learnMore} + onClick={onClose} > - {i18n('GroupV1--Migration--learn-more')} + {i18n('cancel')} +
+
+ ); +}; diff --git a/ts/components/conversation/GroupV1Migration.tsx b/ts/components/conversation/GroupV1Migration.tsx index cf3a4ff86..8432f3829 100644 --- a/ts/components/conversation/GroupV1Migration.tsx +++ b/ts/components/conversation/GroupV1Migration.tsx @@ -55,9 +55,6 @@ export function GroupV1Migration(props: PropsType): React.ReactElement { hasMigrated i18n={i18n} invitedMembers={invitedMembers} - learnMore={() => - window.log.warn('GroupV1Migration: Modal called learnMore()') - } migrate={() => window.log.warn('GroupV1Migration: Modal called migrate()') } diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index ca866d02d..5cbeadf3e 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -8,7 +8,7 @@ import { action } from '@storybook/addon-actions'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; -import { Props, Timeline } from './Timeline'; +import { PropsType, Timeline } from './Timeline'; import { TimelineItem, TimelineItemType } from './TimelineItem'; import { LastSeenIndicator } from './LastSeenIndicator'; import { TimelineLoadingRow } from './TimelineLoadingRow'; @@ -278,7 +278,7 @@ const renderTypingBubble = () => ( /> ); -const createProps = (overrideProps: Partial = {}): Props => ({ +const createProps = (overrideProps: Partial = {}): PropsType => ({ i18n, haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 8dcc8b580..19ed5de09 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { debounce, get, isNumber } from 'lodash'; +import classNames from 'classnames'; import React, { CSSProperties } from 'react'; import { AutoSizer, @@ -44,6 +45,8 @@ type PropsHousekeepingType = { id: string; unreadCount?: number; typingContact?: unknown; + isGroupV1AndDisabled?: boolean; + selectedMessageId?: string; i18n: LocalizerType; @@ -82,7 +85,9 @@ type PropsActionsType = { } & MessageActionsType & 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 type RowRendererParamsType = { @@ -120,7 +125,7 @@ type VisibleRowsType = { }; }; -type State = { +type StateType = { atBottom: boolean; atTop: boolean; oneTimeScrollRow?: number; @@ -133,7 +138,7 @@ type State = { areUnreadBelowCurrentPosition: boolean; }; -export class Timeline extends React.PureComponent { +export class Timeline extends React.PureComponent { public cellSizeCache = new CellMeasurerCache({ defaultHeight: 64, fixedWidth: true, @@ -153,7 +158,7 @@ export class Timeline extends React.PureComponent { public loadCountdownTimeout: NodeJS.Timeout | null = null; - constructor(props: Props) { + constructor(props: PropsType) { super(props); const { scrollToIndex } = this.props; @@ -170,7 +175,10 @@ export class Timeline extends React.PureComponent { }; } - public static getDerivedStateFromProps(props: Props, state: State): State { + public static getDerivedStateFromProps( + props: PropsType, + state: StateType + ): StateType { if ( isNumber(props.scrollToIndex) && (props.scrollToIndex !== state.prevPropScrollToIndex || @@ -646,7 +654,10 @@ export class Timeline extends React.PureComponent { return itemsCount + extraRows; } - public fromRowToItemIndex(row: number, props?: Props): number | undefined { + public fromRowToItemIndex( + row: number, + props?: PropsType + ): number | undefined { const { items } = props || this.props; // We will always render either the hero row or the loading row @@ -666,7 +677,7 @@ export class Timeline extends React.PureComponent { return index; } - public getLastSeenIndicatorRow(props?: Props): number | undefined { + public getLastSeenIndicatorRow(props?: PropsType): number | undefined { const { oldestUnreadIndex } = props || this.props; if (!isNumber(oldestUnreadIndex)) { return; @@ -785,7 +796,7 @@ export class Timeline extends React.PureComponent { window.unregisterForActive(this.updateWithVisibleRows); } - public componentDidUpdate(prevProps: Props): void { + public componentDidUpdate(prevProps: PropsType): void { const { id, clearChangedMessages, @@ -1052,7 +1063,7 @@ export class Timeline extends React.PureComponent { }; public render(): JSX.Element | null { - const { i18n, id, items } = this.props; + const { i18n, id, items, isGroupV1AndDisabled } = this.props; const { shouldShowScrollDownButton, areUnreadBelowCurrentPosition, @@ -1067,7 +1078,10 @@ export class Timeline extends React.PureComponent { return (
; + membersV2: Array; + pendingMembersV2: Array; + previousGroupV1Members: Array; +}> { + 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 = {}; + const membersV2: Array = 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 = []; + const pendingMembersV2: Array = 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, // then let all members know about the new group. export async function initiateMigrationToGroupV2( @@ -732,7 +895,6 @@ export async function initiateMigrationToGroupV2( try { await conversation.queueJob(async () => { - const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; @@ -766,138 +928,14 @@ export async function initiateMigrationToGroupV2( ); } - let areWeMember = false; - let areWeInvited = false; - - const now = Date.now(); - - const previousGroupV1Members = conversation.get('members') || []; - const memberLookup: Record = {}; - const membersV2: Array = compact( - 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 = []; - const pendingMembersV2: Array = 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, - }; - }) - ); + const { + areWeMember, + areWeInvited, + membersV2, + pendingMembersV2, + droppedGV2MemberIds, + previousGroupV1Members, + } = await getGroupMigrationMembers(conversation); if (!areWeMember) { 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: // - avatar // - name @@ -2004,7 +2062,7 @@ async function integrateGroupChange({ }; } -export async function getCurrentGroupState({ +async function getCurrentGroupState({ authCredentialBase64, dropInitialJoinMessage, group, diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 3cf275edb..7dfeedd40 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -632,6 +632,13 @@ export class ConversationModel extends window.Backbone.Model< window.Signal.Data.updateConversation(this.attributes); } + isGroupV1AndDisabled(): boolean { + return ( + this.isGroupV1() && + window.Signal.RemoteConfig.isEnabled('desktop.disableGV1') + ); + } + isBlocked(): boolean { const uuid = this.get('uuid'); if (uuid) { @@ -1181,6 +1188,7 @@ export class ConversationModel extends window.Backbone.Model< isArchived: this.get('isArchived')!, isBlocked: this.isBlocked(), isMe: this.isMe(), + isGroupV1AndDisabled: this.isGroupV1AndDisabled(), isPinned: this.get('isPinned'), isMissingMandatoryProfileSharing: this.isMissingRequiredProfileSharing(), isVerified: this.isVerified(), @@ -4063,6 +4071,10 @@ export class ConversationModel extends window.Backbone.Model< return true; } + if (this.isGroupV1AndDisabled()) { + return false; + } + if (!this.isGroupV2()) { return true; } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index f6f41ddf7..6082c9782 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -2079,33 +2079,42 @@ export class MessageModel extends window.Backbone.Model { const isOutgoing = this.get('type') === 'outgoing'; 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. - if (conversation?.isMissingRequiredProfileSharing()) { + if (conversation.isMissingRequiredProfileSharing()) { return false; } - // Case 2: We cannot reply if we have accepted the message request - if (!conversation?.getAccepted()) { + // We cannot reply if we haven't accepted the message request + if (!conversation.getAccepted()) { 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')) { 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) { 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)) { return true; } - // Case 6: default + // Fail safe. return false; } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 562f493b0..5b3adf7f2 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -54,6 +54,7 @@ export type ConversationType = { isAccepted?: boolean; isArchived?: boolean; isBlocked?: boolean; + isGroupV1AndDisabled?: boolean; isPinned?: boolean; isVerified?: boolean; activeAt?: number; diff --git a/ts/state/roots/createGroupV1MigrationModal.tsx b/ts/state/roots/createGroupV1MigrationModal.tsx new file mode 100644 index 000000000..9e3af97bd --- /dev/null +++ b/ts/state/roots/createGroupV1MigrationModal.tsx @@ -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 ( + + + + + + ); +}; diff --git a/ts/state/smart/GroupV1MigrationDialog.tsx b/ts/state/smart/GroupV1MigrationDialog.tsx new file mode 100644 index 000000000..6df9aea34 --- /dev/null +++ b/ts/state/smart/GroupV1MigrationDialog.tsx @@ -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; + readonly invitedMemberIds: Array; +} & 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; + if (droppedMembers.length !== droppedMemberIds.length) { + window.log.warn( + 'smart/GroupV1MigrationDialog: droppedMembers length changed' + ); + } + + const invitedMembers = invitedMemberIds + .map(getConversation) + .filter(Boolean) as Array; + 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); diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 4ffccac8f..6f0559e7b 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -101,7 +101,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { return { id, - ...pick(conversation, ['unreadCount', 'typingContact']), + ...pick(conversation, [ + 'unreadCount', + 'typingContact', + 'isGroupV1AndDisabled', + ]), ...conversationMessages, selectedMessageId: selectedMessage ? selectedMessage.id : undefined, i18n: getIntl(state), diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index a67206baa..3fb0418ba 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1077,8 +1077,9 @@ export function initialize({ responseType: 'json', }); - return res.config.filter(({ name }: { name: string }) => - name.startsWith('desktop.') + return res.config.filter( + ({ name }: { name: string }) => + name.startsWith('desktop.') || name.startsWith('global.') ); } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index c5da2e28a..acbfdfc24 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14463,7 +14463,7 @@ "rule": "DOM-innerHTML", "path": "ts/components/CompositionArea.js", "line": " el.innerHTML = '';", - "lineNumber": 27, + "lineNumber": 28, "reasonCategory": "usageTrusted", "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Our code, no user input, only clearing out the dom" @@ -14472,7 +14472,7 @@ "rule": "React-useRef", "path": "ts/components/CompositionArea.js", "line": " const inputApiRef = React.useRef();", - "lineNumber": 43, + "lineNumber": 46, "reasonCategory": "falseMatch", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Doesn't refer to a DOM element." @@ -14481,7 +14481,7 @@ "rule": "React-useRef", "path": "ts/components/CompositionArea.js", "line": " const attSlotRef = React.useRef(null);", - "lineNumber": 66, + "lineNumber": 69, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Needed for the composition area." @@ -14490,7 +14490,7 @@ "rule": "React-useRef", "path": "ts/components/CompositionArea.js", "line": " const micCellRef = React.useRef(null);", - "lineNumber": 100, + "lineNumber": 103, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Needed for the composition area." @@ -14499,7 +14499,7 @@ "rule": "DOM-innerHTML", "path": "ts/components/CompositionArea.tsx", "line": " el.innerHTML = '';", - "lineNumber": 85, + "lineNumber": 91, "reasonCategory": "usageTrusted", "updated": "2020-06-03T19:23:21.195Z", "reasonDetail": "Our code, no user input, only clearing out the dom" @@ -14859,7 +14859,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Timeline.js", "line": " this.listRef = react_1.default.createRef();", - "lineNumber": 29, + "lineNumber": 30, "reasonCategory": "usageTrusted", "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Timeline needs to interact with its child List directly" @@ -15172,7 +15172,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/WebAPI.ts", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(", - "lineNumber": 2171, + "lineNumber": 2172, "reasonCategory": "falseMatch", "updated": "2020-09-08T23:07:22.682Z" } diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 4f0f2b6d9..fb9f92079 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -3,9 +3,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -// Note: because this file is pulled in directly from background.html, we can't use any -// imports here aside from types. That means everything will have to be references via -// globals right on window. +// This allows us to pull in types despite the fact that this is not a module. We can't +// use normal import syntax, nor can we use 'import type' syntax, or this will be turned +// 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 { title: string; @@ -404,8 +406,6 @@ Whisper.ConversationView = Whisper.View.extend({ }, events: { - 'click .composition-area-placeholder': 'onClickPlaceholder', - 'click .bottom-bar': 'focusMessageField', 'click .capture-audio .microphone': 'captureAudio', 'change input.file-input': 'onChoseAttachment', @@ -647,6 +647,7 @@ Whisper.ConversationView = Whisper.View.extend({ ), }); }, + onStartGroupMigration: () => this.startMigrationToGV2(), }; this.compositionAreaView = new Whisper.ReactWrapperView({ @@ -661,13 +662,13 @@ Whisper.ConversationView = Whisper.View.extend({ this.$('.composition-area-placeholder').append(this.compositionAreaView.el); }, - async longRunningTaskWrapper({ + async longRunningTaskWrapper({ name, task, }: { name: string; - task: () => Promise; - }): Promise { + task: () => Promise; + }): Promise { const idLog = `${name}/${this.model.idForLogging()}`; const ONE_SECOND = 1000; const TWO_SECONDS = 2000; @@ -690,7 +691,7 @@ Whisper.ConversationView = Whisper.View.extend({ // show a spinner until it's done try { window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`); - await task(); + const result = await task(); window.log.info( `longRunningTaskWrapper/${idLog}: Task completed successfully` ); @@ -710,6 +711,8 @@ Whisper.ConversationView = Whisper.View.extend({ progressView.remove(); progressView = undefined; } + + return result; } catch (error) { window.log.error( `longRunningTaskWrapper/${idLog}: Error!`, @@ -736,6 +739,8 @@ Whisper.ConversationView = Whisper.View.extend({ 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 - // mid-composition message content. - onClickPlaceholder(e: any) { - e.preventDefault(); + async startMigrationToGV2(): Promise { + const logId = this.model.idForLogging(); + + 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() { diff --git a/ts/window.d.ts b/ts/window.d.ts index 1f48aa6fd..e60e37c3c 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -36,6 +36,7 @@ import { createCallManager } from './state/roots/createCallManager'; import { createCompositionArea } from './state/roots/createCompositionArea'; import { createContactModal } from './state/roots/createContactModal'; import { createConversationHeader } from './state/roots/createConversationHeader'; +import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal'; import { createLeftPane } from './state/roots/createLeftPane'; import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer'; import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal'; @@ -430,6 +431,7 @@ declare global { createCompositionArea: typeof createCompositionArea; createContactModal: typeof createContactModal; createConversationHeader: typeof createConversationHeader; + createGroupV1MigrationModal: typeof createGroupV1MigrationModal; createLeftPane: typeof createLeftPane; createSafetyNumberViewer: typeof createSafetyNumberViewer; createShortcutGuideModal: typeof createShortcutGuideModal;