New Group administration: Add users
This commit is contained in:
parent
e81c18e84c
commit
b81a52bbdd
43 changed files with 1789 additions and 277 deletions
50
ts/components/AddGroupMemberErrorDialog.stories.tsx
Normal file
50
ts/components/AddGroupMemberErrorDialog.stories.tsx
Normal 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}
|
||||
/>
|
||||
));
|
90
ts/components/AddGroupMemberErrorDialog.tsx
Normal file
90
ts/components/AddGroupMemberErrorDialog.tsx
Normal 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} />;
|
||||
};
|
41
ts/components/Alert.stories.tsx
Normal file
41
ts/components/Alert.stories.tsx
Normal 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>!
|
||||
</>
|
||||
}
|
||||
/>
|
||||
));
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
])}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 />;
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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');
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue