+
{headerName}
{isNumber(headerDate) && (
@@ -137,7 +162,61 @@ export const BaseConversationListItem: FunctionComponent
= React.memo
) : null}
-
+ {checkboxNode}
+ >
+ );
+
+ const commonClassNames = classNames(BASE_CLASS_NAME, {
+ [`${BASE_CLASS_NAME}--has-unread`]: isUnread,
+ [`${BASE_CLASS_NAME}--is-selected`]: isSelected,
+ });
+
+ if (isCheckbox) {
+ return (
+
+ );
+ }
+
+ if (onClick) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {contents}
+
);
}
);
diff --git a/ts/components/conversationList/ContactCheckbox.tsx b/ts/components/conversationList/ContactCheckbox.tsx
new file mode 100644
index 000000000..e28ea7bfc
--- /dev/null
+++ b/ts/components/conversationList/ContactCheckbox.tsx
@@ -0,0 +1,97 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { CSSProperties, FunctionComponent } from 'react';
+
+import { BaseConversationListItem } from './BaseConversationListItem';
+import { ColorType } from '../../types/Colors';
+import { LocalizerType } from '../../types/Util';
+import { ContactName } from '../conversation/ContactName';
+import { About } from '../conversation/About';
+
+export enum ContactCheckboxDisabledReason {
+ // We start the enum at 1 because the default starting value of 0 is falsy.
+ MaximumContactsSelected = 1,
+ NotCapable,
+}
+
+export type PropsDataType = {
+ about?: string;
+ avatarPath?: string;
+ color?: ColorType;
+ disabledReason?: ContactCheckboxDisabledReason;
+ id: string;
+ isChecked: boolean;
+ name?: string;
+ phoneNumber?: string;
+ profileName?: string;
+ title: string;
+};
+
+type PropsHousekeepingType = {
+ i18n: LocalizerType;
+ style: CSSProperties;
+ onClick: (
+ id: string,
+ disabledReason: undefined | ContactCheckboxDisabledReason
+ ) => void;
+};
+
+type PropsType = PropsDataType & PropsHousekeepingType;
+
+export const ContactCheckbox: FunctionComponent
= React.memo(
+ ({
+ about,
+ avatarPath,
+ color,
+ disabledReason,
+ i18n,
+ id,
+ isChecked,
+ name,
+ onClick,
+ phoneNumber,
+ profileName,
+ style,
+ title,
+ }) => {
+ const disabled = Boolean(disabledReason);
+
+ const headerName = (
+
+ );
+
+ const messageText = about ? : null;
+
+ const onClickItem = () => {
+ onClick(id, disabledReason);
+ };
+
+ return (
+
+ );
+ }
+);
diff --git a/ts/components/conversationList/ContactListItem.tsx b/ts/components/conversationList/ContactListItem.tsx
index 7632ad6f6..5f031a3c9 100644
--- a/ts/components/conversationList/ContactListItem.tsx
+++ b/ts/components/conversationList/ContactListItem.tsx
@@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React, { useCallback, CSSProperties, FunctionComponent } from 'react';
+import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ColorType } from '../../types/Colors';
@@ -25,7 +25,7 @@ export type PropsDataType = {
type PropsHousekeepingType = {
i18n: LocalizerType;
style: CSSProperties;
- onClick: (id: string) => void;
+ onClick?: (id: string) => void;
};
type PropsType = PropsDataType & PropsHousekeepingType;
@@ -61,8 +61,6 @@ export const ContactListItem: FunctionComponent = React.memo(
const messageText =
about && !isMe ? : null;
- const onClickItem = useCallback(() => onClick(id), [onClick, id]);
-
return (
= React.memo(
isSelected={false}
messageText={messageText}
name={name}
- onClick={onClickItem}
+ onClick={onClick ? () => onClick(id) : undefined}
phoneNumber={phoneNumber}
profileName={profileName}
style={style}
diff --git a/ts/components/conversationList/CreateNewGroupButton.tsx b/ts/components/conversationList/CreateNewGroupButton.tsx
new file mode 100644
index 000000000..865e5858f
--- /dev/null
+++ b/ts/components/conversationList/CreateNewGroupButton.tsx
@@ -0,0 +1,32 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { CSSProperties, FunctionComponent } from 'react';
+
+import { BaseConversationListItem } from './BaseConversationListItem';
+import { LocalizerType } from '../../types/Util';
+
+type PropsType = {
+ i18n: LocalizerType;
+ onClick: () => void;
+ style: CSSProperties;
+};
+
+export const CreateNewGroupButton: FunctionComponent = React.memo(
+ ({ i18n, onClick, style }) => {
+ const title = i18n('createNewGroupButton');
+
+ return (
+
+ );
+ }
+);
diff --git a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx
new file mode 100644
index 000000000..43e8dadef
--- /dev/null
+++ b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx
@@ -0,0 +1,304 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { ReactChild, ChangeEvent } from 'react';
+
+import { LeftPaneHelper } from './LeftPaneHelper';
+import { Row, RowType } from '../ConversationList';
+import { ConversationType } from '../../state/ducks/conversations';
+import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox';
+import { ContactPills } from '../ContactPills';
+import { ContactPill } from '../ContactPill';
+import { Alert } from '../Alert';
+import { Button } from '../Button';
+import { LocalizerType } from '../../types/Util';
+import {
+ getGroupSizeRecommendedLimit,
+ getGroupSizeHardLimit,
+} from '../../groups/limits';
+
+export type LeftPaneChooseGroupMembersPropsType = {
+ candidateContacts: ReadonlyArray;
+ cantAddContactForModal: undefined | ConversationType;
+ isShowingRecommendedGroupSizeModal: boolean;
+ isShowingMaximumGroupSizeModal: boolean;
+ searchTerm: string;
+ selectedContacts: Array;
+};
+
+/* eslint-disable class-methods-use-this */
+
+export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
+ LeftPaneChooseGroupMembersPropsType
+> {
+ private readonly candidateContacts: ReadonlyArray;
+
+ private readonly cantAddContactForModal:
+ | undefined
+ | Readonly<{ title: string }>;
+
+ private readonly isShowingMaximumGroupSizeModal: boolean;
+
+ private readonly isShowingRecommendedGroupSizeModal: boolean;
+
+ private readonly searchTerm: string;
+
+ private readonly selectedContacts: Array;
+
+ private readonly selectedConversationIdsSet: Set;
+
+ constructor({
+ candidateContacts,
+ cantAddContactForModal,
+ isShowingMaximumGroupSizeModal,
+ isShowingRecommendedGroupSizeModal,
+ searchTerm,
+ selectedContacts,
+ }: Readonly) {
+ super();
+
+ this.candidateContacts = candidateContacts;
+ this.cantAddContactForModal = cantAddContactForModal;
+ this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal;
+ this.isShowingRecommendedGroupSizeModal = isShowingRecommendedGroupSizeModal;
+ this.searchTerm = searchTerm;
+ this.selectedContacts = selectedContacts;
+
+ this.selectedConversationIdsSet = new Set(
+ selectedContacts.map(contact => contact.id)
+ );
+ }
+
+ getHeaderContents({
+ i18n,
+ startComposing,
+ }: Readonly<{
+ i18n: LocalizerType;
+ startComposing: () => void;
+ }>): ReactChild {
+ const backButtonLabel = i18n('chooseGroupMembers__back-button');
+
+ return (
+
+
+
+ {i18n('chooseGroupMembers__title')}
+
+
+ );
+ }
+
+ getPreRowsNode({
+ closeCantAddContactToGroupModal,
+ closeMaximumGroupSizeModal,
+ closeRecommendedGroupSizeModal,
+ i18n,
+ onChangeComposeSearchTerm,
+ removeSelectedContact,
+ }: Readonly<{
+ closeCantAddContactToGroupModal: () => unknown;
+ closeMaximumGroupSizeModal: () => unknown;
+ closeRecommendedGroupSizeModal: () => unknown;
+ i18n: LocalizerType;
+ onChangeComposeSearchTerm: (
+ event: ChangeEvent
+ ) => unknown;
+ removeSelectedContact: (conversationId: string) => unknown;
+ }>): ReactChild {
+ let modalDetails:
+ | undefined
+ | { title: string; body: string; onClose: () => void };
+ if (this.isShowingMaximumGroupSizeModal) {
+ modalDetails = {
+ title: i18n('chooseGroupMembers__maximum-group-size__title'),
+ body: i18n('chooseGroupMembers__maximum-group-size__body', [
+ this.getMaximumNumberOfContacts().toString(),
+ ]),
+ onClose: closeMaximumGroupSizeModal,
+ };
+ } else if (this.isShowingRecommendedGroupSizeModal) {
+ modalDetails = {
+ title: i18n(
+ 'chooseGroupMembers__maximum-recommended-group-size__title'
+ ),
+ body: i18n('chooseGroupMembers__maximum-recommended-group-size__body', [
+ this.getRecommendedMaximumNumberOfContacts().toString(),
+ ]),
+ onClose: closeRecommendedGroupSizeModal,
+ };
+ } else if (this.cantAddContactForModal) {
+ modalDetails = {
+ title: i18n('chooseGroupMembers__cant-add-member__title'),
+ body: i18n('chooseGroupMembers__cant-add-member__body', [
+ this.cantAddContactForModal.title,
+ ]),
+ onClose: closeCantAddContactToGroupModal,
+ };
+ }
+
+ return (
+ <>
+
+
+
+
+ {Boolean(this.selectedContacts.length) && (
+
+ {this.selectedContacts.map(contact => (
+
+ ))}
+
+ )}
+
+ {this.getRowCount() ? null : (
+
+ {i18n('newConversationNoContacts')}
+
+ )}
+
+ {modalDetails && (
+
+ )}
+ >
+ );
+ }
+
+ getFooterContents({
+ i18n,
+ startSettingGroupMetadata,
+ }: Readonly<{
+ i18n: LocalizerType;
+ startSettingGroupMetadata: () => void;
+ }>): ReactChild {
+ return (
+
+ );
+ }
+
+ getRowCount(): number {
+ if (!this.candidateContacts.length) {
+ return 0;
+ }
+ return this.candidateContacts.length + 2;
+ }
+
+ getRow(rowIndex: number): undefined | Row {
+ if (!this.candidateContacts.length) {
+ return undefined;
+ }
+
+ if (rowIndex === 0) {
+ return {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ };
+ }
+
+ // This puts a blank row for the footer.
+ if (rowIndex === this.candidateContacts.length + 1) {
+ return { type: RowType.Blank };
+ }
+
+ const contact = this.candidateContacts[rowIndex - 1];
+ if (!contact) {
+ return undefined;
+ }
+
+ const isChecked = this.selectedConversationIdsSet.has(contact.id);
+
+ let disabledReason: undefined | ContactCheckboxDisabledReason;
+ if (!isChecked) {
+ if (this.hasSelectedMaximumNumberOfContacts()) {
+ disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
+ } else if (!contact.isGroupV2Capable) {
+ disabledReason = ContactCheckboxDisabledReason.NotCapable;
+ }
+ }
+
+ return {
+ type: RowType.ContactCheckbox,
+ contact,
+ isChecked,
+ disabledReason,
+ };
+ }
+
+ // This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
+ // the composer. The same is true for the "in direction" function below.
+ getConversationAndMessageAtIndex(
+ ..._args: ReadonlyArray
+ ): undefined {
+ return undefined;
+ }
+
+ getConversationAndMessageInDirection(
+ ..._args: ReadonlyArray
+ ): undefined {
+ return undefined;
+ }
+
+ shouldRecomputeRowHeights(_old: unknown): boolean {
+ return false;
+ }
+
+ private hasSelectedMaximumNumberOfContacts(): boolean {
+ return this.selectedContacts.length >= this.getMaximumNumberOfContacts();
+ }
+
+ private hasExceededMaximumNumberOfContacts(): boolean {
+ // It should be impossible to reach this state. This is here as a failsafe.
+ return this.selectedContacts.length > this.getMaximumNumberOfContacts();
+ }
+
+ private getRecommendedMaximumNumberOfContacts(): number {
+ return getGroupSizeRecommendedLimit(151) - 1;
+ }
+
+ private getMaximumNumberOfContacts(): number {
+ return getGroupSizeHardLimit(1001) - 1;
+ }
+}
+
+function focusRef(el: HTMLElement | null) {
+ if (el) {
+ el.focus();
+ }
+}
diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx
index 5800c8101..f232d65e0 100644
--- a/ts/components/leftPane/LeftPaneComposeHelper.tsx
+++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx
@@ -12,6 +12,9 @@ import {
instance as phoneNumberInstance,
PhoneNumberFormat,
} from '../../util/libphonenumberInstance';
+import { assert } from '../../util/assert';
+import { missingCaseError } from '../../util/missingCaseError';
+import { isStorageWriteFeatureEnabled } from '../../storage/isFeatureEnabled';
export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray;
@@ -19,6 +22,12 @@ export type LeftPaneComposePropsType = {
searchTerm: string;
};
+enum TopButton {
+ None,
+ CreateNewGroup,
+ StartNewConversation,
+}
+
/* eslint-disable class-methods-use-this */
export class LeftPaneComposeHelper extends LeftPaneHelper<
@@ -98,24 +107,53 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
}
getRowCount(): number {
- return this.composeContacts.length + (this.phoneNumber ? 1 : 0);
+ let result = this.composeContacts.length;
+ if (this.hasTopButton()) {
+ result += 1;
+ }
+ if (this.hasContactsHeader()) {
+ result += 1;
+ }
+ return result;
}
getRow(rowIndex: number): undefined | Row {
- let contactIndex = rowIndex;
-
- if (this.phoneNumber) {
- if (rowIndex === 0) {
- return {
- type: RowType.StartNewConversation,
- phoneNumber: phoneNumberInstance.format(
+ if (rowIndex === 0) {
+ const topButton = this.getTopButton();
+ switch (topButton) {
+ case TopButton.None:
+ break;
+ case TopButton.StartNewConversation:
+ assert(
this.phoneNumber,
- PhoneNumberFormat.E164
- ),
- };
+ 'LeftPaneComposeHelper: we should have a phone number if the top button is "Start new conversation"'
+ );
+ return {
+ type: RowType.StartNewConversation,
+ phoneNumber: phoneNumberInstance.format(
+ this.phoneNumber,
+ PhoneNumberFormat.E164
+ ),
+ };
+ case TopButton.CreateNewGroup:
+ return { type: RowType.CreateNewGroup };
+ default:
+ throw missingCaseError(topButton);
}
+ }
- contactIndex -= 1;
+ if (rowIndex === 1 && this.hasContactsHeader()) {
+ return {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ };
+ }
+
+ let contactIndex: number;
+ if (this.hasTopButton()) {
+ contactIndex = rowIndex - 2;
+ } else {
+ contactIndex = rowIndex;
}
const contact = this.composeContacts[contactIndex];
@@ -141,8 +179,29 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
return undefined;
}
- shouldRecomputeRowHeights(_old: unknown): boolean {
- return false;
+ shouldRecomputeRowHeights(old: Readonly): boolean {
+ return (
+ this.hasContactsHeader() !==
+ new LeftPaneComposeHelper(old).hasContactsHeader()
+ );
+ }
+
+ private getTopButton(): TopButton {
+ if (this.phoneNumber) {
+ return TopButton.StartNewConversation;
+ }
+ if (this.searchTerm || !isStorageWriteFeatureEnabled()) {
+ return TopButton.None;
+ }
+ return TopButton.CreateNewGroup;
+ }
+
+ private hasTopButton(): boolean {
+ return this.getTopButton() !== TopButton.None;
+ }
+
+ private hasContactsHeader(): boolean {
+ return this.hasTopButton() && Boolean(this.composeContacts.length);
}
}
diff --git a/ts/components/leftPane/LeftPaneHelper.tsx b/ts/components/leftPane/LeftPaneHelper.tsx
index b51e6ade9..fa438abc6 100644
--- a/ts/components/leftPane/LeftPaneHelper.tsx
+++ b/ts/components/leftPane/LeftPaneHelper.tsx
@@ -23,6 +23,8 @@ export abstract class LeftPaneHelper {
_: Readonly<{
i18n: LocalizerType;
showInbox: () => void;
+ startComposing: () => void;
+ showChooseGroupMembers: () => void;
}>
): null | ReactChild {
return null;
@@ -34,10 +36,28 @@ export abstract class LeftPaneHelper {
getPreRowsNode(
_: Readonly<{
+ clearGroupCreationError: () => void;
+ closeCantAddContactToGroupModal: () => unknown;
+ closeMaximumGroupSizeModal: () => unknown;
+ closeRecommendedGroupSizeModal: () => unknown;
+ createGroup: () => unknown;
i18n: LocalizerType;
+ setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
+ setComposeGroupName: (_: string) => unknown;
onChangeComposeSearchTerm: (
event: ChangeEvent
) => unknown;
+ removeSelectedContact: (_: string) => unknown;
+ }>
+ ): null | ReactChild {
+ return null;
+ }
+
+ getFooterContents(
+ _: Readonly<{
+ i18n: LocalizerType;
+ startSettingGroupMetadata: () => void;
+ createGroup: () => unknown;
}>
): null | ReactChild {
return null;
diff --git a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
new file mode 100644
index 000000000..9b3740e50
--- /dev/null
+++ b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
@@ -0,0 +1,218 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { ReactChild } from 'react';
+
+import { LeftPaneHelper } from './LeftPaneHelper';
+import { Row, RowType } from '../ConversationList';
+import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem';
+import { LocalizerType } from '../../types/Util';
+import { AvatarInput } from '../AvatarInput';
+import { Alert } from '../Alert';
+import { Spinner } from '../Spinner';
+import { Button } from '../Button';
+
+export type LeftPaneSetGroupMetadataPropsType = {
+ groupAvatar: undefined | ArrayBuffer;
+ groupName: string;
+ hasError: boolean;
+ isCreating: boolean;
+ selectedContacts: ReadonlyArray;
+};
+
+/* eslint-disable class-methods-use-this */
+
+export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<
+ LeftPaneSetGroupMetadataPropsType
+> {
+ private readonly groupAvatar: undefined | ArrayBuffer;
+
+ private readonly groupName: string;
+
+ private readonly hasError: boolean;
+
+ private readonly isCreating: boolean;
+
+ private readonly selectedContacts: ReadonlyArray;
+
+ constructor({
+ groupAvatar,
+ groupName,
+ isCreating,
+ hasError,
+ selectedContacts,
+ }: Readonly) {
+ super();
+
+ this.groupAvatar = groupAvatar;
+ this.groupName = groupName;
+ this.hasError = hasError;
+ this.isCreating = isCreating;
+ this.selectedContacts = selectedContacts;
+ }
+
+ getHeaderContents({
+ i18n,
+ showChooseGroupMembers,
+ }: Readonly<{
+ i18n: LocalizerType;
+ showChooseGroupMembers: () => void;
+ }>): ReactChild {
+ const backButtonLabel = i18n('setGroupMetadata__back-button');
+
+ return (
+
+
+
+ {i18n('setGroupMetadata__title')}
+
+
+ );
+ }
+
+ getPreRowsNode({
+ clearGroupCreationError,
+ createGroup,
+ i18n,
+ setComposeGroupAvatar,
+ setComposeGroupName,
+ }: Readonly<{
+ clearGroupCreationError: () => unknown;
+ createGroup: () => unknown;
+ i18n: LocalizerType;
+ setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
+ setComposeGroupName: (_: string) => unknown;
+ }>): ReactChild {
+ const disabled = this.isCreating;
+
+ return (
+
+ );
+ }
+
+ getFooterContents({
+ createGroup,
+ i18n,
+ }: Readonly<{
+ createGroup: () => unknown;
+ i18n: LocalizerType;
+ }>): ReactChild {
+ return (
+
+ );
+ }
+
+ getRowCount(): number {
+ if (!this.selectedContacts.length) {
+ return 0;
+ }
+ return this.selectedContacts.length + 2;
+ }
+
+ getRow(rowIndex: number): undefined | Row {
+ if (!this.selectedContacts.length) {
+ return undefined;
+ }
+
+ if (rowIndex === 0) {
+ return {
+ type: RowType.Header,
+ i18nKey: 'setGroupMetadata__members-header',
+ };
+ }
+
+ // This puts a blank row for the footer.
+ if (rowIndex === this.selectedContacts.length + 1) {
+ return { type: RowType.Blank };
+ }
+
+ const contact = this.selectedContacts[rowIndex - 1];
+ return contact
+ ? {
+ type: RowType.Contact,
+ contact,
+ isClickable: false,
+ }
+ : undefined;
+ }
+
+ // This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
+ // the composer. The same is true for the "in direction" function below.
+ getConversationAndMessageAtIndex(
+ ..._args: ReadonlyArray
+ ): undefined {
+ return undefined;
+ }
+
+ getConversationAndMessageInDirection(
+ ..._args: ReadonlyArray
+ ): undefined {
+ return undefined;
+ }
+
+ shouldRecomputeRowHeights(_old: unknown): boolean {
+ return false;
+ }
+
+ private canCreateGroup(): boolean {
+ return !this.isCreating && Boolean(this.groupName.trim());
+ }
+}
+
+function focusRef(el: HTMLElement | null) {
+ if (el) {
+ el.focus();
+ }
+}
diff --git a/ts/groups.ts b/ts/groups.ts
index c7e7d8313..568cc56b5 100644
--- a/ts/groups.ts
+++ b/ts/groups.ts
@@ -7,7 +7,6 @@ import {
difference,
flatten,
fromPairs,
- isFinite,
isNumber,
values,
} from 'lodash';
@@ -18,8 +17,10 @@ import {
GROUP_CREDENTIALS_KEY,
maybeFetchNewCredentials,
} from './services/groupCredentialFetcher';
+import { isStorageWriteFeatureEnabled } from './storage/isFeatureEnabled';
import dataInterface from './sql/Client';
import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64';
+import { assert } from './util/assert';
import {
ConversationAttributesType,
GroupV2MemberType,
@@ -72,6 +73,7 @@ import {
import MessageSender, { CallbackResultType } from './textsecure/SendMessage';
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
import { ConversationModel } from './models/conversations';
+import { getGroupSizeHardLimit } from './groups/limits';
export { joinViaLink } from './groups/joinViaLink';
@@ -222,6 +224,12 @@ type UpdatesResultType = {
newAttributes: ConversationAttributesType;
};
+type UploadedAvatarType = {
+ data: ArrayBuffer;
+ hash: string;
+ key: string;
+};
+
// Constants
export const MASTER_KEY_LENGTH = 32;
@@ -324,21 +332,25 @@ export function parseGroupLink(
// Group Modifications
-async function uploadAvatar({
- logId,
- path,
- publicParams,
- secretParams,
-}: {
- logId: string;
- path: string;
- publicParams: string;
- secretParams: string;
-}): Promise<{ hash: string; key: string }> {
+async function uploadAvatar(
+ options: {
+ logId: string;
+ publicParams: string;
+ secretParams: string;
+ } & ({ path: string } | { data: ArrayBuffer })
+): Promise {
+ const { logId, publicParams, secretParams } = options;
+
try {
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
- const data = await window.Signal.Migrations.readAttachmentData(path);
+ let data: ArrayBuffer;
+ if ('data' in options) {
+ ({ data } = options);
+ } else {
+ data = await window.Signal.Migrations.readAttachmentData(options.path);
+ }
+
const hash = await computeHash(data);
const blob = new window.textsecure.protobuf.GroupAttributeBlob();
@@ -350,13 +362,14 @@ async function uploadAvatar({
logId: `uploadGroupAvatar/${logId}`,
publicParams,
secretParams,
- request: (sender, options) =>
- sender.uploadGroupAvatar(ciphertext, options),
+ request: (sender, requestOptions) =>
+ sender.uploadGroupAvatar(ciphertext, requestOptions),
});
return {
- key,
+ data,
hash,
+ key,
};
} catch (error) {
window.log.warn(
@@ -367,11 +380,22 @@ async function uploadAvatar({
}
}
-async function buildGroupProto({
- attributes,
-}: {
- attributes: ConversationAttributesType;
-}): Promise {
+function buildGroupProto(
+ attributes: Pick<
+ ConversationAttributesType,
+ | 'accessControl'
+ | 'expireTimer'
+ | 'id'
+ | 'membersV2'
+ | 'name'
+ | 'pendingMembersV2'
+ | 'publicParams'
+ | 'revision'
+ | 'secretParams'
+ > & {
+ avatarUrl?: string;
+ }
+): GroupClass {
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const logId = `groupv2(${attributes.id})`;
@@ -404,21 +428,8 @@ async function buildGroupProto({
const titleBlobPlaintext = titleBlob.toArrayBuffer();
proto.title = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext);
- if (attributes.avatar && attributes.avatar.path) {
- const { path } = attributes.avatar;
- const { key, hash } = await uploadAvatar({
- logId,
- path,
- publicParams,
- secretParams,
- });
-
- // eslint-disable-next-line no-param-reassign
- attributes.avatar.hash = hash;
- // eslint-disable-next-line no-param-reassign
- attributes.avatar.url = key;
-
- proto.avatar = key;
+ if (attributes.avatarUrl) {
+ proto.avatar = attributes.avatarUrl;
}
if (attributes.expireTimer) {
@@ -1159,6 +1170,237 @@ export async function fetchMembershipProof({
return response.token;
}
+// Creating a group
+
+export async function createGroupV2({
+ name,
+ avatar,
+ conversationIds,
+}: Readonly<{
+ name: string;
+ avatar: undefined | ArrayBuffer;
+ conversationIds: Array;
+}>): Promise {
+ // Ensure we have the credentials we need before attempting GroupsV2 operations
+ await maybeFetchNewCredentials();
+
+ if (!isStorageWriteFeatureEnabled()) {
+ throw new Error(
+ 'createGroupV2: storage service write is not enabled. Cannot create the group'
+ );
+ }
+
+ const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
+ const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
+
+ const masterKeyBuffer = getRandomBytes(32);
+ const fields = deriveGroupFields(masterKeyBuffer);
+
+ const groupId = arrayBufferToBase64(fields.id);
+ const logId = `groupv2(${groupId})`;
+
+ const masterKey = arrayBufferToBase64(masterKeyBuffer);
+ const secretParams = arrayBufferToBase64(fields.secretParams);
+ const publicParams = arrayBufferToBase64(fields.publicParams);
+
+ const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
+ const ourConversation = window.ConversationController.get(ourConversationId);
+ if (!ourConversation) {
+ throw new Error(
+ `createGroupV2/${logId}: cannot get our own conversation. Cannot create the group`
+ );
+ }
+
+ const membersV2: Array = [
+ {
+ conversationId: ourConversationId,
+ role: MEMBER_ROLE_ENUM.ADMINISTRATOR,
+ joinedAtVersion: 0,
+ },
+ ];
+ const pendingMembersV2: Array = [];
+
+ let uploadedAvatar: undefined | UploadedAvatarType;
+
+ await Promise.all([
+ ...conversationIds.map(async conversationId => {
+ const contact = window.ConversationController.get(conversationId);
+ if (!contact) {
+ assert(
+ false,
+ `createGroupV2/${logId}: missing local contact, skipping`
+ );
+ return;
+ }
+
+ if (!contact.get('uuid')) {
+ assert(false, `createGroupV2/${logId}: missing UUID; skipping`);
+ return;
+ }
+
+ // Refresh our local data to be sure
+ if (
+ !contact.get('capabilities')?.gv2 ||
+ !contact.get('profileKey') ||
+ !contact.get('profileKeyCredential')
+ ) {
+ await contact.getProfiles();
+ }
+
+ if (!contact.get('capabilities')?.gv2) {
+ assert(
+ false,
+ `createGroupV2/${logId}: member is missing GV2 capability; skipping`
+ );
+ return;
+ }
+
+ if (contact.get('profileKey') && contact.get('profileKeyCredential')) {
+ membersV2.push({
+ conversationId,
+ role: MEMBER_ROLE_ENUM.DEFAULT,
+ joinedAtVersion: 0,
+ });
+ } else {
+ pendingMembersV2.push({
+ addedByUserId: ourConversationId,
+ conversationId,
+ timestamp: Date.now(),
+ role: MEMBER_ROLE_ENUM.DEFAULT,
+ });
+ }
+ }),
+ (async () => {
+ if (!avatar) {
+ return;
+ }
+
+ uploadedAvatar = await uploadAvatar({
+ data: avatar,
+ logId,
+ publicParams,
+ secretParams,
+ });
+ })(),
+ ]);
+
+ if (membersV2.length + pendingMembersV2.length > getGroupSizeHardLimit()) {
+ throw new Error(
+ `createGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
+ );
+ }
+
+ const protoAndConversationAttributes = {
+ name,
+
+ // Core GroupV2 info
+ revision: 0,
+ publicParams,
+ secretParams,
+
+ // GroupV2 state
+ accessControl: {
+ attributes: ACCESS_ENUM.MEMBER,
+ members: ACCESS_ENUM.MEMBER,
+ addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
+ },
+ membersV2,
+ pendingMembersV2,
+ };
+
+ const groupProto = await buildGroupProto({
+ id: groupId,
+ avatarUrl: uploadedAvatar?.key,
+ ...protoAndConversationAttributes,
+ });
+
+ await makeRequestWithTemporalRetry({
+ logId: `createGroupV2/${logId}`,
+ publicParams,
+ secretParams,
+ request: (sender, options) => sender.createGroup(groupProto, options),
+ });
+
+ let avatarAttribute: ConversationAttributesType['avatar'];
+ if (uploadedAvatar) {
+ try {
+ avatarAttribute = {
+ url: uploadedAvatar.key,
+ path: await window.Signal.Migrations.writeNewAttachmentData(
+ uploadedAvatar.data
+ ),
+ hash: uploadedAvatar.hash,
+ };
+ } catch (err) {
+ window.log.warn(
+ `createGroupV2/${logId}: avatar failed to save to disk. Continuing on`
+ );
+ }
+ }
+
+ const now = Date.now();
+
+ const conversation = await window.ConversationController.getOrCreateAndWait(
+ groupId,
+ 'group',
+ {
+ ...protoAndConversationAttributes,
+ active_at: now,
+ addedBy: ourConversationId,
+ avatar: avatarAttribute,
+ groupVersion: 2,
+ masterKey,
+ profileSharing: true,
+ timestamp: now,
+ needsStorageServiceSync: true,
+ }
+ );
+
+ await conversation.queueJob(() => {
+ window.Signal.Services.storageServiceUploadJob();
+ });
+
+ const timestamp = Date.now();
+ const profileKey = ourConversation.get('profileKey');
+
+ const groupV2Info = conversation.getGroupV2Info({
+ includePendingMembers: true,
+ });
+
+ await wrapWithSyncMessageSend({
+ conversation,
+ logId: `sendMessageToGroup/${logId}`,
+ send: async sender =>
+ sender.sendMessageToGroup({
+ groupV2: groupV2Info,
+ timestamp,
+ profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined,
+ }),
+ timestamp,
+ });
+
+ const createdTheGroupMessage: MessageAttributesType = {
+ ...generateBasicMessage(),
+ type: 'group-v2-change',
+ sourceUuid: conversation.ourUuid,
+ conversationId: conversation.id,
+ received_at: timestamp,
+ sent_at: timestamp,
+ groupV2Change: {
+ from: ourConversationId,
+ details: [{ type: 'create' }],
+ },
+ };
+ await window.Signal.Data.saveMessages([createdTheGroupMessage], {
+ forceSave: true,
+ });
+ const model = new window.Whisper.Message(createdTheGroupMessage);
+ window.MessageController.register(model.id, model);
+ conversation.trigger('newmessage', model);
+
+ return conversation;
+}
+
// Migrating a group
export async function hasV1GroupBeenMigrated(
@@ -1451,6 +1693,8 @@ export async function initiateMigrationToGroupV2(
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
+ let ourProfileKey: undefined | string;
+
try {
await conversation.queueJob(async () => {
const ACCESS_ENUM =
@@ -1485,6 +1729,15 @@ export async function initiateMigrationToGroupV2(
`initiateMigrationToGroupV2/${logId}: Couldn't fetch our own conversationId!`
);
}
+ const ourConversation = window.ConversationController.get(
+ ourConversationId
+ );
+ if (!ourConversation) {
+ throw new Error(
+ `initiateMigrationToGroupV2/${logId}: cannot get our own conversation. Cannot migrate`
+ );
+ }
+ ourProfileKey = ourConversation.get('profileKey');
const {
membersV2,
@@ -1493,33 +1746,37 @@ export async function initiateMigrationToGroupV2(
previousGroupV1Members,
} = await getGroupMigrationMembers(conversation);
- 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) {
+ if (
+ membersV2.length + pendingMembersV2.length >
+ getGroupSizeHardLimit()
+ ) {
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
// - expireTimer
+ let avatarAttribute: ConversationAttributesType['avatar'];
+ const avatarPath = conversation.attributes.avatar?.path;
+ if (avatarPath) {
+ const { hash, key } = await uploadAvatar({
+ logId,
+ publicParams,
+ secretParams,
+ path: avatarPath,
+ });
+ avatarAttribute = {
+ url: key,
+ path: avatarPath,
+ hash,
+ };
+ }
const newAttributes = {
...conversation.attributes,
+ avatar: avatarAttribute,
// Core GroupV2 info
revision: 0,
@@ -1550,12 +1807,10 @@ export async function initiateMigrationToGroupV2(
members: undefined,
};
- const groupProto = await buildGroupProto({ attributes: newAttributes });
-
- // Capture the CDK key provided by the server when we uploade
- if (groupProto.avatar && newAttributes.avatar) {
- newAttributes.avatar.url = groupProto.avatar;
- }
+ const groupProto = buildGroupProto({
+ ...newAttributes,
+ avatarUrl: avatarAttribute?.url,
+ });
try {
await makeRequestWithTemporalRetry({
@@ -1621,7 +1876,6 @@ export async function initiateMigrationToGroupV2(
// We've migrated the group, now we need to let all other group members know about it
const logId = conversation.idForLogging();
const timestamp = Date.now();
- const profileKey = conversation.get('profileKey');
await wrapWithSyncMessageSend({
conversation,
@@ -1633,7 +1887,9 @@ export async function initiateMigrationToGroupV2(
includePendingMembers: true,
}),
timestamp,
- profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined,
+ profileKey: ourProfileKey
+ ? base64ToArrayBuffer(ourProfileKey)
+ : undefined,
}),
timestamp,
});
diff --git a/ts/groups/limits.ts b/ts/groups/limits.ts
new file mode 100644
index 000000000..9cce18fce
--- /dev/null
+++ b/ts/groups/limits.ts
@@ -0,0 +1,29 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { isNumber } from 'lodash';
+import { parseIntOrThrow } from '../util/parseIntOrThrow';
+import { getValue, ConfigKeyType } from '../RemoteConfig';
+
+function makeGetter(configKey: ConfigKeyType): (fallback?: number) => number {
+ return fallback => {
+ try {
+ return parseIntOrThrow(
+ getValue(configKey),
+ 'Failed to parse group size limit'
+ );
+ } catch (err) {
+ if (isNumber(fallback)) {
+ return fallback;
+ }
+ throw err;
+ }
+ };
+}
+
+export const getGroupSizeRecommendedLimit = makeGetter(
+ 'global.groupsv2.maxGroupSize'
+);
+export const getGroupSizeHardLimit = makeGetter(
+ 'global.groupsv2.groupSizeHardLimit'
+);
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index 14037650c..4fd817e53 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -1320,6 +1320,9 @@ export class ConversationModel extends window.Backbone.Model<
isBlocked: this.isBlocked(),
isMe: this.isMe(),
isGroupV1AndDisabled: this.isGroupV1AndDisabled(),
+ isGroupV2Capable: this.isPrivate()
+ ? Boolean(this.get('capabilities')?.gv2)
+ : undefined,
isPinned: this.get('isPinned'),
isUntrusted: this.isUntrusted(),
isVerified: this.isVerified(),
diff --git a/ts/services/storage.ts b/ts/services/storage.ts
index aef9b3919..053aab740 100644
--- a/ts/services/storage.ts
+++ b/ts/services/storage.ts
@@ -1,4 +1,4 @@
-// Copyright 2020 Signal Messenger, LLC
+// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { debounce, isNumber, partition } from 'lodash';
@@ -19,7 +19,6 @@ import {
StorageManifestClass,
StorageRecordClass,
} from '../textsecure.d';
-import { isEnabled } from '../RemoteConfig';
import {
mergeAccountRecord,
mergeContactRecord,
@@ -33,6 +32,7 @@ import {
import { ConversationModel } from '../models/conversations';
import { storageJobQueue } from '../util/JobQueue';
import { sleep } from '../util/sleep';
+import { isStorageWriteFeatureEnabled } from '../storage/isFeatureEnabled';
const {
eraseStorageServiceStateFromConversations,
@@ -882,7 +882,7 @@ async function processManifest(
}
async function sync(): Promise {
- if (!isEnabled('desktop.storage')) {
+ if (!isStorageWriteFeatureEnabled()) {
window.log.info(
'storageService.sync: Not starting desktop.storage is falsey'
);
@@ -946,16 +946,9 @@ async function sync(): Promise {
}
async function upload(): Promise {
- if (!isEnabled('desktop.storage')) {
+ if (!isStorageWriteFeatureEnabled()) {
window.log.info(
- 'storageService.upload: Not starting desktop.storage is falsey'
- );
-
- return;
- }
- if (!isEnabled('desktop.storageWrite2')) {
- window.log.info(
- 'storageService.upload: Not starting desktop.storageWrite2 is falsey'
+ 'storageService.upload: Not starting because the feature is not enabled'
);
return;
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index 1777ea356..b6767fa7a 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -16,6 +16,7 @@ import {
} from 'lodash';
import { StateType as RootStateType } from '../reducer';
+import * as groups from '../../groups';
import { calling } from '../../services/calling';
import { getOwn } from '../../util/getOwn';
import { assert } from '../../util/assert';
@@ -30,6 +31,10 @@ import {
} from '../../components/conversation/conversation-details/PendingInvites';
import { GroupV2Membership } from '../../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { MediaItemType } from '../../components/LightboxGallery';
+import {
+ getGroupSizeRecommendedLimit,
+ getGroupSizeHardLimit,
+} from '../../groups/limits';
// State
@@ -70,6 +75,7 @@ export type ConversationType = {
isArchived?: boolean;
isBlocked?: boolean;
isGroupV1AndDisabled?: boolean;
+ isGroupV2Capable?: boolean;
isPinned?: boolean;
isUntrusted?: boolean;
isVerified?: boolean;
@@ -220,8 +226,47 @@ export type PreJoinConversationType = {
approvalRequired: boolean;
};
+export enum ComposerStep {
+ StartDirectConversation,
+ ChooseGroupMembers,
+ SetGroupMetadata,
+}
+
+export enum OneTimeModalState {
+ NeverShown,
+ Showing,
+ Shown,
+}
+
+type ComposerGroupCreationState = {
+ groupAvatar: undefined | ArrayBuffer;
+ groupName: string;
+ maximumGroupSizeModalState: OneTimeModalState;
+ recommendedGroupSizeModalState: OneTimeModalState;
+ selectedConversationIds: Array;
+};
+
+type ComposerStateType =
+ | {
+ step: ComposerStep.StartDirectConversation;
+ contactSearchTerm: string;
+ }
+ | ({
+ step: ComposerStep.ChooseGroupMembers;
+ contactSearchTerm: string;
+ cantAddContactIdForModal: undefined | string;
+ } & ComposerGroupCreationState)
+ | ({
+ step: ComposerStep.SetGroupMetadata;
+ } & ComposerGroupCreationState &
+ (
+ | { isCreating: false; hasError: boolean }
+ | { isCreating: true; hasError: false }
+ ));
+
export type ConversationsStateType = {
preJoinConversation?: PreJoinConversationType;
+ invitedConversationIdsForNewlyCreatedGroup?: Array;
conversationLookup: ConversationLookupType;
conversationsByE164: ConversationLookupType;
conversationsByUuid: ConversationLookupType;
@@ -232,9 +277,7 @@ export type ConversationsStateType = {
selectedConversationTitle?: string;
selectedConversationPanelDepth: number;
showArchived: boolean;
- composer?: {
- contactSearchTerm: string;
- };
+ composer?: ComposerStateType;
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
@@ -268,6 +311,25 @@ export const getConversationCallMode = (
// Actions
+type CantAddContactToGroupActionType = {
+ type: 'CANT_ADD_CONTACT_TO_GROUP';
+ payload: {
+ conversationId: string;
+ };
+};
+type ClearGroupCreationErrorActionType = { type: 'CLEAR_GROUP_CREATION_ERROR' };
+type ClearInvitedConversationsForNewlyCreatedGroupActionType = {
+ type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP';
+};
+type CloseCantAddContactToGroupModalActionType = {
+ type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL';
+};
+type CloseMaximumGroupSizeModalActionType = {
+ type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL';
+};
+type CloseRecommendedGroupSizeModalActionType = {
+ type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL';
+};
type SetPreJoinConversationActionType = {
type: 'SET_PRE_JOIN_CONVERSATION';
payload: {
@@ -301,6 +363,18 @@ export type ConversationUnloadedActionType = {
id: string;
};
};
+type CreateGroupPendingActionType = {
+ type: 'CREATE_GROUP_PENDING';
+};
+type CreateGroupFulfilledActionType = {
+ type: 'CREATE_GROUP_FULFILLED';
+ payload: {
+ invitedConversationIds: Array;
+ };
+};
+type CreateGroupRejectedActionType = {
+ type: 'CREATE_GROUP_REJECTED';
+};
export type RemoveAllConversationsActionType = {
type: 'CONVERSATIONS_REMOVE_ALL';
payload: null;
@@ -435,6 +509,14 @@ export type ShowArchivedConversationsActionType = {
type: 'SHOW_ARCHIVED_CONVERSATIONS';
payload: null;
};
+type SetComposeGroupAvatarActionType = {
+ type: 'SET_COMPOSE_GROUP_AVATAR';
+ payload: { groupAvatar: undefined | ArrayBuffer };
+};
+type SetComposeGroupNameActionType = {
+ type: 'SET_COMPOSE_GROUP_NAME';
+ payload: { groupName: string };
+};
type SetComposeSearchTermActionType = {
type: 'SET_COMPOSE_SEARCH_TERM';
payload: { contactSearchTerm: string };
@@ -449,19 +531,42 @@ type SetRecentMediaItemsActionType = {
type StartComposingActionType = {
type: 'START_COMPOSING';
};
+type ShowChooseGroupMembersActionType = {
+ type: 'SHOW_CHOOSE_GROUP_MEMBERS';
+};
+type StartSettingGroupMetadataActionType = {
+ type: 'START_SETTING_GROUP_METADATA';
+};
export type SwitchToAssociatedViewActionType = {
type: 'SWITCH_TO_ASSOCIATED_VIEW';
payload: { conversationId: string };
};
+export type ToggleConversationInChooseMembersActionType = {
+ type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS';
+ payload: {
+ conversationId: string;
+ maxRecommendedGroupSize: number;
+ maxGroupSize: number;
+ };
+};
export type ConversationActionType =
+ | CantAddContactToGroupActionType
| ClearChangedMessagesActionType
+ | ClearGroupCreationErrorActionType
+ | ClearInvitedConversationsForNewlyCreatedGroupActionType
| ClearSelectedMessageActionType
| ClearUnreadMetricsActionType
+ | CloseCantAddContactToGroupModalActionType
+ | CloseMaximumGroupSizeModalActionType
+ | CloseRecommendedGroupSizeModalActionType
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationUnloadedActionType
+ | CreateGroupFulfilledActionType
+ | CreateGroupPendingActionType
+ | CreateGroupRejectedActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessagesAddedActionType
@@ -473,6 +578,8 @@ export type ConversationActionType =
| RepairOldestMessageActionType
| ScrollToMessageActionType
| SelectedConversationChangedActionType
+ | SetComposeGroupAvatarActionType
+ | SetComposeGroupNameActionType
| SetComposeSearchTermActionType
| SetConversationHeaderTitleActionType
| SetIsNearBottomActionType
@@ -484,18 +591,28 @@ export type ConversationActionType =
| ShowArchivedConversationsActionType
| ShowInboxActionType
| StartComposingActionType
- | SwitchToAssociatedViewActionType;
+ | ShowChooseGroupMembersActionType
+ | StartSettingGroupMetadataActionType
+ | SwitchToAssociatedViewActionType
+ | ToggleConversationInChooseMembersActionType;
// Action Creators
export const actions = {
+ cantAddContactToGroup,
clearChangedMessages,
+ clearInvitedConversationsForNewlyCreatedGroup,
+ clearGroupCreationError,
clearSelectedMessage,
clearUnreadMetrics,
+ closeCantAddContactToGroupModal,
+ closeRecommendedGroupSizeModal,
+ closeMaximumGroupSizeModal,
conversationAdded,
conversationChanged,
conversationRemoved,
conversationUnloaded,
+ createGroup,
messageChanged,
messageDeleted,
messagesAdded,
@@ -508,6 +625,8 @@ export const actions = {
repairOldestMessage,
scrollToMessage,
selectMessage,
+ setComposeGroupAvatar,
+ setComposeGroupName,
setComposeSearchTerm,
setIsNearBottom,
setLoadCountdownStart,
@@ -519,9 +638,20 @@ export const actions = {
showArchivedConversations,
showInbox,
startComposing,
+ showChooseGroupMembers,
startNewConversationFromPhoneNumber,
+ startSettingGroupMetadata,
+ toggleConversationInChooseMembers,
};
+function cantAddContactToGroup(
+ conversationId: string
+): CantAddContactToGroupActionType {
+ return {
+ type: 'CANT_ADD_CONTACT_TO_GROUP',
+ payload: { conversationId },
+ };
+}
function setPreJoinConversation(
data: PreJoinConversationType | undefined
): SetPreJoinConversationActionType {
@@ -576,6 +706,52 @@ function conversationUnloaded(id: string): ConversationUnloadedActionType {
},
};
}
+
+function createGroup(): ThunkAction<
+ void,
+ RootStateType,
+ unknown,
+ | CreateGroupPendingActionType
+ | CreateGroupFulfilledActionType
+ | CreateGroupRejectedActionType
+ | SwitchToAssociatedViewActionType
+> {
+ return async (dispatch, getState, ...args) => {
+ const { composer } = getState().conversations;
+ if (
+ composer?.step !== ComposerStep.SetGroupMetadata ||
+ composer.isCreating
+ ) {
+ assert(false, 'Cannot create group in this stage; doing nothing');
+ return;
+ }
+
+ dispatch({ type: 'CREATE_GROUP_PENDING' });
+
+ try {
+ const conversation = await groups.createGroupV2({
+ name: composer.groupName,
+ avatar: composer.groupAvatar,
+ conversationIds: composer.selectedConversationIds,
+ });
+ dispatch({
+ type: 'CREATE_GROUP_FULFILLED',
+ payload: {
+ invitedConversationIds: (
+ conversation.get('pendingMembersV2') || []
+ ).map(member => member.conversationId),
+ },
+ });
+ openConversationInternal({
+ conversationId: conversation.id,
+ switchToAssociatedView: true,
+ })(dispatch, getState, ...args);
+ } catch (err) {
+ dispatch({ type: 'CREATE_GROUP_REJECTED' });
+ }
+ };
+}
+
function removeAllConversations(): RemoveAllConversationsActionType {
return {
type: 'CONVERSATIONS_REMOVE_ALL',
@@ -761,6 +937,12 @@ function clearChangedMessages(
},
};
}
+function clearInvitedConversationsForNewlyCreatedGroup(): ClearInvitedConversationsForNewlyCreatedGroupActionType {
+ return { type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP' };
+}
+function clearGroupCreationError(): ClearGroupCreationErrorActionType {
+ return { type: 'CLEAR_GROUP_CREATION_ERROR' };
+}
function clearSelectedMessage(): ClearSelectedMessageActionType {
return {
type: 'CLEAR_SELECTED_MESSAGE',
@@ -777,7 +959,15 @@ function clearUnreadMetrics(
},
};
}
-
+function closeCantAddContactToGroupModal(): CloseCantAddContactToGroupModalActionType {
+ return { type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL' };
+}
+function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType {
+ return { type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL' };
+}
+function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType {
+ return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
+}
function scrollToMessage(
conversationId: string,
messageId: string
@@ -791,6 +981,22 @@ function scrollToMessage(
};
}
+function setComposeGroupAvatar(
+ groupAvatar: undefined | ArrayBuffer
+): SetComposeGroupAvatarActionType {
+ return {
+ type: 'SET_COMPOSE_GROUP_AVATAR',
+ payload: { groupAvatar },
+ };
+}
+
+function setComposeGroupName(groupName: string): SetComposeGroupNameActionType {
+ return {
+ type: 'SET_COMPOSE_GROUP_NAME',
+ payload: { groupName },
+ };
+}
+
function setComposeSearchTerm(
contactSearchTerm: string
): SetComposeSearchTermActionType {
@@ -804,6 +1010,10 @@ function startComposing(): StartComposingActionType {
return { type: 'START_COMPOSING' };
}
+function showChooseGroupMembers(): ShowChooseGroupMembersActionType {
+ return { type: 'SHOW_CHOOSE_GROUP_MEMBERS' };
+}
+
function startNewConversationFromPhoneNumber(
e164: string
): ThunkAction {
@@ -814,6 +1024,37 @@ function startNewConversationFromPhoneNumber(
};
}
+function startSettingGroupMetadata(): StartSettingGroupMetadataActionType {
+ return { type: 'START_SETTING_GROUP_METADATA' };
+}
+
+function toggleConversationInChooseMembers(
+ conversationId: string
+): ThunkAction<
+ void,
+ RootStateType,
+ unknown,
+ ToggleConversationInChooseMembersActionType
+> {
+ return dispatch => {
+ const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
+ const maxGroupSize = Math.max(
+ getGroupSizeHardLimit(1001),
+ maxRecommendedGroupSize + 1
+ );
+
+ assert(
+ maxGroupSize > maxRecommendedGroupSize,
+ 'Expected the hard max group size to be larger than the recommended maximum'
+ );
+
+ dispatch({
+ type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS',
+ payload: { conversationId, maxGroupSize, maxRecommendedGroupSize },
+ });
+ };
+}
+
// Note: we need two actions here to simplify. Operations outside of the left pane can
// trigger an 'openConversation' so we go through Whisper.events for all
// conversation selection. Internal just triggers the Whisper.event, and External
@@ -1007,10 +1248,94 @@ export function updateConversationLookups(
return result;
}
+function closeComposerModal(
+ state: Readonly,
+ modalToClose: 'maximumGroupSizeModalState' | 'recommendedGroupSizeModalState'
+): ConversationsStateType {
+ const { composer } = state;
+ if (composer?.step !== ComposerStep.ChooseGroupMembers) {
+ assert(false, "Can't close the modal in this composer step. Doing nothing");
+ return state;
+ }
+ if (composer[modalToClose] !== OneTimeModalState.Showing) {
+ return state;
+ }
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ [modalToClose]: OneTimeModalState.Shown,
+ },
+ };
+}
+
export function reducer(
state: Readonly = getEmptyState(),
action: Readonly
): ConversationsStateType {
+ if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') {
+ const { composer } = state;
+ if (composer?.step !== ComposerStep.ChooseGroupMembers) {
+ assert(false, "Can't update modal in this composer step. Doing nothing");
+ return state;
+ }
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ cantAddContactIdForModal: action.payload.conversationId,
+ },
+ };
+ }
+
+ if (action.type === 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP') {
+ return omit(state, 'invitedConversationIdsForNewlyCreatedGroup');
+ }
+
+ if (action.type === 'CLEAR_GROUP_CREATION_ERROR') {
+ const { composer } = state;
+ if (composer?.step !== ComposerStep.SetGroupMetadata) {
+ assert(
+ false,
+ "Can't clear group creation error in this composer state. Doing nothing"
+ );
+ return state;
+ }
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ hasError: false,
+ },
+ };
+ }
+
+ if (action.type === 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL') {
+ const { composer } = state;
+ if (composer?.step !== ComposerStep.ChooseGroupMembers) {
+ assert(
+ false,
+ "Can't close the modal in this composer step. Doing nothing"
+ );
+ return state;
+ }
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ cantAddContactIdForModal: undefined,
+ },
+ };
+ }
+
+ if (action.type === 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL') {
+ return closeComposerModal(state, 'maximumGroupSizeModalState' as const);
+ }
+
+ if (action.type === 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL') {
+ return closeComposerModal(state, 'recommendedGroupSizeModalState' as const);
+ }
+
if (action.type === 'SET_PRE_JOIN_CONVERSATION') {
const { payload } = action;
const { data } = payload;
@@ -1114,6 +1439,47 @@ export function reducer(
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
return getEmptyState();
}
+ if (action.type === 'CREATE_GROUP_PENDING') {
+ const { composer } = state;
+ if (composer?.step !== ComposerStep.SetGroupMetadata) {
+ // This should be unlikely, but it can happen if someone closes the composer while
+ // a group is being created.
+ return state;
+ }
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ hasError: false,
+ isCreating: true,
+ },
+ };
+ }
+ if (action.type === 'CREATE_GROUP_FULFILLED') {
+ // We don't do much here and instead rely on `openConversationInternal` to do most of
+ // the work.
+ return {
+ ...state,
+ invitedConversationIdsForNewlyCreatedGroup:
+ action.payload.invitedConversationIds,
+ };
+ }
+ if (action.type === 'CREATE_GROUP_REJECTED') {
+ const { composer } = state;
+ if (composer?.step !== ComposerStep.SetGroupMetadata) {
+ // This should be unlikely, but it can happen if someone closes the composer while
+ // a group is being created.
+ return state;
+ }
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ hasError: true,
+ isCreating: false,
+ },
+ };
+ }
if (action.type === 'SET_SELECTED_CONVERSATION_PANEL_DEPTH') {
return {
...state,
@@ -1728,7 +2094,7 @@ export function reducer(
}
if (action.type === 'START_COMPOSING') {
- if (state.composer) {
+ if (state.composer?.step === ComposerStep.StartDirectConversation) {
return state;
}
@@ -1736,11 +2102,125 @@ export function reducer(
...state,
showArchived: false,
composer: {
+ step: ComposerStep.StartDirectConversation,
contactSearchTerm: '',
},
};
}
+ if (action.type === 'SHOW_CHOOSE_GROUP_MEMBERS') {
+ let selectedConversationIds: Array;
+ let recommendedGroupSizeModalState: OneTimeModalState;
+ let maximumGroupSizeModalState: OneTimeModalState;
+ let groupName: string;
+ let groupAvatar: undefined | ArrayBuffer;
+
+ switch (state.composer?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ return state;
+ case ComposerStep.SetGroupMetadata:
+ ({
+ selectedConversationIds,
+ recommendedGroupSizeModalState,
+ maximumGroupSizeModalState,
+ groupName,
+ groupAvatar,
+ } = state.composer);
+ break;
+ default:
+ selectedConversationIds = [];
+ recommendedGroupSizeModalState = OneTimeModalState.NeverShown;
+ maximumGroupSizeModalState = OneTimeModalState.NeverShown;
+ groupName = '';
+ break;
+ }
+
+ return {
+ ...state,
+ showArchived: false,
+ composer: {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds,
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState,
+ maximumGroupSizeModalState,
+ groupName,
+ groupAvatar,
+ },
+ };
+ }
+
+ if (action.type === 'START_SETTING_GROUP_METADATA') {
+ const { composer } = state;
+
+ switch (composer?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ return {
+ ...state,
+ showArchived: false,
+ composer: {
+ step: ComposerStep.SetGroupMetadata,
+ isCreating: false,
+ hasError: false,
+ ...pick(composer, [
+ 'groupAvatar',
+ 'groupName',
+ 'maximumGroupSizeModalState',
+ 'recommendedGroupSizeModalState',
+ 'selectedConversationIds',
+ ]),
+ },
+ };
+ case ComposerStep.SetGroupMetadata:
+ return state;
+ default:
+ assert(
+ false,
+ 'Cannot transition to setting group metadata from this state'
+ );
+ return state;
+ }
+ }
+
+ if (action.type === 'SET_COMPOSE_GROUP_AVATAR') {
+ const { composer } = state;
+
+ switch (composer?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ groupAvatar: action.payload.groupAvatar,
+ },
+ };
+ default:
+ assert(false, 'Setting compose group avatar at this step is a no-op');
+ return state;
+ }
+ }
+
+ if (action.type === 'SET_COMPOSE_GROUP_NAME') {
+ const { composer } = state;
+
+ switch (composer?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ groupName: action.payload.groupName,
+ },
+ };
+ default:
+ assert(false, 'Setting compose group name at this step is a no-op');
+ return state;
+ }
+ }
+
if (action.type === 'SET_COMPOSE_SEARCH_TERM') {
const { composer } = state;
if (!composer) {
@@ -1750,6 +2230,10 @@ export function reducer(
);
return state;
}
+ if (composer?.step === ComposerStep.SetGroupMetadata) {
+ assert(false, 'Setting compose search term at this step is a no-op');
+ return state;
+ }
return {
...state,
@@ -1774,5 +2258,63 @@ export function reducer(
};
}
+ if (action.type === 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS') {
+ const { composer } = state;
+ if (composer?.step !== ComposerStep.ChooseGroupMembers) {
+ assert(
+ false,
+ 'Toggling conversation members is a no-op in this composer step'
+ );
+ return state;
+ }
+
+ const { selectedConversationIds: oldSelectedConversationIds } = composer;
+ let {
+ maximumGroupSizeModalState,
+ recommendedGroupSizeModalState,
+ } = composer;
+ const {
+ conversationId,
+ maxGroupSize,
+ maxRecommendedGroupSize,
+ } = action.payload;
+
+ const selectedConversationIds = without(
+ oldSelectedConversationIds,
+ conversationId
+ );
+ const shouldAdd =
+ selectedConversationIds.length === oldSelectedConversationIds.length;
+ if (shouldAdd) {
+ // 1 for you, 1 for the new contact.
+ const newExpectedMemberCount = selectedConversationIds.length + 2;
+ if (newExpectedMemberCount > maxGroupSize) {
+ return state;
+ }
+ if (
+ newExpectedMemberCount === maxGroupSize &&
+ maximumGroupSizeModalState === OneTimeModalState.NeverShown
+ ) {
+ maximumGroupSizeModalState = OneTimeModalState.Showing;
+ } else if (
+ newExpectedMemberCount >= maxRecommendedGroupSize &&
+ recommendedGroupSizeModalState === OneTimeModalState.NeverShown
+ ) {
+ recommendedGroupSizeModalState = OneTimeModalState.Showing;
+ }
+ selectedConversationIds.push(conversationId);
+ }
+
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ maximumGroupSizeModalState,
+ recommendedGroupSizeModalState,
+ selectedConversationIds,
+ },
+ };
+ }
+
return state;
}
diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts
index df6971687..22db86a24 100644
--- a/ts/state/selectors/conversations.ts
+++ b/ts/state/selectors/conversations.ts
@@ -8,6 +8,7 @@ import Fuse, { FuseOptions } from 'fuse.js';
import { StateType } from '../reducer';
import {
+ ComposerStep,
ConversationLookupType,
ConversationMessageType,
ConversationsStateType,
@@ -15,10 +16,12 @@ import {
MessageLookupType,
MessagesByConversationType,
MessageType,
+ OneTimeModalState,
PreJoinConversationType,
} from '../ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { getOwn } from '../../util/getOwn';
+import { deconstructLookup } from '../../util/deconstructLookup';
import type { CallsByConversationType } from '../ducks/calling';
import { getCallsByConversation } from './calling';
import { getBubbleProps } from '../../shims/Whisper';
@@ -143,9 +146,26 @@ const getComposerState = createSelector(
(state: ConversationsStateType) => state.composer
);
-export const isComposing = createSelector(
+export const getComposerStep = createSelector(
getComposerState,
- (composerState): boolean => Boolean(composerState)
+ (composerState): undefined | ComposerStep => composerState?.step
+);
+
+export const hasGroupCreationError = createSelector(
+ getComposerState,
+ (composerState): boolean => {
+ if (composerState?.step === ComposerStep.SetGroupMetadata) {
+ return composerState.hasError;
+ }
+ return false;
+ }
+);
+
+export const isCreatingGroup = createSelector(
+ getComposerState,
+ (composerState): boolean =>
+ composerState?.step === ComposerStep.SetGroupMetadata &&
+ composerState.isCreating
);
export const getMessages = createSelector(
@@ -273,6 +293,40 @@ export const getLeftPaneLists = createSelector(
_getLeftPaneLists
);
+export const getMaximumGroupSizeModalState = createSelector(
+ getComposerState,
+ (composerState): OneTimeModalState => {
+ switch (composerState?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return composerState.maximumGroupSizeModalState;
+ default:
+ assert(
+ false,
+ 'Can\'t get the maximum group size modal state in this composer state; returning "never shown"'
+ );
+ return OneTimeModalState.NeverShown;
+ }
+ }
+);
+
+export const getRecommendedGroupSizeModalState = createSelector(
+ getComposerState,
+ (composerState): OneTimeModalState => {
+ switch (composerState?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return composerState.recommendedGroupSizeModalState;
+ default:
+ assert(
+ false,
+ 'Can\'t get the recommended group size modal state in this composer state; returning "never shown"'
+ );
+ return OneTimeModalState.NeverShown;
+ }
+ }
+);
+
export const getMe = createSelector(
[getConversationLookup, getUserConversationId],
(
@@ -290,6 +344,13 @@ export const getComposerContactSearchTerm = createSelector(
assert(false, 'getComposerContactSearchTerm: composer is not open');
return '';
}
+ if (composer.step === ComposerStep.SetGroupMetadata) {
+ assert(
+ false,
+ 'getComposerContactSearchTerm: composer does not have a search term'
+ );
+ return '';
+ }
return composer.contactSearchTerm;
}
);
@@ -363,6 +424,102 @@ export const getComposeContacts = createSelector(
}
);
+/*
+ * This returns contacts for the composer when you're picking new group members. It casts
+ * a wider net than `getContacts`.
+ */
+const getGroupContacts = createSelector(
+ getConversationLookup,
+ (conversationLookup): Array =>
+ Object.values(conversationLookup).filter(
+ contact =>
+ contact.type === 'direct' &&
+ !contact.isMe &&
+ !contact.isBlocked &&
+ !isConversationUnregistered(contact)
+ )
+);
+
+export const getCandidateGroupContacts = createSelector(
+ getNormalizedComposerContactSearchTerm,
+ getGroupContacts,
+ (searchTerm, contacts): Array => {
+ if (searchTerm.length) {
+ return new Fuse(
+ contacts,
+ COMPOSE_CONTACTS_FUSE_OPTIONS
+ ).search(searchTerm);
+ }
+ return contacts.concat().sort((a, b) => collator.compare(a.title, b.title));
+ }
+);
+
+export const getCantAddContactForModal = createSelector(
+ getConversationLookup,
+ getComposerState,
+ (conversationLookup, composerState): undefined | ConversationType => {
+ if (composerState?.step !== ComposerStep.ChooseGroupMembers) {
+ return undefined;
+ }
+
+ const conversationId = composerState.cantAddContactIdForModal;
+ if (!conversationId) {
+ return undefined;
+ }
+
+ const result = getOwn(conversationLookup, conversationId);
+ assert(
+ result,
+ 'getCantAddContactForModal: failed to look up conversation by ID; returning undefined'
+ );
+ return result;
+ }
+);
+
+const getGroupCreationComposerState = createSelector(
+ getComposerState,
+ (
+ composerState
+ ): {
+ groupName: string;
+ groupAvatar: undefined | ArrayBuffer;
+ selectedConversationIds: Array;
+ } => {
+ switch (composerState?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return composerState;
+ default:
+ assert(
+ false,
+ 'getSetGroupMetadataComposerState: expected step to be SetGroupMetadata'
+ );
+ return {
+ groupName: '',
+ groupAvatar: undefined,
+ selectedConversationIds: [],
+ };
+ }
+ }
+);
+
+export const getComposeGroupAvatar = createSelector(
+ getGroupCreationComposerState,
+ (composerState): undefined | ArrayBuffer => composerState.groupAvatar
+);
+
+export const getComposeGroupName = createSelector(
+ getGroupCreationComposerState,
+ (composerState): string => composerState.groupName
+);
+
+export const getComposeSelectedContacts = createSelector(
+ getConversationLookup,
+ getGroupCreationComposerState,
+ (conversationLookup, composerState): Array =>
+ deconstructLookup(conversationLookup, composerState.selectedConversationIds)
+);
+
// This is where we will put Conversation selector logic, replicating what
// is currently in models/conversation.getProps()
// What needs to happen to pull that selector logic here?
@@ -666,3 +823,16 @@ export const getConversationMessagesSelector = createSelector(
};
}
);
+
+export const getInvitedContactsForNewlyCreatedGroup = createSelector(
+ getConversationLookup,
+ getConversations,
+ (
+ conversationLookup,
+ { invitedConversationIdsForNewlyCreatedGroup = [] }
+ ): Array =>
+ deconstructLookup(
+ conversationLookup,
+ invitedConversationIdsForNewlyCreatedGroup
+ )
+);
diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx
index 7f0ffb502..28e513a3f 100644
--- a/ts/state/smart/LeftPane.tsx
+++ b/ts/state/smart/LeftPane.tsx
@@ -10,17 +10,28 @@ import {
PropsType as LeftPanePropsType,
} from '../../components/LeftPane';
import { StateType } from '../reducer';
+import { missingCaseError } from '../../util/missingCaseError';
+import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
import { getSearchResults, isSearching } from '../selectors/search';
import { getIntl, getRegionCode } from '../selectors/user';
import {
+ getCandidateGroupContacts,
+ getCantAddContactForModal,
getComposeContacts,
+ getComposeGroupAvatar,
+ getComposeGroupName,
+ getComposeSelectedContacts,
getComposerContactSearchTerm,
+ getComposerStep,
getLeftPaneLists,
+ getMaximumGroupSizeModalState,
+ getRecommendedGroupSizeModalState,
getSelectedConversationId,
getSelectedMessage,
getShowArchived,
- isComposing,
+ hasGroupCreationError,
+ isCreatingGroup,
} from '../selectors/conversations';
import { SmartExpiredBuildDialog } from './ExpiredBuildDialog';
@@ -61,34 +72,58 @@ function renderUpdateDialog(): JSX.Element {
const getModeSpecificProps = (
state: StateType
): LeftPanePropsType['modeSpecificProps'] => {
- if (isComposing(state)) {
- return {
- mode: LeftPaneMode.Compose,
- composeContacts: getComposeContacts(state),
- regionCode: getRegionCode(state),
- searchTerm: getComposerContactSearchTerm(state),
- };
+ const composerStep = getComposerStep(state);
+ switch (composerStep) {
+ case undefined:
+ if (getShowArchived(state)) {
+ const { archivedConversations } = getLeftPaneLists(state);
+ return {
+ mode: LeftPaneMode.Archive,
+ archivedConversations,
+ };
+ }
+ if (isSearching(state)) {
+ return {
+ mode: LeftPaneMode.Search,
+ ...getSearchResults(state),
+ };
+ }
+ return {
+ mode: LeftPaneMode.Inbox,
+ ...getLeftPaneLists(state),
+ };
+ case ComposerStep.StartDirectConversation:
+ return {
+ mode: LeftPaneMode.Compose,
+ composeContacts: getComposeContacts(state),
+ regionCode: getRegionCode(state),
+ searchTerm: getComposerContactSearchTerm(state),
+ };
+ case ComposerStep.ChooseGroupMembers:
+ return {
+ mode: LeftPaneMode.ChooseGroupMembers,
+ candidateContacts: getCandidateGroupContacts(state),
+ cantAddContactForModal: getCantAddContactForModal(state),
+ isShowingRecommendedGroupSizeModal:
+ getRecommendedGroupSizeModalState(state) ===
+ OneTimeModalState.Showing,
+ isShowingMaximumGroupSizeModal:
+ getMaximumGroupSizeModalState(state) === OneTimeModalState.Showing,
+ searchTerm: getComposerContactSearchTerm(state),
+ selectedContacts: getComposeSelectedContacts(state),
+ };
+ case ComposerStep.SetGroupMetadata:
+ return {
+ mode: LeftPaneMode.SetGroupMetadata,
+ groupAvatar: getComposeGroupAvatar(state),
+ groupName: getComposeGroupName(state),
+ hasError: hasGroupCreationError(state),
+ isCreating: isCreatingGroup(state),
+ selectedContacts: getComposeSelectedContacts(state),
+ };
+ default:
+ throw missingCaseError(composerStep);
}
-
- if (getShowArchived(state)) {
- const { archivedConversations } = getLeftPaneLists(state);
- return {
- mode: LeftPaneMode.Archive,
- archivedConversations,
- };
- }
-
- if (isSearching(state)) {
- return {
- mode: LeftPaneMode.Search,
- ...getSearchResults(state),
- };
- }
-
- return {
- mode: LeftPaneMode.Inbox,
- ...getLeftPaneLists(state),
- };
};
const mapStateToProps = (state: StateType) => {
diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx
index 6f0559e7b..f1d52833a 100644
--- a/ts/state/smart/Timeline.tsx
+++ b/ts/state/smart/Timeline.tsx
@@ -13,6 +13,7 @@ import { getIntl } from '../selectors/user';
import {
getConversationMessagesSelector,
getConversationSelector,
+ getInvitedContactsForNewlyCreatedGroup,
getSelectedMessage,
} from '../selectors/conversations';
@@ -107,6 +108,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
'isGroupV1AndDisabled',
]),
...conversationMessages,
+ invitedContactsForNewlyCreatedGroup: getInvitedContactsForNewlyCreatedGroup(
+ state
+ ),
selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
i18n: getIntl(state),
renderItem,
diff --git a/ts/storage/isFeatureEnabled.ts b/ts/storage/isFeatureEnabled.ts
new file mode 100644
index 000000000..7cc7b1293
--- /dev/null
+++ b/ts/storage/isFeatureEnabled.ts
@@ -0,0 +1,12 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { isEnabled } from '../RemoteConfig';
+
+function isStorageFeatureEnabled(): boolean {
+ return isEnabled('desktop.storage');
+}
+
+export function isStorageWriteFeatureEnabled(): boolean {
+ return isStorageFeatureEnabled() && isEnabled('desktop.storageWrite2');
+}
diff --git a/ts/test-both/groups/limits_test.ts b/ts/test-both/groups/limits_test.ts
new file mode 100644
index 000000000..77a79e704
--- /dev/null
+++ b/ts/test-both/groups/limits_test.ts
@@ -0,0 +1,67 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import * as sinon from 'sinon';
+import * as remoteConfig from '../../RemoteConfig';
+
+import {
+ getGroupSizeRecommendedLimit,
+ getGroupSizeHardLimit,
+} from '../../groups/limits';
+
+describe('group limit utilities', () => {
+ let sinonSandbox: sinon.SinonSandbox;
+ let getRecommendedLimitStub: sinon.SinonStub;
+ let getHardLimitStub: sinon.SinonStub;
+
+ beforeEach(() => {
+ sinonSandbox = sinon.createSandbox();
+
+ const getValueStub = sinonSandbox.stub(remoteConfig, 'getValue');
+ getRecommendedLimitStub = getValueStub.withArgs(
+ 'global.groupsv2.maxGroupSize'
+ );
+ getHardLimitStub = getValueStub.withArgs(
+ 'global.groupsv2.groupSizeHardLimit'
+ );
+ });
+
+ afterEach(() => {
+ sinonSandbox.restore();
+ });
+
+ describe('getGroupSizeRecommendedLimit', () => {
+ it('throws if the value in remote config is not defined', () => {
+ getRecommendedLimitStub.returns(undefined);
+ assert.throws(getGroupSizeRecommendedLimit);
+ });
+
+ it('throws if the value in remote config is not a parseable integer', () => {
+ getRecommendedLimitStub.returns('uh oh');
+ assert.throws(getGroupSizeRecommendedLimit);
+ });
+
+ it('returns the value in remote config, parsed as an integer', () => {
+ getRecommendedLimitStub.returns('123');
+ assert.strictEqual(getGroupSizeRecommendedLimit(), 123);
+ });
+ });
+
+ describe('getGroupSizeHardLimit', () => {
+ it('throws if the value in remote config is not defined', () => {
+ getHardLimitStub.returns(undefined);
+ assert.throws(getGroupSizeHardLimit);
+ });
+
+ it('throws if the value in remote config is not a parseable integer', () => {
+ getHardLimitStub.returns('uh oh');
+ assert.throws(getGroupSizeHardLimit);
+ });
+
+ it('returns the value in remote config, parsed as an integer', () => {
+ getHardLimitStub.returns('123');
+ assert.strictEqual(getGroupSizeHardLimit(), 123);
+ });
+ });
+});
diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts
index 4238107c6..7afa0bcce 100644
--- a/ts/test-both/state/selectors/conversations_test.ts
+++ b/ts/test-both/state/selectors/conversations_test.ts
@@ -4,6 +4,8 @@
import { assert } from 'chai';
import {
+ OneTimeModalState,
+ ComposerStep,
ConversationLookupType,
ConversationType,
getEmptyState,
@@ -11,14 +13,24 @@ import {
import {
_getConversationComparator,
_getLeftPaneLists,
+ getCandidateGroupContacts,
+ getCantAddContactForModal,
getComposeContacts,
+ getComposeGroupAvatar,
+ getComposeGroupName,
+ getComposeSelectedContacts,
getComposerContactSearchTerm,
+ getComposerStep,
getConversationSelector,
+ getInvitedContactsForNewlyCreatedGroup,
getIsConversationEmptySelector,
+ getMaximumGroupSizeModalState,
getPlaceholderContact,
+ getRecommendedGroupSizeModalState,
getSelectedConversation,
getSelectedConversationId,
- isComposing,
+ hasGroupCreationError,
+ isCreatingGroup,
} from '../../../state/selectors/conversations';
import { noopAction } from '../../../state/ducks/noop';
import { StateType, reducer as rootReducer } from '../../../state/reducer';
@@ -219,6 +231,32 @@ describe('both/state/selectors/conversations', () => {
});
});
+ describe('#getInvitedContactsForNewlyCreatedGroup', () => {
+ it('returns an empty array if there are no invited contacts', () => {
+ const state = getEmptyRootState();
+
+ assert.deepEqual(getInvitedContactsForNewlyCreatedGroup(state), []);
+ });
+
+ it('returns "hydrated" invited contacts', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ conversationLookup: {
+ abc: getDefaultConversation('abc'),
+ def: getDefaultConversation('def'),
+ },
+ invitedConversationIdsForNewlyCreatedGroup: ['def', 'abc'],
+ },
+ };
+ const result = getInvitedContactsForNewlyCreatedGroup(state);
+ const titles = result.map(conversation => conversation.title);
+
+ assert.deepEqual(titles, ['def title', 'abc title']);
+ });
+ });
+
describe('#getIsConversationEmptySelector', () => {
it('returns a selector that returns true for conversations that have no messages', () => {
const state = {
@@ -287,24 +325,196 @@ describe('both/state/selectors/conversations', () => {
});
});
- describe('#isComposing', () => {
- it('returns false if there is no composer state', () => {
- assert.isFalse(isComposing(getEmptyRootState()));
+ describe('#getComposerStep', () => {
+ it("returns undefined if the composer isn't open", () => {
+ const state = getEmptyRootState();
+ const result = getComposerStep(state);
+
+ assert.isUndefined(result);
});
- it('returns true if there is composer state', () => {
- assert.isTrue(
- isComposing({
+ it('returns the first step of the composer', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.StartDirectConversation as const,
+ contactSearchTerm: 'foo',
+ },
+ },
+ };
+ const result = getComposerStep(state);
+
+ assert.strictEqual(result, ComposerStep.StartDirectConversation);
+ });
+
+ it('returns the second step of the composer', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: 'foo',
+ selectedConversationIds: ['abc'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ },
+ };
+ const result = getComposerStep(state);
+
+ assert.strictEqual(result, ComposerStep.ChooseGroupMembers);
+ });
+
+ it('returns the third step of the composer', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: ['abc'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false,
+ hasError: false as const,
+ },
+ },
+ };
+ const result = getComposerStep(state);
+
+ assert.strictEqual(result, ComposerStep.SetGroupMetadata);
+ });
+ });
+
+ describe('#hasGroupCreationError', () => {
+ it('returns false if not in the "set group metadata" composer step', () => {
+ assert.isFalse(hasGroupCreationError(getEmptyRootState()));
+
+ assert.isFalse(
+ hasGroupCreationError({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
+ step: ComposerStep.StartDirectConversation,
contactSearchTerm: '',
},
},
})
);
});
+
+ it('returns false if there is no group creation error', () => {
+ assert.isFalse(
+ hasGroupCreationError({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false as const,
+ hasError: false as const,
+ },
+ },
+ })
+ );
+ });
+
+ it('returns true if there is a group creation error', () => {
+ assert.isTrue(
+ hasGroupCreationError({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false as const,
+ hasError: true as const,
+ },
+ },
+ })
+ );
+ });
+ });
+
+ describe('#isCreatingGroup', () => {
+ it('returns false if not in the "set group metadata" composer step', () => {
+ assert.isFalse(hasGroupCreationError(getEmptyRootState()));
+
+ assert.isFalse(
+ isCreatingGroup({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.StartDirectConversation,
+ contactSearchTerm: '',
+ },
+ },
+ })
+ );
+ });
+
+ it('returns false if the group is not being created', () => {
+ assert.isFalse(
+ isCreatingGroup({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false as const,
+ hasError: true as const,
+ },
+ },
+ })
+ );
+ });
+
+ it('returns true if the group is being created', () => {
+ assert.isTrue(
+ isCreatingGroup({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: true as const,
+ hasError: false as const,
+ },
+ },
+ })
+ );
+ });
});
describe('#getComposeContacts', () => {
@@ -321,6 +531,7 @@ describe('both/state/selectors/conversations', () => {
},
},
composer: {
+ step: ComposerStep.StartDirectConversation,
contactSearchTerm,
},
},
@@ -413,6 +624,154 @@ describe('both/state/selectors/conversations', () => {
});
});
+ describe('#getCandidateGroupContacts', () => {
+ const getRootState = (contactSearchTerm = ''): StateType => {
+ const rootState = getEmptyRootState();
+ return {
+ ...rootState,
+ conversations: {
+ ...getEmptyState(),
+ conversationLookup: {
+ 'our-conversation-id': {
+ ...getDefaultConversation('our-conversation-id'),
+ isMe: true,
+ },
+ 'convo-1': {
+ ...getDefaultConversation('convo-1'),
+ name: 'In System Contacts',
+ title: 'A. Sorted First',
+ },
+ 'convo-2': {
+ ...getDefaultConversation('convo-2'),
+ title: 'B. Sorted Second',
+ },
+ 'convo-3': {
+ ...getDefaultConversation('convo-3'),
+ type: 'group',
+ title: 'Should Be Dropped (group)',
+ },
+ 'convo-4': {
+ ...getDefaultConversation('convo-4'),
+ isBlocked: true,
+ title: 'Should Be Dropped (blocked)',
+ },
+ 'convo-5': {
+ ...getDefaultConversation('convo-5'),
+ discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(),
+ title: 'Should Be Dropped (unregistered)',
+ },
+ 'convo-6': {
+ ...getDefaultConversation('convo-6'),
+ title: 'D. Sorted Last',
+ },
+ 'convo-7': {
+ ...getDefaultConversation('convo-7'),
+ discoveredUnregisteredAt: Date.now(),
+ name: 'In System Contacts (and only recently unregistered)',
+ title: 'C. Sorted Third',
+ },
+ },
+ composer: {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm,
+ selectedConversationIds: ['abc'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ },
+ user: {
+ ...rootState.user,
+ ourConversationId: 'our-conversation-id',
+ i18n,
+ },
+ };
+ };
+
+ it('returns sorted contacts when there is no search term', () => {
+ const state = getRootState();
+ const result = getCandidateGroupContacts(state);
+
+ const ids = result.map(contact => contact.id);
+ assert.deepEqual(ids, ['convo-1', 'convo-2', 'convo-7', 'convo-6']);
+ });
+
+ it('can search for contacts', () => {
+ const state = getRootState('system contacts');
+ const result = getCandidateGroupContacts(state);
+
+ const ids = result.map(contact => contact.id);
+ assert.deepEqual(ids, ['convo-1', 'convo-7']);
+ });
+ });
+
+ describe('#getCantAddContactForModal', () => {
+ it('returns undefined if not in the "choose group members" composer step', () => {
+ assert.isUndefined(getCantAddContactForModal(getEmptyRootState()));
+
+ assert.isUndefined(
+ getCantAddContactForModal({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.StartDirectConversation,
+ contactSearchTerm: '',
+ },
+ },
+ })
+ );
+ });
+
+ it("returns undefined if there's no contact marked", () => {
+ assert.isUndefined(
+ getCantAddContactForModal({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: undefined,
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ },
+ })
+ );
+ });
+
+ it('returns the marked contact', () => {
+ const conversation = getDefaultConversation('abc123');
+
+ assert.deepEqual(
+ getCantAddContactForModal({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ conversationLookup: { abc123: conversation },
+ composer: {
+ cantAddContactIdForModal: 'abc123',
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ },
+ }),
+ conversation
+ );
+ });
+ });
+
describe('#getComposerContactSearchTerm', () => {
it("returns the composer's contact search term", () => {
assert.strictEqual(
@@ -421,6 +780,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ step: ComposerStep.StartDirectConversation,
contactSearchTerm: 'foo bar',
},
},
@@ -668,6 +1028,163 @@ describe('both/state/selectors/conversations', () => {
});
});
+ describe('#getMaximumGroupSizeModalState', () => {
+ it('returns the modal state', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: undefined,
+ contactSearchTerm: 'to be cleared',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.Showing,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ },
+ };
+ assert.strictEqual(
+ getMaximumGroupSizeModalState(state),
+ OneTimeModalState.Showing
+ );
+ });
+ });
+
+ describe('#getRecommendedGroupSizeModalState', () => {
+ it('returns the modal state', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: undefined,
+ contactSearchTerm: 'to be cleared',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.Showing,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ },
+ };
+ assert.strictEqual(
+ getRecommendedGroupSizeModalState(state),
+ OneTimeModalState.Showing
+ );
+ });
+ });
+
+ describe('#getComposeGroupAvatar', () => {
+ it('returns undefined if there is no group avatar', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: ['abc'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false,
+ hasError: false as const,
+ },
+ },
+ };
+ assert.isUndefined(getComposeGroupAvatar(state));
+ });
+
+ it('returns the group avatar', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: ['abc'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: new Uint8Array([1, 2, 3]).buffer,
+ isCreating: false,
+ hasError: false as const,
+ },
+ },
+ };
+ assert.deepEqual(
+ getComposeGroupAvatar(state),
+ new Uint8Array([1, 2, 3]).buffer
+ );
+ });
+ });
+
+ describe('#getComposeGroupName', () => {
+ it('returns the group name', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: ['abc'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'foo bar',
+ groupAvatar: undefined,
+ isCreating: false,
+ hasError: false as const,
+ },
+ },
+ };
+ assert.deepEqual(getComposeGroupName(state), 'foo bar');
+ });
+ });
+
+ describe('#getComposeSelectedContacts', () => {
+ it("returns the composer's selected contacts", () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ conversationLookup: {
+ 'convo-1': {
+ ...getDefaultConversation('convo-1'),
+ title: 'Person One',
+ },
+ 'convo-2': {
+ ...getDefaultConversation('convo-2'),
+ title: 'Person Two',
+ },
+ },
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: ['convo-2', 'convo-1'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'foo bar',
+ groupAvatar: undefined,
+ isCreating: false,
+ hasError: false as const,
+ },
+ },
+ };
+
+ const titles = getComposeSelectedContacts(state).map(
+ contact => contact.title
+ );
+ assert.deepEqual(titles, ['Person Two', 'Person One']);
+ });
+ });
+
describe('#getSelectedConversationId', () => {
it('returns undefined if no conversation is selected', () => {
const state = {
diff --git a/ts/test-both/util/parseIntOrThrow_test.ts b/ts/test-both/util/parseIntOrThrow_test.ts
new file mode 100644
index 000000000..8e2abf4c8
--- /dev/null
+++ b/ts/test-both/util/parseIntOrThrow_test.ts
@@ -0,0 +1,71 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { parseIntOrThrow } from '../../util/parseIntOrThrow';
+
+describe('parseIntOrThrow', () => {
+ describe('when passed a number argument', () => {
+ it('returns the number when passed an integer', () => {
+ assert.strictEqual(parseIntOrThrow(0, "shouldn't happen"), 0);
+ assert.strictEqual(parseIntOrThrow(123, "shouldn't happen"), 123);
+ assert.strictEqual(parseIntOrThrow(-123, "shouldn't happen"), -123);
+ });
+
+ it('throws when passed a decimal value', () => {
+ assert.throws(() => parseIntOrThrow(0.2, 'uh oh'), 'uh oh');
+ assert.throws(() => parseIntOrThrow(1.23, 'uh oh'), 'uh oh');
+ });
+
+ it('throws when passed NaN', () => {
+ assert.throws(() => parseIntOrThrow(NaN, 'uh oh'), 'uh oh');
+ });
+
+ it('throws when passed ∞', () => {
+ assert.throws(() => parseIntOrThrow(Infinity, 'uh oh'), 'uh oh');
+ assert.throws(() => parseIntOrThrow(-Infinity, 'uh oh'), 'uh oh');
+ });
+ });
+
+ describe('when passed a string argument', () => {
+ it('returns the number when passed an integer', () => {
+ assert.strictEqual(parseIntOrThrow('0', "shouldn't happen"), 0);
+ assert.strictEqual(parseIntOrThrow('123', "shouldn't happen"), 123);
+ assert.strictEqual(parseIntOrThrow('-123', "shouldn't happen"), -123);
+ });
+
+ it('parses decimal values like parseInt', () => {
+ assert.strictEqual(parseIntOrThrow('0.2', "shouldn't happen"), 0);
+ assert.strictEqual(parseIntOrThrow('12.34', "shouldn't happen"), 12);
+ assert.strictEqual(parseIntOrThrow('-12.34', "shouldn't happen"), -12);
+ });
+
+ it('parses values in base 10', () => {
+ assert.strictEqual(parseIntOrThrow('0x12', "shouldn't happen"), 0);
+ });
+
+ it('throws when passed non-parseable strings', () => {
+ assert.throws(() => parseIntOrThrow('', 'uh oh'), 'uh oh');
+ assert.throws(() => parseIntOrThrow('uh 123', 'uh oh'), 'uh oh');
+ assert.throws(() => parseIntOrThrow('uh oh', 'uh oh'), 'uh oh');
+ });
+ });
+
+ describe('when passed other arguments', () => {
+ it("throws when passed arguments that aren't strings or numbers", () => {
+ assert.throws(() => parseIntOrThrow(null, 'uh oh'), 'uh oh');
+ assert.throws(() => parseIntOrThrow(undefined, 'uh oh'), 'uh oh');
+ assert.throws(() => parseIntOrThrow(['123'], 'uh oh'), 'uh oh');
+ });
+
+ it('throws when passed a stringifiable argument, unlike parseInt', () => {
+ const obj = {
+ toString() {
+ return '123';
+ },
+ };
+ assert.throws(() => parseIntOrThrow(obj, 'uh oh'), 'uh oh');
+ });
+ });
+});
diff --git a/ts/test-both/util/parseIntWithFallback_test.ts b/ts/test-both/util/parseIntWithFallback_test.ts
new file mode 100644
index 000000000..f5e68f911
--- /dev/null
+++ b/ts/test-both/util/parseIntWithFallback_test.ts
@@ -0,0 +1,71 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { parseIntWithFallback } from '../../util/parseIntWithFallback';
+
+describe('parseIntWithFallback', () => {
+ describe('when passed a number argument', () => {
+ it('returns the number when passed an integer', () => {
+ assert.strictEqual(parseIntWithFallback(0, -1), 0);
+ assert.strictEqual(parseIntWithFallback(123, -1), 123);
+ assert.strictEqual(parseIntWithFallback(-123, -1), -123);
+ });
+
+ it('returns the fallback when passed a decimal value', () => {
+ assert.strictEqual(parseIntWithFallback(0.2, -1), -1);
+ assert.strictEqual(parseIntWithFallback(1.23, -1), -1);
+ });
+
+ it('returns the fallback when passed NaN', () => {
+ assert.strictEqual(parseIntWithFallback(NaN, -1), -1);
+ });
+
+ it('returns the fallback when passed ∞', () => {
+ assert.strictEqual(parseIntWithFallback(Infinity, -1), -1);
+ assert.strictEqual(parseIntWithFallback(-Infinity, -1), -1);
+ });
+ });
+
+ describe('when passed a string argument', () => {
+ it('returns the number when passed an integer', () => {
+ assert.strictEqual(parseIntWithFallback('0', -1), 0);
+ assert.strictEqual(parseIntWithFallback('123', -1), 123);
+ assert.strictEqual(parseIntWithFallback('-123', -1), -123);
+ });
+
+ it('parses decimal values like parseInt', () => {
+ assert.strictEqual(parseIntWithFallback('0.2', -1), 0);
+ assert.strictEqual(parseIntWithFallback('12.34', -1), 12);
+ assert.strictEqual(parseIntWithFallback('-12.34', -1), -12);
+ });
+
+ it('parses values in base 10', () => {
+ assert.strictEqual(parseIntWithFallback('0x12', -1), 0);
+ });
+
+ it('returns the fallback when passed non-parseable strings', () => {
+ assert.strictEqual(parseIntWithFallback('', -1), -1);
+ assert.strictEqual(parseIntWithFallback('uh 123', -1), -1);
+ assert.strictEqual(parseIntWithFallback('uh oh', -1), -1);
+ });
+ });
+
+ describe('when passed other arguments', () => {
+ it("returns the fallback when passed arguments that aren't strings or numbers", () => {
+ assert.strictEqual(parseIntWithFallback(null, -1), -1);
+ assert.strictEqual(parseIntWithFallback(undefined, -1), -1);
+ assert.strictEqual(parseIntWithFallback(['123'], -1), -1);
+ });
+
+ it('returns the fallback when passed a stringifiable argument, unlike parseInt', () => {
+ const obj = {
+ toString() {
+ return '123';
+ },
+ };
+ assert.strictEqual(parseIntWithFallback(obj, -1), -1);
+ });
+ });
+});
diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts
index 56e163253..e94f93b00 100644
--- a/ts/test-electron/state/ducks/conversations_test.ts
+++ b/ts/test-electron/state/ducks/conversations_test.ts
@@ -3,42 +3,65 @@
import { assert } from 'chai';
import * as sinon from 'sinon';
+import { v4 as uuid } from 'uuid';
+import { times } from 'lodash';
import { set } from 'lodash/fp';
import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop';
import {
actions,
+ OneTimeModalState,
+ ComposerStep,
ConversationMessageType,
- ConversationsStateType,
ConversationType,
+ ConversationsStateType,
+ MessageType,
+ SwitchToAssociatedViewActionType,
+ ToggleConversationInChooseMembersActionType,
getConversationCallMode,
getEmptyState,
- MessageType,
reducer,
updateConversationLookups,
- SwitchToAssociatedViewActionType,
} from '../../../state/ducks/conversations';
import { CallMode } from '../../../types/Calling';
+import * as groups from '../../../groups';
const {
+ cantAddContactToGroup,
+ clearGroupCreationError,
+ clearInvitedConversationsForNewlyCreatedGroup,
+ closeCantAddContactToGroupModal,
+ closeMaximumGroupSizeModal,
+ closeRecommendedGroupSizeModal,
+ createGroup,
messageSizeChanged,
openConversationInternal,
repairNewestMessage,
repairOldestMessage,
+ setComposeGroupAvatar,
+ setComposeGroupName,
setComposeSearchTerm,
setPreJoinConversation,
showArchivedConversations,
showInbox,
startComposing,
+ showChooseGroupMembers,
+ startSettingGroupMetadata,
+ toggleConversationInChooseMembers,
} = actions;
describe('both/state/ducks/conversations', () => {
const getEmptyRootState = () => rootReducer(undefined, noopAction());
let sinonSandbox: sinon.SinonSandbox;
+ let createGroupStub: sinon.SinonStub;
beforeEach(() => {
sinonSandbox = sinon.createSandbox();
+
+ sinonSandbox.stub(window.Whisper.events, 'trigger');
+
+ createGroupStub = sinonSandbox.stub(groups, 'createGroupV2');
});
afterEach(() => {
@@ -317,10 +340,6 @@ describe('both/state/ducks/conversations', () => {
}
describe('openConversationInternal', () => {
- beforeEach(() => {
- sinonSandbox.stub(window.Whisper.events, 'trigger');
- });
-
it("returns a thunk that triggers a 'showConversation' event when passed a conversation ID", () => {
const dispatch = sinon.spy();
@@ -442,6 +461,390 @@ describe('both/state/ducks/conversations', () => {
});
});
+ describe('CANT_ADD_CONTACT_TO_GROUP', () => {
+ it('marks the conversation ID as "cannot add"', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: undefined,
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = cantAddContactToGroup('abc123');
+ const result = reducer(state, action);
+
+ assert(
+ result.composer?.step === ComposerStep.ChooseGroupMembers &&
+ result.composer.cantAddContactIdForModal === 'abc123'
+ );
+ });
+ });
+
+ describe('CLEAR_GROUP_CREATION_ERROR', () => {
+ it('clears the group creation error', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false as const,
+ hasError: true as const,
+ },
+ };
+ const action = clearGroupCreationError();
+ const result = reducer(state, action);
+
+ assert(
+ result.composer?.step === ComposerStep.SetGroupMetadata &&
+ result.composer.hasError === false
+ );
+ });
+ });
+
+ describe('CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP', () => {
+ it('clears the list of invited conversation IDs', () => {
+ const state = {
+ ...getEmptyState(),
+ invitedConversationIdsForNewlyCreatedGroup: ['abc123', 'def456'],
+ };
+ const action = clearInvitedConversationsForNewlyCreatedGroup();
+ const result = reducer(state, action);
+
+ assert.isUndefined(result.invitedConversationIdsForNewlyCreatedGroup);
+ });
+ });
+
+ describe('CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL', () => {
+ it('closes the "cannot add contact" modal"', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: 'abc123',
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = closeCantAddContactToGroupModal();
+ const result = reducer(state, action);
+
+ assert(
+ result.composer?.step === ComposerStep.ChooseGroupMembers &&
+ result.composer.cantAddContactIdForModal === undefined,
+ 'Expected the contact ID to be cleared'
+ );
+ });
+ });
+
+ describe('CLOSE_MAXIMUM_GROUP_SIZE_MODAL', () => {
+ it('closes the maximum group size modal if it was open', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: 'abc123',
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.Showing,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = closeMaximumGroupSizeModal();
+ const result = reducer(state, action);
+
+ assert(
+ result.composer?.step === ComposerStep.ChooseGroupMembers &&
+ result.composer.maximumGroupSizeModalState ===
+ OneTimeModalState.Shown,
+ 'Expected the modal to be closed'
+ );
+ });
+
+ it('does nothing if the maximum group size modal was never shown', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: 'abc123',
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = closeMaximumGroupSizeModal();
+ const result = reducer(state, action);
+
+ assert.strictEqual(result, state);
+ });
+
+ it('does nothing if the maximum group size modal already closed', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: 'abc123',
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.Shown,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = closeMaximumGroupSizeModal();
+ const result = reducer(state, action);
+
+ assert.strictEqual(result, state);
+ });
+ });
+
+ describe('CLOSE_RECOMMENDED_GROUP_SIZE_MODAL', () => {
+ it('closes the recommended group size modal if it was open', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: 'abc123',
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.Showing,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = closeRecommendedGroupSizeModal();
+ const result = reducer(state, action);
+
+ assert(
+ result.composer?.step === ComposerStep.ChooseGroupMembers &&
+ result.composer.recommendedGroupSizeModalState ===
+ OneTimeModalState.Shown,
+ 'Expected the modal to be closed'
+ );
+ });
+
+ it('does nothing if the recommended group size modal was never shown', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: 'abc123',
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = closeRecommendedGroupSizeModal();
+ const result = reducer(state, action);
+
+ assert.strictEqual(result, state);
+ });
+
+ it('does nothing if the recommended group size modal already closed', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: 'abc123',
+ contactSearchTerm: '',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.Shown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = closeRecommendedGroupSizeModal();
+ const result = reducer(state, action);
+
+ assert.strictEqual(result, state);
+ });
+ });
+
+ describe('createGroup', () => {
+ const conversationsState = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: ['abc123'],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'Foo Bar Group',
+ groupAvatar: new Uint8Array([1, 2, 3]).buffer,
+ isCreating: false as const,
+ hasError: true as const,
+ },
+ };
+
+ it('immediately dispatches a CREATE_GROUP_PENDING action, which puts the composer in a loading state', () => {
+ const dispatch = sinon.spy();
+
+ createGroup()(
+ dispatch,
+ () => ({
+ ...getEmptyRootState(),
+ conversations: conversationsState,
+ }),
+ null
+ );
+
+ sinon.assert.calledOnce(dispatch);
+ sinon.assert.calledWith(dispatch, { type: 'CREATE_GROUP_PENDING' });
+
+ const action = dispatch.getCall(0).args[0];
+
+ const result = reducer(conversationsState, action);
+
+ assert(
+ result.composer?.step === ComposerStep.SetGroupMetadata &&
+ result.composer.isCreating &&
+ !result.composer.hasError
+ );
+ });
+
+ it('calls groups.createGroupV2', async () => {
+ await createGroup()(
+ sinon.spy(),
+ () => ({
+ ...getEmptyRootState(),
+ conversations: conversationsState,
+ }),
+ null
+ );
+
+ sinon.assert.calledOnce(createGroupStub);
+ sinon.assert.calledWith(createGroupStub, {
+ name: 'Foo Bar Group',
+ avatar: new Uint8Array([1, 2, 3]).buffer,
+ conversationIds: ['abc123'],
+ });
+ });
+
+ it('dispatches a CREATE_GROUP_REJECTED action if group creation fails, which marks the state with an error', async () => {
+ createGroupStub.rejects(new Error('uh oh'));
+
+ const dispatch = sinon.spy();
+
+ const createGroupPromise = createGroup()(
+ dispatch,
+ () => ({
+ ...getEmptyRootState(),
+ conversations: conversationsState,
+ }),
+ null
+ );
+
+ const pendingAction = dispatch.getCall(0).args[0];
+ const stateAfterPending = reducer(conversationsState, pendingAction);
+
+ await createGroupPromise;
+
+ sinon.assert.calledTwice(dispatch);
+ sinon.assert.calledWith(dispatch, { type: 'CREATE_GROUP_REJECTED' });
+
+ const rejectedAction = dispatch.getCall(1).args[0];
+ const result = reducer(stateAfterPending, rejectedAction);
+
+ assert(
+ result.composer?.step === ComposerStep.SetGroupMetadata &&
+ !result.composer.isCreating &&
+ result.composer.hasError
+ );
+ });
+
+ it("when rejecting, does nothing to the left pane if it's no longer in this composer state", async () => {
+ createGroupStub.rejects(new Error('uh oh'));
+
+ const dispatch = sinon.spy();
+
+ const createGroupPromise = createGroup()(
+ dispatch,
+ () => ({
+ ...getEmptyRootState(),
+ conversations: conversationsState,
+ }),
+ null
+ );
+
+ await createGroupPromise;
+
+ const state = getEmptyState();
+ const rejectedAction = dispatch.getCall(1).args[0];
+ const result = reducer(state, rejectedAction);
+
+ assert.strictEqual(result, state);
+ });
+
+ it('dispatches a CREATE_GROUP_FULFILLED event (which updates the newly-created conversation IDs), triggers a showConversation event and switches to the associated conversation on success', async () => {
+ createGroupStub.resolves({
+ id: '9876',
+ get: (key: string) => {
+ if (key !== 'pendingMembersV2') {
+ throw new Error('This getter is not set up for this test');
+ }
+ return [{ conversationId: 'xyz999' }];
+ },
+ });
+
+ const dispatch = sinon.spy();
+
+ await createGroup()(
+ dispatch,
+ () => ({
+ ...getEmptyRootState(),
+ conversations: conversationsState,
+ }),
+ null
+ );
+
+ sinon.assert.calledWith(
+ window.Whisper.events.trigger as sinon.SinonSpy,
+ 'showConversation',
+ '9876',
+ undefined
+ );
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'CREATE_GROUP_FULFILLED',
+ payload: { invitedConversationIds: ['xyz999'] },
+ });
+
+ const fulfilledAction = dispatch.getCall(1).args[0];
+ const result = reducer(conversationsState, fulfilledAction);
+ assert.deepEqual(result.invitedConversationIdsForNewlyCreatedGroup, [
+ 'xyz999',
+ ]);
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'SWITCH_TO_ASSOCIATED_VIEW',
+ payload: { conversationId: '9876' },
+ });
+ });
+ });
+
describe('MESSAGE_SIZE_CHANGED', () => {
const stateWithActiveConversation = {
...getEmptyState(),
@@ -726,18 +1129,97 @@ describe('both/state/ducks/conversations', () => {
});
});
+ describe('SET_COMPOSE_GROUP_AVATAR', () => {
+ it("can clear the composer's group avatar", () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'foo',
+ groupAvatar: new ArrayBuffer(2),
+ isCreating: false as const,
+ hasError: false as const,
+ },
+ };
+ const action = setComposeGroupAvatar(undefined);
+ const result = reducer(state, action);
+
+ assert(
+ result.composer?.step === ComposerStep.SetGroupMetadata &&
+ result.composer.groupAvatar === undefined
+ );
+ });
+
+ it("can set the composer's group avatar", () => {
+ const avatar = new Uint8Array([1, 2, 3]).buffer;
+
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'foo',
+ groupAvatar: undefined,
+ isCreating: false as const,
+ hasError: false as const,
+ },
+ };
+ const action = setComposeGroupAvatar(avatar);
+ const result = reducer(state, action);
+
+ assert(
+ result.composer?.step === ComposerStep.SetGroupMetadata &&
+ result.composer.groupAvatar === avatar
+ );
+ });
+ });
+
+ describe('SET_COMPOSE_GROUP_NAME', () => {
+ it("can set the composer's group name", () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false as const,
+ hasError: false as const,
+ },
+ };
+ const action = setComposeGroupName('bing bong');
+ const result = reducer(state, action);
+
+ assert(
+ result.composer?.step === ComposerStep.SetGroupMetadata &&
+ result.composer.groupName === 'bing bong'
+ );
+ });
+ });
+
describe('SET_COMPOSE_SEARCH_TERM', () => {
it('updates the contact search term', () => {
const state = {
...getEmptyState(),
composer: {
+ step: ComposerStep.StartDirectConversation as const,
contactSearchTerm: '',
},
};
const action = setComposeSearchTerm('foo bar');
const result = reducer(state, action);
- assert.strictEqual(result.composer?.contactSearchTerm, 'foo bar');
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.StartDirectConversation,
+ contactSearchTerm: 'foo bar',
+ });
});
});
@@ -801,6 +1283,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ step: ComposerStep.StartDirectConversation as const,
contactSearchTerm: '',
},
};
@@ -838,6 +1321,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ step: ComposerStep.StartDirectConversation as const,
contactSearchTerm: '',
},
};
@@ -850,10 +1334,11 @@ describe('both/state/ducks/conversations', () => {
});
describe('START_COMPOSING', () => {
- it('if already at the composer, does nothing', () => {
+ it('does nothing if on the first step of the composer', () => {
const state = {
...getEmptyState(),
composer: {
+ step: ComposerStep.StartDirectConversation as const,
contactSearchTerm: 'foo bar',
},
};
@@ -861,7 +1346,58 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.isFalse(result.showArchived);
- assert.deepEqual(result.composer, { contactSearchTerm: 'foo bar' });
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.StartDirectConversation,
+ contactSearchTerm: 'foo bar',
+ });
+ });
+
+ it('if on the second step of the composer, goes back to the first step, clearing the search term', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ cantAddContactIdForModal: undefined,
+ contactSearchTerm: 'to be cleared',
+ groupAvatar: undefined,
+ groupName: '',
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ selectedConversationIds: [],
+ step: ComposerStep.ChooseGroupMembers as const,
+ },
+ };
+ const action = startComposing();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.StartDirectConversation,
+ contactSearchTerm: '',
+ });
+ });
+
+ it('if on the third step of the composer, goes back to the first step, clearing everything', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false,
+ hasError: false as const,
+ },
+ };
+ const action = startComposing();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.StartDirectConversation,
+ contactSearchTerm: '',
+ });
});
it('switches from the inbox to the composer', () => {
@@ -870,7 +1406,10 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.isFalse(result.showArchived);
- assert.deepEqual(result.composer, { contactSearchTerm: '' });
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.StartDirectConversation,
+ contactSearchTerm: '',
+ });
});
it('switches from the archive to the inbox', () => {
@@ -882,7 +1421,520 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.isFalse(result.showArchived);
- assert.deepEqual(result.composer, { contactSearchTerm: '' });
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.StartDirectConversation,
+ contactSearchTerm: '',
+ });
+ });
+ });
+
+ describe('SHOW_CHOOSE_GROUP_MEMBERS', () => {
+ it('switches to the second step of the composer if on the first step', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.StartDirectConversation as const,
+ contactSearchTerm: 'to be cleared',
+ },
+ };
+ const action = showChooseGroupMembers();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+
+ it('does nothing if already on the second step of the composer', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: 'foo bar',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = showChooseGroupMembers();
+ const result = reducer(state, action);
+
+ assert.strictEqual(result, state);
+ });
+
+ it('returns to the second step if on the third step of the composer', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'Foo Bar Group',
+ groupAvatar: new Uint8Array([4, 2]).buffer,
+ isCreating: false,
+ hasError: false as const,
+ },
+ };
+ const action = showChooseGroupMembers();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'Foo Bar Group',
+ groupAvatar: new Uint8Array([4, 2]).buffer,
+ });
+ });
+
+ it('switches from the inbox to the second step of the composer', () => {
+ const state = getEmptyState();
+ const action = showChooseGroupMembers();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+
+ it('switches from the archive to the second step of the composer', () => {
+ const state = {
+ ...getEmptyState(),
+ showArchived: true,
+ };
+ const action = showChooseGroupMembers();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+ });
+
+ describe('START_SETTING_GROUP_METADATA', () => {
+ it('moves from the second to the third step of the composer', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: 'foo bar',
+ selectedConversationIds: ['abc', 'def'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = startSettingGroupMetadata();
+ const result = reducer(state, action);
+
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.SetGroupMetadata,
+ selectedConversationIds: ['abc', 'def'],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ isCreating: false,
+ hasError: false,
+ });
+ });
+
+ it('maintains state when going from the second to third steps of the composer, if the second step already had some data (likely from a previous visit)', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: 'foo bar',
+ selectedConversationIds: ['abc', 'def'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'Foo Bar Group',
+ groupAvatar: new Uint8Array([6, 9]).buffer,
+ },
+ };
+ const action = startSettingGroupMetadata();
+ const result = reducer(state, action);
+
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.SetGroupMetadata,
+ selectedConversationIds: ['abc', 'def'],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'Foo Bar Group',
+ groupAvatar: new Uint8Array([6, 9]).buffer,
+ isCreating: false,
+ hasError: false as const,
+ });
+ });
+
+ it('does nothing if already on the third step of the composer', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.SetGroupMetadata as const,
+ selectedConversationIds: [],
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: 'Foo Bar Group',
+ groupAvatar: new Uint8Array([4, 2]).buffer,
+ isCreating: false,
+ hasError: false as const,
+ },
+ };
+ const action = startSettingGroupMetadata();
+ const result = reducer(state, action);
+
+ assert.strictEqual(result, state);
+ });
+ });
+
+ describe('TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS', () => {
+ function getAction(
+ id: string,
+ conversationsState: ConversationsStateType
+ ): ToggleConversationInChooseMembersActionType {
+ const dispatch = sinon.spy();
+
+ toggleConversationInChooseMembers(id)(
+ dispatch,
+ () => ({
+ ...getEmptyRootState(),
+ conversations: conversationsState,
+ }),
+ null
+ );
+
+ return dispatch.getCall(0).args[0];
+ }
+
+ let remoteConfigGetValueStub: sinon.SinonStub;
+
+ beforeEach(() => {
+ remoteConfigGetValueStub = sinonSandbox
+ .stub(window.Signal.RemoteConfig, 'getValue')
+ .withArgs('global.groupsv2.maxGroupSize')
+ .returns('22')
+ .withArgs('global.groupsv2.groupSizeHardLimit')
+ .returns('33');
+ });
+
+ it('adds conversation IDs to the list', () => {
+ const zero = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const one = reducer(zero, getAction('abc', zero));
+ const two = reducer(one, getAction('def', one));
+
+ assert.deepEqual(two.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: ['abc', 'def'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+
+ it('removes conversation IDs from the list', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: ['abc', 'def'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction('abc', state);
+ const result = reducer(state, action);
+
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: ['def'],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+
+ it('shows the recommended group size modal when first crossing the maximum recommended group size', () => {
+ const oldSelectedConversationIds = times(21, () => uuid());
+ const newUuid = uuid();
+
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: oldSelectedConversationIds,
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction(newUuid, state);
+ const result = reducer(state, action);
+
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: [...oldSelectedConversationIds, newUuid],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.Showing,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+
+ it("doesn't show the recommended group size modal twice", () => {
+ const oldSelectedConversationIds = times(21, () => uuid());
+ const newUuid = uuid();
+
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: oldSelectedConversationIds,
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.Shown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction(newUuid, state);
+ const result = reducer(state, action);
+
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: [...oldSelectedConversationIds, newUuid],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.Shown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+
+ it('defaults the maximum recommended size to 151', () => {
+ [undefined, 'xyz'].forEach(value => {
+ remoteConfigGetValueStub
+ .withArgs('global.groupsv2.maxGroupSize')
+ .returns(value);
+
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction(uuid(), state);
+
+ assert.strictEqual(action.payload.maxRecommendedGroupSize, 151);
+ });
+ });
+
+ it('shows the maximum group size modal when first reaching the maximum group size', () => {
+ const oldSelectedConversationIds = times(31, () => uuid());
+ const newUuid = uuid();
+
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: oldSelectedConversationIds,
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.Shown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction(newUuid, state);
+ const result = reducer(state, action);
+
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: [...oldSelectedConversationIds, newUuid],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.Shown,
+ maximumGroupSizeModalState: OneTimeModalState.Showing,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+
+ it("doesn't show the maximum group size modal twice", () => {
+ const oldSelectedConversationIds = times(31, () => uuid());
+ const newUuid = uuid();
+
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: oldSelectedConversationIds,
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.Shown,
+ maximumGroupSizeModalState: OneTimeModalState.Shown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction(newUuid, state);
+ const result = reducer(state, action);
+
+ assert.deepEqual(result.composer, {
+ step: ComposerStep.ChooseGroupMembers,
+ contactSearchTerm: '',
+ selectedConversationIds: [...oldSelectedConversationIds, newUuid],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.Shown,
+ maximumGroupSizeModalState: OneTimeModalState.Shown,
+ groupName: '',
+ groupAvatar: undefined,
+ });
+ });
+
+ it('cannot select more than the maximum number of conversations', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: times(1000, () => uuid()),
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction(uuid(), state);
+ const result = reducer(state, action);
+
+ assert.strictEqual(result, state);
+ });
+
+ it('defaults the maximum group size to 1001 if the recommended maximum is smaller', () => {
+ [undefined, 'xyz'].forEach(value => {
+ remoteConfigGetValueStub
+ .withArgs('global.groupsv2.maxGroupSize')
+ .returns('2')
+ .withArgs('global.groupsv2.groupSizeHardLimit')
+ .returns(value);
+
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction(uuid(), state);
+
+ assert.strictEqual(action.payload.maxGroupSize, 1001);
+ });
+ });
+
+ it('defaults the maximum group size to (recommended maximum + 1) if the recommended maximum is more than 1001', () => {
+ remoteConfigGetValueStub
+ .withArgs('global.groupsv2.maxGroupSize')
+ .returns('1234')
+ .withArgs('global.groupsv2.groupSizeHardLimit')
+ .returns('2');
+
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ step: ComposerStep.ChooseGroupMembers as const,
+ contactSearchTerm: '',
+ selectedConversationIds: [],
+ cantAddContactIdForModal: undefined,
+ recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
+ maximumGroupSizeModalState: OneTimeModalState.NeverShown,
+ groupName: '',
+ groupAvatar: undefined,
+ },
+ };
+ const action = getAction(uuid(), state);
+
+ assert.strictEqual(action.payload.maxGroupSize, 1235);
});
});
});
diff --git a/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts
new file mode 100644
index 000000000..16b20b23e
--- /dev/null
+++ b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts
@@ -0,0 +1,196 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import * as sinon from 'sinon';
+import { times } from 'lodash';
+import { v4 as uuid } from 'uuid';
+import { RowType } from '../../../components/ConversationList';
+import * as remoteConfig from '../../../RemoteConfig';
+import { ContactCheckboxDisabledReason } from '../../../components/conversationList/ContactCheckbox';
+
+import { LeftPaneChooseGroupMembersHelper } from '../../../components/leftPane/LeftPaneChooseGroupMembersHelper';
+
+describe('LeftPaneChooseGroupMembersHelper', () => {
+ const defaults = {
+ candidateContacts: [],
+ cantAddContactForModal: undefined,
+ isShowingRecommendedGroupSizeModal: false,
+ isShowingMaximumGroupSizeModal: false,
+ searchTerm: '',
+ selectedContacts: [],
+ };
+
+ const fakeContact = () => ({
+ id: uuid(),
+ isGroupV2Capable: true,
+ title: uuid(),
+ type: 'direct' as const,
+ });
+
+ let sinonSandbox: sinon.SinonSandbox;
+
+ beforeEach(() => {
+ sinonSandbox = sinon.createSandbox();
+
+ sinonSandbox
+ .stub(remoteConfig, 'getValue')
+ .withArgs('global.groupsv2.maxGroupSize')
+ .returns('22')
+ .withArgs('global.groupsv2.groupSizeHardLimit')
+ .returns('33');
+ });
+
+ afterEach(() => {
+ sinonSandbox.restore();
+ });
+
+ describe('getRowCount', () => {
+ it('returns 0 if there are no contacts', () => {
+ assert.strictEqual(
+ new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts: [],
+ searchTerm: '',
+ selectedContacts: [fakeContact()],
+ }).getRowCount(),
+ 0
+ );
+ assert.strictEqual(
+ new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts: [],
+ searchTerm: 'foo bar',
+ selectedContacts: [fakeContact()],
+ }).getRowCount(),
+ 0
+ );
+ });
+
+ it('returns the number of candidate contacts + 2 if there are any', () => {
+ assert.strictEqual(
+ new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts: [fakeContact(), fakeContact()],
+ searchTerm: '',
+ selectedContacts: [fakeContact()],
+ }).getRowCount(),
+ 4
+ );
+ });
+ });
+
+ describe('getRow', () => {
+ it('returns undefined if there are no contacts', () => {
+ assert.isUndefined(
+ new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts: [],
+ searchTerm: '',
+ selectedContacts: [fakeContact()],
+ }).getRow(0)
+ );
+ assert.isUndefined(
+ new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts: [],
+ searchTerm: '',
+ selectedContacts: [fakeContact()],
+ }).getRow(99)
+ );
+ assert.isUndefined(
+ new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts: [],
+ searchTerm: 'foo bar',
+ selectedContacts: [fakeContact()],
+ }).getRow(0)
+ );
+ });
+
+ it('returns a header, then the contacts, then a blank space if there are contacts', () => {
+ const candidateContacts = [fakeContact(), fakeContact()];
+ const helper = new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts,
+ searchTerm: 'foo bar',
+ selectedContacts: [candidateContacts[1]],
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.ContactCheckbox,
+ contact: candidateContacts[0],
+ isChecked: false,
+ disabledReason: undefined,
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.ContactCheckbox,
+ contact: candidateContacts[1],
+ isChecked: true,
+ disabledReason: undefined,
+ });
+ assert.deepEqual(helper.getRow(3), { type: RowType.Blank });
+ });
+
+ it("disables non-selected contact checkboxes if you've selected the maximum number of contacts", () => {
+ const candidateContacts = times(50, () => fakeContact());
+ const helper = new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts,
+ searchTerm: 'foo bar',
+ selectedContacts: candidateContacts.slice(1, 33),
+ });
+
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.ContactCheckbox,
+ contact: candidateContacts[0],
+ isChecked: false,
+ disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.ContactCheckbox,
+ contact: candidateContacts[1],
+ isChecked: true,
+ disabledReason: undefined,
+ });
+ });
+
+ it("disables contacts that aren't GV2-capable, unless they are already selected somehow", () => {
+ const candidateContacts = [
+ { ...fakeContact(), isGroupV2Capable: false },
+ { ...fakeContact(), isGroupV2Capable: undefined },
+ { ...fakeContact(), isGroupV2Capable: false },
+ ];
+
+ const helper = new LeftPaneChooseGroupMembersHelper({
+ ...defaults,
+ candidateContacts,
+ searchTerm: 'foo bar',
+ selectedContacts: [candidateContacts[2]],
+ });
+
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.ContactCheckbox,
+ contact: candidateContacts[0],
+ isChecked: false,
+ disabledReason: ContactCheckboxDisabledReason.NotCapable,
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.ContactCheckbox,
+ contact: candidateContacts[1],
+ isChecked: false,
+ disabledReason: ContactCheckboxDisabledReason.NotCapable,
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.ContactCheckbox,
+ contact: candidateContacts[2],
+ isChecked: true,
+ disabledReason: undefined,
+ });
+ });
+ });
+});
diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts
index 9967ac845..a17d3f930 100644
--- a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts
+++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts
@@ -2,9 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
+import * as sinon from 'sinon';
import { v4 as uuid } from 'uuid';
import { RowType } from '../../../components/ConversationList';
import { FindDirection } from '../../../components/leftPane/LeftPaneHelper';
+import * as remoteConfig from '../../../RemoteConfig';
import { LeftPaneComposeHelper } from '../../../components/leftPane/LeftPaneComposeHelper';
@@ -15,8 +17,48 @@ describe('LeftPaneComposeHelper', () => {
type: 'direct' as const,
});
+ let sinonSandbox: sinon.SinonSandbox;
+ let remoteConfigStub: sinon.SinonStub;
+
+ beforeEach(() => {
+ sinonSandbox = sinon.createSandbox();
+
+ remoteConfigStub = sinonSandbox
+ .stub(remoteConfig, 'isEnabled')
+ .withArgs('desktop.storage')
+ .returns(true)
+ .withArgs('desktop.storageWrite2')
+ .returns(true);
+ });
+
+ afterEach(() => {
+ sinonSandbox.restore();
+ });
+
describe('getRowCount', () => {
- it('returns the number of contacts if not searching for a phone number', () => {
+ it('returns 1 (for the "new group" button) if not searching and there are no contacts', () => {
+ assert.strictEqual(
+ new LeftPaneComposeHelper({
+ composeContacts: [],
+ regionCode: 'US',
+ searchTerm: '',
+ }).getRowCount(),
+ 1
+ );
+ });
+
+ it('returns the number of contacts + 2 (for the "new group" button and header) if not searching', () => {
+ assert.strictEqual(
+ new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: '',
+ }).getRowCount(),
+ 4
+ );
+ });
+
+ it('returns the number of contacts if searching, but not for a phone number', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [],
@@ -29,26 +71,50 @@ describe('LeftPaneComposeHelper', () => {
new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
- searchTerm: '',
+ searchTerm: 'foo bar',
}).getRowCount(),
2
);
});
- it('returns the number of contacts + 1 if searching for a phone number', () => {
+ it('returns 1 (for the "Start new conversation" button) if searching for a phone number with no contacts', () => {
+ assert.strictEqual(
+ new LeftPaneComposeHelper({
+ composeContacts: [],
+ regionCode: 'US',
+ searchTerm: '+16505551234',
+ }).getRowCount(),
+ 1
+ );
+ });
+
+ it('returns the number of contacts + 2 (for the "Start new conversation" button and header) if searching for a phone number', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: '+16505551234',
}).getRowCount(),
- 3
+ 4
);
});
});
describe('getRow', () => {
- it('returns each contact as a row if not searching for a phone number', () => {
+ it('returns a "new group" button if not searching and there are no contacts', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [],
+ regionCode: 'US',
+ searchTerm: '',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.CreateNewGroup,
+ });
+ assert.isUndefined(helper.getRow(1));
+ });
+
+ it('returns a "new group" button, a header, and contacts if not searching', () => {
const composeContacts = [fakeContact(), fakeContact()];
const helper = new LeftPaneComposeHelper({
composeContacts,
@@ -56,6 +122,72 @@ describe('LeftPaneComposeHelper', () => {
searchTerm: '',
});
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.CreateNewGroup,
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Contact,
+ contact: composeContacts[0],
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.Contact,
+ contact: composeContacts[1],
+ });
+ });
+
+ it("doesn't let you create new groups if storage service write is disabled", () => {
+ remoteConfigStub
+ .withArgs('desktop.storage')
+ .returns(false)
+ .withArgs('desktop.storageWrite2')
+ .returns(false);
+
+ assert.isUndefined(
+ new LeftPaneComposeHelper({
+ composeContacts: [],
+ regionCode: 'US',
+ searchTerm: '',
+ }).getRow(0)
+ );
+
+ remoteConfigStub
+ .withArgs('desktop.storage')
+ .returns(true)
+ .withArgs('desktop.storageWrite2')
+ .returns(false);
+
+ assert.isUndefined(
+ new LeftPaneComposeHelper({
+ composeContacts: [],
+ regionCode: 'US',
+ searchTerm: '',
+ }).getRow(0)
+ );
+ });
+
+ it('returns no rows if searching and there are no results', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [],
+ regionCode: 'US',
+ searchTerm: 'foo bar',
+ });
+
+ assert.isUndefined(helper.getRow(0));
+ assert.isUndefined(helper.getRow(1));
+ });
+
+ it('returns one row per contact if searching', () => {
+ const composeContacts = [fakeContact(), fakeContact()];
+ const helper = new LeftPaneComposeHelper({
+ composeContacts,
+ regionCode: 'US',
+ searchTerm: 'foo bar',
+ });
+
assert.deepEqual(helper.getRow(0), {
type: RowType.Contact,
contact: composeContacts[0],
@@ -66,7 +198,21 @@ describe('LeftPaneComposeHelper', () => {
});
});
- it('returns a "start new conversation" row if searching for a phone number', () => {
+ it('returns a "start new conversation" row if searching for a phone number and there are no results', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [],
+ regionCode: 'US',
+ searchTerm: '+16505551234',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.StartNewConversation,
+ phoneNumber: '+16505551234',
+ });
+ assert.isUndefined(helper.getRow(1));
+ });
+
+ it('returns a "start new conversation" row, a header, and contacts if searching for a phone number', () => {
const composeContacts = [fakeContact(), fakeContact()];
const helper = new LeftPaneComposeHelper({
composeContacts,
@@ -79,10 +225,14 @@ describe('LeftPaneComposeHelper', () => {
phoneNumber: '+16505551234',
});
assert.deepEqual(helper.getRow(1), {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ });
+ assert.deepEqual(helper.getRow(2), {
type: RowType.Contact,
contact: composeContacts[0],
});
- assert.deepEqual(helper.getRow(2), {
+ assert.deepEqual(helper.getRow(3), {
type: RowType.Contact,
contact: composeContacts[1],
});
@@ -120,7 +270,7 @@ describe('LeftPaneComposeHelper', () => {
});
describe('shouldRecomputeRowHeights', () => {
- it('always returns false because row heights are constant', () => {
+ it('returns false if going from "no header" to "no header"', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
@@ -130,15 +280,79 @@ describe('LeftPaneComposeHelper', () => {
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact()],
+ regionCode: 'US',
searchTerm: 'foo bar',
})
);
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact(), fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: 'bing bong',
+ })
+ );
+ });
+
+ it('returns false if going from "has header" to "has header"', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: '',
+ });
+
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ composeContacts: [fakeContact()],
+ regionCode: 'US',
searchTerm: '',
})
);
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ composeContacts: [fakeContact()],
+ regionCode: 'US',
+ searchTerm: '+16505559876',
+ })
+ );
+ });
+
+ it('returns true if going from "no header" to "has header"', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: 'foo bar',
+ });
+
+ assert.isTrue(
+ helper.shouldRecomputeRowHeights({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: '',
+ })
+ );
+ assert.isTrue(
+ helper.shouldRecomputeRowHeights({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: '+16505551234',
+ })
+ );
+ });
+
+ it('returns true if going from "has header" to "no header"', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: '',
+ });
+
+ assert.isTrue(
+ helper.shouldRecomputeRowHeights({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: 'foo bar',
+ })
+ );
});
});
});
diff --git a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts
new file mode 100644
index 000000000..28a4d88df
--- /dev/null
+++ b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts
@@ -0,0 +1,85 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import { v4 as uuid } from 'uuid';
+import { RowType } from '../../../components/ConversationList';
+
+import { LeftPaneSetGroupMetadataHelper } from '../../../components/leftPane/LeftPaneSetGroupMetadataHelper';
+
+describe('LeftPaneSetGroupMetadataHelper', () => {
+ const fakeContact = () => ({
+ id: uuid(),
+ title: uuid(),
+ type: 'direct' as const,
+ });
+
+ describe('getRowCount', () => {
+ it('returns 0 if there are no contacts', () => {
+ assert.strictEqual(
+ new LeftPaneSetGroupMetadataHelper({
+ groupAvatar: undefined,
+ groupName: '',
+ hasError: false,
+ isCreating: false,
+ selectedContacts: [],
+ }).getRowCount(),
+ 0
+ );
+ });
+
+ it('returns the number of candidate contacts + 2 if there are any', () => {
+ assert.strictEqual(
+ new LeftPaneSetGroupMetadataHelper({
+ groupAvatar: undefined,
+ groupName: '',
+ hasError: false,
+ isCreating: false,
+ selectedContacts: [fakeContact(), fakeContact()],
+ }).getRowCount(),
+ 4
+ );
+ });
+ });
+
+ describe('getRow', () => {
+ it('returns undefined if there are no contacts', () => {
+ assert.isUndefined(
+ new LeftPaneSetGroupMetadataHelper({
+ groupAvatar: undefined,
+ groupName: '',
+ hasError: false,
+ isCreating: false,
+ selectedContacts: [],
+ }).getRow(0)
+ );
+ });
+
+ it('returns a header, then the contacts, then a blank space if there are contacts', () => {
+ const selectedContacts = [fakeContact(), fakeContact()];
+ const helper = new LeftPaneSetGroupMetadataHelper({
+ groupAvatar: undefined,
+ groupName: '',
+ hasError: false,
+ isCreating: false,
+ selectedContacts,
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'setGroupMetadata__members-header',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Contact,
+ contact: selectedContacts[0],
+ isClickable: false,
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Contact,
+ contact: selectedContacts[1],
+ isClickable: false,
+ });
+ assert.deepEqual(helper.getRow(3), { type: RowType.Blank });
+ });
+ });
+});
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index aaef610e4..1872069bd 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -14461,6 +14461,24 @@
"updated": "2021-01-06T00:47:54.313Z",
"reasonDetail": "Needed to render remote video elements. Doesn't interact with the DOM."
},
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarInput.js",
+ "line": " const fileInputRef = react_1.useRef(null);",
+ "lineNumber": 40,
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-03-01T18:34:36.638Z",
+ "reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM."
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarInput.js",
+ "line": " const menuTriggerRef = react_1.useRef(null);",
+ "lineNumber": 43,
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-03-01T18:34:36.638Z",
+ "reasonDetail": "Used to reference popup menu"
+ },
{
"rule": "React-useRef",
"path": "ts/components/AvatarPopup.js",
@@ -14641,11 +14659,29 @@
"updated": "2020-10-26T23:56:13.482Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/ContactPills.js",
+ "line": " const elRef = react_1.useRef(null);",
+ "lineNumber": 27,
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-03-01T18:34:36.638Z",
+ "reasonDetail": "Used for scrolling. Doesn't otherwise manipulate the DOM"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/ContactPills.js",
+ "line": " const previousChildCountRef = react_1.useRef(childCount);",
+ "lineNumber": 29,
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-03-01T18:34:36.638Z",
+ "reasonDetail": "Doesn't reference the DOM. Refers to a number"
+ },
{
"rule": "React-useRef",
"path": "ts/components/ConversationList.js",
"line": " const listRef = react_1.useRef(null);",
- "lineNumber": 44,
+ "lineNumber": 49,
"reasonCategory": "usageTrusted",
"updated": "2021-02-12T16:25:08.285Z",
"reasonDetail": "Used for scroll calculations"
@@ -14706,7 +14742,7 @@
"rule": "React-useRef",
"path": "ts/components/LeftPane.js",
"line": " const previousModeSpecificPropsRef = react_1.useRef(modeSpecificProps);",
- "lineNumber": 47,
+ "lineNumber": 52,
"reasonCategory": "usageTrusted",
"updated": "2021-02-12T16:25:08.285Z",
"reasonDetail": "Doesn't interact with the DOM."
@@ -14715,7 +14751,7 @@
"rule": "React-useRef",
"path": "ts/components/LeftPane.tsx",
"line": " const previousModeSpecificPropsRef = useRef(modeSpecificProps);",
- "lineNumber": 104,
+ "lineNumber": 143,
"reasonCategory": "usageTrusted",
"updated": "2021-02-12T16:25:08.285Z",
"reasonDetail": "Doesn't interact with the DOM."
@@ -14969,7 +15005,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Timeline.js",
"line": " this.listRef = react_1.default.createRef();",
- "lineNumber": 31,
+ "lineNumber": 32,
"reasonCategory": "usageTrusted",
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Timeline needs to interact with its child List directly"
diff --git a/ts/util/parseIntOrThrow.ts b/ts/util/parseIntOrThrow.ts
new file mode 100644
index 000000000..21537f2b2
--- /dev/null
+++ b/ts/util/parseIntOrThrow.ts
@@ -0,0 +1,24 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+export function parseIntOrThrow(value: unknown, message: string): number {
+ let result: number;
+
+ switch (typeof value) {
+ case 'number':
+ result = value;
+ break;
+ case 'string':
+ result = parseInt(value, 10);
+ break;
+ default:
+ result = NaN;
+ break;
+ }
+
+ if (!Number.isInteger(result)) {
+ throw new Error(message);
+ }
+
+ return result;
+}
diff --git a/ts/util/parseIntWithFallback.ts b/ts/util/parseIntWithFallback.ts
new file mode 100644
index 000000000..2978bd472
--- /dev/null
+++ b/ts/util/parseIntWithFallback.ts
@@ -0,0 +1,12 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { parseIntOrThrow } from './parseIntOrThrow';
+
+export function parseIntWithFallback(value: unknown, fallback: number): number {
+ try {
+ return parseIntOrThrow(value, 'Failed to parse');
+ } catch (err) {
+ return fallback;
+ }
+}