Implemented ability to quickly add a user to a group
This commit is contained in:
parent
190cd9408b
commit
22bf3ebcc0
30 changed files with 855 additions and 70 deletions
|
@ -1,4 +1,40 @@
|
||||||
{
|
{
|
||||||
|
"AddUserToAnotherGroupModal__title": {
|
||||||
|
"message": "Add to a group",
|
||||||
|
"description": "Shown as the title of the dialog that allows you to add a contact to an group"
|
||||||
|
},
|
||||||
|
"AddUserToAnotherGroupModal__confirm-title": {
|
||||||
|
"message": "Add new member?",
|
||||||
|
"description": "Shown as the title of the confirmation dialog when adding a contact to a group, after having selected the group"
|
||||||
|
},
|
||||||
|
"AddUserToAnotherGroupModal__confirm-add": {
|
||||||
|
"message": "Add",
|
||||||
|
"description": "Shown in the affirmative button of the confirmation dialog when adding a contact to a group"
|
||||||
|
},
|
||||||
|
"AddUserToAnotherGroupModal__confirm-message": {
|
||||||
|
"message": "Add “$contact$” to the group “$group$”",
|
||||||
|
"description": "Shown in the confirmation dialog body when adding a contact to a group"
|
||||||
|
},
|
||||||
|
"AddUserToAnotherGroupModal__toast--user-added-to-group": {
|
||||||
|
"message": "$contact$ was added to $group$",
|
||||||
|
"description": "Shown in toast after a user is added to an existing group"
|
||||||
|
},
|
||||||
|
"AddUserToAnotherGroupModal__toast--adding-user-to-group": {
|
||||||
|
"message": "Adding $contact$...",
|
||||||
|
"description": "Shown in toast while a user is being added to a group"
|
||||||
|
},
|
||||||
|
"GroupListItem__message-default": {
|
||||||
|
"message": "$count$ members",
|
||||||
|
"description": "Shown below the group name when selecting a group to invite a contact to"
|
||||||
|
},
|
||||||
|
"GroupListItem__message-already-member": {
|
||||||
|
"message": "Already a member",
|
||||||
|
"description": "Shown below the group name when selecting a group to invite a contact to, when the group item is disabled"
|
||||||
|
},
|
||||||
|
"GroupListItem__message-pending": {
|
||||||
|
"message": "Membership is pending",
|
||||||
|
"description": "Shown below the group name when selecting a group to invite a contact to, when the group item is disabled"
|
||||||
|
},
|
||||||
"softwareAcknowledgments": {
|
"softwareAcknowledgments": {
|
||||||
"message": "Software Acknowledgments",
|
"message": "Software Acknowledgments",
|
||||||
"description": "Shown in the about box for the link to software acknowledgments"
|
"description": "Shown in the about box for the link to software acknowledgments"
|
||||||
|
@ -4251,6 +4287,18 @@
|
||||||
"message": "See all",
|
"message": "See all",
|
||||||
"description": "This is a button on the conversation details to show all members"
|
"description": "This is a button on the conversation details to show all members"
|
||||||
},
|
},
|
||||||
|
"ConversationDetailsGroups--title": {
|
||||||
|
"message": "$number$ groups in common",
|
||||||
|
"description": "Title of the groups-in-common panel, in the contact details"
|
||||||
|
},
|
||||||
|
"ConversationDetailsGroups--add-to-group": {
|
||||||
|
"message": "Add to a group",
|
||||||
|
"description": "The button shown on a conversation details (for a direct contact) that you can click to add that contact to groups"
|
||||||
|
},
|
||||||
|
"ConversationDetailsGroups--show-all": {
|
||||||
|
"message": "See all",
|
||||||
|
"description": "This is a button on the conversation details (for a direct contact) to show all groups-in-common"
|
||||||
|
},
|
||||||
"ConversationNotificationsSettings__mentions__label": {
|
"ConversationNotificationsSettings__mentions__label": {
|
||||||
"message": "Mentions",
|
"message": "Mentions",
|
||||||
"description": "In the conversation notifications settings, this is the label for the mentions option"
|
"description": "In the conversation notifications settings, this is the label for the mentions option"
|
||||||
|
|
29
stylesheets/components/AddUserToAnotherGroupModal.scss
Normal file
29
stylesheets/components/AddUserToAnotherGroupModal.scss
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
div.AddUserToAnotherGroupModal__body {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.AddUserToAnotherGroupModal {
|
||||||
|
&__main-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list-wrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.AddUserToAnotherGroupModal .module-conversation-list {
|
||||||
|
&__item,
|
||||||
|
&__item--contact-or-conversation {
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -104,7 +104,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__make-admin__bubble-icon {
|
&__make-admin__bubble-icon,
|
||||||
|
&__add-to-another-group__bubble-icon {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
|
|
||||||
|
|
|
@ -74,8 +74,10 @@
|
||||||
@include color-bubble(20px);
|
@include color-bubble(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&-membership-list {
|
&-membership-list,
|
||||||
&__add-members-icon {
|
&-groups {
|
||||||
|
&__add-members-icon,
|
||||||
|
&__add-to-group-icon {
|
||||||
@mixin plus-icon($color) {
|
@mixin plus-icon($color) {
|
||||||
@include color-svg('../images/icons/v2/plus-24.svg', $color);
|
@include color-svg('../images/icons/v2/plus-24.svg', $color);
|
||||||
content: '';
|
content: '';
|
||||||
|
|
|
@ -132,11 +132,12 @@
|
||||||
@include scrollbar;
|
@include scrollbar;
|
||||||
@include font-body-1;
|
@include font-body-1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--has-header {
|
&--has-header {
|
||||||
.module-Modal__body {
|
.module-Modal__body {
|
||||||
padding: 0 16px 16px 16px;
|
padding-top: 0;
|
||||||
border-top: 1px solid transparent;
|
border-top: 1px solid transparent;
|
||||||
// If there's a header, just the body scrolls
|
// If there's a header, just the body scrolls
|
||||||
overflow-y: overlay;
|
overflow-y: overlay;
|
||||||
|
@ -155,7 +156,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&--no-header {
|
&--no-header {
|
||||||
padding: 16px;
|
|
||||||
// If there's no header, the whole thing scrolls
|
// If there's no header, the whole thing scrolls
|
||||||
overflow-y: overlay;
|
overflow-y: overlay;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
|
@ -8,14 +8,11 @@
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: $border-radius-px;
|
border-radius: $border-radius-px;
|
||||||
bottom: 62px;
|
|
||||||
box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
left: 50%;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transform: translate(-50%, 0);
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: $z-index-toast;
|
z-index: $z-index-toast;
|
||||||
|
@ -29,6 +26,17 @@
|
||||||
color: $color-gray-05;
|
color: $color-gray-05;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--align-center {
|
||||||
|
bottom: 62px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--align-left {
|
||||||
|
left: 20px;
|
||||||
|
bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
// New style: components
|
// New style: components
|
||||||
@import './components/About.scss';
|
@import './components/About.scss';
|
||||||
@import './components/AddGroupMembersModal.scss';
|
@import './components/AddGroupMembersModal.scss';
|
||||||
|
@import './components/AddUserToAnotherGroupModal.scss';
|
||||||
@import './components/AnnouncementsOnlyGroupBanner.scss';
|
@import './components/AnnouncementsOnlyGroupBanner.scss';
|
||||||
@import './components/App.scss';
|
@import './components/App.scss';
|
||||||
@import './components/AudioCapture.scss';
|
@import './components/AudioCapture.scss';
|
||||||
|
|
50
ts/components/AddUserToAnotherGroupModal.stories.tsx
Normal file
50
ts/components/AddUserToAnotherGroupModal.stories.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Meta, Story } from '@storybook/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
|
import type { Props } from './AddUserToAnotherGroupModal';
|
||||||
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
|
import {
|
||||||
|
getDefaultConversation,
|
||||||
|
getDefaultGroup,
|
||||||
|
} from '../test-both/helpers/getDefaultConversation';
|
||||||
|
import { setupI18n } from '../util/setupI18n';
|
||||||
|
import { AddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
|
||||||
|
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
||||||
|
|
||||||
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/AddUserToAnotherGroupModal',
|
||||||
|
component: AddUserToAnotherGroupModal,
|
||||||
|
argTypes: {
|
||||||
|
candidateConversations: {
|
||||||
|
defaultValue: Array.from(Array(100), () => getDefaultGroup()),
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
defaultValue: getDefaultConversation(),
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
defaultValue: i18n,
|
||||||
|
},
|
||||||
|
addMemberToGroup: {
|
||||||
|
defaultValue: action('addMemberToGroup'),
|
||||||
|
},
|
||||||
|
toggleAddUserToAnotherGroupModal: {
|
||||||
|
defaultValue: action('toggleAddUserToAnotherGroupModal'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<Props> = args => (
|
||||||
|
<AddUserToAnotherGroupModal
|
||||||
|
{...args}
|
||||||
|
theme={React.useContext(StorybookThemeContext)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Modal = Template.bind({});
|
||||||
|
Modal.args = {};
|
223
ts/components/AddUserToAnotherGroupModal.tsx
Normal file
223
ts/components/AddUserToAnotherGroupModal.tsx
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { noop, pick } from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
import type { MeasuredComponentProps } from 'react-measure';
|
||||||
|
import Measure from 'react-measure';
|
||||||
|
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import type {
|
||||||
|
LocalizerType,
|
||||||
|
ReplacementValuesType,
|
||||||
|
ThemeType,
|
||||||
|
} from '../types/Util';
|
||||||
|
import { ToastType } from '../state/ducks/toast';
|
||||||
|
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
|
||||||
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
|
import type { Row } from './ConversationList';
|
||||||
|
import { ConversationList, RowType } from './ConversationList';
|
||||||
|
import { DisabledReason } from './conversationList/GroupListItem';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { SearchInput } from './SearchInput';
|
||||||
|
import { useRestoreFocus } from '../hooks/useRestoreFocus';
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
theme: ThemeType;
|
||||||
|
contact: Pick<ConversationType, 'id' | 'title' | 'uuid'>;
|
||||||
|
candidateConversations: ReadonlyArray<ConversationType>;
|
||||||
|
regionCode: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DispatchProps = {
|
||||||
|
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
|
||||||
|
addMemberToGroup: (
|
||||||
|
conversationId: string,
|
||||||
|
contactId: string,
|
||||||
|
onComplete: () => void
|
||||||
|
) => void;
|
||||||
|
showToast: (toastType: ToastType, parameters?: ReplacementValuesType) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Props = OwnProps & DispatchProps;
|
||||||
|
|
||||||
|
export const AddUserToAnotherGroupModal = ({
|
||||||
|
i18n,
|
||||||
|
theme,
|
||||||
|
contact,
|
||||||
|
toggleAddUserToAnotherGroupModal,
|
||||||
|
addMemberToGroup,
|
||||||
|
showToast,
|
||||||
|
candidateConversations,
|
||||||
|
regionCode,
|
||||||
|
}: Props): JSX.Element | null => {
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState('');
|
||||||
|
const [filteredConversations, setFilteredConversations] = React.useState(
|
||||||
|
filterAndSortConversationsByRecent(candidateConversations, '', undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = React.useState<
|
||||||
|
undefined | string
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const groupLookup: Map<string, ConversationType> = React.useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
candidateConversations.forEach(conversation => {
|
||||||
|
map.set(conversation.id, conversation);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [candidateConversations]);
|
||||||
|
|
||||||
|
const [inputRef] = useRestoreFocus();
|
||||||
|
|
||||||
|
const normalizedSearchTerm = searchTerm.trim();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setFilteredConversations(
|
||||||
|
filterAndSortConversationsByRecent(
|
||||||
|
candidateConversations,
|
||||||
|
normalizedSearchTerm,
|
||||||
|
regionCode
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
candidateConversations,
|
||||||
|
normalizedSearchTerm,
|
||||||
|
setFilteredConversations,
|
||||||
|
regionCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedGroup = selectedGroupId
|
||||||
|
? groupLookup.get(selectedGroupId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleSearchInputChange = React.useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchTerm(event.target.value);
|
||||||
|
},
|
||||||
|
[setSearchTerm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGetRow = React.useCallback(
|
||||||
|
(idx: number): Row | undefined => {
|
||||||
|
const convo = filteredConversations[idx];
|
||||||
|
|
||||||
|
// these are always populated in the case of a group
|
||||||
|
const memberships = convo.memberships ?? [];
|
||||||
|
const pendingApprovalMemberships = convo.pendingApprovalMemberships ?? [];
|
||||||
|
const pendingMemberships = convo.pendingMemberships ?? [];
|
||||||
|
const membersCount = convo.membersCount ?? 0;
|
||||||
|
|
||||||
|
let disabledReason;
|
||||||
|
|
||||||
|
if (memberships.some(c => c.uuid === contact.uuid)) {
|
||||||
|
disabledReason = DisabledReason.AlreadyMember;
|
||||||
|
} else if (
|
||||||
|
pendingApprovalMemberships.some(c => c.uuid === contact.uuid) ||
|
||||||
|
pendingMemberships.some(c => c.uuid === contact.uuid)
|
||||||
|
) {
|
||||||
|
disabledReason = DisabledReason.Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: RowType.SelectSingleGroup,
|
||||||
|
group: {
|
||||||
|
...pick(convo, 'id', 'avatarPath', 'title', 'unblurredAvatarPath'),
|
||||||
|
memberships,
|
||||||
|
membersCount,
|
||||||
|
disabledReason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[filteredConversations, contact]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!selectedGroup && (
|
||||||
|
<Modal
|
||||||
|
hasXButton
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={toggleAddUserToAnotherGroupModal}
|
||||||
|
title={i18n('AddUserToAnotherGroupModal__title')}
|
||||||
|
moduleClassName="AddUserToAnotherGroupModal"
|
||||||
|
>
|
||||||
|
<div className="AddUserToAnotherGroupModal__main-body">
|
||||||
|
<SearchInput
|
||||||
|
i18n={i18n}
|
||||||
|
placeholder={i18n('contactSearchPlaceholder')}
|
||||||
|
onChange={handleSearchInputChange}
|
||||||
|
ref={inputRef}
|
||||||
|
value={searchTerm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Measure bounds>
|
||||||
|
{({ contentRect, measureRef }: MeasuredComponentProps) => (
|
||||||
|
<div
|
||||||
|
className="AddUserToAnotherGroupModal__list-wrapper"
|
||||||
|
ref={measureRef}
|
||||||
|
>
|
||||||
|
<ConversationList
|
||||||
|
dimensions={contentRect.bounds}
|
||||||
|
rowCount={filteredConversations.length}
|
||||||
|
getRow={handleGetRow}
|
||||||
|
shouldRecomputeRowHeights={false}
|
||||||
|
showConversation={noop}
|
||||||
|
getPreferredBadge={() => undefined}
|
||||||
|
i18n={i18n}
|
||||||
|
theme={theme}
|
||||||
|
onClickArchiveButton={noop}
|
||||||
|
onClickContactCheckbox={noop}
|
||||||
|
onSelectConversation={setSelectedGroupId}
|
||||||
|
renderMessageSearchResult={_ => <></>}
|
||||||
|
showChooseGroupMembers={noop}
|
||||||
|
lookupConversationWithoutUuid={async _ => undefined}
|
||||||
|
showUserNotFoundModal={noop}
|
||||||
|
setIsFetchingUUID={noop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Measure>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedGroupId && selectedGroup && (
|
||||||
|
<ConfirmationDialog
|
||||||
|
title={i18n('AddUserToAnotherGroupModal__confirm-title')}
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={() => setSelectedGroupId(undefined)}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
text: i18n('AddUserToAnotherGroupModal__confirm-add'),
|
||||||
|
style: 'affirmative',
|
||||||
|
action: () => {
|
||||||
|
showToast(ToastType.AddingUserToGroup, {
|
||||||
|
contact: contact.title,
|
||||||
|
});
|
||||||
|
addMemberToGroup(selectedGroupId, contact.id, () =>
|
||||||
|
showToast(ToastType.UserAddedToGroup, {
|
||||||
|
contact: contact.title,
|
||||||
|
group: selectedGroup.title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toggleAddUserToAnotherGroupModal(undefined);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{i18n('AddUserToAnotherGroupModal__confirm-message', {
|
||||||
|
contact: contact.title,
|
||||||
|
group: selectedGroup.title,
|
||||||
|
})}
|
||||||
|
</ConfirmationDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -11,11 +11,12 @@ import type { LocaleMessagesType } from '../types/I18N';
|
||||||
import type { MenuOptionsType, MenuActionType } from '../types/menu';
|
import type { MenuOptionsType, MenuActionType } from '../types/menu';
|
||||||
import type { ToastType } from '../state/ducks/toast';
|
import type { ToastType } from '../state/ducks/toast';
|
||||||
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
||||||
|
import type { ReplacementValuesType } from '../types/Util';
|
||||||
|
import { ThemeType } from '../types/Util';
|
||||||
import { AppViewType } from '../state/ducks/app';
|
import { AppViewType } from '../state/ducks/app';
|
||||||
import { Inbox } from './Inbox';
|
import { Inbox } from './Inbox';
|
||||||
import { SmartInstallScreen } from '../state/smart/InstallScreen';
|
import { SmartInstallScreen } from '../state/smart/InstallScreen';
|
||||||
import { StandaloneRegistration } from './StandaloneRegistration';
|
import { StandaloneRegistration } from './StandaloneRegistration';
|
||||||
import { ThemeType } from '../types/Util';
|
|
||||||
import { TitleBarContainer } from './TitleBarContainer';
|
import { TitleBarContainer } from './TitleBarContainer';
|
||||||
import { ToastManager } from './ToastManager';
|
import { ToastManager } from './ToastManager';
|
||||||
import { usePageVisibility } from '../hooks/usePageVisibility';
|
import { usePageVisibility } from '../hooks/usePageVisibility';
|
||||||
|
@ -47,7 +48,10 @@ type PropsType = {
|
||||||
executeMenuRole: ExecuteMenuRoleType;
|
executeMenuRole: ExecuteMenuRoleType;
|
||||||
executeMenuAction: (action: MenuActionType) => void;
|
executeMenuAction: (action: MenuActionType) => void;
|
||||||
titleBarDoubleClick: () => void;
|
titleBarDoubleClick: () => void;
|
||||||
toastType?: ToastType;
|
toast?: {
|
||||||
|
toastType: ToastType;
|
||||||
|
parameters?: ReplacementValuesType;
|
||||||
|
};
|
||||||
hideToast: () => unknown;
|
hideToast: () => unknown;
|
||||||
toggleStoriesView: () => unknown;
|
toggleStoriesView: () => unknown;
|
||||||
viewStory: ViewStoryActionCreatorType;
|
viewStory: ViewStoryActionCreatorType;
|
||||||
|
@ -84,7 +88,7 @@ export const App = ({
|
||||||
showWhatsNewModal,
|
showWhatsNewModal,
|
||||||
theme,
|
theme,
|
||||||
titleBarDoubleClick,
|
titleBarDoubleClick,
|
||||||
toastType,
|
toast,
|
||||||
toggleStoriesView,
|
toggleStoriesView,
|
||||||
viewStory,
|
viewStory,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
|
@ -170,7 +174,7 @@ export const App = ({
|
||||||
'dark-theme': theme === ThemeType.dark,
|
'dark-theme': theme === ThemeType.dark,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ToastManager hideToast={hideToast} i18n={i18n} toastType={toastType} />
|
<ToastManager hideToast={hideToast} i18n={i18n} toast={toast} />
|
||||||
{renderGlobalModalContainer()}
|
{renderGlobalModalContainer()}
|
||||||
{renderCallManager()}
|
{renderCallManager()}
|
||||||
{isShowingStoriesView && renderStories(toggleStoriesView)}
|
{isShowingStoriesView && renderStories(toggleStoriesView)}
|
||||||
|
|
|
@ -19,10 +19,11 @@ import type { LookupConversationWithoutUuidActionsType } from '../util/lookupCon
|
||||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||||
|
|
||||||
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
|
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
|
||||||
import { ConversationListItem } from './conversationList/ConversationListItem';
|
|
||||||
import type { ContactListItemConversationType as ContactListItemPropsType } from './conversationList/ContactListItem';
|
|
||||||
import { ContactListItem } from './conversationList/ContactListItem';
|
|
||||||
import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||||
|
import type { ContactListItemConversationType as ContactListItemPropsType } from './conversationList/ContactListItem';
|
||||||
|
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
|
||||||
|
import { ConversationListItem } from './conversationList/ConversationListItem';
|
||||||
|
import { ContactListItem } from './conversationList/ContactListItem';
|
||||||
import { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox';
|
import { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox';
|
||||||
import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox';
|
import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox';
|
||||||
import { UsernameCheckbox as UsernameCheckboxComponent } from './conversationList/UsernameCheckbox';
|
import { UsernameCheckbox as UsernameCheckboxComponent } from './conversationList/UsernameCheckbox';
|
||||||
|
@ -31,6 +32,7 @@ import { StartNewConversation as StartNewConversationComponent } from './convers
|
||||||
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
|
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
|
||||||
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
|
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
|
||||||
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
|
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
|
||||||
|
import { GroupListItem } from './conversationList/GroupListItem';
|
||||||
|
|
||||||
export enum RowType {
|
export enum RowType {
|
||||||
ArchiveButton = 'ArchiveButton',
|
ArchiveButton = 'ArchiveButton',
|
||||||
|
@ -45,6 +47,8 @@ export enum RowType {
|
||||||
MessageSearchResult = 'MessageSearchResult',
|
MessageSearchResult = 'MessageSearchResult',
|
||||||
SearchResultsLoadingFakeHeader = 'SearchResultsLoadingFakeHeader',
|
SearchResultsLoadingFakeHeader = 'SearchResultsLoadingFakeHeader',
|
||||||
SearchResultsLoadingFakeRow = 'SearchResultsLoadingFakeRow',
|
SearchResultsLoadingFakeRow = 'SearchResultsLoadingFakeRow',
|
||||||
|
// this could later be expanded to SelectSingleConversation
|
||||||
|
SelectSingleGroup = 'SelectSingleGroup',
|
||||||
StartNewConversation = 'StartNewConversation',
|
StartNewConversation = 'StartNewConversation',
|
||||||
UsernameSearchResult = 'UsernameSearchResult',
|
UsernameSearchResult = 'UsernameSearchResult',
|
||||||
}
|
}
|
||||||
|
@ -110,6 +114,11 @@ type SearchResultsLoadingFakeRowType = {
|
||||||
type: RowType.SearchResultsLoadingFakeRow;
|
type: RowType.SearchResultsLoadingFakeRow;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SelectSingleGroupRowType = {
|
||||||
|
type: RowType.SelectSingleGroup;
|
||||||
|
group: GroupListItemConversationType;
|
||||||
|
};
|
||||||
|
|
||||||
type StartNewConversationRowType = {
|
type StartNewConversationRowType = {
|
||||||
type: RowType.StartNewConversation;
|
type: RowType.StartNewConversation;
|
||||||
phoneNumber: ParsedE164Type;
|
phoneNumber: ParsedE164Type;
|
||||||
|
@ -136,6 +145,7 @@ export type Row =
|
||||||
| SearchResultsLoadingFakeHeaderType
|
| SearchResultsLoadingFakeHeaderType
|
||||||
| SearchResultsLoadingFakeRowType
|
| SearchResultsLoadingFakeRowType
|
||||||
| StartNewConversationRowType
|
| StartNewConversationRowType
|
||||||
|
| SelectSingleGroupRowType
|
||||||
| UsernameRowType;
|
| UsernameRowType;
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
|
@ -169,6 +179,7 @@ export type PropsType = {
|
||||||
} & LookupConversationWithoutUuidActionsType;
|
} & LookupConversationWithoutUuidActionsType;
|
||||||
|
|
||||||
const NORMAL_ROW_HEIGHT = 76;
|
const NORMAL_ROW_HEIGHT = 76;
|
||||||
|
const SELECT_ROW_HEIGHT = 52;
|
||||||
const HEADER_ROW_HEIGHT = 40;
|
const HEADER_ROW_HEIGHT = 40;
|
||||||
|
|
||||||
export const ConversationList: React.FC<PropsType> = ({
|
export const ConversationList: React.FC<PropsType> = ({
|
||||||
|
@ -212,6 +223,8 @@ export const ConversationList: React.FC<PropsType> = ({
|
||||||
case RowType.Header:
|
case RowType.Header:
|
||||||
case RowType.SearchResultsLoadingFakeHeader:
|
case RowType.SearchResultsLoadingFakeHeader:
|
||||||
return HEADER_ROW_HEIGHT;
|
return HEADER_ROW_HEIGHT;
|
||||||
|
case RowType.SelectSingleGroup:
|
||||||
|
return SELECT_ROW_HEIGHT;
|
||||||
default:
|
default:
|
||||||
return NORMAL_ROW_HEIGHT;
|
return NORMAL_ROW_HEIGHT;
|
||||||
}
|
}
|
||||||
|
@ -386,6 +399,15 @@ export const ConversationList: React.FC<PropsType> = ({
|
||||||
case RowType.SearchResultsLoadingFakeRow:
|
case RowType.SearchResultsLoadingFakeRow:
|
||||||
result = <SearchResultsLoadingFakeRowComponent />;
|
result = <SearchResultsLoadingFakeRowComponent />;
|
||||||
break;
|
break;
|
||||||
|
case RowType.SelectSingleGroup:
|
||||||
|
result = (
|
||||||
|
<GroupListItem
|
||||||
|
i18n={i18n}
|
||||||
|
group={row.group}
|
||||||
|
onSelectGroup={onSelectConversation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
case RowType.StartNewConversation:
|
case RowType.StartNewConversation:
|
||||||
result = (
|
result = (
|
||||||
<StartNewConversationComponent
|
<StartNewConversationComponent
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
UserNotFoundModalStateType,
|
UserNotFoundModalStateType,
|
||||||
SafetyNumberChangedBlockingDataType,
|
SafetyNumberChangedBlockingDataType,
|
||||||
} from '../state/ducks/globalModals';
|
} from '../state/ducks/globalModals';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
|
||||||
import { ButtonVariant } from './Button';
|
import { ButtonVariant } from './Button';
|
||||||
|
@ -16,8 +16,9 @@ import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import { SignalConnectionsModal } from './SignalConnectionsModal';
|
import { SignalConnectionsModal } from './SignalConnectionsModal';
|
||||||
import { WhatsNewModal } from './WhatsNewModal';
|
import { WhatsNewModal } from './WhatsNewModal';
|
||||||
|
|
||||||
type PropsType = {
|
export type PropsType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
theme: ThemeType;
|
||||||
// ContactModal
|
// ContactModal
|
||||||
contactModalState?: ContactModalStateType;
|
contactModalState?: ContactModalStateType;
|
||||||
renderContactModal: () => JSX.Element;
|
renderContactModal: () => JSX.Element;
|
||||||
|
@ -30,6 +31,9 @@ type PropsType = {
|
||||||
// SafetyNumberModal
|
// SafetyNumberModal
|
||||||
safetyNumberModalContactId?: string;
|
safetyNumberModalContactId?: string;
|
||||||
renderSafetyNumber: () => JSX.Element;
|
renderSafetyNumber: () => JSX.Element;
|
||||||
|
// AddUserToAnotherGroupModal
|
||||||
|
addUserToAnotherGroupModalContactId?: string;
|
||||||
|
renderAddUserToAnotherGroup: () => JSX.Element;
|
||||||
// SignalConnectionsModal
|
// SignalConnectionsModal
|
||||||
isSignalConnectionsVisible: boolean;
|
isSignalConnectionsVisible: boolean;
|
||||||
toggleSignalConnectionsModal: () => unknown;
|
toggleSignalConnectionsModal: () => unknown;
|
||||||
|
@ -62,6 +66,9 @@ export const GlobalModalContainer = ({
|
||||||
// SafetyNumberModal
|
// SafetyNumberModal
|
||||||
safetyNumberModalContactId,
|
safetyNumberModalContactId,
|
||||||
renderSafetyNumber,
|
renderSafetyNumber,
|
||||||
|
// AddUserToAnotherGroupModal
|
||||||
|
addUserToAnotherGroupModalContactId,
|
||||||
|
renderAddUserToAnotherGroup,
|
||||||
// SignalConnectionsModal
|
// SignalConnectionsModal
|
||||||
isSignalConnectionsVisible,
|
isSignalConnectionsVisible,
|
||||||
toggleSignalConnectionsModal,
|
toggleSignalConnectionsModal,
|
||||||
|
@ -89,6 +96,10 @@ export const GlobalModalContainer = ({
|
||||||
return renderSafetyNumber();
|
return renderSafetyNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (addUserToAnotherGroupModalContactId) {
|
||||||
|
return renderAddUserToAnotherGroup();
|
||||||
|
}
|
||||||
|
|
||||||
if (userNotFoundModalState) {
|
if (userNotFoundModalState) {
|
||||||
let content: string;
|
let content: string;
|
||||||
if (userNotFoundModalState.type === 'phoneNumber') {
|
if (userNotFoundModalState.type === 'phoneNumber') {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
autoDismissDisabled?: boolean;
|
autoDismissDisabled?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
align?: 'left' | 'center';
|
||||||
className?: string;
|
className?: string;
|
||||||
disableCloseOnClick?: boolean;
|
disableCloseOnClick?: boolean;
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
|
@ -26,6 +27,7 @@ export const Toast = memo(
|
||||||
({
|
({
|
||||||
autoDismissDisabled = false,
|
autoDismissDisabled = false,
|
||||||
children,
|
children,
|
||||||
|
align = 'center',
|
||||||
className,
|
className,
|
||||||
disableCloseOnClick = false,
|
disableCloseOnClick = false,
|
||||||
onClose,
|
onClose,
|
||||||
|
@ -63,7 +65,7 @@ export const Toast = memo(
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className={classNames('Toast', className)}
|
className={classNames('Toast', `Toast--align-${align}`, className)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!disableCloseOnClick) {
|
if (!disableCloseOnClick) {
|
||||||
onClose();
|
onClose();
|
||||||
|
|
|
@ -20,7 +20,7 @@ export default {
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultValue: i18n,
|
defaultValue: i18n,
|
||||||
},
|
},
|
||||||
toastType: {
|
toast: {
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -33,35 +33,49 @@ UndefinedToast.args = {};
|
||||||
|
|
||||||
export const InvalidToast = Template.bind({});
|
export const InvalidToast = Template.bind({});
|
||||||
InvalidToast.args = {
|
InvalidToast.args = {
|
||||||
toastType: 'this is a toast that does not exist' as ToastType,
|
toast: {
|
||||||
|
toastType: 'this is a toast that does not exist' as ToastType,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StoryReact = Template.bind({});
|
export const StoryReact = Template.bind({});
|
||||||
StoryReact.args = {
|
StoryReact.args = {
|
||||||
toastType: ToastType.StoryReact,
|
toast: {
|
||||||
|
toastType: ToastType.StoryReact,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StoryReply = Template.bind({});
|
export const StoryReply = Template.bind({});
|
||||||
StoryReply.args = {
|
StoryReply.args = {
|
||||||
toastType: ToastType.StoryReply,
|
toast: {
|
||||||
|
toastType: ToastType.StoryReply,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageBodyTooLong = Template.bind({});
|
export const MessageBodyTooLong = Template.bind({});
|
||||||
MessageBodyTooLong.args = {
|
MessageBodyTooLong.args = {
|
||||||
toastType: ToastType.MessageBodyTooLong,
|
toast: {
|
||||||
|
toastType: ToastType.MessageBodyTooLong,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StoryVideoTooLong = Template.bind({});
|
export const StoryVideoTooLong = Template.bind({});
|
||||||
StoryVideoTooLong.args = {
|
StoryVideoTooLong.args = {
|
||||||
toastType: ToastType.StoryVideoTooLong,
|
toast: {
|
||||||
|
toastType: ToastType.StoryVideoTooLong,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StoryVideoUnsupported = Template.bind({});
|
export const StoryVideoUnsupported = Template.bind({});
|
||||||
StoryVideoUnsupported.args = {
|
StoryVideoUnsupported.args = {
|
||||||
toastType: ToastType.StoryVideoUnsupported,
|
toast: {
|
||||||
|
toastType: ToastType.StoryVideoUnsupported,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StoryVideoError = Template.bind({});
|
export const StoryVideoError = Template.bind({});
|
||||||
StoryVideoError.args = {
|
StoryVideoError.args = {
|
||||||
toastType: ToastType.StoryVideoError,
|
toast: {
|
||||||
|
toastType: ToastType.StoryVideoError,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
|
||||||
import { SECOND } from '../util/durations';
|
import { SECOND } from '../util/durations';
|
||||||
import { Toast } from './Toast';
|
import { Toast } from './Toast';
|
||||||
import { ToastMessageBodyTooLong } from './ToastMessageBodyTooLong';
|
import { ToastMessageBodyTooLong } from './ToastMessageBodyTooLong';
|
||||||
|
@ -12,15 +12,20 @@ import { strictAssert } from '../util/assert';
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
hideToast: () => unknown;
|
hideToast: () => unknown;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
toastType?: ToastType;
|
toast?: {
|
||||||
|
toastType: ToastType;
|
||||||
|
parameters?: ReplacementValuesType;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SHORT_TIMEOUT = 3 * SECOND;
|
||||||
|
|
||||||
export const ToastManager = ({
|
export const ToastManager = ({
|
||||||
hideToast,
|
hideToast,
|
||||||
i18n,
|
i18n,
|
||||||
toastType,
|
toast,
|
||||||
}: PropsType): JSX.Element | null => {
|
}: PropsType): JSX.Element | null => {
|
||||||
if (toastType === ToastType.Error) {
|
if (toast?.toastType === ToastType.Error) {
|
||||||
return (
|
return (
|
||||||
<Toast
|
<Toast
|
||||||
autoDismissDisabled
|
autoDismissDisabled
|
||||||
|
@ -35,35 +40,35 @@ export const ToastManager = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.MessageBodyTooLong) {
|
if (toast?.toastType === ToastType.MessageBodyTooLong) {
|
||||||
return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
|
return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.StoryReact) {
|
if (toast?.toastType === ToastType.StoryReact) {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={hideToast} timeout={3 * SECOND}>
|
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
|
||||||
{i18n('Stories__toast--sending-reaction')}
|
{i18n('Stories__toast--sending-reaction')}
|
||||||
</Toast>
|
</Toast>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.StoryReply) {
|
if (toast?.toastType === ToastType.StoryReply) {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={hideToast} timeout={3 * SECOND}>
|
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
|
||||||
{i18n('Stories__toast--sending-reply')}
|
{i18n('Stories__toast--sending-reply')}
|
||||||
</Toast>
|
</Toast>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.StoryMuted) {
|
if (toast?.toastType === ToastType.StoryMuted) {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={hideToast} timeout={3 * SECOND}>
|
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
|
||||||
{i18n('Stories__toast--hasNoSound')}
|
{i18n('Stories__toast--hasNoSound')}
|
||||||
</Toast>
|
</Toast>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.StoryVideoTooLong) {
|
if (toast?.toastType === ToastType.StoryVideoTooLong) {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={hideToast}>
|
<Toast onClose={hideToast}>
|
||||||
{i18n('StoryCreator__error--video-too-long')}
|
{i18n('StoryCreator__error--video-too-long')}
|
||||||
|
@ -71,7 +76,7 @@ export const ToastManager = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.StoryVideoUnsupported) {
|
if (toast?.toastType === ToastType.StoryVideoUnsupported) {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={hideToast}>
|
<Toast onClose={hideToast}>
|
||||||
{i18n('StoryCreator__error--video-unsupported')}
|
{i18n('StoryCreator__error--video-unsupported')}
|
||||||
|
@ -79,7 +84,7 @@ export const ToastManager = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.StoryVideoError) {
|
if (toast?.toastType === ToastType.StoryVideoError) {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={hideToast}>
|
<Toast onClose={hideToast}>
|
||||||
{i18n('StoryCreator__error--video-error')}
|
{i18n('StoryCreator__error--video-error')}
|
||||||
|
@ -87,9 +92,31 @@ export const ToastManager = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toast?.toastType === ToastType.AddingUserToGroup) {
|
||||||
|
return (
|
||||||
|
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT} align="left">
|
||||||
|
{i18n(
|
||||||
|
'AddUserToAnotherGroupModal__toast--adding-user-to-group',
|
||||||
|
toast.parameters
|
||||||
|
)}
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toast?.toastType === ToastType.UserAddedToGroup) {
|
||||||
|
return (
|
||||||
|
<Toast onClose={hideToast} align="left">
|
||||||
|
{i18n(
|
||||||
|
'AddUserToAnotherGroupModal__toast--user-added-to-group',
|
||||||
|
toast.parameters
|
||||||
|
)}
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
strictAssert(
|
strictAssert(
|
||||||
toastType === undefined,
|
toast === undefined,
|
||||||
`Unhandled toast of type: ${toastType}`
|
`Unhandled toast of type: ${toast?.toastType}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -43,6 +43,7 @@ type PropsActionType = {
|
||||||
showConversation: ShowConversationType;
|
showConversation: ShowConversationType;
|
||||||
toggleAdmin: (conversationId: string, contactId: string) => void;
|
toggleAdmin: (conversationId: string, contactId: string) => void;
|
||||||
toggleSafetyNumberModal: (conversationId: string) => unknown;
|
toggleSafetyNumberModal: (conversationId: string) => unknown;
|
||||||
|
toggleAddUserToAnotherGroupModal: (conversationId: string) => void;
|
||||||
updateConversationModelSharedGroups: (conversationId: string) => void;
|
updateConversationModelSharedGroups: (conversationId: string) => void;
|
||||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||||
};
|
};
|
||||||
|
@ -77,6 +78,7 @@ export const ContactModal = ({
|
||||||
theme,
|
theme,
|
||||||
toggleAdmin,
|
toggleAdmin,
|
||||||
toggleSafetyNumberModal,
|
toggleSafetyNumberModal,
|
||||||
|
toggleAddUserToAnotherGroupModal,
|
||||||
updateConversationModelSharedGroups,
|
updateConversationModelSharedGroups,
|
||||||
viewUserStories,
|
viewUserStories,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
|
@ -242,6 +244,21 @@ export const ContactModal = ({
|
||||||
<span>{i18n('showSafetyNumber')}</span>
|
<span>{i18n('showSafetyNumber')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!contact.isMe && isMember && conversation?.id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ContactModal__button"
|
||||||
|
onClick={() => {
|
||||||
|
hideContactModal();
|
||||||
|
toggleAddUserToAnotherGroupModal(contact.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="ContactModal__bubble-icon">
|
||||||
|
<div className="ContactModal__add-to-another-group__bubble-icon" />
|
||||||
|
</div>
|
||||||
|
Add to another group
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!contact.isMe && areWeAdmin && isMember && conversation?.id && (
|
{!contact.isMe && areWeAdmin && isMember && conversation?.id && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -41,6 +41,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
|
||||||
},
|
},
|
||||||
areWeASubscriber: false,
|
areWeASubscriber: false,
|
||||||
canEditGroupInfo: false,
|
canEditGroupInfo: false,
|
||||||
|
canAddNewMembers: false,
|
||||||
conversation: expireTimer
|
conversation: expireTimer
|
||||||
? {
|
? {
|
||||||
...conversation,
|
...conversation,
|
||||||
|
@ -50,6 +51,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
|
||||||
hasActiveCall: false,
|
hasActiveCall: false,
|
||||||
hasGroupLink,
|
hasGroupLink,
|
||||||
getPreferredBadge: () => undefined,
|
getPreferredBadge: () => undefined,
|
||||||
|
groupsInCommon: [],
|
||||||
i18n,
|
i18n,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isGroup: true,
|
isGroup: true,
|
||||||
|
@ -90,6 +92,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
|
||||||
setMuteExpiration: action('setMuteExpiration'),
|
setMuteExpiration: action('setMuteExpiration'),
|
||||||
userAvatarData: [],
|
userAvatarData: [],
|
||||||
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
||||||
|
toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'),
|
||||||
onOutgoingAudioCallInConversation: action(
|
onOutgoingAudioCallInConversation: action(
|
||||||
'onOutgoingAudioCallInConversation'
|
'onOutgoingAudioCallInConversation'
|
||||||
),
|
),
|
||||||
|
|
|
@ -45,6 +45,7 @@ import type {
|
||||||
SaveAvatarToDiskActionType,
|
SaveAvatarToDiskActionType,
|
||||||
} from '../../../types/Avatar';
|
} from '../../../types/Avatar';
|
||||||
import { isConversationMuted } from '../../../util/isConversationMuted';
|
import { isConversationMuted } from '../../../util/isConversationMuted';
|
||||||
|
import { ConversationDetailsGroups } from './ConversationDetailsGroups';
|
||||||
|
|
||||||
enum ModalState {
|
enum ModalState {
|
||||||
NothingOpen,
|
NothingOpen,
|
||||||
|
@ -60,6 +61,7 @@ export type StateProps = {
|
||||||
areWeASubscriber: boolean;
|
areWeASubscriber: boolean;
|
||||||
badges?: ReadonlyArray<BadgeType>;
|
badges?: ReadonlyArray<BadgeType>;
|
||||||
canEditGroupInfo: boolean;
|
canEditGroupInfo: boolean;
|
||||||
|
canAddNewMembers: boolean;
|
||||||
conversation?: ConversationType;
|
conversation?: ConversationType;
|
||||||
hasGroupLink: boolean;
|
hasGroupLink: boolean;
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
|
@ -68,6 +70,7 @@ export type StateProps = {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
loadRecentMediaItems: (limit: number) => void;
|
loadRecentMediaItems: (limit: number) => void;
|
||||||
|
groupsInCommon: Array<ConversationType>;
|
||||||
memberships: Array<GroupV2Membership>;
|
memberships: Array<GroupV2Membership>;
|
||||||
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
|
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
|
||||||
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
|
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
|
||||||
|
@ -112,6 +115,7 @@ type ActionProps = {
|
||||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||||
toggleSafetyNumberModal: (conversationId: string) => unknown;
|
toggleSafetyNumberModal: (conversationId: string) => unknown;
|
||||||
searchInConversation: (id: string) => unknown;
|
searchInConversation: (id: string) => unknown;
|
||||||
|
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = StateProps & ActionProps;
|
export type Props = StateProps & ActionProps;
|
||||||
|
@ -121,10 +125,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
areWeASubscriber,
|
areWeASubscriber,
|
||||||
badges,
|
badges,
|
||||||
canEditGroupInfo,
|
canEditGroupInfo,
|
||||||
|
canAddNewMembers,
|
||||||
conversation,
|
conversation,
|
||||||
deleteAvatarFromDisk,
|
deleteAvatarFromDisk,
|
||||||
hasGroupLink,
|
hasGroupLink,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
|
groupsInCommon,
|
||||||
hasActiveCall,
|
hasActiveCall,
|
||||||
i18n,
|
i18n,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
@ -155,6 +161,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
showPendingInvites,
|
showPendingInvites,
|
||||||
theme,
|
theme,
|
||||||
toggleSafetyNumberModal,
|
toggleSafetyNumberModal,
|
||||||
|
toggleAddUserToAnotherGroupModal,
|
||||||
updateGroupAttributes,
|
updateGroupAttributes,
|
||||||
userAvatarData,
|
userAvatarData,
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -454,7 +461,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
|
|
||||||
{isGroup && (
|
{isGroup && (
|
||||||
<ConversationDetailsMembershipList
|
<ConversationDetailsMembershipList
|
||||||
canAddNewMembers={canEditGroupInfo}
|
canAddNewMembers={canAddNewMembers}
|
||||||
conversationId={conversation.id}
|
conversationId={conversation.id}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -516,6 +523,15 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
showLightboxForMedia={showLightboxForMedia}
|
showLightboxForMedia={showLightboxForMedia}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!isGroup && !conversation.isMe && (
|
||||||
|
<ConversationDetailsGroups
|
||||||
|
contactId={conversation.id}
|
||||||
|
i18n={i18n}
|
||||||
|
groupsInCommon={groupsInCommon}
|
||||||
|
toggleAddUserToAnotherGroupModal={toggleAddUserToAnotherGroupModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!conversation.isMe && (
|
{!conversation.isMe && (
|
||||||
<ConversationDetailsActions
|
<ConversationDetailsActions
|
||||||
cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin}
|
cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin}
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { ConversationType } from '../../../state/ducks/conversations';
|
||||||
|
import type { LocalizerType } from '../../../types/Util';
|
||||||
|
import { Avatar } from '../../Avatar';
|
||||||
|
import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon';
|
||||||
|
import { PanelRow } from './PanelRow';
|
||||||
|
import { PanelSection } from './PanelSection';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
contactId: string;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
groupsInCommon: Array<ConversationType>;
|
||||||
|
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConversationDetailsGroups = ({
|
||||||
|
contactId,
|
||||||
|
i18n,
|
||||||
|
groupsInCommon,
|
||||||
|
toggleAddUserToAnotherGroupModal,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const [showAllGroups, setShowAllGroups] = React.useState(false);
|
||||||
|
|
||||||
|
const maxShownGroupCount = 5;
|
||||||
|
const isMoreThanMaxShown = groupsInCommon.length - maxShownGroupCount > 1;
|
||||||
|
const groupsToShow = showAllGroups
|
||||||
|
? groupsInCommon.length
|
||||||
|
: maxShownGroupCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelSection
|
||||||
|
title={i18n('ConversationDetailsGroups--title', {
|
||||||
|
number: groupsInCommon.length,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<PanelRow
|
||||||
|
icon={<div className="ConversationDetails-groups__add-to-group-icon" />}
|
||||||
|
label={i18n('ConversationDetailsGroups--add-to-group')}
|
||||||
|
onClick={() => toggleAddUserToAnotherGroupModal(contactId)}
|
||||||
|
/>
|
||||||
|
{groupsInCommon.slice(0, groupsToShow).map(group => (
|
||||||
|
<PanelRow
|
||||||
|
key={group.id}
|
||||||
|
icon={
|
||||||
|
<Avatar
|
||||||
|
conversationType="group"
|
||||||
|
badge={undefined}
|
||||||
|
i18n={i18n}
|
||||||
|
size={32}
|
||||||
|
{...group}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={group.title}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!showAllGroups && isMoreThanMaxShown && (
|
||||||
|
<PanelRow
|
||||||
|
icon={
|
||||||
|
<ConversationDetailsIcon
|
||||||
|
ariaLabel={i18n('ConversationDetailsGroups--show-all')}
|
||||||
|
icon={IconType.down}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={() => setShowAllGroups(true)}
|
||||||
|
label={i18n('ConversationDetailsGroups--show-all')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
);
|
||||||
|
};
|
|
@ -51,6 +51,7 @@ type PropsType = {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
shouldShowSpinner?: boolean;
|
shouldShowSpinner?: boolean;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
avatarSize?: AvatarSize;
|
||||||
} & Pick<
|
} & Pick<
|
||||||
ConversationType,
|
ConversationType,
|
||||||
| 'acceptedMessageRequest'
|
| 'acceptedMessageRequest'
|
||||||
|
@ -75,6 +76,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
const {
|
const {
|
||||||
acceptedMessageRequest,
|
acceptedMessageRequest,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
|
avatarSize,
|
||||||
checked,
|
checked,
|
||||||
color,
|
color,
|
||||||
conversationType,
|
conversationType,
|
||||||
|
@ -168,7 +170,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
profileName={profileName}
|
profileName={profileName}
|
||||||
title={title}
|
title={title}
|
||||||
sharedGroupNames={sharedGroupNames}
|
sharedGroupNames={sharedGroupNames}
|
||||||
size={AvatarSize.FORTY_EIGHT}
|
size={avatarSize ?? AvatarSize.FORTY_EIGHT}
|
||||||
unblurredAvatarPath={unblurredAvatarPath}
|
unblurredAvatarPath={unblurredAvatarPath}
|
||||||
// This is here to appease the type checker.
|
// This is here to appease the type checker.
|
||||||
{...(props.badge
|
{...(props.badge
|
||||||
|
|
69
ts/components/conversationList/GroupListItem.tsx
Normal file
69
ts/components/conversationList/GroupListItem.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { ConversationType } from '../../state/ducks/conversations';
|
||||||
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
|
import { AvatarSize } from '../Avatar';
|
||||||
|
import { BaseConversationListItem } from './BaseConversationListItem';
|
||||||
|
|
||||||
|
export enum DisabledReason {
|
||||||
|
AlreadyMember = 'already-member',
|
||||||
|
Pending = 'pending',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupListItemConversationType = Pick<
|
||||||
|
ConversationType,
|
||||||
|
'id' | 'title' | 'avatarPath'
|
||||||
|
> & {
|
||||||
|
disabledReason: DisabledReason | undefined;
|
||||||
|
membersCount: number;
|
||||||
|
memberships: Array<{
|
||||||
|
uuid: UUIDStringType;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
onSelectGroup: (id: string) => void;
|
||||||
|
group: GroupListItemConversationType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GroupListItem = ({
|
||||||
|
group,
|
||||||
|
i18n,
|
||||||
|
onSelectGroup,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
let messageText: string;
|
||||||
|
switch (group.disabledReason) {
|
||||||
|
case DisabledReason.AlreadyMember:
|
||||||
|
messageText = i18n('GroupListItem__message-already-member');
|
||||||
|
break;
|
||||||
|
case DisabledReason.Pending:
|
||||||
|
messageText = i18n('GroupListItem__message-pending');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
messageText = i18n('GroupListItem__message-default', {
|
||||||
|
count: group.membersCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BaseConversationListItem
|
||||||
|
disabled={group.disabledReason !== undefined}
|
||||||
|
conversationType="group"
|
||||||
|
title={group.title}
|
||||||
|
avatarSize={AvatarSize.THIRTY_SIX}
|
||||||
|
avatarPath={group.avatarPath}
|
||||||
|
acceptedMessageRequest
|
||||||
|
isMe={false}
|
||||||
|
sharedGroupNames={[]}
|
||||||
|
headerName={group.title}
|
||||||
|
i18n={i18n}
|
||||||
|
isSelected={false}
|
||||||
|
onClick={() => onSelectGroup(group.id)}
|
||||||
|
messageText={messageText}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1850,6 +1850,7 @@ export class ConversationModel extends window.Backbone
|
||||||
badges: this.get('badges') || [],
|
badges: this.get('badges') || [],
|
||||||
canChangeTimer: this.canChangeTimer(),
|
canChangeTimer: this.canChangeTimer(),
|
||||||
canEditGroupInfo: this.canEditGroupInfo(),
|
canEditGroupInfo: this.canEditGroupInfo(),
|
||||||
|
canAddNewMembers: this.canAddNewMembers(),
|
||||||
avatarPath: this.getAbsoluteAvatarPath(),
|
avatarPath: this.getAbsoluteAvatarPath(),
|
||||||
avatarHash: this.getAvatarHash(),
|
avatarHash: this.getAvatarHash(),
|
||||||
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
|
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
|
||||||
|
@ -5111,6 +5112,22 @@ export class ConversationModel extends window.Backbone
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canAddNewMembers(): boolean {
|
||||||
|
if (!isGroupV2(this.attributes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.get('left')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.areWeAdmin() ||
|
||||||
|
this.get('accessControl')?.members ===
|
||||||
|
Proto.AccessControl.AccessRequired.MEMBER
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
areWeAdmin(): boolean {
|
areWeAdmin(): boolean {
|
||||||
if (!isGroupV2(this.attributes)) {
|
if (!isGroupV2(this.attributes)) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -130,6 +130,7 @@ export type ConversationType = {
|
||||||
areWePendingApproval?: boolean;
|
areWePendingApproval?: boolean;
|
||||||
canChangeTimer?: boolean;
|
canChangeTimer?: boolean;
|
||||||
canEditGroupInfo?: boolean;
|
canEditGroupInfo?: boolean;
|
||||||
|
canAddNewMembers?: boolean;
|
||||||
color?: AvatarColorType;
|
color?: AvatarColorType;
|
||||||
conversationColor?: ConversationColorType;
|
conversationColor?: ConversationColorType;
|
||||||
customColor?: CustomColorType;
|
customColor?: CustomColorType;
|
||||||
|
@ -803,6 +804,7 @@ export type ConversationActionType =
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
addMemberToGroup,
|
||||||
cancelConversationVerification,
|
cancelConversationVerification,
|
||||||
changeHasGroupLink,
|
changeHasGroupLink,
|
||||||
clearCancelledConversationVerification,
|
clearCancelledConversationVerification,
|
||||||
|
@ -2004,6 +2006,25 @@ function removeMemberFromGroup(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addMemberToGroup(
|
||||||
|
conversationId: string,
|
||||||
|
contactId: string,
|
||||||
|
onComplete: () => void
|
||||||
|
): ThunkAction<void, RootStateType, unknown, never> {
|
||||||
|
return async () => {
|
||||||
|
const conversationModel = window.ConversationController.get(conversationId);
|
||||||
|
if (conversationModel) {
|
||||||
|
const idForLogging = conversationModel.idForLogging();
|
||||||
|
await longRunningTaskWrapper({
|
||||||
|
name: 'addMemberToGroup',
|
||||||
|
idForLogging,
|
||||||
|
task: () => conversationModel.addMembersV2([contactId]),
|
||||||
|
});
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toggleGroupsForStorySend(
|
function toggleGroupsForStorySend(
|
||||||
conversationIds: Array<string>
|
conversationIds: Array<string>
|
||||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||||
|
|
|
@ -15,23 +15,24 @@ import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
// State
|
// State
|
||||||
|
|
||||||
export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>;
|
export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>;
|
||||||
export type SafetyNumberChangedBlockingDataType = {
|
export type SafetyNumberChangedBlockingDataType = Readonly<{
|
||||||
readonly promiseUuid: UUIDStringType;
|
promiseUuid: UUIDStringType;
|
||||||
readonly source?: SafetyNumberChangeSource;
|
source?: SafetyNumberChangeSource;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export type GlobalModalsStateType = {
|
export type GlobalModalsStateType = Readonly<{
|
||||||
readonly contactModalState?: ContactModalStateType;
|
contactModalState?: ContactModalStateType;
|
||||||
readonly forwardMessageProps?: ForwardMessagePropsType;
|
forwardMessageProps?: ForwardMessagePropsType;
|
||||||
readonly isProfileEditorVisible: boolean;
|
isProfileEditorVisible: boolean;
|
||||||
readonly isSignalConnectionsVisible: boolean;
|
isSignalConnectionsVisible: boolean;
|
||||||
readonly isStoriesSettingsVisible: boolean;
|
isStoriesSettingsVisible: boolean;
|
||||||
readonly isWhatsNewVisible: boolean;
|
isWhatsNewVisible: boolean;
|
||||||
readonly profileEditorHasError: boolean;
|
profileEditorHasError: boolean;
|
||||||
readonly safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType;
|
safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType;
|
||||||
readonly safetyNumberModalContactId?: string;
|
safetyNumberModalContactId?: string;
|
||||||
readonly userNotFoundModalState?: UserNotFoundModalStateType;
|
addUserToAnotherGroupModalContactId?: string;
|
||||||
};
|
userNotFoundModalState?: UserNotFoundModalStateType;
|
||||||
|
}>;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
|
@ -49,6 +50,8 @@ const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
|
||||||
export const TOGGLE_PROFILE_EDITOR_ERROR =
|
export const TOGGLE_PROFILE_EDITOR_ERROR =
|
||||||
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
|
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
|
||||||
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
|
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
|
||||||
|
const TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL =
|
||||||
|
'globalModals/TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL';
|
||||||
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
|
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
|
||||||
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
|
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
|
||||||
export const SHOW_SEND_ANYWAY_DIALOG = 'globalModals/SHOW_SEND_ANYWAY_DIALOG';
|
export const SHOW_SEND_ANYWAY_DIALOG = 'globalModals/SHOW_SEND_ANYWAY_DIALOG';
|
||||||
|
@ -113,6 +116,11 @@ type ToggleSafetyNumberModalActionType = {
|
||||||
payload: string | undefined;
|
payload: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ToggleAddUserToAnotherGroupModalActionType = {
|
||||||
|
type: typeof TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL;
|
||||||
|
payload: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
type ToggleSignalConnectionsModalActionType = {
|
type ToggleSignalConnectionsModalActionType = {
|
||||||
type: typeof TOGGLE_SIGNAL_CONNECTIONS_MODAL;
|
type: typeof TOGGLE_SIGNAL_CONNECTIONS_MODAL;
|
||||||
};
|
};
|
||||||
|
@ -151,6 +159,7 @@ export type GlobalModalsActionType =
|
||||||
| ToggleProfileEditorActionType
|
| ToggleProfileEditorActionType
|
||||||
| ToggleProfileEditorErrorActionType
|
| ToggleProfileEditorErrorActionType
|
||||||
| ToggleSafetyNumberModalActionType
|
| ToggleSafetyNumberModalActionType
|
||||||
|
| ToggleAddUserToAnotherGroupModalActionType
|
||||||
| ToggleSignalConnectionsModalActionType;
|
| ToggleSignalConnectionsModalActionType;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
@ -170,6 +179,7 @@ export const actions = {
|
||||||
toggleProfileEditor,
|
toggleProfileEditor,
|
||||||
toggleProfileEditorHasError,
|
toggleProfileEditorHasError,
|
||||||
toggleSafetyNumberModal,
|
toggleSafetyNumberModal,
|
||||||
|
toggleAddUserToAnotherGroupModal,
|
||||||
toggleSignalConnectionsModal,
|
toggleSignalConnectionsModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -282,6 +292,15 @@ function toggleSafetyNumberModal(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAddUserToAnotherGroupModal(
|
||||||
|
contactId?: string
|
||||||
|
): ToggleAddUserToAnotherGroupModalActionType {
|
||||||
|
return {
|
||||||
|
type: TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL,
|
||||||
|
payload: contactId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSignalConnectionsModal(): ToggleSignalConnectionsModalActionType {
|
function toggleSignalConnectionsModal(): ToggleSignalConnectionsModalActionType {
|
||||||
return {
|
return {
|
||||||
type: TOGGLE_SIGNAL_CONNECTIONS_MODAL,
|
type: TOGGLE_SIGNAL_CONNECTIONS_MODAL,
|
||||||
|
@ -394,6 +413,13 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
addUserToAnotherGroupModalContactId: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (action.type === TOGGLE_FORWARD_MESSAGE_MODAL) {
|
if (action.type === TOGGLE_FORWARD_MESSAGE_MODAL) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
|
import type { ReplacementValuesType } from '../../types/Util';
|
||||||
|
|
||||||
export enum ToastType {
|
export enum ToastType {
|
||||||
Error = 'Error',
|
Error = 'Error',
|
||||||
|
@ -12,12 +13,17 @@ export enum ToastType {
|
||||||
StoryVideoError = 'StoryVideoError',
|
StoryVideoError = 'StoryVideoError',
|
||||||
StoryVideoTooLong = 'StoryVideoTooLong',
|
StoryVideoTooLong = 'StoryVideoTooLong',
|
||||||
StoryVideoUnsupported = 'StoryVideoUnsupported',
|
StoryVideoUnsupported = 'StoryVideoUnsupported',
|
||||||
|
AddingUserToGroup = 'AddingUserToGroup',
|
||||||
|
UserAddedToGroup = 'UserAddedToGroup',
|
||||||
}
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
export type ToastStateType = {
|
export type ToastStateType = {
|
||||||
toastType?: ToastType;
|
toast?: {
|
||||||
|
toastType: ToastType;
|
||||||
|
parameters?: ReplacementValuesType;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
@ -31,7 +37,10 @@ type HideToastActionType = {
|
||||||
|
|
||||||
type ShowToastActionType = {
|
type ShowToastActionType = {
|
||||||
type: typeof SHOW_TOAST;
|
type: typeof SHOW_TOAST;
|
||||||
payload: ToastType;
|
payload: {
|
||||||
|
toastType: ToastType;
|
||||||
|
parameters?: ReplacementValuesType;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToastActionType = HideToastActionType | ShowToastActionType;
|
export type ToastActionType = HideToastActionType | ShowToastActionType;
|
||||||
|
@ -45,13 +54,17 @@ function hideToast(): HideToastActionType {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShowToastActionCreatorType = (
|
export type ShowToastActionCreatorType = (
|
||||||
toastType: ToastType
|
toastType: ToastType,
|
||||||
|
parameters?: ReplacementValuesType
|
||||||
) => ShowToastActionType;
|
) => ShowToastActionType;
|
||||||
|
|
||||||
const showToast: ShowToastActionCreatorType = toastType => {
|
const showToast: ShowToastActionCreatorType = (toastType, parameters) => {
|
||||||
return {
|
return {
|
||||||
type: SHOW_TOAST,
|
type: SHOW_TOAST,
|
||||||
payload: toastType,
|
payload: {
|
||||||
|
toastType,
|
||||||
|
parameters,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -75,14 +88,14 @@ export function reducer(
|
||||||
if (action.type === HIDE_TOAST) {
|
if (action.type === HIDE_TOAST) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toastType: undefined,
|
toast: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === SHOW_TOAST) {
|
if (action.type === SHOW_TOAST) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toastType: action.payload,
|
toast: action.payload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import memoizee from 'memoizee';
|
import memoizee from 'memoizee';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber, pick } from 'lodash';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
|
@ -477,6 +477,18 @@ export const getAllComposableConversations = createSelector(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getAllGroupsWithInviteAccess = createSelector(
|
||||||
|
getConversationLookup,
|
||||||
|
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
|
||||||
|
Object.values(conversationLookup).filter(conversation => {
|
||||||
|
return (
|
||||||
|
conversation.type === 'group' &&
|
||||||
|
conversation.title &&
|
||||||
|
conversation.canAddNewMembers
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
|
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
|
||||||
* composer and group members, a different list from your primary system contacts.
|
* composer and group members, a different list from your primary system contacts.
|
||||||
|
@ -1010,6 +1022,14 @@ export const getGroupAdminsSelector = createSelector(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getContactSelector = createSelector(
|
||||||
|
getConversationSelector,
|
||||||
|
conversationSelector => {
|
||||||
|
return (contactId: string) =>
|
||||||
|
pick(conversationSelector(contactId), 'id', 'title', 'uuid');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const getConversationVerificationData = createSelector(
|
const getConversationVerificationData = createSelector(
|
||||||
getConversations,
|
getConversations,
|
||||||
(
|
(
|
||||||
|
|
37
ts/state/smart/AddUserToAnotherGroupModal.tsx
Normal file
37
ts/state/smart/AddUserToAnotherGroupModal.tsx
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { mapDispatchToProps } from '../actions';
|
||||||
|
import { AddUserToAnotherGroupModal } from '../../components/AddUserToAnotherGroupModal';
|
||||||
|
import type { StateType } from '../reducer';
|
||||||
|
import {
|
||||||
|
getAllGroupsWithInviteAccess,
|
||||||
|
getContactSelector,
|
||||||
|
} from '../selectors/conversations';
|
||||||
|
import { getIntl, getRegionCode, getTheme } from '../selectors/user';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
contactID: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: StateType, props: Props) => {
|
||||||
|
const candidateConversations = getAllGroupsWithInviteAccess(state);
|
||||||
|
const getContact = getContactSelector(state);
|
||||||
|
|
||||||
|
const regionCode = getRegionCode(state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contact: getContact(props.contactID),
|
||||||
|
i18n: getIntl(state),
|
||||||
|
theme: getTheme(state),
|
||||||
|
candidateConversations,
|
||||||
|
regionCode,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
|
export const SmartAddUserToAnotherGroupModal = smart(
|
||||||
|
AddUserToAnotherGroupModal
|
||||||
|
);
|
|
@ -91,7 +91,7 @@ const mapStateToProps = (state: StateType) => {
|
||||||
titleBarDoubleClick: (): void => {
|
titleBarDoubleClick: (): void => {
|
||||||
window.titleBarDoubleClick();
|
window.titleBarDoubleClick();
|
||||||
},
|
},
|
||||||
toastType: state.toast.toastType,
|
toast: state.toast.toast,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { sortBy } from 'lodash';
|
||||||
|
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
|
@ -11,6 +12,7 @@ import { ConversationDetails } from '../../components/conversation/conversation-
|
||||||
import {
|
import {
|
||||||
getConversationByIdSelector,
|
getConversationByIdSelector,
|
||||||
getConversationByUuidSelector,
|
getConversationByUuidSelector,
|
||||||
|
getAllComposableConversations,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import { getGroupMemberships } from '../../util/getGroupMemberships';
|
import { getGroupMemberships } from '../../util/getGroupMemberships';
|
||||||
import { getActiveCallState } from '../selectors/calling';
|
import { getActiveCallState } from '../selectors/calling';
|
||||||
|
@ -84,6 +86,7 @@ const mapStateToProps = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const canEditGroupInfo = Boolean(conversation.canEditGroupInfo);
|
const canEditGroupInfo = Boolean(conversation.canEditGroupInfo);
|
||||||
|
const canAddNewMembers = Boolean(conversation.canAddNewMembers);
|
||||||
const isAdmin = Boolean(conversation.areWeAdmin);
|
const isAdmin = Boolean(conversation.areWeAdmin);
|
||||||
|
|
||||||
const hasGroupLink =
|
const hasGroupLink =
|
||||||
|
@ -98,11 +101,25 @@ const mapStateToProps = (
|
||||||
|
|
||||||
const badges = getBadgesSelector(state)(conversation.badges);
|
const badges = getBadgesSelector(state)(conversation.badges);
|
||||||
|
|
||||||
|
const groupsInCommon =
|
||||||
|
conversation.type === 'direct'
|
||||||
|
? getAllComposableConversations(state).filter(
|
||||||
|
c =>
|
||||||
|
c.type === 'group' &&
|
||||||
|
(c.memberships ?? []).some(
|
||||||
|
member => member.uuid === conversation.uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const groupsInCommonSorted = sortBy(groupsInCommon, 'title');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...props,
|
...props,
|
||||||
areWeASubscriber: getAreWeASubscriber(state),
|
areWeASubscriber: getAreWeASubscriber(state),
|
||||||
badges,
|
badges,
|
||||||
canEditGroupInfo,
|
canEditGroupInfo,
|
||||||
|
canAddNewMembers,
|
||||||
conversation: {
|
conversation: {
|
||||||
...conversation,
|
...conversation,
|
||||||
...getConversationColorAttributes(conversation),
|
...getConversationColorAttributes(conversation),
|
||||||
|
@ -114,6 +131,7 @@ const mapStateToProps = (
|
||||||
...groupMemberships,
|
...groupMemberships,
|
||||||
userAvatarData: conversation.avatars || [],
|
userAvatarData: conversation.avatars || [],
|
||||||
hasGroupLink,
|
hasGroupLink,
|
||||||
|
groupsInCommon: groupsInCommonSorted,
|
||||||
isGroup: conversation.type === 'group',
|
isGroup: conversation.type === 'group',
|
||||||
theme: getTheme(state),
|
theme: getTheme(state),
|
||||||
renderChooseGroupMembersModal,
|
renderChooseGroupMembersModal,
|
||||||
|
|
|
@ -14,7 +14,8 @@ import { SmartStoriesSettingsModal } from './StoriesSettingsModal';
|
||||||
import { getConversationsStoppingSend } from '../selectors/conversations';
|
import { getConversationsStoppingSend } from '../selectors/conversations';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
|
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl, getTheme } from '../selectors/user';
|
||||||
|
import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
|
||||||
|
|
||||||
function renderProfileEditor(): JSX.Element {
|
function renderProfileEditor(): JSX.Element {
|
||||||
return <SmartProfileEditorModal />;
|
return <SmartProfileEditorModal />;
|
||||||
|
@ -43,6 +44,7 @@ const mapStateToProps = (state: StateType) => {
|
||||||
...state.globalModals,
|
...state.globalModals,
|
||||||
hasSafetyNumberChangeModal: getConversationsStoppingSend(state).length > 0,
|
hasSafetyNumberChangeModal: getConversationsStoppingSend(state).length > 0,
|
||||||
i18n,
|
i18n,
|
||||||
|
theme: getTheme(state),
|
||||||
renderContactModal,
|
renderContactModal,
|
||||||
renderForwardMessageModal,
|
renderForwardMessageModal,
|
||||||
renderProfileEditor,
|
renderProfileEditor,
|
||||||
|
@ -52,6 +54,15 @@ const mapStateToProps = (state: StateType) => {
|
||||||
contactID={String(state.globalModals.safetyNumberModalContactId)}
|
contactID={String(state.globalModals.safetyNumberModalContactId)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
renderAddUserToAnotherGroup: () => {
|
||||||
|
return (
|
||||||
|
<SmartAddUserToAnotherGroupModal
|
||||||
|
contactID={String(
|
||||||
|
state.globalModals.addUserToAnotherGroupModalContactId
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
renderSendAnywayDialog,
|
renderSendAnywayDialog,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue