From 22bf3ebcc0c8987d4cd81734d95355773aeaf83c Mon Sep 17 00:00:00 2001 From: Alvaro <110414366+alvaro-signal@users.noreply.github.com> Date: Mon, 26 Sep 2022 10:24:52 -0600 Subject: [PATCH] Implemented ability to quickly add a user to a group --- _locales/en/messages.json | 48 ++++ .../AddUserToAnotherGroupModal.scss | 29 +++ stylesheets/components/ContactModal.scss | 3 +- .../components/ConversationDetails.scss | 6 +- stylesheets/components/Modal.scss | 4 +- stylesheets/components/Toast.scss | 14 +- stylesheets/manifest.scss | 1 + .../AddUserToAnotherGroupModal.stories.tsx | 50 ++++ ts/components/AddUserToAnotherGroupModal.tsx | 223 ++++++++++++++++++ ts/components/App.tsx | 12 +- ts/components/ConversationList.tsx | 28 ++- ts/components/GlobalModalContainer.tsx | 15 +- ts/components/Toast.tsx | 4 +- ts/components/ToastManager.stories.tsx | 30 ++- ts/components/ToastManager.tsx | 59 +++-- ts/components/conversation/ContactModal.tsx | 17 ++ .../ConversationDetails.stories.tsx | 3 + .../ConversationDetails.tsx | 18 +- .../ConversationDetailsGroups.tsx | 73 ++++++ .../BaseConversationListItem.tsx | 4 +- .../conversationList/GroupListItem.tsx | 69 ++++++ ts/models/conversations.ts | 17 ++ ts/state/ducks/conversations.ts | 21 ++ ts/state/ducks/globalModals.ts | 58 +++-- ts/state/ducks/toast.ts | 27 ++- ts/state/selectors/conversations.ts | 22 +- ts/state/smart/AddUserToAnotherGroupModal.tsx | 37 +++ ts/state/smart/App.tsx | 2 +- ts/state/smart/ConversationDetails.tsx | 18 ++ ts/state/smart/GlobalModalContainer.tsx | 13 +- 30 files changed, 855 insertions(+), 70 deletions(-) create mode 100644 stylesheets/components/AddUserToAnotherGroupModal.scss create mode 100644 ts/components/AddUserToAnotherGroupModal.stories.tsx create mode 100644 ts/components/AddUserToAnotherGroupModal.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsGroups.tsx create mode 100644 ts/components/conversationList/GroupListItem.tsx create mode 100644 ts/state/smart/AddUserToAnotherGroupModal.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 28790a59b..56ceeddb2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/stylesheets/components/AddUserToAnotherGroupModal.scss b/stylesheets/components/AddUserToAnotherGroupModal.scss new file mode 100644 index 000000000..248d1c7e8 --- /dev/null +++ b/stylesheets/components/AddUserToAnotherGroupModal.scss @@ -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; + } +} diff --git a/stylesheets/components/ContactModal.scss b/stylesheets/components/ContactModal.scss index 8ceff02ab..5b8f8c718 100644 --- a/stylesheets/components/ContactModal.scss +++ b/stylesheets/components/ContactModal.scss @@ -104,7 +104,8 @@ } } - &__make-admin__bubble-icon { + &__make-admin__bubble-icon, + &__add-to-another-group__bubble-icon { height: 16px; width: 18px; diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index d0fe3dfa7..8e80a3952 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -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: ''; diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss index 0b22bc49d..a3883c122 100644 --- a/stylesheets/components/Modal.scss +++ b/stylesheets/components/Modal.scss @@ -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; diff --git a/stylesheets/components/Toast.scss b/stylesheets/components/Toast.scss index d1d24de28..834219a2c 100644 --- a/stylesheets/components/Toast.scss +++ b/stylesheets/components/Toast.scss @@ -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; } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 4fcc7b2e1..3d41641d3 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/components/AddUserToAnotherGroupModal.stories.tsx b/ts/components/AddUserToAnotherGroupModal.stories.tsx new file mode 100644 index 000000000..1947406ac --- /dev/null +++ b/ts/components/AddUserToAnotherGroupModal.stories.tsx @@ -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 = args => ( + +); + +export const Modal = Template.bind({}); +Modal.args = {}; diff --git a/ts/components/AddUserToAnotherGroupModal.tsx b/ts/components/AddUserToAnotherGroupModal.tsx new file mode 100644 index 000000000..07d4709a0 --- /dev/null +++ b/ts/components/AddUserToAnotherGroupModal.tsx @@ -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; + candidateConversations: ReadonlyArray; + 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 = 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) => { + 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 && ( + +
+ + + + {({ contentRect, measureRef }: MeasuredComponentProps) => ( +
+ undefined} + i18n={i18n} + theme={theme} + onClickArchiveButton={noop} + onClickContactCheckbox={noop} + onSelectConversation={setSelectedGroupId} + renderMessageSearchResult={_ => <>} + showChooseGroupMembers={noop} + lookupConversationWithoutUuid={async _ => undefined} + showUserNotFoundModal={noop} + setIsFetchingUUID={noop} + /> +
+ )} +
+
+
+ )} + + {selectedGroupId && selectedGroup && ( + 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, + })} + + )} + + ); +}; diff --git a/ts/components/App.tsx b/ts/components/App.tsx index 250bf3f79..f168db4d6 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -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, })} > - + {renderGlobalModalContainer()} {renderCallManager()} {isShowingStoriesView && renderStories(toggleStoriesView)} diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 3a5ffb2b6..7b49c5520 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -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 = ({ @@ -212,6 +223,8 @@ export const ConversationList: React.FC = ({ 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 = ({ case RowType.SearchResultsLoadingFakeRow: result = ; break; + case RowType.SelectSingleGroup: + result = ( + + ); + break; case RowType.StartNewConversation: result = ( 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') { diff --git a/ts/components/Toast.tsx b/ts/components/Toast.tsx index 916687a96..06dfd9296 100644 --- a/ts/components/Toast.tsx +++ b/ts/components/Toast.tsx @@ -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(
{ if (!disableCloseOnClick) { onClose(); diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 1b1b415db..fe6a7e2eb 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -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, + }, }; diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index efffe1c51..f974214ad 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -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 ( ; } - if (toastType === ToastType.StoryReact) { + if (toast?.toastType === ToastType.StoryReact) { return ( - + {i18n('Stories__toast--sending-reaction')} ); } - if (toastType === ToastType.StoryReply) { + if (toast?.toastType === ToastType.StoryReply) { return ( - + {i18n('Stories__toast--sending-reply')} ); } - if (toastType === ToastType.StoryMuted) { + if (toast?.toastType === ToastType.StoryMuted) { return ( - + {i18n('Stories__toast--hasNoSound')} ); } - if (toastType === ToastType.StoryVideoTooLong) { + if (toast?.toastType === ToastType.StoryVideoTooLong) { return ( {i18n('StoryCreator__error--video-too-long')} @@ -71,7 +76,7 @@ export const ToastManager = ({ ); } - if (toastType === ToastType.StoryVideoUnsupported) { + if (toast?.toastType === ToastType.StoryVideoUnsupported) { return ( {i18n('StoryCreator__error--video-unsupported')} @@ -79,7 +84,7 @@ export const ToastManager = ({ ); } - if (toastType === ToastType.StoryVideoError) { + if (toast?.toastType === ToastType.StoryVideoError) { return ( {i18n('StoryCreator__error--video-error')} @@ -87,9 +92,31 @@ export const ToastManager = ({ ); } + if (toast?.toastType === ToastType.AddingUserToGroup) { + return ( + + {i18n( + 'AddUserToAnotherGroupModal__toast--adding-user-to-group', + toast.parameters + )} + + ); + } + + if (toast?.toastType === ToastType.UserAddedToGroup) { + return ( + + {i18n( + 'AddUserToAnotherGroupModal__toast--user-added-to-group', + toast.parameters + )} + + ); + } + strictAssert( - toastType === undefined, - `Unhandled toast of type: ${toastType}` + toast === undefined, + `Unhandled toast of type: ${toast?.toastType}` ); return null; diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index 5bf5e08de..ce15d484c 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -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 = ({ {i18n('showSafetyNumber')} )} + {!contact.isMe && isMember && conversation?.id && ( + + )} {!contact.isMe && areWeAdmin && isMember && conversation?.id && ( <>