Implemented ability to quickly add a user to a group

This commit is contained in:
Alvaro 2022-09-26 10:24:52 -06:00 committed by GitHub
parent 190cd9408b
commit 22bf3ebcc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 855 additions and 70 deletions

View File

@ -1,4 +1,40 @@
{
"AddUserToAnotherGroupModal__title": {
"message": "Add to a group",
"description": "Shown as the title of the dialog that allows you to add a contact to an group"
},
"AddUserToAnotherGroupModal__confirm-title": {
"message": "Add new member?",
"description": "Shown as the title of the confirmation dialog when adding a contact to a group, after having selected the group"
},
"AddUserToAnotherGroupModal__confirm-add": {
"message": "Add",
"description": "Shown in the affirmative button of the confirmation dialog when adding a contact to a group"
},
"AddUserToAnotherGroupModal__confirm-message": {
"message": "Add “$contact$” to the group “$group$”",
"description": "Shown in the confirmation dialog body when adding a contact to a group"
},
"AddUserToAnotherGroupModal__toast--user-added-to-group": {
"message": "$contact$ was added to $group$",
"description": "Shown in toast after a user is added to an existing group"
},
"AddUserToAnotherGroupModal__toast--adding-user-to-group": {
"message": "Adding $contact$...",
"description": "Shown in toast while a user is being added to a group"
},
"GroupListItem__message-default": {
"message": "$count$ members",
"description": "Shown below the group name when selecting a group to invite a contact to"
},
"GroupListItem__message-already-member": {
"message": "Already a member",
"description": "Shown below the group name when selecting a group to invite a contact to, when the group item is disabled"
},
"GroupListItem__message-pending": {
"message": "Membership is pending",
"description": "Shown below the group name when selecting a group to invite a contact to, when the group item is disabled"
},
"softwareAcknowledgments": {
"message": "Software Acknowledgments",
"description": "Shown in the about box for the link to software acknowledgments"
@ -4251,6 +4287,18 @@
"message": "See all",
"description": "This is a button on the conversation details to show all members"
},
"ConversationDetailsGroups--title": {
"message": "$number$ groups in common",
"description": "Title of the groups-in-common panel, in the contact details"
},
"ConversationDetailsGroups--add-to-group": {
"message": "Add to a group",
"description": "The button shown on a conversation details (for a direct contact) that you can click to add that contact to groups"
},
"ConversationDetailsGroups--show-all": {
"message": "See all",
"description": "This is a button on the conversation details (for a direct contact) to show all groups-in-common"
},
"ConversationNotificationsSettings__mentions__label": {
"message": "Mentions",
"description": "In the conversation notifications settings, this is the label for the mentions option"

View File

@ -0,0 +1,29 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
div.AddUserToAnotherGroupModal__body {
padding-left: 0;
padding-bottom: 0;
padding-right: 0;
}
.AddUserToAnotherGroupModal {
&__main-body {
display: flex;
flex-direction: column;
min-height: 300px;
}
&__list-wrapper {
flex-grow: 1;
overflow: hidden;
}
}
.AddUserToAnotherGroupModal .module-conversation-list {
&__item,
&__item--contact-or-conversation {
height: 52px;
padding: 0 6px;
}
}

View File

@ -104,7 +104,8 @@
}
}
&__make-admin__bubble-icon {
&__make-admin__bubble-icon,
&__add-to-another-group__bubble-icon {
height: 16px;
width: 18px;

View File

@ -74,8 +74,10 @@
@include color-bubble(20px);
}
&-membership-list {
&__add-members-icon {
&-membership-list,
&-groups {
&__add-members-icon,
&__add-to-group-icon {
@mixin plus-icon($color) {
@include color-svg('../images/icons/v2/plus-24.svg', $color);
content: '';

View File

@ -132,11 +132,12 @@
@include scrollbar;
@include font-body-1;
margin: 0;
padding: 16px;
}
&--has-header {
.module-Modal__body {
padding: 0 16px 16px 16px;
padding-top: 0;
border-top: 1px solid transparent;
// If there's a header, just the body scrolls
overflow-y: overlay;
@ -155,7 +156,6 @@
}
&--no-header {
padding: 16px;
// If there's no header, the whole thing scrolls
overflow-y: overlay;
overflow-x: auto;

View File

@ -8,14 +8,11 @@
align-items: center;
border-radius: $border-radius-px;
bottom: 62px;
box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
justify-content: space-between;
left: 50%;
position: absolute;
text-align: center;
transform: translate(-50%, 0);
user-select: none;
overflow: hidden;
z-index: $z-index-toast;
@ -29,6 +26,17 @@
color: $color-gray-05;
}
&--align-center {
bottom: 62px;
left: 50%;
transform: translate(-50%, 0);
}
&--align-left {
left: 20px;
bottom: 18px;
}
&__content {
padding: 8px 12px;
}

View File

@ -25,6 +25,7 @@
// New style: components
@import './components/About.scss';
@import './components/AddGroupMembersModal.scss';
@import './components/AddUserToAnotherGroupModal.scss';
@import './components/AnnouncementsOnlyGroupBanner.scss';
@import './components/App.scss';
@import './components/AudioCapture.scss';

View File

@ -0,0 +1,50 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Props } from './AddUserToAnotherGroupModal';
import enMessages from '../../_locales/en/messages.json';
import {
getDefaultConversation,
getDefaultGroup,
} from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import { AddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/AddUserToAnotherGroupModal',
component: AddUserToAnotherGroupModal,
argTypes: {
candidateConversations: {
defaultValue: Array.from(Array(100), () => getDefaultGroup()),
},
contact: {
defaultValue: getDefaultConversation(),
},
i18n: {
defaultValue: i18n,
},
addMemberToGroup: {
defaultValue: action('addMemberToGroup'),
},
toggleAddUserToAnotherGroupModal: {
defaultValue: action('toggleAddUserToAnotherGroupModal'),
},
},
} as Meta;
const Template: Story<Props> = args => (
<AddUserToAnotherGroupModal
{...args}
theme={React.useContext(StorybookThemeContext)}
/>
);
export const Modal = Template.bind({});
Modal.args = {};

View File

@ -0,0 +1,223 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { noop, pick } from 'lodash';
import React from 'react';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { ConversationType } from '../state/ducks/conversations';
import type {
LocalizerType,
ReplacementValuesType,
ThemeType,
} from '../types/Util';
import { ToastType } from '../state/ducks/toast';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { Row } from './ConversationList';
import { ConversationList, RowType } from './ConversationList';
import { DisabledReason } from './conversationList/GroupListItem';
import { Modal } from './Modal';
import { SearchInput } from './SearchInput';
import { useRestoreFocus } from '../hooks/useRestoreFocus';
type OwnProps = {
i18n: LocalizerType;
theme: ThemeType;
contact: Pick<ConversationType, 'id' | 'title' | 'uuid'>;
candidateConversations: ReadonlyArray<ConversationType>;
regionCode: string | undefined;
};
type DispatchProps = {
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
addMemberToGroup: (
conversationId: string,
contactId: string,
onComplete: () => void
) => void;
showToast: (toastType: ToastType, parameters?: ReplacementValuesType) => void;
};
export type Props = OwnProps & DispatchProps;
export const AddUserToAnotherGroupModal = ({
i18n,
theme,
contact,
toggleAddUserToAnotherGroupModal,
addMemberToGroup,
showToast,
candidateConversations,
regionCode,
}: Props): JSX.Element | null => {
const [searchTerm, setSearchTerm] = React.useState('');
const [filteredConversations, setFilteredConversations] = React.useState(
filterAndSortConversationsByRecent(candidateConversations, '', undefined)
);
const [selectedGroupId, setSelectedGroupId] = React.useState<
undefined | string
>(undefined);
const groupLookup: Map<string, ConversationType> = React.useMemo(() => {
const map = new Map();
candidateConversations.forEach(conversation => {
map.set(conversation.id, conversation);
});
return map;
}, [candidateConversations]);
const [inputRef] = useRestoreFocus();
const normalizedSearchTerm = searchTerm.trim();
React.useEffect(() => {
const timeout = setTimeout(() => {
setFilteredConversations(
filterAndSortConversationsByRecent(
candidateConversations,
normalizedSearchTerm,
regionCode
)
);
}, 200);
return () => {
clearTimeout(timeout);
};
}, [
candidateConversations,
normalizedSearchTerm,
setFilteredConversations,
regionCode,
]);
const selectedGroup = selectedGroupId
? groupLookup.get(selectedGroupId)
: undefined;
const handleSearchInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value);
},
[setSearchTerm]
);
const handleGetRow = React.useCallback(
(idx: number): Row | undefined => {
const convo = filteredConversations[idx];
// these are always populated in the case of a group
const memberships = convo.memberships ?? [];
const pendingApprovalMemberships = convo.pendingApprovalMemberships ?? [];
const pendingMemberships = convo.pendingMemberships ?? [];
const membersCount = convo.membersCount ?? 0;
let disabledReason;
if (memberships.some(c => c.uuid === contact.uuid)) {
disabledReason = DisabledReason.AlreadyMember;
} else if (
pendingApprovalMemberships.some(c => c.uuid === contact.uuid) ||
pendingMemberships.some(c => c.uuid === contact.uuid)
) {
disabledReason = DisabledReason.Pending;
}
return {
type: RowType.SelectSingleGroup,
group: {
...pick(convo, 'id', 'avatarPath', 'title', 'unblurredAvatarPath'),
memberships,
membersCount,
disabledReason,
},
};
},
[filteredConversations, contact]
);
return (
<>
{!selectedGroup && (
<Modal
hasXButton
i18n={i18n}
onClose={toggleAddUserToAnotherGroupModal}
title={i18n('AddUserToAnotherGroupModal__title')}
moduleClassName="AddUserToAnotherGroupModal"
>
<div className="AddUserToAnotherGroupModal__main-body">
<SearchInput
i18n={i18n}
placeholder={i18n('contactSearchPlaceholder')}
onChange={handleSearchInputChange}
ref={inputRef}
value={searchTerm}
/>
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div
className="AddUserToAnotherGroupModal__list-wrapper"
ref={measureRef}
>
<ConversationList
dimensions={contentRect.bounds}
rowCount={filteredConversations.length}
getRow={handleGetRow}
shouldRecomputeRowHeights={false}
showConversation={noop}
getPreferredBadge={() => undefined}
i18n={i18n}
theme={theme}
onClickArchiveButton={noop}
onClickContactCheckbox={noop}
onSelectConversation={setSelectedGroupId}
renderMessageSearchResult={_ => <></>}
showChooseGroupMembers={noop}
lookupConversationWithoutUuid={async _ => undefined}
showUserNotFoundModal={noop}
setIsFetchingUUID={noop}
/>
</div>
)}
</Measure>
</div>
</Modal>
)}
{selectedGroupId && selectedGroup && (
<ConfirmationDialog
title={i18n('AddUserToAnotherGroupModal__confirm-title')}
i18n={i18n}
onClose={() => setSelectedGroupId(undefined)}
actions={[
{
text: i18n('AddUserToAnotherGroupModal__confirm-add'),
style: 'affirmative',
action: () => {
showToast(ToastType.AddingUserToGroup, {
contact: contact.title,
});
addMemberToGroup(selectedGroupId, contact.id, () =>
showToast(ToastType.UserAddedToGroup, {
contact: contact.title,
group: selectedGroup.title,
})
);
toggleAddUserToAnotherGroupModal(undefined);
},
},
]}
>
{i18n('AddUserToAnotherGroupModal__confirm-message', {
contact: contact.title,
group: selectedGroup.title,
})}
</ConfirmationDialog>
)}
</>
);
};

View File

@ -11,11 +11,12 @@ import type { LocaleMessagesType } from '../types/I18N';
import type { MenuOptionsType, MenuActionType } from '../types/menu';
import type { ToastType } from '../state/ducks/toast';
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import type { ReplacementValuesType } from '../types/Util';
import { ThemeType } from '../types/Util';
import { AppViewType } from '../state/ducks/app';
import { Inbox } from './Inbox';
import { SmartInstallScreen } from '../state/smart/InstallScreen';
import { StandaloneRegistration } from './StandaloneRegistration';
import { ThemeType } from '../types/Util';
import { TitleBarContainer } from './TitleBarContainer';
import { ToastManager } from './ToastManager';
import { usePageVisibility } from '../hooks/usePageVisibility';
@ -47,7 +48,10 @@ type PropsType = {
executeMenuRole: ExecuteMenuRoleType;
executeMenuAction: (action: MenuActionType) => void;
titleBarDoubleClick: () => void;
toastType?: ToastType;
toast?: {
toastType: ToastType;
parameters?: ReplacementValuesType;
};
hideToast: () => unknown;
toggleStoriesView: () => unknown;
viewStory: ViewStoryActionCreatorType;
@ -84,7 +88,7 @@ export const App = ({
showWhatsNewModal,
theme,
titleBarDoubleClick,
toastType,
toast,
toggleStoriesView,
viewStory,
}: PropsType): JSX.Element => {
@ -170,7 +174,7 @@ export const App = ({
'dark-theme': theme === ThemeType.dark,
})}
>
<ToastManager hideToast={hideToast} i18n={i18n} toastType={toastType} />
<ToastManager hideToast={hideToast} i18n={i18n} toast={toast} />
{renderGlobalModalContainer()}
{renderCallManager()}
{isShowingStoriesView && renderStories(toggleStoriesView)}

View File

@ -19,10 +19,11 @@ import type { LookupConversationWithoutUuidActionsType } from '../util/lookupCon
import type { ShowConversationType } from '../state/ducks/conversations';
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
import { ConversationListItem } from './conversationList/ConversationListItem';
import type { ContactListItemConversationType as ContactListItemPropsType } from './conversationList/ContactListItem';
import { ContactListItem } from './conversationList/ContactListItem';
import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import type { ContactListItemConversationType as ContactListItemPropsType } from './conversationList/ContactListItem';
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
import { ConversationListItem } from './conversationList/ConversationListItem';
import { ContactListItem } from './conversationList/ContactListItem';
import { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox';
import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox';
import { UsernameCheckbox as UsernameCheckboxComponent } from './conversationList/UsernameCheckbox';
@ -31,6 +32,7 @@ import { StartNewConversation as StartNewConversationComponent } from './convers
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
import { GroupListItem } from './conversationList/GroupListItem';
export enum RowType {
ArchiveButton = 'ArchiveButton',
@ -45,6 +47,8 @@ export enum RowType {
MessageSearchResult = 'MessageSearchResult',
SearchResultsLoadingFakeHeader = 'SearchResultsLoadingFakeHeader',
SearchResultsLoadingFakeRow = 'SearchResultsLoadingFakeRow',
// this could later be expanded to SelectSingleConversation
SelectSingleGroup = 'SelectSingleGroup',
StartNewConversation = 'StartNewConversation',
UsernameSearchResult = 'UsernameSearchResult',
}
@ -110,6 +114,11 @@ type SearchResultsLoadingFakeRowType = {
type: RowType.SearchResultsLoadingFakeRow;
};
type SelectSingleGroupRowType = {
type: RowType.SelectSingleGroup;
group: GroupListItemConversationType;
};
type StartNewConversationRowType = {
type: RowType.StartNewConversation;
phoneNumber: ParsedE164Type;
@ -136,6 +145,7 @@ export type Row =
| SearchResultsLoadingFakeHeaderType
| SearchResultsLoadingFakeRowType
| StartNewConversationRowType
| SelectSingleGroupRowType
| UsernameRowType;
export type PropsType = {
@ -169,6 +179,7 @@ export type PropsType = {
} & LookupConversationWithoutUuidActionsType;
const NORMAL_ROW_HEIGHT = 76;
const SELECT_ROW_HEIGHT = 52;
const HEADER_ROW_HEIGHT = 40;
export const ConversationList: React.FC<PropsType> = ({
@ -212,6 +223,8 @@ export const ConversationList: React.FC<PropsType> = ({
case RowType.Header:
case RowType.SearchResultsLoadingFakeHeader:
return HEADER_ROW_HEIGHT;
case RowType.SelectSingleGroup:
return SELECT_ROW_HEIGHT;
default:
return NORMAL_ROW_HEIGHT;
}
@ -386,6 +399,15 @@ export const ConversationList: React.FC<PropsType> = ({
case RowType.SearchResultsLoadingFakeRow:
result = <SearchResultsLoadingFakeRowComponent />;
break;
case RowType.SelectSingleGroup:
result = (
<GroupListItem
i18n={i18n}
group={row.group}
onSelectGroup={onSelectConversation}
/>
);
break;
case RowType.StartNewConversation:
result = (
<StartNewConversationComponent

View File

@ -8,7 +8,7 @@ import type {
UserNotFoundModalStateType,
SafetyNumberChangedBlockingDataType,
} from '../state/ducks/globalModals';
import type { LocalizerType } from '../types/Util';
import type { LocalizerType, ThemeType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { ButtonVariant } from './Button';
@ -16,8 +16,9 @@ import { ConfirmationDialog } from './ConfirmationDialog';
import { SignalConnectionsModal } from './SignalConnectionsModal';
import { WhatsNewModal } from './WhatsNewModal';
type PropsType = {
export type PropsType = {
i18n: LocalizerType;
theme: ThemeType;
// ContactModal
contactModalState?: ContactModalStateType;
renderContactModal: () => JSX.Element;
@ -30,6 +31,9 @@ type PropsType = {
// SafetyNumberModal
safetyNumberModalContactId?: string;
renderSafetyNumber: () => JSX.Element;
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId?: string;
renderAddUserToAnotherGroup: () => JSX.Element;
// SignalConnectionsModal
isSignalConnectionsVisible: boolean;
toggleSignalConnectionsModal: () => unknown;
@ -62,6 +66,9 @@ export const GlobalModalContainer = ({
// SafetyNumberModal
safetyNumberModalContactId,
renderSafetyNumber,
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId,
renderAddUserToAnotherGroup,
// SignalConnectionsModal
isSignalConnectionsVisible,
toggleSignalConnectionsModal,
@ -89,6 +96,10 @@ export const GlobalModalContainer = ({
return renderSafetyNumber();
}
if (addUserToAnotherGroupModalContactId) {
return renderAddUserToAnotherGroup();
}
if (userNotFoundModalState) {
let content: string;
if (userNotFoundModalState.type === 'phoneNumber') {

View File

@ -11,6 +11,7 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
export type PropsType = {
autoDismissDisabled?: boolean;
children: ReactNode;
align?: 'left' | 'center';
className?: string;
disableCloseOnClick?: boolean;
onClose: () => unknown;
@ -26,6 +27,7 @@ export const Toast = memo(
({
autoDismissDisabled = false,
children,
align = 'center',
className,
disableCloseOnClick = false,
onClose,
@ -63,7 +65,7 @@ export const Toast = memo(
? createPortal(
<div
aria-live="assertive"
className={classNames('Toast', className)}
className={classNames('Toast', `Toast--align-${align}`, className)}
onClick={() => {
if (!disableCloseOnClick) {
onClose();

View File

@ -20,7 +20,7 @@ export default {
i18n: {
defaultValue: i18n,
},
toastType: {
toast: {
defaultValue: undefined,
},
},
@ -33,35 +33,49 @@ UndefinedToast.args = {};
export const InvalidToast = Template.bind({});
InvalidToast.args = {
toastType: 'this is a toast that does not exist' as ToastType,
toast: {
toastType: 'this is a toast that does not exist' as ToastType,
},
};
export const StoryReact = Template.bind({});
StoryReact.args = {
toastType: ToastType.StoryReact,
toast: {
toastType: ToastType.StoryReact,
},
};
export const StoryReply = Template.bind({});
StoryReply.args = {
toastType: ToastType.StoryReply,
toast: {
toastType: ToastType.StoryReply,
},
};
export const MessageBodyTooLong = Template.bind({});
MessageBodyTooLong.args = {
toastType: ToastType.MessageBodyTooLong,
toast: {
toastType: ToastType.MessageBodyTooLong,
},
};
export const StoryVideoTooLong = Template.bind({});
StoryVideoTooLong.args = {
toastType: ToastType.StoryVideoTooLong,
toast: {
toastType: ToastType.StoryVideoTooLong,
},
};
export const StoryVideoUnsupported = Template.bind({});
StoryVideoUnsupported.args = {
toastType: ToastType.StoryVideoUnsupported,
toast: {
toastType: ToastType.StoryVideoUnsupported,
},
};
export const StoryVideoError = Template.bind({});
StoryVideoError.args = {
toastType: ToastType.StoryVideoError,
toast: {
toastType: ToastType.StoryVideoError,
},
};

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
import { SECOND } from '../util/durations';
import { Toast } from './Toast';
import { ToastMessageBodyTooLong } from './ToastMessageBodyTooLong';
@ -12,15 +12,20 @@ import { strictAssert } from '../util/assert';
export type PropsType = {
hideToast: () => unknown;
i18n: LocalizerType;
toastType?: ToastType;
toast?: {
toastType: ToastType;
parameters?: ReplacementValuesType;
};
};
const SHORT_TIMEOUT = 3 * SECOND;
export const ToastManager = ({
hideToast,
i18n,
toastType,
toast,
}: PropsType): JSX.Element | null => {
if (toastType === ToastType.Error) {
if (toast?.toastType === ToastType.Error) {
return (
<Toast
autoDismissDisabled
@ -35,35 +40,35 @@ export const ToastManager = ({
);
}
if (toastType === ToastType.MessageBodyTooLong) {
if (toast?.toastType === ToastType.MessageBodyTooLong) {
return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
}
if (toastType === ToastType.StoryReact) {
if (toast?.toastType === ToastType.StoryReact) {
return (
<Toast onClose={hideToast} timeout={3 * SECOND}>
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--sending-reaction')}
</Toast>
);
}
if (toastType === ToastType.StoryReply) {
if (toast?.toastType === ToastType.StoryReply) {
return (
<Toast onClose={hideToast} timeout={3 * SECOND}>
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--sending-reply')}
</Toast>
);
}
if (toastType === ToastType.StoryMuted) {
if (toast?.toastType === ToastType.StoryMuted) {
return (
<Toast onClose={hideToast} timeout={3 * SECOND}>
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--hasNoSound')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoTooLong) {
if (toast?.toastType === ToastType.StoryVideoTooLong) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-too-long')}
@ -71,7 +76,7 @@ export const ToastManager = ({
);
}
if (toastType === ToastType.StoryVideoUnsupported) {
if (toast?.toastType === ToastType.StoryVideoUnsupported) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-unsupported')}
@ -79,7 +84,7 @@ export const ToastManager = ({
);
}
if (toastType === ToastType.StoryVideoError) {
if (toast?.toastType === ToastType.StoryVideoError) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-error')}
@ -87,9 +92,31 @@ export const ToastManager = ({
);
}
if (toast?.toastType === ToastType.AddingUserToGroup) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT} align="left">
{i18n(
'AddUserToAnotherGroupModal__toast--adding-user-to-group',
toast.parameters
)}
</Toast>
);
}
if (toast?.toastType === ToastType.UserAddedToGroup) {
return (
<Toast onClose={hideToast} align="left">
{i18n(
'AddUserToAnotherGroupModal__toast--user-added-to-group',
toast.parameters
)}
</Toast>
);
}
strictAssert(
toastType === undefined,
`Unhandled toast of type: ${toastType}`
toast === undefined,
`Unhandled toast of type: ${toast?.toastType}`
);
return null;

View File

@ -43,6 +43,7 @@ type PropsActionType = {
showConversation: ShowConversationType;
toggleAdmin: (conversationId: string, contactId: string) => void;
toggleSafetyNumberModal: (conversationId: string) => unknown;
toggleAddUserToAnotherGroupModal: (conversationId: string) => void;
updateConversationModelSharedGroups: (conversationId: string) => void;
viewUserStories: ViewUserStoriesActionCreatorType;
};
@ -77,6 +78,7 @@ export const ContactModal = ({
theme,
toggleAdmin,
toggleSafetyNumberModal,
toggleAddUserToAnotherGroupModal,
updateConversationModelSharedGroups,
viewUserStories,
}: PropsType): JSX.Element => {
@ -242,6 +244,21 @@ export const ContactModal = ({
<span>{i18n('showSafetyNumber')}</span>
</button>
)}
{!contact.isMe && isMember && conversation?.id && (
<button
type="button"
className="ContactModal__button"
onClick={() => {
hideContactModal();
toggleAddUserToAnotherGroupModal(contact.id);
}}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__add-to-another-group__bubble-icon" />
</div>
Add to another group
</button>
)}
{!contact.isMe && areWeAdmin && isMember && conversation?.id && (
<>
<button

View File

@ -41,6 +41,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
},
areWeASubscriber: false,
canEditGroupInfo: false,
canAddNewMembers: false,
conversation: expireTimer
? {
...conversation,
@ -50,6 +51,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
hasActiveCall: false,
hasGroupLink,
getPreferredBadge: () => undefined,
groupsInCommon: [],
i18n,
isAdmin: false,
isGroup: true,
@ -90,6 +92,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
setMuteExpiration: action('setMuteExpiration'),
userAvatarData: [],
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'),
onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation'
),

View File

@ -45,6 +45,7 @@ import type {
SaveAvatarToDiskActionType,
} from '../../../types/Avatar';
import { isConversationMuted } from '../../../util/isConversationMuted';
import { ConversationDetailsGroups } from './ConversationDetailsGroups';
enum ModalState {
NothingOpen,
@ -60,6 +61,7 @@ export type StateProps = {
areWeASubscriber: boolean;
badges?: ReadonlyArray<BadgeType>;
canEditGroupInfo: boolean;
canAddNewMembers: boolean;
conversation?: ConversationType;
hasGroupLink: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
@ -68,6 +70,7 @@ export type StateProps = {
isAdmin: boolean;
isGroup: boolean;
loadRecentMediaItems: (limit: number) => void;
groupsInCommon: Array<ConversationType>;
memberships: Array<GroupV2Membership>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
@ -112,6 +115,7 @@ type ActionProps = {
showContactModal: (contactId: string, conversationId?: string) => void;
toggleSafetyNumberModal: (conversationId: string) => unknown;
searchInConversation: (id: string) => unknown;
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
};
export type Props = StateProps & ActionProps;
@ -121,10 +125,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
areWeASubscriber,
badges,
canEditGroupInfo,
canAddNewMembers,
conversation,
deleteAvatarFromDisk,
hasGroupLink,
getPreferredBadge,
groupsInCommon,
hasActiveCall,
i18n,
isAdmin,
@ -155,6 +161,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
showPendingInvites,
theme,
toggleSafetyNumberModal,
toggleAddUserToAnotherGroupModal,
updateGroupAttributes,
userAvatarData,
}) => {
@ -454,7 +461,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
{isGroup && (
<ConversationDetailsMembershipList
canAddNewMembers={canEditGroupInfo}
canAddNewMembers={canAddNewMembers}
conversationId={conversation.id}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
@ -516,6 +523,15 @@ export const ConversationDetails: React.ComponentType<Props> = ({
showLightboxForMedia={showLightboxForMedia}
/>
{!isGroup && !conversation.isMe && (
<ConversationDetailsGroups
contactId={conversation.id}
i18n={i18n}
groupsInCommon={groupsInCommon}
toggleAddUserToAnotherGroupModal={toggleAddUserToAnotherGroupModal}
/>
)}
{!conversation.isMe && (
<ConversationDetailsActions
cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin}

View File

@ -0,0 +1,73 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ConversationType } from '../../../state/ducks/conversations';
import type { LocalizerType } from '../../../types/Util';
import { Avatar } from '../../Avatar';
import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
type Props = {
contactId: string;
i18n: LocalizerType;
groupsInCommon: Array<ConversationType>;
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
};
export const ConversationDetailsGroups = ({
contactId,
i18n,
groupsInCommon,
toggleAddUserToAnotherGroupModal,
}: Props): JSX.Element => {
const [showAllGroups, setShowAllGroups] = React.useState(false);
const maxShownGroupCount = 5;
const isMoreThanMaxShown = groupsInCommon.length - maxShownGroupCount > 1;
const groupsToShow = showAllGroups
? groupsInCommon.length
: maxShownGroupCount;
return (
<PanelSection
title={i18n('ConversationDetailsGroups--title', {
number: groupsInCommon.length,
})}
>
<PanelRow
icon={<div className="ConversationDetails-groups__add-to-group-icon" />}
label={i18n('ConversationDetailsGroups--add-to-group')}
onClick={() => toggleAddUserToAnotherGroupModal(contactId)}
/>
{groupsInCommon.slice(0, groupsToShow).map(group => (
<PanelRow
key={group.id}
icon={
<Avatar
conversationType="group"
badge={undefined}
i18n={i18n}
size={32}
{...group}
/>
}
label={group.title}
/>
))}
{!showAllGroups && isMoreThanMaxShown && (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsGroups--show-all')}
icon={IconType.down}
/>
}
onClick={() => setShowAllGroups(true)}
label={i18n('ConversationDetailsGroups--show-all')}
/>
)}
</PanelSection>
);
};

View File

@ -51,6 +51,7 @@ type PropsType = {
onClick?: () => void;
shouldShowSpinner?: boolean;
unreadCount?: number;
avatarSize?: AvatarSize;
} & Pick<
ConversationType,
| 'acceptedMessageRequest'
@ -75,6 +76,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
const {
acceptedMessageRequest,
avatarPath,
avatarSize,
checked,
color,
conversationType,
@ -168,7 +170,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.FORTY_EIGHT}
size={avatarSize ?? AvatarSize.FORTY_EIGHT}
unblurredAvatarPath={unblurredAvatarPath}
// This is here to appease the type checker.
{...(props.badge

View File

@ -0,0 +1,69 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ConversationType } from '../../state/ducks/conversations';
import type { LocalizerType } from '../../types/Util';
import type { UUIDStringType } from '../../types/UUID';
import { AvatarSize } from '../Avatar';
import { BaseConversationListItem } from './BaseConversationListItem';
export enum DisabledReason {
AlreadyMember = 'already-member',
Pending = 'pending',
}
export type GroupListItemConversationType = Pick<
ConversationType,
'id' | 'title' | 'avatarPath'
> & {
disabledReason: DisabledReason | undefined;
membersCount: number;
memberships: Array<{
uuid: UUIDStringType;
isAdmin: boolean;
}>;
};
export type Props = {
i18n: LocalizerType;
onSelectGroup: (id: string) => void;
group: GroupListItemConversationType;
};
export const GroupListItem = ({
group,
i18n,
onSelectGroup,
}: Props): JSX.Element => {
let messageText: string;
switch (group.disabledReason) {
case DisabledReason.AlreadyMember:
messageText = i18n('GroupListItem__message-already-member');
break;
case DisabledReason.Pending:
messageText = i18n('GroupListItem__message-pending');
break;
default:
messageText = i18n('GroupListItem__message-default', {
count: group.membersCount,
});
}
return (
<BaseConversationListItem
disabled={group.disabledReason !== undefined}
conversationType="group"
title={group.title}
avatarSize={AvatarSize.THIRTY_SIX}
avatarPath={group.avatarPath}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
headerName={group.title}
i18n={i18n}
isSelected={false}
onClick={() => onSelectGroup(group.id)}
messageText={messageText}
/>
);
};

View File

@ -1850,6 +1850,7 @@ export class ConversationModel extends window.Backbone
badges: this.get('badges') || [],
canChangeTimer: this.canChangeTimer(),
canEditGroupInfo: this.canEditGroupInfo(),
canAddNewMembers: this.canAddNewMembers(),
avatarPath: this.getAbsoluteAvatarPath(),
avatarHash: this.getAvatarHash(),
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
@ -5111,6 +5112,22 @@ export class ConversationModel extends window.Backbone
);
}
canAddNewMembers(): boolean {
if (!isGroupV2(this.attributes)) {
return false;
}
if (this.get('left')) {
return false;
}
return (
this.areWeAdmin() ||
this.get('accessControl')?.members ===
Proto.AccessControl.AccessRequired.MEMBER
);
}
areWeAdmin(): boolean {
if (!isGroupV2(this.attributes)) {
return false;

View File

@ -130,6 +130,7 @@ export type ConversationType = {
areWePendingApproval?: boolean;
canChangeTimer?: boolean;
canEditGroupInfo?: boolean;
canAddNewMembers?: boolean;
color?: AvatarColorType;
conversationColor?: ConversationColorType;
customColor?: CustomColorType;
@ -803,6 +804,7 @@ export type ConversationActionType =
// Action Creators
export const actions = {
addMemberToGroup,
cancelConversationVerification,
changeHasGroupLink,
clearCancelledConversationVerification,
@ -2004,6 +2006,25 @@ function removeMemberFromGroup(
};
}
function addMemberToGroup(
conversationId: string,
contactId: string,
onComplete: () => void
): ThunkAction<void, RootStateType, unknown, never> {
return async () => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
const idForLogging = conversationModel.idForLogging();
await longRunningTaskWrapper({
name: 'addMemberToGroup',
idForLogging,
task: () => conversationModel.addMembersV2([contactId]),
});
onComplete();
}
};
}
function toggleGroupsForStorySend(
conversationIds: Array<string>
): ThunkAction<void, RootStateType, unknown, NoopActionType> {

View File

@ -15,23 +15,24 @@ import { useBoundActions } from '../../hooks/useBoundActions';
// State
export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>;
export type SafetyNumberChangedBlockingDataType = {
readonly promiseUuid: UUIDStringType;
readonly source?: SafetyNumberChangeSource;
};
export type SafetyNumberChangedBlockingDataType = Readonly<{
promiseUuid: UUIDStringType;
source?: SafetyNumberChangeSource;
}>;
export type GlobalModalsStateType = {
readonly contactModalState?: ContactModalStateType;
readonly forwardMessageProps?: ForwardMessagePropsType;
readonly isProfileEditorVisible: boolean;
readonly isSignalConnectionsVisible: boolean;
readonly isStoriesSettingsVisible: boolean;
readonly isWhatsNewVisible: boolean;
readonly profileEditorHasError: boolean;
readonly safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType;
readonly safetyNumberModalContactId?: string;
readonly userNotFoundModalState?: UserNotFoundModalStateType;
};
export type GlobalModalsStateType = Readonly<{
contactModalState?: ContactModalStateType;
forwardMessageProps?: ForwardMessagePropsType;
isProfileEditorVisible: boolean;
isSignalConnectionsVisible: boolean;
isStoriesSettingsVisible: boolean;
isWhatsNewVisible: boolean;
profileEditorHasError: boolean;
safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType;
safetyNumberModalContactId?: string;
addUserToAnotherGroupModalContactId?: string;
userNotFoundModalState?: UserNotFoundModalStateType;
}>;
// Actions
@ -49,6 +50,8 @@ const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR =
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
const TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL =
'globalModals/TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL';
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
export const SHOW_SEND_ANYWAY_DIALOG = 'globalModals/SHOW_SEND_ANYWAY_DIALOG';
@ -113,6 +116,11 @@ type ToggleSafetyNumberModalActionType = {
payload: string | undefined;
};
type ToggleAddUserToAnotherGroupModalActionType = {
type: typeof TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL;
payload: string | undefined;
};
type ToggleSignalConnectionsModalActionType = {
type: typeof TOGGLE_SIGNAL_CONNECTIONS_MODAL;
};
@ -151,6 +159,7 @@ export type GlobalModalsActionType =
| ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType
| ToggleSafetyNumberModalActionType
| ToggleAddUserToAnotherGroupModalActionType
| ToggleSignalConnectionsModalActionType;
// Action Creators
@ -170,6 +179,7 @@ export const actions = {
toggleProfileEditor,
toggleProfileEditorHasError,
toggleSafetyNumberModal,
toggleAddUserToAnotherGroupModal,
toggleSignalConnectionsModal,
};
@ -282,6 +292,15 @@ function toggleSafetyNumberModal(
};
}
function toggleAddUserToAnotherGroupModal(
contactId?: string
): ToggleAddUserToAnotherGroupModalActionType {
return {
type: TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL,
payload: contactId,
};
}
function toggleSignalConnectionsModal(): ToggleSignalConnectionsModalActionType {
return {
type: TOGGLE_SIGNAL_CONNECTIONS_MODAL,
@ -394,6 +413,13 @@ export function reducer(
};
}
if (action.type === TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL) {
return {
...state,
addUserToAnotherGroupModalContactId: action.payload,
};
}
if (action.type === TOGGLE_FORWARD_MESSAGE_MODAL) {
return {
...state,

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { useBoundActions } from '../../hooks/useBoundActions';
import type { ReplacementValuesType } from '../../types/Util';
export enum ToastType {
Error = 'Error',
@ -12,12 +13,17 @@ export enum ToastType {
StoryVideoError = 'StoryVideoError',
StoryVideoTooLong = 'StoryVideoTooLong',
StoryVideoUnsupported = 'StoryVideoUnsupported',
AddingUserToGroup = 'AddingUserToGroup',
UserAddedToGroup = 'UserAddedToGroup',
}
// State
export type ToastStateType = {
toastType?: ToastType;
toast?: {
toastType: ToastType;
parameters?: ReplacementValuesType;
};
};
// Actions
@ -31,7 +37,10 @@ type HideToastActionType = {
type ShowToastActionType = {
type: typeof SHOW_TOAST;
payload: ToastType;
payload: {
toastType: ToastType;
parameters?: ReplacementValuesType;
};
};
export type ToastActionType = HideToastActionType | ShowToastActionType;
@ -45,13 +54,17 @@ function hideToast(): HideToastActionType {
}
export type ShowToastActionCreatorType = (
toastType: ToastType
toastType: ToastType,
parameters?: ReplacementValuesType
) => ShowToastActionType;
const showToast: ShowToastActionCreatorType = toastType => {
const showToast: ShowToastActionCreatorType = (toastType, parameters) => {
return {
type: SHOW_TOAST,
payload: toastType,
payload: {
toastType,
parameters,
},
};
};
@ -75,14 +88,14 @@ export function reducer(
if (action.type === HIDE_TOAST) {
return {
...state,
toastType: undefined,
toast: undefined,
};
}
if (action.type === SHOW_TOAST) {
return {
...state,
toastType: action.payload,
toast: action.payload,
};
}

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import memoizee from 'memoizee';
import { isNumber } from 'lodash';
import { isNumber, pick } from 'lodash';
import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
@ -477,6 +477,18 @@ export const getAllComposableConversations = createSelector(
)
);
export const getAllGroupsWithInviteAccess = createSelector(
getConversationLookup,
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
Object.values(conversationLookup).filter(conversation => {
return (
conversation.type === 'group' &&
conversation.title &&
conversation.canAddNewMembers
);
})
);
/**
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
* composer and group members, a different list from your primary system contacts.
@ -1010,6 +1022,14 @@ export const getGroupAdminsSelector = createSelector(
}
);
export const getContactSelector = createSelector(
getConversationSelector,
conversationSelector => {
return (contactId: string) =>
pick(conversationSelector(contactId), 'id', 'title', 'uuid');
}
);
const getConversationVerificationData = createSelector(
getConversations,
(

View File

@ -0,0 +1,37 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { AddUserToAnotherGroupModal } from '../../components/AddUserToAnotherGroupModal';
import type { StateType } from '../reducer';
import {
getAllGroupsWithInviteAccess,
getContactSelector,
} from '../selectors/conversations';
import { getIntl, getRegionCode, getTheme } from '../selectors/user';
export type Props = {
contactID: string;
};
const mapStateToProps = (state: StateType, props: Props) => {
const candidateConversations = getAllGroupsWithInviteAccess(state);
const getContact = getContactSelector(state);
const regionCode = getRegionCode(state);
return {
contact: getContact(props.contactID),
i18n: getIntl(state),
theme: getTheme(state),
candidateConversations,
regionCode,
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartAddUserToAnotherGroupModal = smart(
AddUserToAnotherGroupModal
);

View File

@ -91,7 +91,7 @@ const mapStateToProps = (state: StateType) => {
titleBarDoubleClick: (): void => {
window.titleBarDoubleClick();
},
toastType: state.toast.toastType,
toast: state.toast.toast,
};
};

View File

@ -3,6 +3,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { sortBy } from 'lodash';
import type { StateType } from '../reducer';
import { mapDispatchToProps } from '../actions';
@ -11,6 +12,7 @@ import { ConversationDetails } from '../../components/conversation/conversation-
import {
getConversationByIdSelector,
getConversationByUuidSelector,
getAllComposableConversations,
} from '../selectors/conversations';
import { getGroupMemberships } from '../../util/getGroupMemberships';
import { getActiveCallState } from '../selectors/calling';
@ -84,6 +86,7 @@ const mapStateToProps = (
);
const canEditGroupInfo = Boolean(conversation.canEditGroupInfo);
const canAddNewMembers = Boolean(conversation.canAddNewMembers);
const isAdmin = Boolean(conversation.areWeAdmin);
const hasGroupLink =
@ -98,11 +101,25 @@ const mapStateToProps = (
const badges = getBadgesSelector(state)(conversation.badges);
const groupsInCommon =
conversation.type === 'direct'
? getAllComposableConversations(state).filter(
c =>
c.type === 'group' &&
(c.memberships ?? []).some(
member => member.uuid === conversation.uuid
)
)
: [];
const groupsInCommonSorted = sortBy(groupsInCommon, 'title');
return {
...props,
areWeASubscriber: getAreWeASubscriber(state),
badges,
canEditGroupInfo,
canAddNewMembers,
conversation: {
...conversation,
...getConversationColorAttributes(conversation),
@ -114,6 +131,7 @@ const mapStateToProps = (
...groupMemberships,
userAvatarData: conversation.avatars || [],
hasGroupLink,
groupsInCommon: groupsInCommonSorted,
isGroup: conversation.type === 'group',
theme: getTheme(state),
renderChooseGroupMembersModal,

View File

@ -14,7 +14,8 @@ import { SmartStoriesSettingsModal } from './StoriesSettingsModal';
import { getConversationsStoppingSend } from '../selectors/conversations';
import { mapDispatchToProps } from '../actions';
import { getIntl } from '../selectors/user';
import { getIntl, getTheme } from '../selectors/user';
import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
function renderProfileEditor(): JSX.Element {
return <SmartProfileEditorModal />;
@ -43,6 +44,7 @@ const mapStateToProps = (state: StateType) => {
...state.globalModals,
hasSafetyNumberChangeModal: getConversationsStoppingSend(state).length > 0,
i18n,
theme: getTheme(state),
renderContactModal,
renderForwardMessageModal,
renderProfileEditor,
@ -52,6 +54,15 @@ const mapStateToProps = (state: StateType) => {
contactID={String(state.globalModals.safetyNumberModalContactId)}
/>
),
renderAddUserToAnotherGroup: () => {
return (
<SmartAddUserToAnotherGroupModal
contactID={String(
state.globalModals.addUserToAnotherGroupModalContactId
)}
/>
);
},
renderSendAnywayDialog,
};
};