Implemented ability to quickly add a user to a group
This commit is contained in:
		
					parent
					
						
							
								190cd9408b
							
						
					
				
			
			
				commit
				
					
						22bf3ebcc0
					
				
			
		
					 30 changed files with 855 additions and 70 deletions
				
			
		| 
						 | 
					@ -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": {
 | 
					  "softwareAcknowledgments": {
 | 
				
			||||||
    "message": "Software Acknowledgments",
 | 
					    "message": "Software Acknowledgments",
 | 
				
			||||||
    "description": "Shown in the about box for the link to software acknowledgments"
 | 
					    "description": "Shown in the about box for the link to software acknowledgments"
 | 
				
			||||||
| 
						 | 
					@ -4251,6 +4287,18 @@
 | 
				
			||||||
    "message": "See all",
 | 
					    "message": "See all",
 | 
				
			||||||
    "description": "This is a button on the conversation details to show all members"
 | 
					    "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": {
 | 
					  "ConversationNotificationsSettings__mentions__label": {
 | 
				
			||||||
    "message": "Mentions",
 | 
					    "message": "Mentions",
 | 
				
			||||||
    "description": "In the conversation notifications settings, this is the label for the mentions option"
 | 
					    "description": "In the conversation notifications settings, this is the label for the mentions option"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										29
									
								
								stylesheets/components/AddUserToAnotherGroupModal.scss
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								stylesheets/components/AddUserToAnotherGroupModal.scss
									
										
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -104,7 +104,8 @@
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &__make-admin__bubble-icon {
 | 
					  &__make-admin__bubble-icon,
 | 
				
			||||||
 | 
					  &__add-to-another-group__bubble-icon {
 | 
				
			||||||
    height: 16px;
 | 
					    height: 16px;
 | 
				
			||||||
    width: 18px;
 | 
					    width: 18px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -74,8 +74,10 @@
 | 
				
			||||||
    @include color-bubble(20px);
 | 
					    @include color-bubble(20px);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &-membership-list {
 | 
					  &-membership-list,
 | 
				
			||||||
    &__add-members-icon {
 | 
					  &-groups {
 | 
				
			||||||
 | 
					    &__add-members-icon,
 | 
				
			||||||
 | 
					    &__add-to-group-icon {
 | 
				
			||||||
      @mixin plus-icon($color) {
 | 
					      @mixin plus-icon($color) {
 | 
				
			||||||
        @include color-svg('../images/icons/v2/plus-24.svg', $color);
 | 
					        @include color-svg('../images/icons/v2/plus-24.svg', $color);
 | 
				
			||||||
        content: '';
 | 
					        content: '';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -132,11 +132,12 @@
 | 
				
			||||||
    @include scrollbar;
 | 
					    @include scrollbar;
 | 
				
			||||||
    @include font-body-1;
 | 
					    @include font-body-1;
 | 
				
			||||||
    margin: 0;
 | 
					    margin: 0;
 | 
				
			||||||
 | 
					    padding: 16px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &--has-header {
 | 
					  &--has-header {
 | 
				
			||||||
    .module-Modal__body {
 | 
					    .module-Modal__body {
 | 
				
			||||||
      padding: 0 16px 16px 16px;
 | 
					      padding-top: 0;
 | 
				
			||||||
      border-top: 1px solid transparent;
 | 
					      border-top: 1px solid transparent;
 | 
				
			||||||
      // If there's a header, just the body scrolls
 | 
					      // If there's a header, just the body scrolls
 | 
				
			||||||
      overflow-y: overlay;
 | 
					      overflow-y: overlay;
 | 
				
			||||||
| 
						 | 
					@ -155,7 +156,6 @@
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &--no-header {
 | 
					  &--no-header {
 | 
				
			||||||
    padding: 16px;
 | 
					 | 
				
			||||||
    // If there's no header, the whole thing scrolls
 | 
					    // If there's no header, the whole thing scrolls
 | 
				
			||||||
    overflow-y: overlay;
 | 
					    overflow-y: overlay;
 | 
				
			||||||
    overflow-x: auto;
 | 
					    overflow-x: auto;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,14 +8,11 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
  border-radius: $border-radius-px;
 | 
					  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);
 | 
					  box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.3);
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  justify-content: space-between;
 | 
					  justify-content: space-between;
 | 
				
			||||||
  left: 50%;
 | 
					 | 
				
			||||||
  position: absolute;
 | 
					  position: absolute;
 | 
				
			||||||
  text-align: center;
 | 
					  text-align: center;
 | 
				
			||||||
  transform: translate(-50%, 0);
 | 
					 | 
				
			||||||
  user-select: none;
 | 
					  user-select: none;
 | 
				
			||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
  z-index: $z-index-toast;
 | 
					  z-index: $z-index-toast;
 | 
				
			||||||
| 
						 | 
					@ -29,6 +26,17 @@
 | 
				
			||||||
    color: $color-gray-05;
 | 
					    color: $color-gray-05;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &--align-center {
 | 
				
			||||||
 | 
					    bottom: 62px;
 | 
				
			||||||
 | 
					    left: 50%;
 | 
				
			||||||
 | 
					    transform: translate(-50%, 0);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &--align-left {
 | 
				
			||||||
 | 
					    left: 20px;
 | 
				
			||||||
 | 
					    bottom: 18px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &__content {
 | 
					  &__content {
 | 
				
			||||||
    padding: 8px 12px;
 | 
					    padding: 8px 12px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -25,6 +25,7 @@
 | 
				
			||||||
// New style: components
 | 
					// New style: components
 | 
				
			||||||
@import './components/About.scss';
 | 
					@import './components/About.scss';
 | 
				
			||||||
@import './components/AddGroupMembersModal.scss';
 | 
					@import './components/AddGroupMembersModal.scss';
 | 
				
			||||||
 | 
					@import './components/AddUserToAnotherGroupModal.scss';
 | 
				
			||||||
@import './components/AnnouncementsOnlyGroupBanner.scss';
 | 
					@import './components/AnnouncementsOnlyGroupBanner.scss';
 | 
				
			||||||
@import './components/App.scss';
 | 
					@import './components/App.scss';
 | 
				
			||||||
@import './components/AudioCapture.scss';
 | 
					@import './components/AudioCapture.scss';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										50
									
								
								ts/components/AddUserToAnotherGroupModal.stories.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								ts/components/AddUserToAnotherGroupModal.stories.tsx
									
										
									
									
									
										Normal 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 = {};
 | 
				
			||||||
							
								
								
									
										223
									
								
								ts/components/AddUserToAnotherGroupModal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								ts/components/AddUserToAnotherGroupModal.tsx
									
										
									
									
									
										Normal 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>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -11,11 +11,12 @@ import type { LocaleMessagesType } from '../types/I18N';
 | 
				
			||||||
import type { MenuOptionsType, MenuActionType } from '../types/menu';
 | 
					import type { MenuOptionsType, MenuActionType } from '../types/menu';
 | 
				
			||||||
import type { ToastType } from '../state/ducks/toast';
 | 
					import type { ToastType } from '../state/ducks/toast';
 | 
				
			||||||
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
 | 
					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 { AppViewType } from '../state/ducks/app';
 | 
				
			||||||
import { Inbox } from './Inbox';
 | 
					import { Inbox } from './Inbox';
 | 
				
			||||||
import { SmartInstallScreen } from '../state/smart/InstallScreen';
 | 
					import { SmartInstallScreen } from '../state/smart/InstallScreen';
 | 
				
			||||||
import { StandaloneRegistration } from './StandaloneRegistration';
 | 
					import { StandaloneRegistration } from './StandaloneRegistration';
 | 
				
			||||||
import { ThemeType } from '../types/Util';
 | 
					 | 
				
			||||||
import { TitleBarContainer } from './TitleBarContainer';
 | 
					import { TitleBarContainer } from './TitleBarContainer';
 | 
				
			||||||
import { ToastManager } from './ToastManager';
 | 
					import { ToastManager } from './ToastManager';
 | 
				
			||||||
import { usePageVisibility } from '../hooks/usePageVisibility';
 | 
					import { usePageVisibility } from '../hooks/usePageVisibility';
 | 
				
			||||||
| 
						 | 
					@ -47,7 +48,10 @@ type PropsType = {
 | 
				
			||||||
  executeMenuRole: ExecuteMenuRoleType;
 | 
					  executeMenuRole: ExecuteMenuRoleType;
 | 
				
			||||||
  executeMenuAction: (action: MenuActionType) => void;
 | 
					  executeMenuAction: (action: MenuActionType) => void;
 | 
				
			||||||
  titleBarDoubleClick: () => void;
 | 
					  titleBarDoubleClick: () => void;
 | 
				
			||||||
  toastType?: ToastType;
 | 
					  toast?: {
 | 
				
			||||||
 | 
					    toastType: ToastType;
 | 
				
			||||||
 | 
					    parameters?: ReplacementValuesType;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
  hideToast: () => unknown;
 | 
					  hideToast: () => unknown;
 | 
				
			||||||
  toggleStoriesView: () => unknown;
 | 
					  toggleStoriesView: () => unknown;
 | 
				
			||||||
  viewStory: ViewStoryActionCreatorType;
 | 
					  viewStory: ViewStoryActionCreatorType;
 | 
				
			||||||
| 
						 | 
					@ -84,7 +88,7 @@ export const App = ({
 | 
				
			||||||
  showWhatsNewModal,
 | 
					  showWhatsNewModal,
 | 
				
			||||||
  theme,
 | 
					  theme,
 | 
				
			||||||
  titleBarDoubleClick,
 | 
					  titleBarDoubleClick,
 | 
				
			||||||
  toastType,
 | 
					  toast,
 | 
				
			||||||
  toggleStoriesView,
 | 
					  toggleStoriesView,
 | 
				
			||||||
  viewStory,
 | 
					  viewStory,
 | 
				
			||||||
}: PropsType): JSX.Element => {
 | 
					}: PropsType): JSX.Element => {
 | 
				
			||||||
| 
						 | 
					@ -170,7 +174,7 @@ export const App = ({
 | 
				
			||||||
          'dark-theme': theme === ThemeType.dark,
 | 
					          'dark-theme': theme === ThemeType.dark,
 | 
				
			||||||
        })}
 | 
					        })}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <ToastManager hideToast={hideToast} i18n={i18n} toastType={toastType} />
 | 
					        <ToastManager hideToast={hideToast} i18n={i18n} toast={toast} />
 | 
				
			||||||
        {renderGlobalModalContainer()}
 | 
					        {renderGlobalModalContainer()}
 | 
				
			||||||
        {renderCallManager()}
 | 
					        {renderCallManager()}
 | 
				
			||||||
        {isShowingStoriesView && renderStories(toggleStoriesView)}
 | 
					        {isShowingStoriesView && renderStories(toggleStoriesView)}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,10 +19,11 @@ import type { LookupConversationWithoutUuidActionsType } from '../util/lookupCon
 | 
				
			||||||
import type { ShowConversationType } from '../state/ducks/conversations';
 | 
					import type { ShowConversationType } from '../state/ducks/conversations';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
 | 
					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 { 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 { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox';
 | 
				
			||||||
import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox';
 | 
					import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox';
 | 
				
			||||||
import { UsernameCheckbox as UsernameCheckboxComponent } from './conversationList/UsernameCheckbox';
 | 
					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 { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
 | 
				
			||||||
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
 | 
					import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
 | 
				
			||||||
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
 | 
					import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
 | 
				
			||||||
 | 
					import { GroupListItem } from './conversationList/GroupListItem';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum RowType {
 | 
					export enum RowType {
 | 
				
			||||||
  ArchiveButton = 'ArchiveButton',
 | 
					  ArchiveButton = 'ArchiveButton',
 | 
				
			||||||
| 
						 | 
					@ -45,6 +47,8 @@ export enum RowType {
 | 
				
			||||||
  MessageSearchResult = 'MessageSearchResult',
 | 
					  MessageSearchResult = 'MessageSearchResult',
 | 
				
			||||||
  SearchResultsLoadingFakeHeader = 'SearchResultsLoadingFakeHeader',
 | 
					  SearchResultsLoadingFakeHeader = 'SearchResultsLoadingFakeHeader',
 | 
				
			||||||
  SearchResultsLoadingFakeRow = 'SearchResultsLoadingFakeRow',
 | 
					  SearchResultsLoadingFakeRow = 'SearchResultsLoadingFakeRow',
 | 
				
			||||||
 | 
					  // this could later be expanded to SelectSingleConversation
 | 
				
			||||||
 | 
					  SelectSingleGroup = 'SelectSingleGroup',
 | 
				
			||||||
  StartNewConversation = 'StartNewConversation',
 | 
					  StartNewConversation = 'StartNewConversation',
 | 
				
			||||||
  UsernameSearchResult = 'UsernameSearchResult',
 | 
					  UsernameSearchResult = 'UsernameSearchResult',
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -110,6 +114,11 @@ type SearchResultsLoadingFakeRowType = {
 | 
				
			||||||
  type: RowType.SearchResultsLoadingFakeRow;
 | 
					  type: RowType.SearchResultsLoadingFakeRow;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SelectSingleGroupRowType = {
 | 
				
			||||||
 | 
					  type: RowType.SelectSingleGroup;
 | 
				
			||||||
 | 
					  group: GroupListItemConversationType;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type StartNewConversationRowType = {
 | 
					type StartNewConversationRowType = {
 | 
				
			||||||
  type: RowType.StartNewConversation;
 | 
					  type: RowType.StartNewConversation;
 | 
				
			||||||
  phoneNumber: ParsedE164Type;
 | 
					  phoneNumber: ParsedE164Type;
 | 
				
			||||||
| 
						 | 
					@ -136,6 +145,7 @@ export type Row =
 | 
				
			||||||
  | SearchResultsLoadingFakeHeaderType
 | 
					  | SearchResultsLoadingFakeHeaderType
 | 
				
			||||||
  | SearchResultsLoadingFakeRowType
 | 
					  | SearchResultsLoadingFakeRowType
 | 
				
			||||||
  | StartNewConversationRowType
 | 
					  | StartNewConversationRowType
 | 
				
			||||||
 | 
					  | SelectSingleGroupRowType
 | 
				
			||||||
  | UsernameRowType;
 | 
					  | UsernameRowType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type PropsType = {
 | 
					export type PropsType = {
 | 
				
			||||||
| 
						 | 
					@ -169,6 +179,7 @@ export type PropsType = {
 | 
				
			||||||
} & LookupConversationWithoutUuidActionsType;
 | 
					} & LookupConversationWithoutUuidActionsType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const NORMAL_ROW_HEIGHT = 76;
 | 
					const NORMAL_ROW_HEIGHT = 76;
 | 
				
			||||||
 | 
					const SELECT_ROW_HEIGHT = 52;
 | 
				
			||||||
const HEADER_ROW_HEIGHT = 40;
 | 
					const HEADER_ROW_HEIGHT = 40;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ConversationList: React.FC<PropsType> = ({
 | 
					export const ConversationList: React.FC<PropsType> = ({
 | 
				
			||||||
| 
						 | 
					@ -212,6 +223,8 @@ export const ConversationList: React.FC<PropsType> = ({
 | 
				
			||||||
        case RowType.Header:
 | 
					        case RowType.Header:
 | 
				
			||||||
        case RowType.SearchResultsLoadingFakeHeader:
 | 
					        case RowType.SearchResultsLoadingFakeHeader:
 | 
				
			||||||
          return HEADER_ROW_HEIGHT;
 | 
					          return HEADER_ROW_HEIGHT;
 | 
				
			||||||
 | 
					        case RowType.SelectSingleGroup:
 | 
				
			||||||
 | 
					          return SELECT_ROW_HEIGHT;
 | 
				
			||||||
        default:
 | 
					        default:
 | 
				
			||||||
          return NORMAL_ROW_HEIGHT;
 | 
					          return NORMAL_ROW_HEIGHT;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
| 
						 | 
					@ -386,6 +399,15 @@ export const ConversationList: React.FC<PropsType> = ({
 | 
				
			||||||
        case RowType.SearchResultsLoadingFakeRow:
 | 
					        case RowType.SearchResultsLoadingFakeRow:
 | 
				
			||||||
          result = <SearchResultsLoadingFakeRowComponent />;
 | 
					          result = <SearchResultsLoadingFakeRowComponent />;
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
 | 
					        case RowType.SelectSingleGroup:
 | 
				
			||||||
 | 
					          result = (
 | 
				
			||||||
 | 
					            <GroupListItem
 | 
				
			||||||
 | 
					              i18n={i18n}
 | 
				
			||||||
 | 
					              group={row.group}
 | 
				
			||||||
 | 
					              onSelectGroup={onSelectConversation}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
        case RowType.StartNewConversation:
 | 
					        case RowType.StartNewConversation:
 | 
				
			||||||
          result = (
 | 
					          result = (
 | 
				
			||||||
            <StartNewConversationComponent
 | 
					            <StartNewConversationComponent
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,7 +8,7 @@ import type {
 | 
				
			||||||
  UserNotFoundModalStateType,
 | 
					  UserNotFoundModalStateType,
 | 
				
			||||||
  SafetyNumberChangedBlockingDataType,
 | 
					  SafetyNumberChangedBlockingDataType,
 | 
				
			||||||
} from '../state/ducks/globalModals';
 | 
					} from '../state/ducks/globalModals';
 | 
				
			||||||
import type { LocalizerType } from '../types/Util';
 | 
					import type { LocalizerType, ThemeType } from '../types/Util';
 | 
				
			||||||
import { missingCaseError } from '../util/missingCaseError';
 | 
					import { missingCaseError } from '../util/missingCaseError';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { ButtonVariant } from './Button';
 | 
					import { ButtonVariant } from './Button';
 | 
				
			||||||
| 
						 | 
					@ -16,8 +16,9 @@ import { ConfirmationDialog } from './ConfirmationDialog';
 | 
				
			||||||
import { SignalConnectionsModal } from './SignalConnectionsModal';
 | 
					import { SignalConnectionsModal } from './SignalConnectionsModal';
 | 
				
			||||||
import { WhatsNewModal } from './WhatsNewModal';
 | 
					import { WhatsNewModal } from './WhatsNewModal';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type PropsType = {
 | 
					export type PropsType = {
 | 
				
			||||||
  i18n: LocalizerType;
 | 
					  i18n: LocalizerType;
 | 
				
			||||||
 | 
					  theme: ThemeType;
 | 
				
			||||||
  // ContactModal
 | 
					  // ContactModal
 | 
				
			||||||
  contactModalState?: ContactModalStateType;
 | 
					  contactModalState?: ContactModalStateType;
 | 
				
			||||||
  renderContactModal: () => JSX.Element;
 | 
					  renderContactModal: () => JSX.Element;
 | 
				
			||||||
| 
						 | 
					@ -30,6 +31,9 @@ type PropsType = {
 | 
				
			||||||
  // SafetyNumberModal
 | 
					  // SafetyNumberModal
 | 
				
			||||||
  safetyNumberModalContactId?: string;
 | 
					  safetyNumberModalContactId?: string;
 | 
				
			||||||
  renderSafetyNumber: () => JSX.Element;
 | 
					  renderSafetyNumber: () => JSX.Element;
 | 
				
			||||||
 | 
					  // AddUserToAnotherGroupModal
 | 
				
			||||||
 | 
					  addUserToAnotherGroupModalContactId?: string;
 | 
				
			||||||
 | 
					  renderAddUserToAnotherGroup: () => JSX.Element;
 | 
				
			||||||
  // SignalConnectionsModal
 | 
					  // SignalConnectionsModal
 | 
				
			||||||
  isSignalConnectionsVisible: boolean;
 | 
					  isSignalConnectionsVisible: boolean;
 | 
				
			||||||
  toggleSignalConnectionsModal: () => unknown;
 | 
					  toggleSignalConnectionsModal: () => unknown;
 | 
				
			||||||
| 
						 | 
					@ -62,6 +66,9 @@ export const GlobalModalContainer = ({
 | 
				
			||||||
  // SafetyNumberModal
 | 
					  // SafetyNumberModal
 | 
				
			||||||
  safetyNumberModalContactId,
 | 
					  safetyNumberModalContactId,
 | 
				
			||||||
  renderSafetyNumber,
 | 
					  renderSafetyNumber,
 | 
				
			||||||
 | 
					  // AddUserToAnotherGroupModal
 | 
				
			||||||
 | 
					  addUserToAnotherGroupModalContactId,
 | 
				
			||||||
 | 
					  renderAddUserToAnotherGroup,
 | 
				
			||||||
  // SignalConnectionsModal
 | 
					  // SignalConnectionsModal
 | 
				
			||||||
  isSignalConnectionsVisible,
 | 
					  isSignalConnectionsVisible,
 | 
				
			||||||
  toggleSignalConnectionsModal,
 | 
					  toggleSignalConnectionsModal,
 | 
				
			||||||
| 
						 | 
					@ -89,6 +96,10 @@ export const GlobalModalContainer = ({
 | 
				
			||||||
    return renderSafetyNumber();
 | 
					    return renderSafetyNumber();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (addUserToAnotherGroupModalContactId) {
 | 
				
			||||||
 | 
					    return renderAddUserToAnotherGroup();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (userNotFoundModalState) {
 | 
					  if (userNotFoundModalState) {
 | 
				
			||||||
    let content: string;
 | 
					    let content: string;
 | 
				
			||||||
    if (userNotFoundModalState.type === 'phoneNumber') {
 | 
					    if (userNotFoundModalState.type === 'phoneNumber') {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,6 +11,7 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
 | 
				
			||||||
export type PropsType = {
 | 
					export type PropsType = {
 | 
				
			||||||
  autoDismissDisabled?: boolean;
 | 
					  autoDismissDisabled?: boolean;
 | 
				
			||||||
  children: ReactNode;
 | 
					  children: ReactNode;
 | 
				
			||||||
 | 
					  align?: 'left' | 'center';
 | 
				
			||||||
  className?: string;
 | 
					  className?: string;
 | 
				
			||||||
  disableCloseOnClick?: boolean;
 | 
					  disableCloseOnClick?: boolean;
 | 
				
			||||||
  onClose: () => unknown;
 | 
					  onClose: () => unknown;
 | 
				
			||||||
| 
						 | 
					@ -26,6 +27,7 @@ export const Toast = memo(
 | 
				
			||||||
  ({
 | 
					  ({
 | 
				
			||||||
    autoDismissDisabled = false,
 | 
					    autoDismissDisabled = false,
 | 
				
			||||||
    children,
 | 
					    children,
 | 
				
			||||||
 | 
					    align = 'center',
 | 
				
			||||||
    className,
 | 
					    className,
 | 
				
			||||||
    disableCloseOnClick = false,
 | 
					    disableCloseOnClick = false,
 | 
				
			||||||
    onClose,
 | 
					    onClose,
 | 
				
			||||||
| 
						 | 
					@ -63,7 +65,7 @@ export const Toast = memo(
 | 
				
			||||||
      ? createPortal(
 | 
					      ? createPortal(
 | 
				
			||||||
          <div
 | 
					          <div
 | 
				
			||||||
            aria-live="assertive"
 | 
					            aria-live="assertive"
 | 
				
			||||||
            className={classNames('Toast', className)}
 | 
					            className={classNames('Toast', `Toast--align-${align}`, className)}
 | 
				
			||||||
            onClick={() => {
 | 
					            onClick={() => {
 | 
				
			||||||
              if (!disableCloseOnClick) {
 | 
					              if (!disableCloseOnClick) {
 | 
				
			||||||
                onClose();
 | 
					                onClose();
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,7 +20,7 @@ export default {
 | 
				
			||||||
    i18n: {
 | 
					    i18n: {
 | 
				
			||||||
      defaultValue: i18n,
 | 
					      defaultValue: i18n,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    toastType: {
 | 
					    toast: {
 | 
				
			||||||
      defaultValue: undefined,
 | 
					      defaultValue: undefined,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
| 
						 | 
					@ -33,35 +33,49 @@ UndefinedToast.args = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const InvalidToast = Template.bind({});
 | 
					export const InvalidToast = Template.bind({});
 | 
				
			||||||
InvalidToast.args = {
 | 
					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({});
 | 
					export const StoryReact = Template.bind({});
 | 
				
			||||||
StoryReact.args = {
 | 
					StoryReact.args = {
 | 
				
			||||||
  toastType: ToastType.StoryReact,
 | 
					  toast: {
 | 
				
			||||||
 | 
					    toastType: ToastType.StoryReact,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const StoryReply = Template.bind({});
 | 
					export const StoryReply = Template.bind({});
 | 
				
			||||||
StoryReply.args = {
 | 
					StoryReply.args = {
 | 
				
			||||||
  toastType: ToastType.StoryReply,
 | 
					  toast: {
 | 
				
			||||||
 | 
					    toastType: ToastType.StoryReply,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const MessageBodyTooLong = Template.bind({});
 | 
					export const MessageBodyTooLong = Template.bind({});
 | 
				
			||||||
MessageBodyTooLong.args = {
 | 
					MessageBodyTooLong.args = {
 | 
				
			||||||
  toastType: ToastType.MessageBodyTooLong,
 | 
					  toast: {
 | 
				
			||||||
 | 
					    toastType: ToastType.MessageBodyTooLong,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const StoryVideoTooLong = Template.bind({});
 | 
					export const StoryVideoTooLong = Template.bind({});
 | 
				
			||||||
StoryVideoTooLong.args = {
 | 
					StoryVideoTooLong.args = {
 | 
				
			||||||
  toastType: ToastType.StoryVideoTooLong,
 | 
					  toast: {
 | 
				
			||||||
 | 
					    toastType: ToastType.StoryVideoTooLong,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const StoryVideoUnsupported = Template.bind({});
 | 
					export const StoryVideoUnsupported = Template.bind({});
 | 
				
			||||||
StoryVideoUnsupported.args = {
 | 
					StoryVideoUnsupported.args = {
 | 
				
			||||||
  toastType: ToastType.StoryVideoUnsupported,
 | 
					  toast: {
 | 
				
			||||||
 | 
					    toastType: ToastType.StoryVideoUnsupported,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const StoryVideoError = Template.bind({});
 | 
					export const StoryVideoError = Template.bind({});
 | 
				
			||||||
StoryVideoError.args = {
 | 
					StoryVideoError.args = {
 | 
				
			||||||
  toastType: ToastType.StoryVideoError,
 | 
					  toast: {
 | 
				
			||||||
 | 
					    toastType: ToastType.StoryVideoError,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,7 @@
 | 
				
			||||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import type { LocalizerType } from '../types/Util';
 | 
					import type { LocalizerType, ReplacementValuesType } from '../types/Util';
 | 
				
			||||||
import { SECOND } from '../util/durations';
 | 
					import { SECOND } from '../util/durations';
 | 
				
			||||||
import { Toast } from './Toast';
 | 
					import { Toast } from './Toast';
 | 
				
			||||||
import { ToastMessageBodyTooLong } from './ToastMessageBodyTooLong';
 | 
					import { ToastMessageBodyTooLong } from './ToastMessageBodyTooLong';
 | 
				
			||||||
| 
						 | 
					@ -12,15 +12,20 @@ import { strictAssert } from '../util/assert';
 | 
				
			||||||
export type PropsType = {
 | 
					export type PropsType = {
 | 
				
			||||||
  hideToast: () => unknown;
 | 
					  hideToast: () => unknown;
 | 
				
			||||||
  i18n: LocalizerType;
 | 
					  i18n: LocalizerType;
 | 
				
			||||||
  toastType?: ToastType;
 | 
					  toast?: {
 | 
				
			||||||
 | 
					    toastType: ToastType;
 | 
				
			||||||
 | 
					    parameters?: ReplacementValuesType;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SHORT_TIMEOUT = 3 * SECOND;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ToastManager = ({
 | 
					export const ToastManager = ({
 | 
				
			||||||
  hideToast,
 | 
					  hideToast,
 | 
				
			||||||
  i18n,
 | 
					  i18n,
 | 
				
			||||||
  toastType,
 | 
					  toast,
 | 
				
			||||||
}: PropsType): JSX.Element | null => {
 | 
					}: PropsType): JSX.Element | null => {
 | 
				
			||||||
  if (toastType === ToastType.Error) {
 | 
					  if (toast?.toastType === ToastType.Error) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Toast
 | 
					      <Toast
 | 
				
			||||||
        autoDismissDisabled
 | 
					        autoDismissDisabled
 | 
				
			||||||
| 
						 | 
					@ -35,35 +40,35 @@ export const ToastManager = ({
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (toastType === ToastType.MessageBodyTooLong) {
 | 
					  if (toast?.toastType === ToastType.MessageBodyTooLong) {
 | 
				
			||||||
    return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
 | 
					    return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (toastType === ToastType.StoryReact) {
 | 
					  if (toast?.toastType === ToastType.StoryReact) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Toast onClose={hideToast} timeout={3 * SECOND}>
 | 
					      <Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
 | 
				
			||||||
        {i18n('Stories__toast--sending-reaction')}
 | 
					        {i18n('Stories__toast--sending-reaction')}
 | 
				
			||||||
      </Toast>
 | 
					      </Toast>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (toastType === ToastType.StoryReply) {
 | 
					  if (toast?.toastType === ToastType.StoryReply) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Toast onClose={hideToast} timeout={3 * SECOND}>
 | 
					      <Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
 | 
				
			||||||
        {i18n('Stories__toast--sending-reply')}
 | 
					        {i18n('Stories__toast--sending-reply')}
 | 
				
			||||||
      </Toast>
 | 
					      </Toast>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (toastType === ToastType.StoryMuted) {
 | 
					  if (toast?.toastType === ToastType.StoryMuted) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Toast onClose={hideToast} timeout={3 * SECOND}>
 | 
					      <Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
 | 
				
			||||||
        {i18n('Stories__toast--hasNoSound')}
 | 
					        {i18n('Stories__toast--hasNoSound')}
 | 
				
			||||||
      </Toast>
 | 
					      </Toast>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (toastType === ToastType.StoryVideoTooLong) {
 | 
					  if (toast?.toastType === ToastType.StoryVideoTooLong) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Toast onClose={hideToast}>
 | 
					      <Toast onClose={hideToast}>
 | 
				
			||||||
        {i18n('StoryCreator__error--video-too-long')}
 | 
					        {i18n('StoryCreator__error--video-too-long')}
 | 
				
			||||||
| 
						 | 
					@ -71,7 +76,7 @@ export const ToastManager = ({
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (toastType === ToastType.StoryVideoUnsupported) {
 | 
					  if (toast?.toastType === ToastType.StoryVideoUnsupported) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Toast onClose={hideToast}>
 | 
					      <Toast onClose={hideToast}>
 | 
				
			||||||
        {i18n('StoryCreator__error--video-unsupported')}
 | 
					        {i18n('StoryCreator__error--video-unsupported')}
 | 
				
			||||||
| 
						 | 
					@ -79,7 +84,7 @@ export const ToastManager = ({
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (toastType === ToastType.StoryVideoError) {
 | 
					  if (toast?.toastType === ToastType.StoryVideoError) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Toast onClose={hideToast}>
 | 
					      <Toast onClose={hideToast}>
 | 
				
			||||||
        {i18n('StoryCreator__error--video-error')}
 | 
					        {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(
 | 
					  strictAssert(
 | 
				
			||||||
    toastType === undefined,
 | 
					    toast === undefined,
 | 
				
			||||||
    `Unhandled toast of type: ${toastType}`
 | 
					    `Unhandled toast of type: ${toast?.toastType}`
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return null;
 | 
					  return null;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -43,6 +43,7 @@ type PropsActionType = {
 | 
				
			||||||
  showConversation: ShowConversationType;
 | 
					  showConversation: ShowConversationType;
 | 
				
			||||||
  toggleAdmin: (conversationId: string, contactId: string) => void;
 | 
					  toggleAdmin: (conversationId: string, contactId: string) => void;
 | 
				
			||||||
  toggleSafetyNumberModal: (conversationId: string) => unknown;
 | 
					  toggleSafetyNumberModal: (conversationId: string) => unknown;
 | 
				
			||||||
 | 
					  toggleAddUserToAnotherGroupModal: (conversationId: string) => void;
 | 
				
			||||||
  updateConversationModelSharedGroups: (conversationId: string) => void;
 | 
					  updateConversationModelSharedGroups: (conversationId: string) => void;
 | 
				
			||||||
  viewUserStories: ViewUserStoriesActionCreatorType;
 | 
					  viewUserStories: ViewUserStoriesActionCreatorType;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -77,6 +78,7 @@ export const ContactModal = ({
 | 
				
			||||||
  theme,
 | 
					  theme,
 | 
				
			||||||
  toggleAdmin,
 | 
					  toggleAdmin,
 | 
				
			||||||
  toggleSafetyNumberModal,
 | 
					  toggleSafetyNumberModal,
 | 
				
			||||||
 | 
					  toggleAddUserToAnotherGroupModal,
 | 
				
			||||||
  updateConversationModelSharedGroups,
 | 
					  updateConversationModelSharedGroups,
 | 
				
			||||||
  viewUserStories,
 | 
					  viewUserStories,
 | 
				
			||||||
}: PropsType): JSX.Element => {
 | 
					}: PropsType): JSX.Element => {
 | 
				
			||||||
| 
						 | 
					@ -242,6 +244,21 @@ export const ContactModal = ({
 | 
				
			||||||
                  <span>{i18n('showSafetyNumber')}</span>
 | 
					                  <span>{i18n('showSafetyNumber')}</span>
 | 
				
			||||||
                </button>
 | 
					                </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 && (
 | 
					              {!contact.isMe && areWeAdmin && isMember && conversation?.id && (
 | 
				
			||||||
                <>
 | 
					                <>
 | 
				
			||||||
                  <button
 | 
					                  <button
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -41,6 +41,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  areWeASubscriber: false,
 | 
					  areWeASubscriber: false,
 | 
				
			||||||
  canEditGroupInfo: false,
 | 
					  canEditGroupInfo: false,
 | 
				
			||||||
 | 
					  canAddNewMembers: false,
 | 
				
			||||||
  conversation: expireTimer
 | 
					  conversation: expireTimer
 | 
				
			||||||
    ? {
 | 
					    ? {
 | 
				
			||||||
        ...conversation,
 | 
					        ...conversation,
 | 
				
			||||||
| 
						 | 
					@ -50,6 +51,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
 | 
				
			||||||
  hasActiveCall: false,
 | 
					  hasActiveCall: false,
 | 
				
			||||||
  hasGroupLink,
 | 
					  hasGroupLink,
 | 
				
			||||||
  getPreferredBadge: () => undefined,
 | 
					  getPreferredBadge: () => undefined,
 | 
				
			||||||
 | 
					  groupsInCommon: [],
 | 
				
			||||||
  i18n,
 | 
					  i18n,
 | 
				
			||||||
  isAdmin: false,
 | 
					  isAdmin: false,
 | 
				
			||||||
  isGroup: true,
 | 
					  isGroup: true,
 | 
				
			||||||
| 
						 | 
					@ -90,6 +92,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
 | 
				
			||||||
  setMuteExpiration: action('setMuteExpiration'),
 | 
					  setMuteExpiration: action('setMuteExpiration'),
 | 
				
			||||||
  userAvatarData: [],
 | 
					  userAvatarData: [],
 | 
				
			||||||
  toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
 | 
					  toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
 | 
				
			||||||
 | 
					  toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'),
 | 
				
			||||||
  onOutgoingAudioCallInConversation: action(
 | 
					  onOutgoingAudioCallInConversation: action(
 | 
				
			||||||
    'onOutgoingAudioCallInConversation'
 | 
					    'onOutgoingAudioCallInConversation'
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -45,6 +45,7 @@ import type {
 | 
				
			||||||
  SaveAvatarToDiskActionType,
 | 
					  SaveAvatarToDiskActionType,
 | 
				
			||||||
} from '../../../types/Avatar';
 | 
					} from '../../../types/Avatar';
 | 
				
			||||||
import { isConversationMuted } from '../../../util/isConversationMuted';
 | 
					import { isConversationMuted } from '../../../util/isConversationMuted';
 | 
				
			||||||
 | 
					import { ConversationDetailsGroups } from './ConversationDetailsGroups';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
enum ModalState {
 | 
					enum ModalState {
 | 
				
			||||||
  NothingOpen,
 | 
					  NothingOpen,
 | 
				
			||||||
| 
						 | 
					@ -60,6 +61,7 @@ export type StateProps = {
 | 
				
			||||||
  areWeASubscriber: boolean;
 | 
					  areWeASubscriber: boolean;
 | 
				
			||||||
  badges?: ReadonlyArray<BadgeType>;
 | 
					  badges?: ReadonlyArray<BadgeType>;
 | 
				
			||||||
  canEditGroupInfo: boolean;
 | 
					  canEditGroupInfo: boolean;
 | 
				
			||||||
 | 
					  canAddNewMembers: boolean;
 | 
				
			||||||
  conversation?: ConversationType;
 | 
					  conversation?: ConversationType;
 | 
				
			||||||
  hasGroupLink: boolean;
 | 
					  hasGroupLink: boolean;
 | 
				
			||||||
  getPreferredBadge: PreferredBadgeSelectorType;
 | 
					  getPreferredBadge: PreferredBadgeSelectorType;
 | 
				
			||||||
| 
						 | 
					@ -68,6 +70,7 @@ export type StateProps = {
 | 
				
			||||||
  isAdmin: boolean;
 | 
					  isAdmin: boolean;
 | 
				
			||||||
  isGroup: boolean;
 | 
					  isGroup: boolean;
 | 
				
			||||||
  loadRecentMediaItems: (limit: number) => void;
 | 
					  loadRecentMediaItems: (limit: number) => void;
 | 
				
			||||||
 | 
					  groupsInCommon: Array<ConversationType>;
 | 
				
			||||||
  memberships: Array<GroupV2Membership>;
 | 
					  memberships: Array<GroupV2Membership>;
 | 
				
			||||||
  pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
 | 
					  pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
 | 
				
			||||||
  pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
 | 
					  pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
 | 
				
			||||||
| 
						 | 
					@ -112,6 +115,7 @@ type ActionProps = {
 | 
				
			||||||
  showContactModal: (contactId: string, conversationId?: string) => void;
 | 
					  showContactModal: (contactId: string, conversationId?: string) => void;
 | 
				
			||||||
  toggleSafetyNumberModal: (conversationId: string) => unknown;
 | 
					  toggleSafetyNumberModal: (conversationId: string) => unknown;
 | 
				
			||||||
  searchInConversation: (id: string) => unknown;
 | 
					  searchInConversation: (id: string) => unknown;
 | 
				
			||||||
 | 
					  toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type Props = StateProps & ActionProps;
 | 
					export type Props = StateProps & ActionProps;
 | 
				
			||||||
| 
						 | 
					@ -121,10 +125,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
 | 
				
			||||||
  areWeASubscriber,
 | 
					  areWeASubscriber,
 | 
				
			||||||
  badges,
 | 
					  badges,
 | 
				
			||||||
  canEditGroupInfo,
 | 
					  canEditGroupInfo,
 | 
				
			||||||
 | 
					  canAddNewMembers,
 | 
				
			||||||
  conversation,
 | 
					  conversation,
 | 
				
			||||||
  deleteAvatarFromDisk,
 | 
					  deleteAvatarFromDisk,
 | 
				
			||||||
  hasGroupLink,
 | 
					  hasGroupLink,
 | 
				
			||||||
  getPreferredBadge,
 | 
					  getPreferredBadge,
 | 
				
			||||||
 | 
					  groupsInCommon,
 | 
				
			||||||
  hasActiveCall,
 | 
					  hasActiveCall,
 | 
				
			||||||
  i18n,
 | 
					  i18n,
 | 
				
			||||||
  isAdmin,
 | 
					  isAdmin,
 | 
				
			||||||
| 
						 | 
					@ -155,6 +161,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
 | 
				
			||||||
  showPendingInvites,
 | 
					  showPendingInvites,
 | 
				
			||||||
  theme,
 | 
					  theme,
 | 
				
			||||||
  toggleSafetyNumberModal,
 | 
					  toggleSafetyNumberModal,
 | 
				
			||||||
 | 
					  toggleAddUserToAnotherGroupModal,
 | 
				
			||||||
  updateGroupAttributes,
 | 
					  updateGroupAttributes,
 | 
				
			||||||
  userAvatarData,
 | 
					  userAvatarData,
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
| 
						 | 
					@ -454,7 +461,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {isGroup && (
 | 
					      {isGroup && (
 | 
				
			||||||
        <ConversationDetailsMembershipList
 | 
					        <ConversationDetailsMembershipList
 | 
				
			||||||
          canAddNewMembers={canEditGroupInfo}
 | 
					          canAddNewMembers={canAddNewMembers}
 | 
				
			||||||
          conversationId={conversation.id}
 | 
					          conversationId={conversation.id}
 | 
				
			||||||
          getPreferredBadge={getPreferredBadge}
 | 
					          getPreferredBadge={getPreferredBadge}
 | 
				
			||||||
          i18n={i18n}
 | 
					          i18n={i18n}
 | 
				
			||||||
| 
						 | 
					@ -516,6 +523,15 @@ export const ConversationDetails: React.ComponentType<Props> = ({
 | 
				
			||||||
        showLightboxForMedia={showLightboxForMedia}
 | 
					        showLightboxForMedia={showLightboxForMedia}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {!isGroup && !conversation.isMe && (
 | 
				
			||||||
 | 
					        <ConversationDetailsGroups
 | 
				
			||||||
 | 
					          contactId={conversation.id}
 | 
				
			||||||
 | 
					          i18n={i18n}
 | 
				
			||||||
 | 
					          groupsInCommon={groupsInCommon}
 | 
				
			||||||
 | 
					          toggleAddUserToAnotherGroupModal={toggleAddUserToAnotherGroupModal}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {!conversation.isMe && (
 | 
					      {!conversation.isMe && (
 | 
				
			||||||
        <ConversationDetailsActions
 | 
					        <ConversationDetailsActions
 | 
				
			||||||
          cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin}
 | 
					          cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -51,6 +51,7 @@ type PropsType = {
 | 
				
			||||||
  onClick?: () => void;
 | 
					  onClick?: () => void;
 | 
				
			||||||
  shouldShowSpinner?: boolean;
 | 
					  shouldShowSpinner?: boolean;
 | 
				
			||||||
  unreadCount?: number;
 | 
					  unreadCount?: number;
 | 
				
			||||||
 | 
					  avatarSize?: AvatarSize;
 | 
				
			||||||
} & Pick<
 | 
					} & Pick<
 | 
				
			||||||
  ConversationType,
 | 
					  ConversationType,
 | 
				
			||||||
  | 'acceptedMessageRequest'
 | 
					  | 'acceptedMessageRequest'
 | 
				
			||||||
| 
						 | 
					@ -75,6 +76,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
 | 
				
			||||||
    const {
 | 
					    const {
 | 
				
			||||||
      acceptedMessageRequest,
 | 
					      acceptedMessageRequest,
 | 
				
			||||||
      avatarPath,
 | 
					      avatarPath,
 | 
				
			||||||
 | 
					      avatarSize,
 | 
				
			||||||
      checked,
 | 
					      checked,
 | 
				
			||||||
      color,
 | 
					      color,
 | 
				
			||||||
      conversationType,
 | 
					      conversationType,
 | 
				
			||||||
| 
						 | 
					@ -168,7 +170,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
 | 
				
			||||||
            profileName={profileName}
 | 
					            profileName={profileName}
 | 
				
			||||||
            title={title}
 | 
					            title={title}
 | 
				
			||||||
            sharedGroupNames={sharedGroupNames}
 | 
					            sharedGroupNames={sharedGroupNames}
 | 
				
			||||||
            size={AvatarSize.FORTY_EIGHT}
 | 
					            size={avatarSize ?? AvatarSize.FORTY_EIGHT}
 | 
				
			||||||
            unblurredAvatarPath={unblurredAvatarPath}
 | 
					            unblurredAvatarPath={unblurredAvatarPath}
 | 
				
			||||||
            // This is here to appease the type checker.
 | 
					            // This is here to appease the type checker.
 | 
				
			||||||
            {...(props.badge
 | 
					            {...(props.badge
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										69
									
								
								ts/components/conversationList/GroupListItem.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								ts/components/conversationList/GroupListItem.tsx
									
										
									
									
									
										Normal 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}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -1850,6 +1850,7 @@ export class ConversationModel extends window.Backbone
 | 
				
			||||||
      badges: this.get('badges') || [],
 | 
					      badges: this.get('badges') || [],
 | 
				
			||||||
      canChangeTimer: this.canChangeTimer(),
 | 
					      canChangeTimer: this.canChangeTimer(),
 | 
				
			||||||
      canEditGroupInfo: this.canEditGroupInfo(),
 | 
					      canEditGroupInfo: this.canEditGroupInfo(),
 | 
				
			||||||
 | 
					      canAddNewMembers: this.canAddNewMembers(),
 | 
				
			||||||
      avatarPath: this.getAbsoluteAvatarPath(),
 | 
					      avatarPath: this.getAbsoluteAvatarPath(),
 | 
				
			||||||
      avatarHash: this.getAvatarHash(),
 | 
					      avatarHash: this.getAvatarHash(),
 | 
				
			||||||
      unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
 | 
					      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 {
 | 
					  areWeAdmin(): boolean {
 | 
				
			||||||
    if (!isGroupV2(this.attributes)) {
 | 
					    if (!isGroupV2(this.attributes)) {
 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -130,6 +130,7 @@ export type ConversationType = {
 | 
				
			||||||
  areWePendingApproval?: boolean;
 | 
					  areWePendingApproval?: boolean;
 | 
				
			||||||
  canChangeTimer?: boolean;
 | 
					  canChangeTimer?: boolean;
 | 
				
			||||||
  canEditGroupInfo?: boolean;
 | 
					  canEditGroupInfo?: boolean;
 | 
				
			||||||
 | 
					  canAddNewMembers?: boolean;
 | 
				
			||||||
  color?: AvatarColorType;
 | 
					  color?: AvatarColorType;
 | 
				
			||||||
  conversationColor?: ConversationColorType;
 | 
					  conversationColor?: ConversationColorType;
 | 
				
			||||||
  customColor?: CustomColorType;
 | 
					  customColor?: CustomColorType;
 | 
				
			||||||
| 
						 | 
					@ -803,6 +804,7 @@ export type ConversationActionType =
 | 
				
			||||||
// Action Creators
 | 
					// Action Creators
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actions = {
 | 
					export const actions = {
 | 
				
			||||||
 | 
					  addMemberToGroup,
 | 
				
			||||||
  cancelConversationVerification,
 | 
					  cancelConversationVerification,
 | 
				
			||||||
  changeHasGroupLink,
 | 
					  changeHasGroupLink,
 | 
				
			||||||
  clearCancelledConversationVerification,
 | 
					  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(
 | 
					function toggleGroupsForStorySend(
 | 
				
			||||||
  conversationIds: Array<string>
 | 
					  conversationIds: Array<string>
 | 
				
			||||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
 | 
					): ThunkAction<void, RootStateType, unknown, NoopActionType> {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,23 +15,24 @@ import { useBoundActions } from '../../hooks/useBoundActions';
 | 
				
			||||||
// State
 | 
					// State
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>;
 | 
					export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>;
 | 
				
			||||||
export type SafetyNumberChangedBlockingDataType = {
 | 
					export type SafetyNumberChangedBlockingDataType = Readonly<{
 | 
				
			||||||
  readonly promiseUuid: UUIDStringType;
 | 
					  promiseUuid: UUIDStringType;
 | 
				
			||||||
  readonly source?: SafetyNumberChangeSource;
 | 
					  source?: SafetyNumberChangeSource;
 | 
				
			||||||
};
 | 
					}>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type GlobalModalsStateType = {
 | 
					export type GlobalModalsStateType = Readonly<{
 | 
				
			||||||
  readonly contactModalState?: ContactModalStateType;
 | 
					  contactModalState?: ContactModalStateType;
 | 
				
			||||||
  readonly forwardMessageProps?: ForwardMessagePropsType;
 | 
					  forwardMessageProps?: ForwardMessagePropsType;
 | 
				
			||||||
  readonly isProfileEditorVisible: boolean;
 | 
					  isProfileEditorVisible: boolean;
 | 
				
			||||||
  readonly isSignalConnectionsVisible: boolean;
 | 
					  isSignalConnectionsVisible: boolean;
 | 
				
			||||||
  readonly isStoriesSettingsVisible: boolean;
 | 
					  isStoriesSettingsVisible: boolean;
 | 
				
			||||||
  readonly isWhatsNewVisible: boolean;
 | 
					  isWhatsNewVisible: boolean;
 | 
				
			||||||
  readonly profileEditorHasError: boolean;
 | 
					  profileEditorHasError: boolean;
 | 
				
			||||||
  readonly safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType;
 | 
					  safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType;
 | 
				
			||||||
  readonly safetyNumberModalContactId?: string;
 | 
					  safetyNumberModalContactId?: string;
 | 
				
			||||||
  readonly userNotFoundModalState?: UserNotFoundModalStateType;
 | 
					  addUserToAnotherGroupModalContactId?: string;
 | 
				
			||||||
};
 | 
					  userNotFoundModalState?: UserNotFoundModalStateType;
 | 
				
			||||||
 | 
					}>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Actions
 | 
					// Actions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -49,6 +50,8 @@ const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
 | 
				
			||||||
export const TOGGLE_PROFILE_EDITOR_ERROR =
 | 
					export const TOGGLE_PROFILE_EDITOR_ERROR =
 | 
				
			||||||
  'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
 | 
					  'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
 | 
				
			||||||
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
 | 
					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 =
 | 
					const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
 | 
				
			||||||
  'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
 | 
					  'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
 | 
				
			||||||
export const SHOW_SEND_ANYWAY_DIALOG = 'globalModals/SHOW_SEND_ANYWAY_DIALOG';
 | 
					export const SHOW_SEND_ANYWAY_DIALOG = 'globalModals/SHOW_SEND_ANYWAY_DIALOG';
 | 
				
			||||||
| 
						 | 
					@ -113,6 +116,11 @@ type ToggleSafetyNumberModalActionType = {
 | 
				
			||||||
  payload: string | undefined;
 | 
					  payload: string | undefined;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ToggleAddUserToAnotherGroupModalActionType = {
 | 
				
			||||||
 | 
					  type: typeof TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL;
 | 
				
			||||||
 | 
					  payload: string | undefined;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ToggleSignalConnectionsModalActionType = {
 | 
					type ToggleSignalConnectionsModalActionType = {
 | 
				
			||||||
  type: typeof TOGGLE_SIGNAL_CONNECTIONS_MODAL;
 | 
					  type: typeof TOGGLE_SIGNAL_CONNECTIONS_MODAL;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -151,6 +159,7 @@ export type GlobalModalsActionType =
 | 
				
			||||||
  | ToggleProfileEditorActionType
 | 
					  | ToggleProfileEditorActionType
 | 
				
			||||||
  | ToggleProfileEditorErrorActionType
 | 
					  | ToggleProfileEditorErrorActionType
 | 
				
			||||||
  | ToggleSafetyNumberModalActionType
 | 
					  | ToggleSafetyNumberModalActionType
 | 
				
			||||||
 | 
					  | ToggleAddUserToAnotherGroupModalActionType
 | 
				
			||||||
  | ToggleSignalConnectionsModalActionType;
 | 
					  | ToggleSignalConnectionsModalActionType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Action Creators
 | 
					// Action Creators
 | 
				
			||||||
| 
						 | 
					@ -170,6 +179,7 @@ export const actions = {
 | 
				
			||||||
  toggleProfileEditor,
 | 
					  toggleProfileEditor,
 | 
				
			||||||
  toggleProfileEditorHasError,
 | 
					  toggleProfileEditorHasError,
 | 
				
			||||||
  toggleSafetyNumberModal,
 | 
					  toggleSafetyNumberModal,
 | 
				
			||||||
 | 
					  toggleAddUserToAnotherGroupModal,
 | 
				
			||||||
  toggleSignalConnectionsModal,
 | 
					  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 {
 | 
					function toggleSignalConnectionsModal(): ToggleSignalConnectionsModalActionType {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    type: TOGGLE_SIGNAL_CONNECTIONS_MODAL,
 | 
					    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) {
 | 
					  if (action.type === TOGGLE_FORWARD_MESSAGE_MODAL) {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      ...state,
 | 
					      ...state,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,6 +2,7 @@
 | 
				
			||||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { useBoundActions } from '../../hooks/useBoundActions';
 | 
					import { useBoundActions } from '../../hooks/useBoundActions';
 | 
				
			||||||
 | 
					import type { ReplacementValuesType } from '../../types/Util';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum ToastType {
 | 
					export enum ToastType {
 | 
				
			||||||
  Error = 'Error',
 | 
					  Error = 'Error',
 | 
				
			||||||
| 
						 | 
					@ -12,12 +13,17 @@ export enum ToastType {
 | 
				
			||||||
  StoryVideoError = 'StoryVideoError',
 | 
					  StoryVideoError = 'StoryVideoError',
 | 
				
			||||||
  StoryVideoTooLong = 'StoryVideoTooLong',
 | 
					  StoryVideoTooLong = 'StoryVideoTooLong',
 | 
				
			||||||
  StoryVideoUnsupported = 'StoryVideoUnsupported',
 | 
					  StoryVideoUnsupported = 'StoryVideoUnsupported',
 | 
				
			||||||
 | 
					  AddingUserToGroup = 'AddingUserToGroup',
 | 
				
			||||||
 | 
					  UserAddedToGroup = 'UserAddedToGroup',
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// State
 | 
					// State
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ToastStateType = {
 | 
					export type ToastStateType = {
 | 
				
			||||||
  toastType?: ToastType;
 | 
					  toast?: {
 | 
				
			||||||
 | 
					    toastType: ToastType;
 | 
				
			||||||
 | 
					    parameters?: ReplacementValuesType;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Actions
 | 
					// Actions
 | 
				
			||||||
| 
						 | 
					@ -31,7 +37,10 @@ type HideToastActionType = {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ShowToastActionType = {
 | 
					type ShowToastActionType = {
 | 
				
			||||||
  type: typeof SHOW_TOAST;
 | 
					  type: typeof SHOW_TOAST;
 | 
				
			||||||
  payload: ToastType;
 | 
					  payload: {
 | 
				
			||||||
 | 
					    toastType: ToastType;
 | 
				
			||||||
 | 
					    parameters?: ReplacementValuesType;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ToastActionType = HideToastActionType | ShowToastActionType;
 | 
					export type ToastActionType = HideToastActionType | ShowToastActionType;
 | 
				
			||||||
| 
						 | 
					@ -45,13 +54,17 @@ function hideToast(): HideToastActionType {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ShowToastActionCreatorType = (
 | 
					export type ShowToastActionCreatorType = (
 | 
				
			||||||
  toastType: ToastType
 | 
					  toastType: ToastType,
 | 
				
			||||||
 | 
					  parameters?: ReplacementValuesType
 | 
				
			||||||
) => ShowToastActionType;
 | 
					) => ShowToastActionType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const showToast: ShowToastActionCreatorType = toastType => {
 | 
					const showToast: ShowToastActionCreatorType = (toastType, parameters) => {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    type: SHOW_TOAST,
 | 
					    type: SHOW_TOAST,
 | 
				
			||||||
    payload: toastType,
 | 
					    payload: {
 | 
				
			||||||
 | 
					      toastType,
 | 
				
			||||||
 | 
					      parameters,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -75,14 +88,14 @@ export function reducer(
 | 
				
			||||||
  if (action.type === HIDE_TOAST) {
 | 
					  if (action.type === HIDE_TOAST) {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      ...state,
 | 
					      ...state,
 | 
				
			||||||
      toastType: undefined,
 | 
					      toast: undefined,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (action.type === SHOW_TOAST) {
 | 
					  if (action.type === SHOW_TOAST) {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      ...state,
 | 
					      ...state,
 | 
				
			||||||
      toastType: action.payload,
 | 
					      toast: action.payload,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,7 @@
 | 
				
			||||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import memoizee from 'memoizee';
 | 
					import memoizee from 'memoizee';
 | 
				
			||||||
import { isNumber } from 'lodash';
 | 
					import { isNumber, pick } from 'lodash';
 | 
				
			||||||
import { createSelector } from 'reselect';
 | 
					import { createSelector } from 'reselect';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import type { StateType } from '../reducer';
 | 
					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
 | 
					 * getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
 | 
				
			||||||
 * composer and group members, a different list from your primary system contacts.
 | 
					 * 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(
 | 
					const getConversationVerificationData = createSelector(
 | 
				
			||||||
  getConversations,
 | 
					  getConversations,
 | 
				
			||||||
  (
 | 
					  (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										37
									
								
								ts/state/smart/AddUserToAnotherGroupModal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								ts/state/smart/AddUserToAnotherGroupModal.tsx
									
										
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
| 
						 | 
					@ -91,7 +91,7 @@ const mapStateToProps = (state: StateType) => {
 | 
				
			||||||
    titleBarDoubleClick: (): void => {
 | 
					    titleBarDoubleClick: (): void => {
 | 
				
			||||||
      window.titleBarDoubleClick();
 | 
					      window.titleBarDoubleClick();
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    toastType: state.toast.toastType,
 | 
					    toast: state.toast.toast,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,7 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import { connect } from 'react-redux';
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
 | 
					import { sortBy } from 'lodash';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import type { StateType } from '../reducer';
 | 
					import type { StateType } from '../reducer';
 | 
				
			||||||
import { mapDispatchToProps } from '../actions';
 | 
					import { mapDispatchToProps } from '../actions';
 | 
				
			||||||
| 
						 | 
					@ -11,6 +12,7 @@ import { ConversationDetails } from '../../components/conversation/conversation-
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  getConversationByIdSelector,
 | 
					  getConversationByIdSelector,
 | 
				
			||||||
  getConversationByUuidSelector,
 | 
					  getConversationByUuidSelector,
 | 
				
			||||||
 | 
					  getAllComposableConversations,
 | 
				
			||||||
} from '../selectors/conversations';
 | 
					} from '../selectors/conversations';
 | 
				
			||||||
import { getGroupMemberships } from '../../util/getGroupMemberships';
 | 
					import { getGroupMemberships } from '../../util/getGroupMemberships';
 | 
				
			||||||
import { getActiveCallState } from '../selectors/calling';
 | 
					import { getActiveCallState } from '../selectors/calling';
 | 
				
			||||||
| 
						 | 
					@ -84,6 +86,7 @@ const mapStateToProps = (
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const canEditGroupInfo = Boolean(conversation.canEditGroupInfo);
 | 
					  const canEditGroupInfo = Boolean(conversation.canEditGroupInfo);
 | 
				
			||||||
 | 
					  const canAddNewMembers = Boolean(conversation.canAddNewMembers);
 | 
				
			||||||
  const isAdmin = Boolean(conversation.areWeAdmin);
 | 
					  const isAdmin = Boolean(conversation.areWeAdmin);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const hasGroupLink =
 | 
					  const hasGroupLink =
 | 
				
			||||||
| 
						 | 
					@ -98,11 +101,25 @@ const mapStateToProps = (
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const badges = getBadgesSelector(state)(conversation.badges);
 | 
					  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 {
 | 
					  return {
 | 
				
			||||||
    ...props,
 | 
					    ...props,
 | 
				
			||||||
    areWeASubscriber: getAreWeASubscriber(state),
 | 
					    areWeASubscriber: getAreWeASubscriber(state),
 | 
				
			||||||
    badges,
 | 
					    badges,
 | 
				
			||||||
    canEditGroupInfo,
 | 
					    canEditGroupInfo,
 | 
				
			||||||
 | 
					    canAddNewMembers,
 | 
				
			||||||
    conversation: {
 | 
					    conversation: {
 | 
				
			||||||
      ...conversation,
 | 
					      ...conversation,
 | 
				
			||||||
      ...getConversationColorAttributes(conversation),
 | 
					      ...getConversationColorAttributes(conversation),
 | 
				
			||||||
| 
						 | 
					@ -114,6 +131,7 @@ const mapStateToProps = (
 | 
				
			||||||
    ...groupMemberships,
 | 
					    ...groupMemberships,
 | 
				
			||||||
    userAvatarData: conversation.avatars || [],
 | 
					    userAvatarData: conversation.avatars || [],
 | 
				
			||||||
    hasGroupLink,
 | 
					    hasGroupLink,
 | 
				
			||||||
 | 
					    groupsInCommon: groupsInCommonSorted,
 | 
				
			||||||
    isGroup: conversation.type === 'group',
 | 
					    isGroup: conversation.type === 'group',
 | 
				
			||||||
    theme: getTheme(state),
 | 
					    theme: getTheme(state),
 | 
				
			||||||
    renderChooseGroupMembersModal,
 | 
					    renderChooseGroupMembersModal,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,7 +14,8 @@ import { SmartStoriesSettingsModal } from './StoriesSettingsModal';
 | 
				
			||||||
import { getConversationsStoppingSend } from '../selectors/conversations';
 | 
					import { getConversationsStoppingSend } from '../selectors/conversations';
 | 
				
			||||||
import { mapDispatchToProps } from '../actions';
 | 
					import { mapDispatchToProps } from '../actions';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { getIntl } from '../selectors/user';
 | 
					import { getIntl, getTheme } from '../selectors/user';
 | 
				
			||||||
 | 
					import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function renderProfileEditor(): JSX.Element {
 | 
					function renderProfileEditor(): JSX.Element {
 | 
				
			||||||
  return <SmartProfileEditorModal />;
 | 
					  return <SmartProfileEditorModal />;
 | 
				
			||||||
| 
						 | 
					@ -43,6 +44,7 @@ const mapStateToProps = (state: StateType) => {
 | 
				
			||||||
    ...state.globalModals,
 | 
					    ...state.globalModals,
 | 
				
			||||||
    hasSafetyNumberChangeModal: getConversationsStoppingSend(state).length > 0,
 | 
					    hasSafetyNumberChangeModal: getConversationsStoppingSend(state).length > 0,
 | 
				
			||||||
    i18n,
 | 
					    i18n,
 | 
				
			||||||
 | 
					    theme: getTheme(state),
 | 
				
			||||||
    renderContactModal,
 | 
					    renderContactModal,
 | 
				
			||||||
    renderForwardMessageModal,
 | 
					    renderForwardMessageModal,
 | 
				
			||||||
    renderProfileEditor,
 | 
					    renderProfileEditor,
 | 
				
			||||||
| 
						 | 
					@ -52,6 +54,15 @@ const mapStateToProps = (state: StateType) => {
 | 
				
			||||||
        contactID={String(state.globalModals.safetyNumberModalContactId)}
 | 
					        contactID={String(state.globalModals.safetyNumberModalContactId)}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
 | 
					    renderAddUserToAnotherGroup: () => {
 | 
				
			||||||
 | 
					      return (
 | 
				
			||||||
 | 
					        <SmartAddUserToAnotherGroupModal
 | 
				
			||||||
 | 
					          contactID={String(
 | 
				
			||||||
 | 
					            state.globalModals.addUserToAnotherGroupModalContactId
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    renderSendAnywayDialog,
 | 
					    renderSendAnywayDialog,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue