Implemented ability to quickly add a user to a group

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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