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