Implemented ability to quickly add a user to a group

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

View file

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

View file

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

View file

@ -11,11 +11,12 @@ import type { LocaleMessagesType } from '../types/I18N';
import type { MenuOptionsType, MenuActionType } from '../types/menu';
import type { 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)}

View file

@ -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

View file

@ -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') {

View file

@ -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();

View file

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

View file

@ -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;

View file

@ -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

View file

@ -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'
),

View file

@ -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}

View file

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

View file

@ -51,6 +51,7 @@ type PropsType = {
onClick?: () => void;
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

View file

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