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 = {
|
||||
toast: {
|
||||
toastType: 'this is a toast that does not exist' as ToastType,
|
||||
},
|
||||
};
|
||||
|
||||
export const StoryReact = Template.bind({});
|
||||
StoryReact.args = {
|
||||
toast: {
|
||||
toastType: ToastType.StoryReact,
|
||||
},
|
||||
};
|
||||
|
||||
export const StoryReply = Template.bind({});
|
||||
StoryReply.args = {
|
||||
toast: {
|
||||
toastType: ToastType.StoryReply,
|
||||
},
|
||||
};
|
||||
|
||||
export const MessageBodyTooLong = Template.bind({});
|
||||
MessageBodyTooLong.args = {
|
||||
toast: {
|
||||
toastType: ToastType.MessageBodyTooLong,
|
||||
},
|
||||
};
|
||||
|
||||
export const StoryVideoTooLong = Template.bind({});
|
||||
StoryVideoTooLong.args = {
|
||||
toast: {
|
||||
toastType: ToastType.StoryVideoTooLong,
|
||||
},
|
||||
};
|
||||
|
||||
export const StoryVideoUnsupported = Template.bind({});
|
||||
StoryVideoUnsupported.args = {
|
||||
toast: {
|
||||
toastType: ToastType.StoryVideoUnsupported,
|
||||
},
|
||||
};
|
||||
|
||||
export const StoryVideoError = Template.bind({});
|
||||
StoryVideoError.args = {
|
||||
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…
Reference in a new issue