New Group administration: Add users

This commit is contained in:
Evan Hahn 2021-03-11 15:29:31 -06:00 committed by Josh Perez
parent e81c18e84c
commit b81a52bbdd
43 changed files with 1789 additions and 277 deletions

View file

@ -0,0 +1,50 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import {
AddGroupMemberErrorDialog,
AddGroupMemberErrorDialogMode,
} from './AddGroupMemberErrorDialog';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/AddGroupMemberErrorDialog', module);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
story.add("Can't add a contact", () => (
<AddGroupMemberErrorDialog
{...defaultProps}
mode={AddGroupMemberErrorDialogMode.CantAddContact}
contact={{
name: 'Foo Bar',
title: 'Foo Bar',
}}
/>
));
story.add('Maximum group size', () => (
<AddGroupMemberErrorDialog
{...defaultProps}
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
maximumNumberOfContacts={123}
/>
));
story.add('Maximum recommended group size', () => (
<AddGroupMemberErrorDialog
{...defaultProps}
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
recommendedMaximumNumberOfContacts={123}
/>
));

View file

@ -0,0 +1,90 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent, ReactNode } from 'react';
import { LocalizerType } from '../types/Util';
import { Alert } from './Alert';
import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName';
import { missingCaseError } from '../util/missingCaseError';
export enum AddGroupMemberErrorDialogMode {
CantAddContact,
MaximumGroupSize,
RecommendedMaximumGroupSize,
}
type PropsDataType =
| {
mode: AddGroupMemberErrorDialogMode.CantAddContact;
contact: {
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
}
| {
mode: AddGroupMemberErrorDialogMode.MaximumGroupSize;
maximumNumberOfContacts: number;
}
| {
mode: AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize;
recommendedMaximumNumberOfContacts: number;
};
type PropsType = {
i18n: LocalizerType;
onClose: () => void;
} & PropsDataType;
export const AddGroupMemberErrorDialog: FunctionComponent<PropsType> = props => {
const { i18n, onClose } = props;
let title: string;
let body: ReactNode;
switch (props.mode) {
case AddGroupMemberErrorDialogMode.CantAddContact: {
const { contact } = props;
title = i18n('chooseGroupMembers__cant-add-member__title');
body = (
<Intl
i18n={i18n}
id="chooseGroupMembers__cant-add-member__body"
components={[
<ContactName
key="name"
name={contact.name}
profileName={contact.profileName}
phoneNumber={contact.phoneNumber}
title={contact.title}
i18n={i18n}
/>,
]}
/>
);
break;
}
case AddGroupMemberErrorDialogMode.MaximumGroupSize: {
const { maximumNumberOfContacts } = props;
title = i18n('chooseGroupMembers__maximum-group-size__title');
body = i18n('chooseGroupMembers__maximum-group-size__body', [
maximumNumberOfContacts.toString(),
]);
break;
}
case AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize: {
const { recommendedMaximumNumberOfContacts } = props;
title = i18n('chooseGroupMembers__maximum-recommended-group-size__title');
body = i18n('chooseGroupMembers__maximum-recommended-group-size__body', [
recommendedMaximumNumberOfContacts.toString(),
]);
break;
}
default:
throw missingCaseError(props);
}
return <Alert body={body} i18n={i18n} onClose={onClose} title={title} />;
};

View file

@ -0,0 +1,41 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { Alert } from './Alert';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Alert', module);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
story.add('Title and body are strings', () => (
<Alert
{...defaultProps}
title="Hello world"
body="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus."
/>
));
story.add('Body is a ReactNode', () => (
<Alert
{...defaultProps}
title="Hello world"
body={
<>
<span style={{ color: 'red' }}>Hello</span>{' '}
<span style={{ color: 'green', fontWeight: 'bold' }}>world</span>!
</>
}
/>
));

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent } from 'react';
import React, { FunctionComponent, ReactNode } from 'react';
import { LocalizerType } from '../types/Util';
import { Button } from './Button';
@ -9,7 +9,7 @@ import { ModalHost } from './ModalHost';
type PropsType = {
title?: string;
body: string;
body: ReactNode;
i18n: LocalizerType;
onClose: () => void;
};

View file

@ -15,6 +15,7 @@ import {
MessageStatuses,
} from './conversationList/ConversationListItem';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
@ -49,6 +50,7 @@ const defaultConversations: Array<ConversationListItemPropsType> = [
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
type: 'direct',
},
getDefaultConversation(),
];
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
@ -204,6 +206,12 @@ story.add('Contact checkboxes: disabled', () => (
isChecked: true,
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
},
{
type: RowType.ContactCheckbox,
contact: defaultConversations[3],
isChecked: true,
disabledReason: ContactCheckboxDisabledReason.AlreadyAdded,
},
])}
/>
));

View file

@ -380,8 +380,9 @@ export const LeftPane: React.FC<PropsType> = ({
case undefined:
toggleConversationInChooseMembers(conversationId);
break;
case ContactCheckboxDisabledReason.AlreadyAdded:
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// This is a no-op.
// These are no-ops.
break;
case ContactCheckboxDisabledReason.NotCapable:
cantAddContactToGroup(conversationId);

View file

@ -0,0 +1,92 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ComponentProps, useState } from 'react';
import { times } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { sleep } from '../../../util/sleep';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { AddGroupMembersModal } from './AddGroupMembersModal';
import { RequestState } from './util';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/AddGroupMembersModal',
module
);
const allCandidateContacts = times(50, () => getDefaultConversation());
type PropsType = ComponentProps<typeof AddGroupMembersModal>;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
candidateContacts: allCandidateContacts,
clearRequestError: action('clearRequestError'),
conversationIdsAlreadyInGroup: new Set(),
groupTitle: 'Tahoe Trip',
i18n,
onClose: action('onClose'),
makeRequest: async (conversationIds: ReadonlyArray<string>) => {
action('onMakeRequest')(conversationIds);
},
requestState: RequestState.Inactive,
...overrideProps,
});
story.add('Default', () => <AddGroupMembersModal {...createProps()} />);
story.add('Only 3 contacts', () => (
<AddGroupMembersModal
{...createProps({
candidateContacts: allCandidateContacts.slice(0, 3),
})}
/>
));
story.add('No candidate contacts', () => (
<AddGroupMembersModal
{...createProps({
candidateContacts: [],
})}
/>
));
story.add('Everyone already added', () => (
<AddGroupMembersModal
{...createProps({
conversationIdsAlreadyInGroup: new Set(
allCandidateContacts.map(contact => contact.id)
),
})}
/>
));
story.add('Request fails after 1 second', () => {
const Wrapper = () => {
const [requestState, setRequestState] = useState(RequestState.Inactive);
return (
<AddGroupMembersModal
{...createProps({
clearRequestError: () => {
setRequestState(RequestState.Inactive);
},
makeRequest: async () => {
setRequestState(RequestState.Active);
await sleep(1000);
setRequestState(RequestState.InactiveWithError);
},
requestState,
})}
/>
);
};
return <Wrapper />;
});

View file

@ -0,0 +1,320 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent, useMemo, useReducer } from 'react';
import { without } from 'lodash';
import { LocalizerType } from '../../../types/Util';
import {
AddGroupMemberErrorDialog,
AddGroupMemberErrorDialogMode,
} from '../../AddGroupMemberErrorDialog';
import { ConversationType } from '../../../state/ducks/conversations';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../../groups/limits';
import {
toggleSelectedContactForGroupAddition,
OneTimeModalState,
} from '../../../groups/toggleSelectedContactForGroupAddition';
import { makeLookup } from '../../../util/makeLookup';
import { deconstructLookup } from '../../../util/deconstructLookup';
import { missingCaseError } from '../../../util/missingCaseError';
import { RequestState } from './util';
import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal';
import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal';
type PropsType = {
candidateContacts: ReadonlyArray<ConversationType>;
clearRequestError: () => void;
conversationIdsAlreadyInGroup: Set<string>;
groupTitle: string;
i18n: LocalizerType;
makeRequest: (conversationIds: ReadonlyArray<string>) => Promise<void>;
onClose: () => void;
requestState: RequestState;
};
enum Stage {
ChoosingContacts,
ConfirmingAdds,
}
type StateType = {
cantAddContactForModal: undefined | ConversationType;
maximumGroupSizeModalState: OneTimeModalState;
recommendedGroupSizeModalState: OneTimeModalState;
searchTerm: string;
selectedConversationIds: Array<string>;
stage: Stage;
};
enum ActionType {
CloseMaximumGroupSizeModal,
CloseRecommendedMaximumGroupSizeModal,
ConfirmAdds,
RemoveSelectedContact,
ReturnToContactChooser,
SetCantAddContactForModal,
ToggleSelectedContact,
UpdateSearchTerm,
}
type Action =
| { type: ActionType.CloseMaximumGroupSizeModal }
| { type: ActionType.CloseRecommendedMaximumGroupSizeModal }
| { type: ActionType.ConfirmAdds }
| { type: ActionType.ReturnToContactChooser }
| { type: ActionType.RemoveSelectedContact; conversationId: string }
| {
type: ActionType.SetCantAddContactForModal;
contact: undefined | ConversationType;
}
| {
type: ActionType.ToggleSelectedContact;
conversationId: string;
numberOfContactsAlreadyInGroup: number;
}
| { type: ActionType.UpdateSearchTerm; searchTerm: string };
// `<ConversationDetails>` isn't currently hooked up to Redux, but that's not desirable in
// the long term (see DESKTOP-1260). For now, this component has internal state with a
// reducer. Hopefully, this will make things easier to port to Redux in the future.
function reducer(
state: Readonly<StateType>,
action: Readonly<Action>
): StateType {
switch (action.type) {
case ActionType.CloseMaximumGroupSizeModal:
return {
...state,
maximumGroupSizeModalState: OneTimeModalState.Shown,
};
case ActionType.CloseRecommendedMaximumGroupSizeModal:
return {
...state,
recommendedGroupSizeModalState: OneTimeModalState.Shown,
};
case ActionType.ConfirmAdds:
return {
...state,
stage: Stage.ConfirmingAdds,
};
case ActionType.ReturnToContactChooser:
return {
...state,
stage: Stage.ChoosingContacts,
};
case ActionType.RemoveSelectedContact:
return {
...state,
selectedConversationIds: without(
state.selectedConversationIds,
action.conversationId
),
};
case ActionType.SetCantAddContactForModal:
return {
...state,
cantAddContactForModal: action.contact,
};
case ActionType.ToggleSelectedContact:
return {
...state,
...toggleSelectedContactForGroupAddition(action.conversationId, {
maxGroupSize: getMaximumNumberOfContacts(),
maxRecommendedGroupSize: getRecommendedMaximumNumberOfContacts(),
maximumGroupSizeModalState: state.maximumGroupSizeModalState,
numberOfContactsAlreadyInGroup: action.numberOfContactsAlreadyInGroup,
recommendedGroupSizeModalState: state.recommendedGroupSizeModalState,
selectedConversationIds: state.selectedConversationIds,
}),
};
case ActionType.UpdateSearchTerm:
return {
...state,
searchTerm: action.searchTerm,
};
default:
throw missingCaseError(action);
}
}
export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
candidateContacts,
clearRequestError,
conversationIdsAlreadyInGroup,
groupTitle,
i18n,
onClose,
makeRequest,
requestState,
}) => {
const maxGroupSize = getMaximumNumberOfContacts();
const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts();
const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size;
const isGroupAlreadyFull = numberOfContactsAlreadyInGroup >= maxGroupSize;
const isGroupAlreadyOverRecommendedMaximum =
numberOfContactsAlreadyInGroup >= maxRecommendedGroupSize;
const [
{
cantAddContactForModal,
maximumGroupSizeModalState,
recommendedGroupSizeModalState,
searchTerm,
selectedConversationIds,
stage,
},
dispatch,
] = useReducer(reducer, {
cantAddContactForModal: undefined,
maximumGroupSizeModalState: isGroupAlreadyFull
? OneTimeModalState.Showing
: OneTimeModalState.NeverShown,
recommendedGroupSizeModalState: isGroupAlreadyOverRecommendedMaximum
? OneTimeModalState.Shown
: OneTimeModalState.NeverShown,
searchTerm: '',
selectedConversationIds: [],
stage: Stage.ChoosingContacts,
});
const contactLookup = useMemo(() => makeLookup(candidateContacts, 'id'), [
candidateContacts,
]);
const selectedContacts = deconstructLookup(
contactLookup,
selectedConversationIds
);
if (cantAddContactForModal) {
return (
<AddGroupMemberErrorDialog
contact={cantAddContactForModal}
i18n={i18n}
mode={AddGroupMemberErrorDialogMode.CantAddContact}
onClose={() => {
dispatch({
type: ActionType.SetCantAddContactForModal,
contact: undefined,
});
}}
/>
);
}
if (maximumGroupSizeModalState === OneTimeModalState.Showing) {
return (
<AddGroupMemberErrorDialog
i18n={i18n}
maximumNumberOfContacts={maxGroupSize}
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
onClose={() => {
dispatch({ type: ActionType.CloseMaximumGroupSizeModal });
}}
/>
);
}
if (recommendedGroupSizeModalState === OneTimeModalState.Showing) {
return (
<AddGroupMemberErrorDialog
i18n={i18n}
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
onClose={() => {
dispatch({
type: ActionType.CloseRecommendedMaximumGroupSizeModal,
});
}}
recommendedMaximumNumberOfContacts={maxRecommendedGroupSize}
/>
);
}
switch (stage) {
case Stage.ChoosingContacts: {
// See note above: these will soon become Redux actions.
const confirmAdds = () => {
dispatch({ type: ActionType.ConfirmAdds });
};
const removeSelectedContact = (conversationId: string) => {
dispatch({
type: ActionType.RemoveSelectedContact,
conversationId,
});
};
const setCantAddContactForModal = (
contact: undefined | Readonly<ConversationType>
) => {
dispatch({
type: ActionType.SetCantAddContactForModal,
contact,
});
};
const setSearchTerm = (term: string) => {
dispatch({
type: ActionType.UpdateSearchTerm,
searchTerm: term,
});
};
const toggleSelectedContact = (conversationId: string) => {
dispatch({
type: ActionType.ToggleSelectedContact,
conversationId,
numberOfContactsAlreadyInGroup,
});
};
return (
<ChooseGroupMembersModal
candidateContacts={candidateContacts}
confirmAdds={confirmAdds}
contactLookup={contactLookup}
conversationIdsAlreadyInGroup={conversationIdsAlreadyInGroup}
i18n={i18n}
maxGroupSize={maxGroupSize}
onClose={onClose}
removeSelectedContact={removeSelectedContact}
searchTerm={searchTerm}
selectedContacts={selectedContacts}
setCantAddContactForModal={setCantAddContactForModal}
setSearchTerm={setSearchTerm}
toggleSelectedContact={toggleSelectedContact}
/>
);
}
case Stage.ConfirmingAdds: {
const onCloseConfirmationDialog = () => {
dispatch({ type: ActionType.ReturnToContactChooser });
clearRequestError();
};
return (
<ConfirmAdditionsModal
groupTitle={groupTitle}
i18n={i18n}
makeRequest={() => {
makeRequest(selectedConversationIds);
}}
onClose={onCloseConfirmationDialog}
requestState={requestState}
selectedContacts={selectedContacts}
/>
);
}
default:
throw missingCaseError(stage);
}
};
function getRecommendedMaximumNumberOfContacts(): number {
return getGroupSizeRecommendedLimit(151);
}
function getMaximumNumberOfContacts(): number {
return getGroupSizeHardLimit(1001);
}

View file

@ -0,0 +1,250 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
FunctionComponent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Measure, { MeasuredComponentProps } from 'react-measure';
import { LocalizerType } from '../../../../types/Util';
import { assert } from '../../../../util/assert';
import { getOwn } from '../../../../util/getOwn';
import { missingCaseError } from '../../../../util/missingCaseError';
import { filterAndSortContacts } from '../../../../util/filterAndSortContacts';
import { ConversationType } from '../../../../state/ducks/conversations';
import { ModalHost } from '../../../ModalHost';
import { ContactPills } from '../../../ContactPills';
import { ContactPill } from '../../../ContactPill';
import { ConversationList, Row, RowType } from '../../../ConversationList';
import { ContactCheckboxDisabledReason } from '../../../conversationList/ContactCheckbox';
import { Button, ButtonVariant } from '../../../Button';
type PropsType = {
candidateContacts: ReadonlyArray<ConversationType>;
confirmAdds: () => void;
contactLookup: Record<string, ConversationType>;
conversationIdsAlreadyInGroup: Set<string>;
i18n: LocalizerType;
maxGroupSize: number;
onClose: () => void;
removeSelectedContact: (_: string) => void;
searchTerm: string;
selectedContacts: ReadonlyArray<ConversationType>;
setCantAddContactForModal: (
_: Readonly<undefined | ConversationType>
) => void;
setSearchTerm: (_: string) => void;
toggleSelectedContact: (conversationId: string) => void;
};
export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
candidateContacts,
confirmAdds,
contactLookup,
conversationIdsAlreadyInGroup,
i18n,
maxGroupSize,
onClose,
removeSelectedContact,
searchTerm,
selectedContacts,
setCantAddContactForModal,
setSearchTerm,
toggleSelectedContact,
}) => {
const inputRef = useRef<null | HTMLInputElement>(null);
const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size;
const hasSelectedMaximumNumberOfContacts =
selectedContacts.length + numberOfContactsAlreadyInGroup >= maxGroupSize;
const selectedConversationIdsSet: Set<string> = useMemo(
() => new Set(selectedContacts.map(contact => contact.id)),
[selectedContacts]
);
const canContinue = Boolean(selectedContacts.length);
const [filteredContacts, setFilteredContacts] = useState(
filterAndSortContacts(candidateContacts, '')
);
const normalizedSearchTerm = searchTerm.trim();
useEffect(() => {
const timeout = setTimeout(() => {
setFilteredContacts(
filterAndSortContacts(candidateContacts, normalizedSearchTerm)
);
}, 200);
return () => {
clearTimeout(timeout);
};
}, [candidateContacts, normalizedSearchTerm, setFilteredContacts]);
const rowCount = filteredContacts.length;
const getRow = (index: number): undefined | Row => {
const contact = filteredContacts[index];
if (!contact) {
return undefined;
}
const isSelected = selectedConversationIdsSet.has(contact.id);
const isAlreadyInGroup = conversationIdsAlreadyInGroup.has(contact.id);
let disabledReason: undefined | ContactCheckboxDisabledReason;
if (isAlreadyInGroup) {
disabledReason = ContactCheckboxDisabledReason.AlreadyAdded;
} else if (hasSelectedMaximumNumberOfContacts && !isSelected) {
disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
} else if (!contact.isGroupV2Capable) {
disabledReason = ContactCheckboxDisabledReason.NotCapable;
}
return {
type: RowType.ContactCheckbox,
contact,
isChecked: isSelected || isAlreadyInGroup,
disabledReason,
};
};
return (
<ModalHost onClose={onClose}>
<div className="module-AddGroupMembersModal module-AddGroupMembersModal--choose-members">
<button
aria-label={i18n('close')}
className="module-AddGroupMembersModal__close-button"
type="button"
onClick={() => {
onClose();
}}
/>
<h1 className="module-AddGroupMembersModal__header">
{i18n('AddGroupMembersModal--title')}
</h1>
<input
type="text"
className="module-AddGroupMembersModal__search-input"
disabled={candidateContacts.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
onKeyDown={event => {
if (canContinue && event.key === 'Enter') {
confirmAdds();
}
}}
ref={inputRef}
value={searchTerm}
/>
{Boolean(selectedContacts.length) && (
<ContactPills>
{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(contact.id);
}}
/>
))}
</ContactPills>
)}
{candidateContacts.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// We disable this ESLint rule because we're capturing a bubbled keydown
// event. See [this note in the jsx-a11y docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div
className="module-AddGroupMembersModal__list-wrapper"
ref={measureRef}
onKeyDown={event => {
if (event.key === 'Enter') {
inputRef.current?.focus();
}
}}
>
<ConversationList
dimensions={contentRect.bounds}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleSelectedContact(conversationId);
break;
case ContactCheckboxDisabledReason.AlreadyAdded:
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// These are no-ops.
break;
case ContactCheckboxDisabledReason.NotCapable: {
const contact = getOwn(contactLookup, conversationId);
assert(
contact,
'Contact was not in lookup; not showing modal'
);
setCantAddContactForModal(contact);
break;
}
default:
throw missingCaseError(disabledReason);
}
}}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
/>
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}}
</Measure>
) : (
<div className="module-AddGroupMembersModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
<div className="module-AddGroupMembersModal__button-container">
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
{i18n('cancel')}
</Button>
<Button disabled={!canContinue} onClick={confirmAdds}>
{i18n('AddGroupMembersModal--continue-to-confirm')}
</Button>
</div>
</div>
</ModalHost>
);
};
function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): unknown {
assert(false, 'This should never be called. Doing nothing');
}

View file

@ -0,0 +1,109 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent, ReactNode } from 'react';
import { LocalizerType } from '../../../../types/Util';
import { assert } from '../../../../util/assert';
import { ModalHost } from '../../../ModalHost';
import { Button, ButtonVariant } from '../../../Button';
import { Spinner } from '../../../Spinner';
import { ConversationType } from '../../../../state/ducks/conversations';
import { RequestState } from '../util';
import { Intl } from '../../../Intl';
import { Emojify } from '../../Emojify';
import { ContactName } from '../../ContactName';
type PropsType = {
groupTitle: string;
i18n: LocalizerType;
makeRequest: () => void;
onClose: () => void;
requestState: RequestState;
selectedContacts: ReadonlyArray<ConversationType>;
};
export const ConfirmAdditionsModal: FunctionComponent<PropsType> = ({
groupTitle,
i18n,
makeRequest,
onClose,
requestState,
selectedContacts,
}) => {
const firstContact = selectedContacts[0];
assert(
firstContact,
'Expected at least one conversation to be selected but none were picked'
);
const groupTitleNode: JSX.Element = <Emojify text={groupTitle} />;
let headerText: ReactNode;
if (selectedContacts.length === 1) {
headerText = (
<Intl
i18n={i18n}
id="AddGroupMembersModal--confirm-title--one"
components={{
person: (
<ContactName
profileName={firstContact.profileName}
title={firstContact.title}
i18n={i18n}
/>
),
group: groupTitleNode,
}}
/>
);
} else {
headerText = (
<Intl
i18n={i18n}
id="AddGroupMembersModal--confirm-title--many"
components={{
count: selectedContacts.length.toString(),
group: groupTitleNode,
}}
/>
);
}
let buttonContents: ReactNode;
if (requestState === RequestState.Active) {
buttonContents = (
<Spinner size="20px" svgSize="small" direction="on-avatar" />
);
} else if (selectedContacts.length === 1) {
buttonContents = i18n('AddGroupMembersModal--confirm-button--one');
} else {
buttonContents = i18n('AddGroupMembersModal--confirm-button--many');
}
return (
<ModalHost onClose={onClose}>
<div className="module-AddGroupMembersModal module-AddGroupMembersModal--confirm-adds">
<h1 className="module-AddGroupMembersModal__header">{headerText}</h1>
{requestState === RequestState.InactiveWithError && (
<div className="module-AddGroupMembersModal__error-message">
{i18n('updateGroupAttributes__error-message')}
</div>
)}
<div className="module-AddGroupMembersModal__button-container">
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
{i18n('cancel')}
</Button>
<Button
disabled={requestState === RequestState.Active}
onClick={makeRequest}
variant={ButtonVariant.Primary}
>
{buttonContents}
</Button>
</div>
</div>
</ModalHost>
);
};

View file

@ -5,6 +5,7 @@ import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { times } from 'lodash';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
@ -47,7 +48,11 @@ const conversation: ConversationType = {
};
const createProps = (hasGroupLink = false): Props => ({
addMembers: async () => {
action('addMembers');
},
canEditGroupInfo: false,
candidateContactsToAdd: times(10, () => getDefaultConversation()),
conversation,
hasGroupLink,
i18n,

View file

@ -1,30 +1,39 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import React, { useState, ReactNode } from 'react';
import { ConversationType } from '../../../state/ducks/conversations';
import { assert } from '../../../util/assert';
import {
ExpirationTimerOptions,
TimerOption,
} from '../../../util/ExpirationTimerOptions';
import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { missingCaseError } from '../../../util/missingCaseError';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
import { AddGroupMembersModal } from './AddGroupMembersModal';
import { ConversationDetailsActions } from './ConversationDetailsActions';
import { ConversationDetailsHeader } from './ConversationDetailsHeader';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
import {
EditConversationAttributesModal,
RequestState as EditGroupAttributesRequestState,
} from './EditConversationAttributesModal';
import { EditConversationAttributesModal } from './EditConversationAttributesModal';
import { RequestState } from './util';
enum ModalState {
NothingOpen,
EditingGroupAttributes,
AddingGroupMembers,
}
export type StateProps = {
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
canEditGroupInfo: boolean;
candidateContactsToAdd: Array<ConversationType>;
conversation?: ConversationType;
hasGroupLink: boolean;
i18n: LocalizerType;
@ -53,7 +62,9 @@ export type StateProps = {
export type Props = StateProps;
export const ConversationDetails: React.ComponentType<Props> = ({
addMembers,
canEditGroupInfo,
candidateContactsToAdd,
conversation,
hasGroupLink,
i18n,
@ -70,15 +81,17 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onBlockAndDelete,
onDelete,
}) => {
const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState(
false
const [modalState, setModalState] = useState<ModalState>(
ModalState.NothingOpen
);
const [
editGroupAttributesRequestState,
setEditGroupAttributesRequestState,
] = useState<EditGroupAttributesRequestState>(
EditGroupAttributesRequestState.Inactive
);
] = useState<RequestState>(RequestState.Inactive);
const [
addGroupMembersRequestState,
setAddGroupMembersRequestState,
] = useState<RequestState>(RequestState.Inactive);
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
setDisappearingMessages(parseInt(event.target.value, 10));
@ -94,6 +107,88 @@ export const ConversationDetails: React.ComponentType<Props> = ({
const invitesCount =
pendingMemberships.length + pendingApprovalMemberships.length;
let modalNode: ReactNode;
switch (modalState) {
case ModalState.NothingOpen:
modalNode = undefined;
break;
case ModalState.EditingGroupAttributes:
modalNode = (
<EditConversationAttributesModal
avatarPath={conversation.avatarPath}
i18n={i18n}
makeRequest={async (
options: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => {
setEditGroupAttributesRequestState(RequestState.Active);
try {
await updateGroupAttributes(options);
setModalState(ModalState.NothingOpen);
setEditGroupAttributesRequestState(RequestState.Inactive);
} catch (err) {
setEditGroupAttributesRequestState(
RequestState.InactiveWithError
);
}
}}
onClose={() => {
setModalState(ModalState.NothingOpen);
setEditGroupAttributesRequestState(RequestState.Inactive);
}}
requestState={editGroupAttributesRequestState}
title={conversation.title}
/>
);
break;
case ModalState.AddingGroupMembers:
modalNode = (
<AddGroupMembersModal
candidateContacts={candidateContactsToAdd}
clearRequestError={() => {
setAddGroupMembersRequestState(oldRequestState => {
assert(
oldRequestState !== RequestState.Active,
'Should not be clearing an active request state'
);
return RequestState.Inactive;
});
}}
conversationIdsAlreadyInGroup={
new Set(
(conversation.memberships || []).map(
membership => membership.member.id
)
)
}
groupTitle={conversation.title}
i18n={i18n}
makeRequest={async conversationIds => {
setAddGroupMembersRequestState(RequestState.Active);
try {
await addMembers(conversationIds);
setModalState(ModalState.NothingOpen);
setAddGroupMembersRequestState(RequestState.Inactive);
} catch (err) {
setAddGroupMembersRequestState(RequestState.InactiveWithError);
}
}}
onClose={() => {
setModalState(ModalState.NothingOpen);
setEditGroupAttributesRequestState(RequestState.Inactive);
}}
requestState={addGroupMembersRequestState}
/>
);
break;
default:
throw missingCaseError(modalState);
}
return (
<div className="conversation-details-panel">
<ConversationDetailsHeader
@ -101,7 +196,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
conversation={conversation}
i18n={i18n}
startEditing={() => {
setIsEditingGroupAttributes(true);
setModalState(ModalState.EditingGroupAttributes);
}}
/>
@ -141,9 +236,13 @@ export const ConversationDetails: React.ComponentType<Props> = ({
) : null}
<ConversationDetailsMembershipList
canAddNewMembers={canEditGroupInfo}
i18n={i18n}
showContactModal={showContactModal}
memberships={conversation.memberships || []}
showContactModal={showContactModal}
startAddingNewMembers={() => {
setModalState(ModalState.AddingGroupMembers);
}}
/>
<PanelSection>
@ -200,42 +299,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onBlockAndDelete={onBlockAndDelete}
/>
{isEditingGroupAttributes && (
<EditConversationAttributesModal
avatarPath={conversation.avatarPath}
i18n={i18n}
makeRequest={async (
options: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => {
setEditGroupAttributesRequestState(
EditGroupAttributesRequestState.Active
);
try {
await updateGroupAttributes(options);
setIsEditingGroupAttributes(false);
setEditGroupAttributesRequestState(
EditGroupAttributesRequestState.Inactive
);
} catch (err) {
setEditGroupAttributesRequestState(
EditGroupAttributesRequestState.InactiveWithError
);
}
}}
onClose={() => {
setIsEditingGroupAttributes(false);
setEditGroupAttributesRequestState(
EditGroupAttributesRequestState.Inactive
);
}}
requestState={editGroupAttributesRequestState}
title={conversation.title}
/>
)}
{modalNode}
</div>
);
};

View file

@ -1,7 +1,8 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { isBoolean } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
@ -42,9 +43,13 @@ const createMemberships = (
};
const createProps = (overrideProps: Partial<Props>): Props => ({
canAddNewMembers: isBoolean(overrideProps.canAddNewMembers)
? overrideProps.canAddNewMembers
: false,
i18n,
memberships: overrideProps.memberships || [],
showContactModal: action('showContactModal'),
startAddingNewMembers: action('startAddingNewMembers'),
});
story.add('Few', () => {
@ -92,3 +97,11 @@ story.add('None', () => {
return <ConversationDetailsMembershipList {...props} />;
});
story.add('Can add new members', () => {
const memberships = createMemberships(10);
const props = createProps({ canAddNewMembers: true, memberships });
return <ConversationDetailsMembershipList {...props} />;
});

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -19,8 +19,10 @@ export type GroupV2Membership = {
};
export type Props = {
canAddNewMembers: boolean;
memberships: Array<GroupV2Membership>;
showContactModal: (conversationId: string) => void;
startAddingNewMembers: () => void;
i18n: LocalizerType;
};
@ -66,8 +68,10 @@ function sortMemberships(
}
export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
canAddNewMembers,
memberships,
showContactModal,
startAddingNewMembers,
i18n,
}) => {
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
@ -85,6 +89,15 @@ export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
sortedMemberships.length.toString(),
])}
>
{canAddNewMembers && (
<PanelRow
icon={
<div className="module-conversation-details-membership-list__add-members-icon" />
}
label={i18n('ConversationDetailsMembershipList--add-members')}
onClick={startAddingNewMembers}
/>
)}
{sortedMemberships.slice(0, membersToShow).map(({ isAdmin, member }) => (
<PanelRow
key={member.id}

View file

@ -8,10 +8,8 @@ import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import {
EditConversationAttributesModal,
RequestState,
} from './EditConversationAttributesModal';
import { EditConversationAttributesModal } from './EditConversationAttributesModal';
import { RequestState } from './util';
const i18n = setupI18n('en', enMessages);

View file

@ -18,6 +18,7 @@ import { Spinner } from '../../Spinner';
import { GroupTitleInput } from '../../GroupTitleInput';
import * as log from '../../../logging/log';
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
import { RequestState } from './util';
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
@ -35,12 +36,6 @@ type PropsType = {
title: string;
};
export enum RequestState {
Inactive,
InactiveWithError,
Active,
}
export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
avatarPath: externalAvatarPath,
i18n,

View file

@ -1,8 +1,14 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
export enum RequestState {
Inactive,
InactiveWithError,
Active,
}
export const bemGenerator = (block: string) => (
element: string,
modifier?: string | Record<string, boolean>

View file

@ -98,6 +98,11 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
className={CHECKBOX_CLASS_NAME}
disabled={disabled}
onChange={onClick}
onKeyDown={event => {
if (onClick && !disabled && event.key === 'Enter') {
onClick();
}
}}
type="checkbox"
/>
);

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
import React, { CSSProperties, FunctionComponent, ReactNode } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ColorType } from '../../types/Colors';
@ -11,7 +11,8 @@ 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,
AlreadyAdded = 1,
MaximumContactsSelected,
NotCapable,
}
@ -67,7 +68,14 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
/>
);
const messageText = about ? <About className="" text={about} /> : null;
let messageText: ReactNode;
if (disabledReason === ContactCheckboxDisabledReason.AlreadyAdded) {
messageText = i18n('alreadyAMember');
} else if (about) {
messageText = <About className="" text={about} />;
} else {
messageText = null;
}
const onClickItem = () => {
onClick(id, disabledReason);

View file

@ -9,7 +9,10 @@ import { ConversationType } from '../../state/ducks/conversations';
import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox';
import { ContactPills } from '../ContactPills';
import { ContactPill } from '../ContactPill';
import { Alert } from '../Alert';
import {
AddGroupMemberErrorDialog,
AddGroupMemberErrorDialogMode,
} from '../AddGroupMemberErrorDialog';
import { Button } from '../Button';
import { LocalizerType } from '../../types/Util';
import {
@ -111,35 +114,34 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
) => unknown;
removeSelectedContact: (conversationId: string) => unknown;
}>): ReactChild {
let modalDetails:
| undefined
| { title: string; body: string; onClose: () => void };
let modalNode: undefined | ReactChild;
if (this.isShowingMaximumGroupSizeModal) {
modalDetails = {
title: i18n('chooseGroupMembers__maximum-group-size__title'),
body: i18n('chooseGroupMembers__maximum-group-size__body', [
this.getMaximumNumberOfContacts().toString(),
]),
onClose: closeMaximumGroupSizeModal,
};
modalNode = (
<AddGroupMemberErrorDialog
i18n={i18n}
maximumNumberOfContacts={this.getMaximumNumberOfContacts()}
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
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,
};
modalNode = (
<AddGroupMemberErrorDialog
i18n={i18n}
recommendedMaximumNumberOfContacts={this.getRecommendedMaximumNumberOfContacts()}
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
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,
};
modalNode = (
<AddGroupMemberErrorDialog
i18n={i18n}
contact={this.cantAddContactForModal}
mode={AddGroupMemberErrorDialogMode.CantAddContact}
onClose={closeCantAddContactToGroupModal}
/>
);
}
return (
@ -149,7 +151,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
type="text"
ref={focusRef}
className="module-left-pane__compose-search-form__input"
placeholder={i18n('newConversationContactSearchPlaceholder')}
placeholder={i18n('contactSearchPlaceholder')}
dir="auto"
value={this.searchTerm}
onChange={onChangeComposeSearchTerm}
@ -178,18 +180,11 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
{this.getRowCount() ? null : (
<div className="module-left-pane__compose-no-contacts">
{i18n('newConversationNoContacts')}
{i18n('noContactsFound')}
</div>
)}
{modalDetails && (
<Alert
body={modalDetails.body}
i18n={i18n}
onClose={modalDetails.onClose}
title={modalDetails.title}
/>
)}
{modalNode}
</>
);
}

View file

@ -90,7 +90,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
type="text"
ref={focusRef}
className="module-left-pane__compose-search-form__input"
placeholder={i18n('newConversationContactSearchPlaceholder')}
placeholder={i18n('contactSearchPlaceholder')}
dir="auto"
value={this.searchTerm}
onChange={onChangeComposeSearchTerm}
@ -99,7 +99,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
{this.getRowCount() ? null : (
<div className="module-left-pane__compose-no-contacts">
{i18n('newConversationNoContacts')}
{i18n('noContactsFound')}
</div>
)}
</>