Support for creating New Groups

This commit is contained in:
Evan Hahn 2021-03-03 14:09:58 -06:00 committed by Josh Perez
parent 1934120e46
commit 5de4babc0d
56 changed files with 6222 additions and 526 deletions

View file

@ -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<ConversationType>;
cantAddContactForModal: undefined | ConversationType;
isShowingRecommendedGroupSizeModal: boolean;
isShowingMaximumGroupSizeModal: boolean;
searchTerm: string;
selectedContacts: Array<ConversationType>;
};
/* eslint-disable class-methods-use-this */
export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
LeftPaneChooseGroupMembersPropsType
> {
private readonly candidateContacts: ReadonlyArray<ConversationType>;
private readonly cantAddContactForModal:
| undefined
| Readonly<{ title: string }>;
private readonly isShowingMaximumGroupSizeModal: boolean;
private readonly isShowingRecommendedGroupSizeModal: boolean;
private readonly searchTerm: string;
private readonly selectedContacts: Array<ConversationType>;
private readonly selectedConversationIdsSet: Set<string>;
constructor({
candidateContacts,
cantAddContactForModal,
isShowingMaximumGroupSizeModal,
isShowingRecommendedGroupSizeModal,
searchTerm,
selectedContacts,
}: Readonly<LeftPaneChooseGroupMembersPropsType>) {
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 (
<div className="module-left-pane__header__contents">
<button
aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button"
onClick={startComposing}
title={backButtonLabel}
type="button"
/>
<div className="module-left-pane__header__contents__text">
{i18n('chooseGroupMembers__title')}
</div>
</div>
);
}
getPreRowsNode({
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
i18n,
onChangeComposeSearchTerm,
removeSelectedContact,
}: Readonly<{
closeCantAddContactToGroupModal: () => unknown;
closeMaximumGroupSizeModal: () => unknown;
closeRecommendedGroupSizeModal: () => unknown;
i18n: LocalizerType;
onChangeComposeSearchTerm: (
event: ChangeEvent<HTMLInputElement>
) => 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 (
<>
<div className="module-left-pane__compose-search-form">
<input
type="text"
ref={focusRef}
className="module-left-pane__compose-search-form__input"
placeholder={i18n('newConversationContactSearchPlaceholder')}
dir="auto"
value={this.searchTerm}
onChange={onChangeComposeSearchTerm}
/>
</div>
{Boolean(this.selectedContacts.length) && (
<ContactPills>
{this.selectedContacts.map(contact => (
<ContactPill
key={contact.id}
avatarPath={contact.avatarPath}
color={contact.color}
firstName={contact.firstName}
i18n={i18n}
id={contact.id}
name={contact.name}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
title={contact.title}
onClickRemove={removeSelectedContact}
/>
))}
</ContactPills>
)}
{this.getRowCount() ? null : (
<div className="module-left-pane__compose-no-contacts">
{i18n('newConversationNoContacts')}
</div>
)}
{modalDetails && (
<Alert
body={modalDetails.body}
i18n={i18n}
onClose={modalDetails.onClose}
title={modalDetails.title}
/>
)}
</>
);
}
getFooterContents({
i18n,
startSettingGroupMetadata,
}: Readonly<{
i18n: LocalizerType;
startSettingGroupMetadata: () => void;
}>): ReactChild {
return (
<Button
disabled={this.hasExceededMaximumNumberOfContacts()}
onClick={startSettingGroupMetadata}
>
{this.selectedContacts.length
? i18n('chooseGroupMembers__next')
: i18n('chooseGroupMembers__skip')}
</Button>
);
}
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<unknown>
): undefined {
return undefined;
}
getConversationAndMessageInDirection(
..._args: ReadonlyArray<unknown>
): 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();
}
}

View file

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

View file

@ -23,6 +23,8 @@ export abstract class LeftPaneHelper<T> {
_: Readonly<{
i18n: LocalizerType;
showInbox: () => void;
startComposing: () => void;
showChooseGroupMembers: () => void;
}>
): null | ReactChild {
return null;
@ -34,10 +36,28 @@ export abstract class LeftPaneHelper<T> {
getPreRowsNode(
_: Readonly<{
clearGroupCreationError: () => void;
closeCantAddContactToGroupModal: () => unknown;
closeMaximumGroupSizeModal: () => unknown;
closeRecommendedGroupSizeModal: () => unknown;
createGroup: () => unknown;
i18n: LocalizerType;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
setComposeGroupName: (_: string) => unknown;
onChangeComposeSearchTerm: (
event: ChangeEvent<HTMLInputElement>
) => unknown;
removeSelectedContact: (_: string) => unknown;
}>
): null | ReactChild {
return null;
}
getFooterContents(
_: Readonly<{
i18n: LocalizerType;
startSettingGroupMetadata: () => void;
createGroup: () => unknown;
}>
): null | ReactChild {
return null;

View file

@ -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<ContactListItemPropsType>;
};
/* 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<ContactListItemPropsType>;
constructor({
groupAvatar,
groupName,
isCreating,
hasError,
selectedContacts,
}: Readonly<LeftPaneSetGroupMetadataPropsType>) {
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 (
<div className="module-left-pane__header__contents">
<button
aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button"
disabled={this.isCreating}
onClick={showChooseGroupMembers}
title={backButtonLabel}
type="button"
/>
<div className="module-left-pane__header__contents__text">
{i18n('setGroupMetadata__title')}
</div>
</div>
);
}
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 (
<form
className="module-left-pane__header__form"
onSubmit={event => {
event.preventDefault();
event.stopPropagation();
if (!this.canCreateGroup()) {
return;
}
createGroup();
}}
>
<AvatarInput
contextMenuId="left pane group avatar uploader"
disabled={disabled}
i18n={i18n}
onChange={setComposeGroupAvatar}
value={this.groupAvatar}
/>
<input
disabled={disabled}
className="module-left-pane__compose-input"
onChange={event => {
setComposeGroupName(event.target.value);
}}
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
ref={focusRef}
type="text"
value={this.groupName}
/>
{this.hasError && (
<Alert
body={i18n('setGroupMetadata__error-message')}
i18n={i18n}
onClose={clearGroupCreationError}
/>
)}
</form>
);
}
getFooterContents({
createGroup,
i18n,
}: Readonly<{
createGroup: () => unknown;
i18n: LocalizerType;
}>): ReactChild {
return (
<Button disabled={!this.canCreateGroup()} onClick={createGroup}>
{this.isCreating ? (
<Spinner size="20px" svgSize="small" direction="on-avatar" />
) : (
i18n('setGroupMetadata__create-group')
)}
</Button>
);
}
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<unknown>
): undefined {
return undefined;
}
getConversationAndMessageInDirection(
..._args: ReadonlyArray<unknown>
): 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();
}
}