New feature flag with ability to migrate GV1 groups
This commit is contained in:
parent
089a6fb5a2
commit
2b8ae412e0
26 changed files with 608 additions and 189 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -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
|
||||||
|
|
|
@ -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)"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()} />;
|
||||||
|
});
|
49
ts/components/conversation/GroupV1DisabledActions.tsx
Normal file
49
ts/components/conversation/GroupV1DisabledActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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()')
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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}
|
||||||
|
|
326
ts/groups.ts
326
ts/groups.ts
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
28
ts/state/roots/createGroupV1MigrationModal.tsx
Normal file
28
ts/state/roots/createGroupV1MigrationModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
59
ts/state/smart/GroupV1MigrationDialog.tsx
Normal file
59
ts/state/smart/GroupV1MigrationDialog.tsx
Normal 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);
|
|
@ -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),
|
||||||
|
|
|
@ -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.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
2
ts/window.d.ts
vendored
|
@ -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;
|
||||||
|
|
Loading…
Add table
Reference in a new issue