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 lint
- run: yarn lint-deps
- run: git diff --quiet --exit-code
- run: git diff --exit-code
macos:
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": {
"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)"

View file

@ -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,

View file

@ -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 {

View file

@ -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);
}

View file

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

View file

@ -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<GroupV1DisabledActionsPropsType, 'onStartGroupMigration'> &
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 (
<GroupV1DisabledActions
i18n={i18n}
onStartGroupMigration={onStartGroupMigration}
/>
);
}
return (
<div className="module-composition-area">
<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 (
<Manager>
<Reference>
@ -577,6 +584,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
<div className="module-composition-input__input" ref={ref}>
<div
ref={scrollerRef}
onClick={focus}
className={classNames(
'module-composition-input__input__scroller',
large

View file

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

View file

@ -19,7 +19,6 @@ export type DataPropsType = {
readonly droppedMembers: Array<ConversationType>;
readonly hasMigrated: boolean;
readonly invitedMembers: Array<ConversationType>;
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)}
</div>
{renderButtons(hasMigrated, onClose, learnMore, migrate, i18n)}
{renderButtons(hasMigrated, onClose, migrate, i18n)}
</div>
);
});
@ -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')}
</button>
<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
i18n={i18n}
invitedMembers={invitedMembers}
learnMore={() =>
window.log.warn('GroupV1Migration: Modal called learnMore()')
}
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 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> = {}): Props => ({
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,
haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false),

View file

@ -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<Props, State> {
export class Timeline extends React.PureComponent<PropsType, StateType> {
public cellSizeCache = new CellMeasurerCache({
defaultHeight: 64,
fixedWidth: true,
@ -153,7 +158,7 @@ export class Timeline extends React.PureComponent<Props, State> {
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<Props, State> {
};
}
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<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
};
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<Props, State> {
return (
<div
className="module-timeline"
className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
)}
role="presentation"
tabIndex={-1}
onBlur={this.handleBlur}

View file

@ -7,6 +7,7 @@ import {
difference,
flatten,
fromPairs,
isFinite,
isNumber,
values,
} from 'lodash';
@ -722,6 +723,168 @@ export async function isGroupEligibleToMigrate(
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,
// 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<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(
`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,
};
})
);
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,

View file

@ -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;
}

View file

@ -2079,33 +2079,42 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
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;
}

View file

@ -54,6 +54,7 @@ export type ConversationType = {
isAccepted?: boolean;
isArchived?: boolean;
isBlocked?: boolean;
isGroupV1AndDisabled?: boolean;
isPinned?: boolean;
isVerified?: boolean;
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 {
id,
...pick(conversation, ['unreadCount', 'typingContact']),
...pick(conversation, [
'unreadCount',
'typingContact',
'isGroupV1AndDisabled',
]),
...conversationMessages,
selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
i18n: getIntl(state),

View file

@ -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.')
);
}

View file

@ -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"
}

View file

@ -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<T>({
name,
task,
}: {
name: string;
task: () => Promise<void>;
}): Promise<void> {
task: () => Promise<T>;
}): Promise<T> {
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<void> {
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() {

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 { 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;