Introduce conversation details screen for New Groups

Co-authored-by: Chris Svenningsen <chris@carbonfive.com>
Co-authored-by: Sidney Keese <me@sidke.com>
This commit is contained in:
Josh Perez 2021-01-29 16:19:24 -05:00 committed by GitHub
parent 1268945840
commit c0510b08a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 4699 additions and 81 deletions

View file

@ -39,7 +39,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
draftText: overrideProps.draftText || undefined,
clearQuotedMessage: action('clearQuotedMessage'),
getQuotedMessage: action('getQuotedMessage'),
members: [],
sortedGroupMembers: [],
// EmojiButton
onPickEmoji: action('onPickEmoji'),
onSetSkinTone: action('onSetSkinTone'),

View file

@ -54,7 +54,7 @@ export type OwnProps = {
export type Props = Pick<
CompositionInputProps,
| 'members'
| 'sortedGroupMembers'
| 'onSubmit'
| 'onEditorStateChange'
| 'onTextTooLong'
@ -106,7 +106,7 @@ export const CompositionArea = ({
draftBodyRanges,
clearQuotedMessage,
getQuotedMessage,
members,
sortedGroupMembers,
// EmojiButton
onPickEmoji,
onSetSkinTone,
@ -450,7 +450,7 @@ export const CompositionArea = ({
draftBodyRanges={draftBodyRanges}
clearQuotedMessage={clearQuotedMessage}
getQuotedMessage={getQuotedMessage}
members={members}
sortedGroupMembers={sortedGroupMembers}
/>
</div>
{!large ? (

View file

@ -28,7 +28,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
getQuotedMessage: action('getQuotedMessage'),
onPickEmoji: action('onPickEmoji'),
large: boolean('large', overrideProps.large || false),
members: overrideProps.members || [],
sortedGroupMembers: overrideProps.sortedGroupMembers || [],
skinTone: select(
'skinTone',
{
@ -103,7 +103,7 @@ story.add('Emojis', () => {
story.add('Mentions', () => {
const props = createProps({
members: [
sortedGroupMembers: [
{
id: '0',
type: 'direct',

View file

@ -63,7 +63,7 @@ export type Props = {
readonly skinTone?: EmojiPickDataType['skinTone'];
readonly draftText?: string;
readonly draftBodyRanges?: Array<BodyRangeType>;
members?: Array<ConversationType>;
sortedGroupMembers?: Array<ConversationType>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(
messageText: string,
@ -92,7 +92,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
draftBodyRanges,
getQuotedMessage,
clearQuotedMessage,
members,
sortedGroupMembers,
} = props;
const [emojiCompletionElement, setEmojiCompletionElement] = React.useState<
@ -459,11 +459,11 @@ export const CompositionInput: React.ComponentType<Props> = props => {
quill.updateContents(newDelta as any);
};
const memberIds = members ? members.map(m => m.id) : [];
const memberIds = sortedGroupMembers ? sortedGroupMembers.map(m => m.id) : [];
React.useEffect(() => {
memberRepositoryRef.current.updateMembers(members || []);
removeStaleMentions(members || []);
memberRepositoryRef.current.updateMembers(sortedGroupMembers || []);
removeStaleMentions(sortedGroupMembers || []);
// We are still depending on members, but ESLint can't tell
// Comparing the actual members list does not work for a couple reasons:
// * Arrays with the same objects are not "equal" to React
@ -510,7 +510,9 @@ export const CompositionInput: React.ComponentType<Props> = props => {
skinTone,
},
mentionCompletion: {
me: members ? members.find(foo => foo.isMe) : undefined,
me: sortedGroupMembers
? sortedGroupMembers.find(foo => foo.isMe)
: undefined,
memberRepositoryRef,
setMentionPickerElement: setMentionCompletionElement,
i18n,

View file

@ -31,11 +31,13 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),
contact: overrideProps.contact || defaultContact,
i18n,
isAdmin: boolean('isAdmin', overrideProps.isAdmin || false),
isMember: boolean('isMember', overrideProps.isMember || true),
onClose: action('onClose'),
openConversation: action('openConversation'),
removeMember: action('removeMember'),
showSafetyNumber: action('showSafetyNumber'),
toggleAdmin: action('toggleAdmin'),
});
story.add('As non-admin', () => {

View file

@ -13,22 +13,26 @@ export type PropsType = {
areWeAdmin: boolean;
contact?: ConversationType;
readonly i18n: LocalizerType;
isAdmin: boolean;
isMember: boolean;
onClose: () => void;
openConversation: (conversationId: string) => void;
removeMember: (conversationId: string) => void;
showSafetyNumber: (conversationId: string) => void;
toggleAdmin: (conversationId: string) => void;
};
export const ContactModal = ({
areWeAdmin,
contact,
i18n,
isAdmin,
isMember,
onClose,
openConversation,
removeMember,
showSafetyNumber,
toggleAdmin,
}: PropsType): ReactPortal | null => {
if (!contact) {
throw new Error('Contact modal opened without a matching contact');
@ -143,16 +147,32 @@ export const ContactModal = ({
</button>
)}
{!contact.isMe && areWeAdmin && isMember && (
<button
type="button"
className="module-contact-modal__button module-contact-modal__remove-from-group"
onClick={() => removeMember(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
<>
<button
type="button"
className="module-contact-modal__button module-contact-modal__make-admin"
onClick={() => toggleAdmin(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__make-admin__bubble-icon" />
</div>
{isAdmin ? (
<span>{i18n('ContactModal--rm-admin')}</span>
) : (
<span>{i18n('ContactModal--make-admin')}</span>
)}
</button>
<button
type="button"
className="module-contact-modal__button module-contact-modal__remove-from-group"
onClick={() => removeMember(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
</>
)}
</div>
</div>

View file

@ -33,6 +33,7 @@ const commonProps = {
i18n,
onShowConversationDetails: action('onShowConversationDetails'),
onSetDisappearingMessages: action('onSetDisappearingMessages'),
onDeleteMessages: action('onDeleteMessages'),
onResetSession: action('onResetSession'),

View file

@ -33,6 +33,7 @@ export enum OutgoingCallButtonStyle {
}
export type PropsDataType = {
conversationTitle?: string;
id: string;
name?: string;
@ -51,6 +52,7 @@ export type PropsDataType = {
isMissingMandatoryProfileSharing?: boolean;
left?: boolean;
markedUnread?: boolean;
groupVersion?: number;
canChangeTimer?: boolean;
expireTimer?: number;
@ -71,6 +73,7 @@ export type PropsActionsType = {
onOutgoingVideoCallInConversation: () => void;
onSetPin: (value: boolean) => void;
onShowConversationDetails: () => void;
onShowSafetyNumber: () => void;
onShowAllMedia: () => void;
onShowGroupMembers: () => void;
@ -126,7 +129,7 @@ export class ConversationHeader extends React.Component<PropsType> {
);
}
public renderTitle(): JSX.Element {
public renderTitle(): JSX.Element | null {
const {
name,
phoneNumber,
@ -352,11 +355,13 @@ export class ConversationHeader extends React.Component<PropsType> {
muteExpiresAt,
isMissingMandatoryProfileSharing,
left,
groupVersion,
onDeleteMessages,
onResetSession,
onSetDisappearingMessages,
onSetMuteNotifications,
onShowAllMedia,
onShowConversationDetails,
onShowGroupMembers,
onShowSafetyNumber,
onArchive,
@ -401,6 +406,11 @@ export class ConversationHeader extends React.Component<PropsType> {
isMissingMandatoryProfileSharing
);
const hasGV2AdminEnabled =
isGroup &&
groupVersion === 2 &&
window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin');
return (
<ContextMenu id={triggerId}>
{disableTimerChanges ? null : (
@ -430,7 +440,12 @@ export class ConversationHeader extends React.Component<PropsType> {
</MenuItem>
))}
</SubMenu>
{isGroup ? (
{hasGV2AdminEnabled ? (
<MenuItem onClick={onShowConversationDetails}>
{i18n('showConversationDetails')}
</MenuItem>
) : null}
{isGroup && !hasGV2AdminEnabled ? (
<MenuItem onClick={onShowGroupMembers}>
{i18n('showMembers')}
</MenuItem>
@ -470,7 +485,23 @@ export class ConversationHeader extends React.Component<PropsType> {
}
private renderHeader(): JSX.Element {
const { id, isMe, onShowContactModal, type } = this.props;
const {
conversationTitle,
id,
isMe,
onShowContactModal,
type,
} = this.props;
if (conversationTitle) {
return (
<div className="module-conversation-header__title-flex">
<div className="module-conversation-header__title">
{conversationTitle}
</div>
</div>
);
}
if (type === 'group' || isMe) {
return (

View file

@ -0,0 +1,87 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { ConversationDetails, Props } from './ConversationDetails';
import { ConversationType } from '../../../state/ducks/conversations';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/ConversationDetails',
module
);
const conversation: ConversationType = {
id: '',
lastUpdated: 0,
markedUnread: false,
memberships: Array.from(Array(32)).map(() => ({
isAdmin: false,
member: getDefaultConversation({}),
metadata: {
conversationId: '',
joinedAtVersion: 0,
role: 2,
},
})),
pendingMemberships: Array.from(Array(16)).map(() => ({
member: getDefaultConversation({}),
metadata: {
conversationId: '',
role: 2,
timestamp: Date.now(),
},
})),
title: 'Some Conversation',
type: 'group',
};
const createProps = (hasGroupLink = false): Props => ({
canEditGroupInfo: false,
conversation,
hasGroupLink,
i18n,
isAdmin: false,
loadRecentMediaItems: action('loadRecentMediaItems'),
setDisappearingMessages: action('setDisappearingMessages'),
showAllMedia: action('showAllMedia'),
showContactModal: action('showContactModal'),
showGroupLinkManagement: action('showGroupLinkManagement'),
showGroupV2Permissions: action('showGroupV2Permissions'),
showPendingInvites: action('showPendingInvites'),
showLightboxForMedia: action('showLightboxForMedia'),
onBlockAndDelete: action('onBlockAndDelete'),
onDelete: action('onDelete'),
});
story.add('Basic', () => {
const props = createProps();
return <ConversationDetails {...props} />;
});
story.add('as Admin', () => {
const props = createProps();
return <ConversationDetails {...props} isAdmin />;
});
story.add('Group Editable', () => {
const props = createProps();
return <ConversationDetails {...props} canEditGroupInfo />;
});
story.add('Group Links On', () => {
const props = createProps(true);
return <ConversationDetails {...props} isAdmin />;
});

View file

@ -0,0 +1,176 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { ConversationType } from '../../../state/ducks/conversations';
import {
ExpirationTimerOptions,
TimerOption,
} from '../../../util/ExpirationTimerOptions';
import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
import { ConversationDetailsActions } from './ConversationDetailsActions';
import { ConversationDetailsHeader } from './ConversationDetailsHeader';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
export type StateProps = {
canEditGroupInfo: boolean;
conversation?: ConversationType;
hasGroupLink: boolean;
i18n: LocalizerType;
isAdmin: boolean;
loadRecentMediaItems: (limit: number) => void;
setDisappearingMessages: (seconds: number) => void;
showAllMedia: () => void;
showContactModal: (conversationId: string) => void;
showGroupLinkManagement: () => void;
showGroupV2Permissions: () => void;
showPendingInvites: () => void;
showLightboxForMedia: (
selectedMediaItem: MediaItemType,
media: Array<MediaItemType>
) => void;
onBlockAndDelete: () => void;
onDelete: () => void;
};
export type Props = StateProps;
export const ConversationDetails: React.ComponentType<Props> = ({
canEditGroupInfo,
conversation,
hasGroupLink,
i18n,
isAdmin,
loadRecentMediaItems,
setDisappearingMessages,
showAllMedia,
showContactModal,
showGroupLinkManagement,
showGroupV2Permissions,
showPendingInvites,
showLightboxForMedia,
onBlockAndDelete,
onDelete,
}) => {
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
setDisappearingMessages(parseInt(event.target.value, 10));
};
if (conversation === undefined) {
throw new Error('ConversationDetails rendered without a conversation');
}
const pendingMemberships = conversation.pendingMemberships || [];
const pendingApprovalMemberships =
conversation.pendingApprovalMemberships || [];
const invitesCount =
pendingMemberships.length + pendingApprovalMemberships.length;
return (
<div className="conversation-details-panel">
<ConversationDetailsHeader i18n={i18n} conversation={conversation} />
{canEditGroupInfo ? (
<PanelSection>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n(
'ConversationDetails--disappearing-messages-label'
)}
icon="timer"
/>
}
info={i18n('ConversationDetails--disappearing-messages-info')}
label={i18n('ConversationDetails--disappearing-messages-label')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateExpireTimer}
value={conversation.expireTimer || 0}
>
{ExpirationTimerOptions.map((item: typeof TimerOption) => (
<option
value={item.get('seconds')}
key={item.get('seconds')}
aria-label={item.getName(i18n)}
>
{item.getName(i18n)}
</option>
))}
</select>
</div>
}
/>
</PanelSection>
) : null}
<ConversationDetailsMembershipList
i18n={i18n}
showContactModal={showContactModal}
memberships={conversation.memberships || []}
/>
<PanelSection>
{isAdmin ? (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetails--group-link')}
icon="link"
/>
}
label={i18n('ConversationDetails--group-link')}
onClick={showGroupLinkManagement}
right={hasGroupLink ? i18n('on') : i18n('off')}
/>
) : null}
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetails--requests-and-invites')}
icon="invites"
/>
}
label={i18n('ConversationDetails--requests-and-invites')}
onClick={showPendingInvites}
right={invitesCount}
/>
{isAdmin ? (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('permissions')}
icon="lock"
/>
}
label={i18n('permissions')}
onClick={showGroupV2Permissions}
/>
) : null}
</PanelSection>
<ConversationDetailsMediaList
conversation={conversation}
i18n={i18n}
loadRecentMediaItems={loadRecentMediaItems}
showAllMedia={showAllMedia}
showLightboxForMedia={showLightboxForMedia}
/>
<ConversationDetailsActions
i18n={i18n}
conversationTitle={conversation.title}
onDelete={onDelete}
onBlockAndDelete={onBlockAndDelete}
/>
</div>
);
};

View file

@ -0,0 +1,34 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import {
ConversationDetailsActions,
Props,
} from './ConversationDetailsActions';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/ConversationDetailsActions',
module
);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationTitle: overrideProps.conversationTitle || '',
onBlockAndDelete: action('onBlockAndDelete'),
onDelete: action('onDelete'),
i18n,
});
story.add('Basic', () => {
const props = createProps();
return <ConversationDetailsActions {...props} />;
});

View file

@ -0,0 +1,95 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { LocalizerType } from '../../../types/Util';
import { ConfirmationModal } from '../../ConfirmationModal';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
export type Props = {
conversationTitle: string;
onBlockAndDelete: () => void;
onDelete: () => void;
i18n: LocalizerType;
};
export const ConversationDetailsActions: React.ComponentType<Props> = ({
conversationTitle,
onBlockAndDelete,
onDelete,
i18n,
}) => {
const [confirmingLeave, setConfirmingLeave] = React.useState<boolean>(false);
const [confirmingBlock, setConfirmingBlock] = React.useState<boolean>(false);
return (
<>
<PanelSection>
<PanelRow
onClick={() => setConfirmingLeave(true)}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsActions--leave-group')}
icon="leave"
/>
}
label={i18n('ConversationDetailsActions--leave-group')}
/>
<PanelRow
onClick={() => setConfirmingBlock(true)}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsActions--block-group')}
icon="block"
/>
}
label={i18n('ConversationDetailsActions--block-group')}
/>
</PanelSection>
{confirmingLeave && (
<ConfirmationModal
actions={[
{
text: i18n(
'ConversationDetailsActions--leave-group-modal-confirm'
),
action: onDelete,
style: 'affirmative',
},
]}
i18n={i18n}
onClose={() => setConfirmingLeave(false)}
title={i18n('ConversationDetailsActions--leave-group-modal-title')}
>
{i18n('ConversationDetailsActions--leave-group-modal-content')}
</ConfirmationModal>
)}
{confirmingBlock && (
<ConfirmationModal
actions={[
{
text: i18n(
'ConversationDetailsActions--block-group-modal-confirm'
),
action: onBlockAndDelete,
style: 'affirmative',
},
]}
i18n={i18n}
onClose={() => setConfirmingBlock(false)}
title={i18n('ConversationDetailsActions--block-group-modal-title', [
conversationTitle,
])}
>
{i18n('ConversationDetailsActions--block-group-modal-content')}
</ConfirmationModal>
)}
</>
);
};

View file

@ -0,0 +1,40 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { number, text } from '@storybook/addon-knobs';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { ConversationType } from '../../../state/ducks/conversations';
import { ConversationDetailsHeader, Props } from './ConversationDetailsHeader';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/ConversationDetailHeader',
module
);
const createConversation = (): ConversationType => ({
id: '',
markedUnread: false,
type: 'group',
lastUpdated: 0,
title: text('conversation title', 'Some Conversation'),
memberships: new Array(number('conversation members length', 0)),
});
const createProps = (): Props => ({
conversation: createConversation(),
i18n,
});
story.add('Basic', () => {
const props = createProps();
return <ConversationDetailsHeader {...props} />;
});

View file

@ -0,0 +1,42 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Avatar } from '../../Avatar';
import { LocalizerType } from '../../../types/Util';
import { ConversationType } from '../../../state/ducks/conversations';
import { bemGenerator } from './util';
export type Props = {
i18n: LocalizerType;
conversation: ConversationType;
};
const bem = bemGenerator('module-conversation-details-header');
export const ConversationDetailsHeader: React.ComponentType<Props> = ({
i18n,
conversation,
}) => {
const memberships = conversation.memberships || [];
return (
<div className={bem('root')}>
<Avatar
conversationType="group"
i18n={i18n}
size={80}
{...conversation}
/>
<div>
<div className={bem('title')}>{conversation.title}</div>
<div className={bem('subtitle')}>
{i18n('ConversationDetailsHeader--members', [
memberships.length.toString(),
])}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,38 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { ConversationDetailsIcon, Props } from './ConversationDetailsIcon';
const story = storiesOf(
'Components/Conversation/ConversationDetails/ConversationDetailIcon',
module
);
const createProps = (overrideProps: Partial<Props>): Props => ({
ariaLabel: overrideProps.ariaLabel || '',
icon: overrideProps.icon || '',
onClick: overrideProps.onClick,
});
story.add('All', () => {
const icons = ['timer', 'trash', 'invites', 'block', 'leave', 'down'];
return icons.map(icon => (
<ConversationDetailsIcon {...createProps({ icon })} />
));
});
story.add('Clickable Icons', () => {
const icons = ['timer', 'trash', 'invites', 'block', 'leave', 'down'];
const onClick = action('onClick');
return icons.map(icon => (
<ConversationDetailsIcon {...createProps({ icon, onClick })} />
));
});

View file

@ -0,0 +1,37 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { bemGenerator } from './util';
export type Props = {
ariaLabel: string;
icon: string;
onClick?: () => void;
};
const bem = bemGenerator('module-conversation-details-icon');
export const ConversationDetailsIcon: React.ComponentType<Props> = ({
ariaLabel,
icon,
onClick,
}) => {
const content = <div className={bem('icon', icon)} />;
if (onClick) {
return (
<button
aria-label={ariaLabel}
className={bem('button')}
type="button"
onClick={onClick}
>
{content}
</button>
);
}
return content;
};

View file

@ -0,0 +1,45 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import {
ConversationDetailsMediaList,
Props,
} from './ConversationDetailsMediaList';
import {
createPreparedMediaItems,
createRandomMedia,
} from '../media-gallery/AttachmentSection.stories';
import { MediaItemType } from '../../LightboxGallery';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/ConversationMediaList',
module
);
const createProps = (mediaItems?: Array<MediaItemType>): Props => ({
conversation: getDefaultConversation({
recentMediaItems: mediaItems || [],
}),
i18n,
loadRecentMediaItems: action('loadRecentMediaItems'),
showAllMedia: action('showAllMedia'),
showLightboxForMedia: action('showLightboxForMedia'),
});
story.add('Basic', () => {
const mediaItems = createPreparedMediaItems(createRandomMedia);
const props = createProps(mediaItems);
return <ConversationDetailsMediaList {...props} />;
});

View file

@ -0,0 +1,73 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { ConversationType } from '../../../state/ducks/conversations';
import { PanelSection } from './PanelSection';
import { bemGenerator } from './util';
import { MediaGridItem } from '../media-gallery/MediaGridItem';
export type Props = {
conversation: ConversationType;
i18n: LocalizerType;
loadRecentMediaItems: (limit: number) => void;
showAllMedia: () => void;
showLightboxForMedia: (
selectedMediaItem: MediaItemType,
media: Array<MediaItemType>
) => void;
};
const MEDIA_ITEM_LIMIT = 6;
const bem = bemGenerator('module-conversation-details-media-list');
export const ConversationDetailsMediaList: React.ComponentType<Props> = ({
conversation,
i18n,
loadRecentMediaItems,
showAllMedia,
showLightboxForMedia,
}) => {
const mediaItems = conversation.recentMediaItems || [];
React.useEffect(() => {
loadRecentMediaItems(MEDIA_ITEM_LIMIT);
}, [loadRecentMediaItems]);
if (mediaItems.length === 0) {
return null;
}
return (
<PanelSection
actions={
<button
className={bem('show-all')}
onClick={showAllMedia}
type="button"
>
{i18n('ConversationDetailsMediaList--show-all')}
</button>
}
borderless
title={i18n('ConversationDetailsMediaList--shared-media')}
>
<div className={bem('root')}>
{mediaItems.slice(0, MEDIA_ITEM_LIMIT).map(mediaItem => (
<MediaGridItem
key={`${mediaItem.message.id}-${mediaItem.index}`}
mediaItem={mediaItem}
i18n={i18n}
onClick={() => showLightboxForMedia(mediaItem, mediaItems)}
/>
))}
</div>
</PanelSection>
);
};

View file

@ -0,0 +1,76 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { number } from '@storybook/addon-knobs';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import {
ConversationDetailsMembershipList,
Props,
GroupV2Membership,
} from './ConversationDetailsMembershipList';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/ConversationDetailsMembershipList',
module
);
const createMemberships = (
numberOfMemberships = 10
): Array<GroupV2Membership> => {
return Array.from(
new Array(number('number of memberships', numberOfMemberships))
).map(
(_, i): GroupV2Membership => ({
isAdmin: i % 3 === 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata: {} as any,
member: getDefaultConversation({}),
})
);
};
const createProps = (overrideProps: Partial<Props>): Props => ({
i18n,
showContactModal: action('showContactModal'),
memberships: overrideProps.memberships || [],
});
story.add('Basic', () => {
const memberships = createMemberships(10);
const props = createProps({ memberships });
return <ConversationDetailsMembershipList {...props} />;
});
story.add('Few', () => {
const memberships = createMemberships(3);
const props = createProps({ memberships });
return <ConversationDetailsMembershipList {...props} />;
});
story.add('Many', () => {
const memberships = createMemberships(100);
const props = createProps({ memberships });
return <ConversationDetailsMembershipList {...props} />;
});
story.add('None', () => {
const props = createProps({ memberships: [] });
return <ConversationDetailsMembershipList {...props} />;
});

View file

@ -0,0 +1,76 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { LocalizerType } from '../../../types/Util';
import { Avatar } from '../../Avatar';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import { ConversationType } from '../../../state/ducks/conversations';
import { GroupV2MemberType } from '../../../model-types.d';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
export type GroupV2Membership = {
isAdmin: boolean;
metadata: GroupV2MemberType;
member: ConversationType;
};
export type Props = {
memberships: Array<GroupV2Membership>;
showContactModal: (conversationId: string) => void;
i18n: LocalizerType;
};
const INITIAL_MEMBER_COUNT = 5;
export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
memberships,
showContactModal,
i18n,
}) => {
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
return (
<PanelSection
title={i18n('ConversationDetailsMembershipList--title', [
memberships.length.toString(),
])}
>
{memberships
.slice(0, showAllMembers ? undefined : INITIAL_MEMBER_COUNT)
.map(({ isAdmin, member }) => (
<PanelRow
key={member.id}
onClick={() => showContactModal(member.id)}
icon={
<Avatar
conversationType="direct"
i18n={i18n}
size={32}
{...member}
/>
}
label={member.title}
right={isAdmin ? i18n('GroupV2--admin') : ''}
/>
))}
{showAllMembers === false &&
memberships.length > INITIAL_MEMBER_COUNT && (
<PanelRow
className="module-conversation-details-membership-list--show-all"
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsMembershipList--show-all')}
icon="down"
/>
}
onClick={() => setShowAllMembers(true)}
label={i18n('ConversationDetailsMembershipList--show-all')}
/>
)}
</PanelSection>
);
};

View file

@ -0,0 +1,86 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { GroupLinkManagement, PropsType } from './GroupLinkManagement';
import { ConversationType } from '../../../state/ducks/conversations';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/GroupLinkManagement',
module
);
class AccessEnum {
static ANY = 0;
static UNKNOWN = 1;
static MEMBER = 2;
static ADMINISTRATOR = 3;
static UNSATISFIABLE = 4;
}
function getConversation(
groupLink?: string,
accessControlAddFromInviteLink?: number
): ConversationType {
return {
id: '',
lastUpdated: 0,
markedUnread: false,
memberships: Array(32).fill({ member: getDefaultConversation({}) }),
pendingMemberships: Array(16).fill({ member: getDefaultConversation({}) }),
title: 'Some Conversation',
type: 'group',
groupLink,
accessControlAddFromInviteLink:
accessControlAddFromInviteLink !== undefined
? accessControlAddFromInviteLink
: AccessEnum.UNSATISFIABLE,
};
}
const createProps = (conversation?: ConversationType): PropsType => ({
accessEnum: AccessEnum,
changeHasGroupLink: action('changeHasGroupLink'),
conversation: conversation || getConversation(),
copyGroupLink: action('copyGroupLink'),
generateNewGroupLink: action('generateNewGroupLink'),
i18n,
setAccessControlAddFromInviteLinkSetting: action(
'setAccessControlAddFromInviteLinkSetting'
),
});
story.add('Off', () => {
const props = createProps();
return <GroupLinkManagement {...props} />;
});
story.add('On', () => {
const props = createProps(
getConversation('https://signal.group/1', AccessEnum.ANY)
);
return <GroupLinkManagement {...props} />;
});
story.add('On (Admin Approval Needed)', () => {
const props = createProps(
getConversation('https://signal.group/1', AccessEnum.ADMINISTRATOR)
);
return <GroupLinkManagement {...props} />;
});

View file

@ -0,0 +1,130 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import { ConversationType } from '../../../state/ducks/conversations';
import { LocalizerType } from '../../../types/Util';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
import { AccessControlClass } from '../../../textsecure.d';
export type PropsType = {
accessEnum: typeof AccessControlClass.AccessRequired;
changeHasGroupLink: (value: boolean) => void;
conversation?: ConversationType;
copyGroupLink: (groupLink: string) => void;
generateNewGroupLink: () => void;
i18n: LocalizerType;
setAccessControlAddFromInviteLinkSetting: (value: boolean) => void;
};
export const GroupLinkManagement: React.ComponentType<PropsType> = ({
accessEnum,
changeHasGroupLink,
conversation,
copyGroupLink,
generateNewGroupLink,
i18n,
setAccessControlAddFromInviteLinkSetting,
}) => {
if (conversation === undefined) {
throw new Error('GroupLinkManagement rendered without a conversation');
}
const createEventHandler = (handleEvent: (x: boolean) => void) => {
return (event: React.ChangeEvent<HTMLSelectElement>) => {
handleEvent(event.target.value === 'true');
};
};
const membersNeedAdminApproval =
conversation.accessControlAddFromInviteLink === accessEnum.ADMINISTRATOR;
const hasGroupLink =
conversation.groupLink &&
conversation.accessControlAddFromInviteLink !== accessEnum.UNSATISFIABLE;
const groupLinkInfo = hasGroupLink ? conversation.groupLink : '';
return (
<>
<PanelSection>
<PanelRow
info={groupLinkInfo}
label={i18n('ConversationDetails--group-link')}
right={
<div className="module-conversation-details-select">
<select
onChange={createEventHandler(changeHasGroupLink)}
value={String(Boolean(hasGroupLink))}
>
<option value="true" aria-label={i18n('on')}>
{i18n('on')}
</option>
<option value="false" aria-label={i18n('off')}>
{i18n('off')}
</option>
</select>
</div>
}
/>
</PanelSection>
{hasGroupLink ? (
<>
<PanelSection>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('GroupLinkManagement--share')}
icon="share"
/>
}
label={i18n('GroupLinkManagement--share')}
onClick={() => {
if (conversation.groupLink) {
copyGroupLink(conversation.groupLink);
}
}}
/>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('GroupLinkManagement--reset')}
icon="reset"
/>
}
label={i18n('GroupLinkManagement--reset')}
onClick={generateNewGroupLink}
/>
</PanelSection>
<PanelSection>
<PanelRow
info={i18n('GroupLinkManagement--approve-info')}
label={i18n('GroupLinkManagement--approve-label')}
right={
<div className="module-conversation-details-select">
<select
onChange={createEventHandler(
setAccessControlAddFromInviteLinkSetting
)}
value={String(membersNeedAdminApproval)}
>
<option value="true" aria-label={i18n('on')}>
{i18n('on')}
</option>
<option value="false" aria-label={i18n('off')}>
{i18n('off')}
</option>
</select>
</div>
}
/>
</PanelSection>
</>
) : null}
</>
);
};

View file

@ -0,0 +1,58 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { GroupV2Permissions, PropsType } from './GroupV2Permissions';
import { ConversationType } from '../../../state/ducks/conversations';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/GroupV2Permissions',
module
);
const conversation: ConversationType = {
id: '',
lastUpdated: 0,
markedUnread: false,
memberships: Array(32).fill({ member: getDefaultConversation({}) }),
pendingMemberships: Array(16).fill({ member: getDefaultConversation({}) }),
title: 'Some Conversation',
type: 'group',
};
class AccessEnum {
static ANY = 0;
static UNKNOWN = 1;
static MEMBER = 2;
static ADMINISTRATOR = 3;
static UNSATISFIABLE = 4;
}
const createProps = (): PropsType => ({
accessEnum: AccessEnum,
conversation,
i18n,
setAccessControlAttributesSetting: action(
'setAccessControlAttributesSetting'
),
setAccessControlMembersSetting: action('setAccessControlMembersSetting'),
});
story.add('Basic', () => {
const props = createProps();
return <GroupV2Permissions {...props} />;
});

View file

@ -0,0 +1,85 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { ConversationType } from '../../../state/ducks/conversations';
import { LocalizerType } from '../../../types/Util';
import { getAccessControlOptions } from '../../../util/getAccessControlOptions';
import { AccessControlClass } from '../../../textsecure.d';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
export type PropsType = {
accessEnum: typeof AccessControlClass.AccessRequired;
conversation?: ConversationType;
i18n: LocalizerType;
setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void;
};
export const GroupV2Permissions: React.ComponentType<PropsType> = ({
accessEnum,
conversation,
i18n,
setAccessControlAttributesSetting,
setAccessControlMembersSetting,
}) => {
if (conversation === undefined) {
throw new Error('GroupV2Permissions rendered without a conversation');
}
const updateAccessControlAttributes = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
setAccessControlAttributesSetting(Number(event.target.value));
};
const updateAccessControlMembers = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
setAccessControlMembersSetting(Number(event.target.value));
};
const accessControlOptions = getAccessControlOptions(accessEnum, i18n);
return (
<PanelSection>
<PanelRow
label={i18n('ConversationDetails--group-info-label')}
info={i18n('ConversationDetails--group-info-info')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateAccessControlAttributes}
value={conversation.accessControlAttributes}
>
{accessControlOptions.map(({ name, value }) => (
<option aria-label={name} key={name} value={value}>
{name}
</option>
))}
</select>
</div>
}
/>
<PanelRow
label={i18n('ConversationDetails--add-members-label')}
info={i18n('ConversationDetails--add-members-info')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateAccessControlMembers}
value={conversation.accessControlMembers}
>
{accessControlOptions.map(({ name, value }) => (
<option aria-label={name} key={name} value={value}>
{name}
</option>
))}
</select>
</div>
}
/>
</PanelSection>
);
};

View file

@ -0,0 +1,76 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import { PanelRow, Props } from './PanelRow';
const story = storiesOf(
'Components/Conversation/ConversationDetails/PanelRow',
module
);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
icon: boolean('with icon', overrideProps.icon !== undefined) ? (
<ConversationDetailsIcon ariaLabel="timer" icon="timer" />
) : null,
label: text('label', overrideProps.label || ''),
info: text('info', overrideProps.info || ''),
right: text('right', (overrideProps.right as string) || ''),
actions: boolean('with action', overrideProps.actions !== undefined) ? (
<ConversationDetailsIcon
ariaLabel="trash"
icon="trash"
onClick={action('action onClick')}
/>
) : null,
onClick: boolean('clickable', overrideProps.onClick !== undefined)
? overrideProps.onClick || action('onClick')
: undefined,
});
story.add('Basic', () => {
const props = createProps({
label: 'this is a panel row',
});
return <PanelRow {...props} />;
});
story.add('Simple', () => {
const props = createProps({
label: 'this is a panel row',
icon: 'with icon',
right: 'side text',
});
return <PanelRow {...props} />;
});
story.add('Full', () => {
const props = createProps({
label: 'this is a panel row',
icon: 'with icon',
info: 'this is some info that exists below the main label',
right: 'side text',
actions: 'with action',
});
return <PanelRow {...props} />;
});
story.add('Button', () => {
const props = createProps({
label: 'this is a panel row',
icon: 'with icon',
right: 'side text',
onClick: action('onClick'),
});
return <PanelRow {...props} />;
});

View file

@ -0,0 +1,58 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { bemGenerator } from './util';
export type Props = {
alwaysShowActions?: boolean;
className?: string;
icon?: React.ReactNode;
label: string;
info?: string;
right?: string | React.ReactNode;
actions?: React.ReactNode;
onClick?: () => void;
};
const bem = bemGenerator('module-conversation-details-panel-row');
export const PanelRow: React.ComponentType<Props> = ({
alwaysShowActions,
className,
icon,
label,
info,
right,
actions,
onClick,
}) => {
const content = (
<>
{icon && <div className={bem('icon')}>{icon}</div>}
<div className={bem('label')}>
<div>{label}</div>
{info && <div className={bem('info')}>{info}</div>}
</div>
{right && <div className={bem('right')}>{right}</div>}
{actions && (
<div className={alwaysShowActions ? '' : bem('actions')}>{actions}</div>
)}
</>
);
if (onClick) {
return (
<button
type="button"
className={classNames(bem('root', 'button'), className)}
onClick={onClick}
>
{content}
</button>
);
}
return <div className={classNames(bem('root'), className)}>{content}</div>;
};

View file

@ -0,0 +1,71 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { PanelSection, Props } from './PanelSection';
import { PanelRow } from './PanelRow';
const story = storiesOf(
'Components/Conversation/ConversationDetails/PanelSection',
module
);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
title: text('label', overrideProps.title || ''),
centerTitle: boolean('centerTitle', overrideProps.centerTitle || false),
actions: boolean('with action', overrideProps.actions !== undefined) ? (
<button onClick={action('actions onClick')} type="button">
action
</button>
) : null,
});
story.add('Basic', () => {
const props = createProps({
title: 'panel section header',
});
return <PanelSection {...props} />;
});
story.add('Centered', () => {
const props = createProps({
title: 'this is a panel row',
centerTitle: true,
});
return <PanelSection {...props} />;
});
story.add('With Actions', () => {
const props = createProps({
title: 'this is a panel row',
actions: (
<button onClick={action('actions onClick')} type="button">
action
</button>
),
});
return <PanelSection {...props} />;
});
story.add('With Content', () => {
const props = createProps({
title: 'this is a panel row',
});
return (
<PanelSection {...props}>
<PanelRow label="this is panel row one" />
<PanelRow label="this is panel row two" />
<PanelRow label="this is panel row three" />
<PanelRow label="this is panel row four" />
</PanelSection>
);
});

View file

@ -0,0 +1,34 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { bemGenerator } from './util';
export type Props = {
actions?: React.ReactNode;
borderless?: boolean;
centerTitle?: boolean;
title?: string;
};
const bem = bemGenerator('module-conversation-details-panel-section');
const borderlessClass = bem('root', 'borderless');
export const PanelSection: React.ComponentType<Props> = ({
actions,
borderless,
centerTitle,
children,
title,
}) => (
<div className={classNames(bem('root'), borderless ? borderlessClass : null)}>
{(title || actions) && (
<div className={bem('header', { center: centerTitle || false })}>
{title && <div className={bem('title')}>{title}</div>}
{actions && <div>{actions}</div>}
</div>
)}
<div>{children}</div>
</div>
);

View file

@ -0,0 +1,87 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { PendingInvites, PropsType } from './PendingInvites';
import { ConversationType } from '../../../state/ducks/conversations';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/PendingInvites',
module
);
const sortedGroupMembers = Array.from(Array(32)).map((_, i) =>
i === 0
? getDefaultConversation({ id: 'def456' })
: getDefaultConversation({})
);
const conversation: ConversationType = {
areWeAdmin: true,
id: '',
lastUpdated: 0,
markedUnread: false,
memberships: sortedGroupMembers.map(member => ({
isAdmin: false,
member,
metadata: {
conversationId: 'abc123',
joinedAtVersion: 1,
role: 1,
},
})),
pendingMemberships: Array.from(Array(4))
.map(() => ({
member: getDefaultConversation({}),
metadata: {
addedByUserId: 'abc123',
conversationId: 'xyz789',
role: 1,
timestamp: Date.now(),
},
}))
.concat(
Array.from(Array(8)).map(() => ({
member: getDefaultConversation({}),
metadata: {
addedByUserId: 'def456',
conversationId: 'xyz789',
role: 1,
timestamp: Date.now(),
},
}))
),
pendingApprovalMemberships: Array.from(Array(5)).map(() => ({
member: getDefaultConversation({}),
metadata: {
conversationId: 'xyz789',
timestamp: Date.now(),
},
})),
sortedGroupMembers,
title: 'Some Conversation',
type: 'group',
};
const createProps = (): PropsType => ({
approvePendingMembership: action('approvePendingMembership'),
conversation,
i18n,
ourConversationId: 'abc123',
revokePendingMemberships: action('revokePendingMemberships'),
});
story.add('Basic', () => {
const props = createProps();
return <PendingInvites {...props} />;
});

View file

@ -0,0 +1,477 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import _ from 'lodash';
import { ConversationType } from '../../../state/ducks/conversations';
import { LocalizerType } from '../../../types/Util';
import { Avatar } from '../../Avatar';
import { ConfirmationModal } from '../../ConfirmationModal';
import { PanelSection } from './PanelSection';
import { PanelRow } from './PanelRow';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import {
GroupV2PendingAdminApprovalType,
GroupV2PendingMemberType,
} from '../../../model-types.d';
export type PropsType = {
conversation?: ConversationType;
readonly i18n: LocalizerType;
ourConversationId?: string;
readonly approvePendingMembership: (conversationId: string) => void;
readonly revokePendingMemberships: (conversationIds: Array<string>) => void;
};
export type GroupV2PendingMembership = {
metadata: GroupV2PendingMemberType;
member: ConversationType;
};
export type GroupV2RequestingMembership = {
metadata: GroupV2PendingAdminApprovalType;
member: ConversationType;
};
enum Tab {
Requests = 'Requests',
Pending = 'Pending',
}
enum StageType {
APPROVE_REQUEST = 'APPROVE_REQUEST',
DENY_REQUEST = 'DENY_REQUEST',
REVOKE_INVITE = 'REVOKE_INVITE',
}
type StagedMembershipType = {
type: StageType;
membership: GroupV2PendingMembership | GroupV2RequestingMembership;
};
export const PendingInvites: React.ComponentType<PropsType> = ({
approvePendingMembership,
conversation,
i18n,
ourConversationId,
revokePendingMemberships,
}) => {
if (!conversation || !ourConversationId) {
throw new Error(
'PendingInvites rendered without a conversation or ourConversationId'
);
}
const [selectedTab, setSelectedTab] = React.useState(Tab.Requests);
const [stagedMemberships, setStagedMemberships] = React.useState<Array<
StagedMembershipType
> | null>(null);
const allPendingMemberships = conversation.pendingMemberships || [];
const allRequestingMemberships =
conversation.pendingApprovalMemberships || [];
return (
<div className="conversation-details-panel">
<div className="module-conversation-details__tabs">
<div
className={classNames({
'module-conversation-details__tab': true,
'module-conversation-details__tab--selected':
selectedTab === Tab.Requests,
})}
onClick={() => {
setSelectedTab(Tab.Requests);
}}
onKeyUp={(e: React.KeyboardEvent) => {
if (e.target === e.currentTarget && e.keyCode === 13) {
setSelectedTab(Tab.Requests);
}
}}
role="tab"
tabIndex={0}
>
{i18n('PendingInvites--tab-requests', {
count: String(allRequestingMemberships.length),
})}
</div>
<div
className={classNames({
'module-conversation-details__tab': true,
'module-conversation-details__tab--selected':
selectedTab === Tab.Pending,
})}
onClick={() => {
setSelectedTab(Tab.Pending);
}}
onKeyUp={(e: React.KeyboardEvent) => {
if (e.target === e.currentTarget && e.keyCode === 13) {
setSelectedTab(Tab.Pending);
}
}}
role="tab"
tabIndex={0}
>
{i18n('PendingInvites--tab-invites', {
count: String(allPendingMemberships.length),
})}
</div>
</div>
{selectedTab === Tab.Requests ? (
<MembersPendingAdminApproval
conversation={conversation}
i18n={i18n}
memberships={allRequestingMemberships}
setStagedMemberships={setStagedMemberships}
/>
) : null}
{selectedTab === Tab.Pending ? (
<MembersPendingProfileKey
conversation={conversation}
i18n={i18n}
members={conversation.sortedGroupMembers || []}
memberships={allPendingMemberships}
ourConversationId={ourConversationId}
setStagedMemberships={setStagedMemberships}
/>
) : null}
{stagedMemberships && stagedMemberships.length && (
<MembershipActionConfirmation
approvePendingMembership={approvePendingMembership}
i18n={i18n}
members={conversation.sortedGroupMembers || []}
onClose={() => setStagedMemberships(null)}
ourConversationId={ourConversationId}
revokePendingMemberships={revokePendingMemberships}
stagedMemberships={stagedMemberships}
/>
)}
</div>
);
};
function MembershipActionConfirmation({
approvePendingMembership,
i18n,
members,
onClose,
ourConversationId,
revokePendingMemberships,
stagedMemberships,
}: {
approvePendingMembership: (conversationId: string) => void;
i18n: LocalizerType;
members: Array<ConversationType>;
onClose: () => void;
ourConversationId: string;
revokePendingMemberships: (conversationIds: Array<string>) => void;
stagedMemberships: Array<StagedMembershipType>;
}) {
const revokeStagedMemberships = () => {
if (!stagedMemberships) {
return;
}
revokePendingMemberships(
stagedMemberships.map(({ membership }) => membership.member.id)
);
};
const approveStagedMembership = () => {
if (!stagedMemberships) {
return;
}
approvePendingMembership(stagedMemberships[0].membership.member.id);
};
const membershipType = stagedMemberships[0].type;
const modalAction =
membershipType === StageType.APPROVE_REQUEST
? approveStagedMembership
: revokeStagedMemberships;
let modalActionText = i18n('PendingInvites--revoke');
if (membershipType === StageType.APPROVE_REQUEST) {
modalActionText = i18n('PendingRequests--approve');
} else if (membershipType === StageType.DENY_REQUEST) {
modalActionText = i18n('PendingRequests--deny');
} else if (membershipType === StageType.REVOKE_INVITE) {
modalActionText = i18n('PendingInvites--revoke');
}
return (
<ConfirmationModal
actions={[
{
action: modalAction,
style: 'affirmative',
text: modalActionText,
},
]}
i18n={i18n}
onClose={onClose}
>
{getConfirmationMessage({
i18n,
members,
ourConversationId,
stagedMemberships,
})}
</ConfirmationModal>
);
}
function getConfirmationMessage({
i18n,
members,
ourConversationId,
stagedMemberships,
}: {
i18n: LocalizerType;
members: Array<ConversationType>;
ourConversationId: string;
stagedMemberships: Array<StagedMembershipType>;
}): string {
if (!stagedMemberships || !stagedMemberships.length) {
return '';
}
const membershipType = stagedMemberships[0].type;
const firstMembership = stagedMemberships[0].membership;
// Requesting a membership since they weren't added by anyone
if (membershipType === StageType.DENY_REQUEST) {
return i18n('PendingRequests--deny-for', {
name: firstMembership.member.title,
});
}
if (membershipType === StageType.APPROVE_REQUEST) {
return i18n('PendingRequests--approve-for', {
name: firstMembership.member.title,
});
}
if (membershipType !== StageType.REVOKE_INVITE) {
throw new Error('getConfirmationMessage: Invalid staging type');
}
const firstPendingMembership = firstMembership as GroupV2PendingMembership;
// Pending invite
const invitedByUs =
firstPendingMembership.metadata.addedByUserId === ourConversationId;
if (invitedByUs) {
return i18n('PendingInvites--revoke-for', {
name: firstPendingMembership.member.title,
});
}
const inviter = members.find(
({ id }) => id === firstPendingMembership.metadata.addedByUserId
);
if (inviter === undefined) {
return '';
}
const name = inviter.title;
if (stagedMemberships.length === 1) {
return i18n('PendingInvites--revoke-from-singular', { name });
}
return i18n('PendingInvites--revoke-from-plural', {
number: stagedMemberships.length.toString(),
name,
});
}
function MembersPendingAdminApproval({
conversation,
i18n,
memberships,
setStagedMemberships,
}: {
conversation: ConversationType;
i18n: LocalizerType;
memberships: Array<GroupV2RequestingMembership>;
setStagedMemberships: (stagedMembership: Array<StagedMembershipType>) => void;
}) {
return (
<PanelSection>
{memberships.map(membership => (
<PanelRow
alwaysShowActions
key={membership.member.id}
icon={
<Avatar
conversationType="direct"
size={32}
i18n={i18n}
{...membership.member}
/>
}
label={membership.member.title}
actions={
conversation.areWeAdmin ? (
<>
<button
type="button"
className="module-button__small module-conversation-details__action-button"
onClick={() => {
setStagedMemberships([
{
type: StageType.DENY_REQUEST,
membership,
},
]);
}}
>
{i18n('delete')}
</button>
<button
type="button"
className="module-button__small module-conversation-details__action-button"
onClick={() => {
setStagedMemberships([
{
type: StageType.APPROVE_REQUEST,
membership,
},
]);
}}
>
{i18n('accept')}
</button>
</>
) : null
}
/>
))}
<div className="module-conversation-details__pending--info">
{i18n('PendingRequests--info', [conversation.title])}
</div>
</PanelSection>
);
}
function MembersPendingProfileKey({
conversation,
i18n,
members,
memberships,
ourConversationId,
setStagedMemberships,
}: {
conversation: ConversationType;
i18n: LocalizerType;
members: Array<ConversationType>;
memberships: Array<GroupV2PendingMembership>;
ourConversationId: string;
setStagedMemberships: (stagedMembership: Array<StagedMembershipType>) => void;
}) {
const groupedPendingMemberships = _.groupBy(
memberships,
membership => membership.metadata.addedByUserId
);
const {
[ourConversationId]: ourPendingMemberships,
...otherPendingMembershipGroups
} = groupedPendingMemberships;
const otherPendingMemberships = Object.keys(otherPendingMembershipGroups)
.map(id => members.find(member => member.id === id))
.filter((member): member is ConversationType => member !== undefined)
.map(member => ({
member,
pendingMemberships: otherPendingMembershipGroups[member.id],
}));
return (
<PanelSection>
{ourPendingMemberships && (
<PanelSection title={i18n('PendingInvites--invited-by-you')}>
{ourPendingMemberships.map(membership => (
<PanelRow
key={membership.member.id}
icon={
<Avatar
conversationType="direct"
size={32}
i18n={i18n}
{...membership.member}
/>
}
label={membership.member.title}
actions={
conversation.areWeAdmin ? (
<ConversationDetailsIcon
ariaLabel={i18n('PendingInvites--revoke-for-label')}
icon="trash"
onClick={() => {
setStagedMemberships([
{
type: StageType.REVOKE_INVITE,
membership,
},
]);
}}
/>
) : null
}
/>
))}
</PanelSection>
)}
{otherPendingMemberships.length > 0 && (
<PanelSection title={i18n('PendingInvites--invited-by-others')}>
{otherPendingMemberships.map(({ member, pendingMemberships }) => (
<PanelRow
key={member.id}
icon={
<Avatar
conversationType="direct"
size={32}
i18n={i18n}
{...member}
/>
}
label={member.title}
right={i18n('PendingInvites--invited-count', [
pendingMemberships.length.toString(),
])}
actions={
conversation.areWeAdmin ? (
<ConversationDetailsIcon
ariaLabel={i18n('PendingInvites--revoke-for-label')}
icon="trash"
onClick={() => {
setStagedMemberships(
pendingMemberships.map(membership => ({
type: StageType.REVOKE_INVITE,
membership,
}))
);
}}
/>
) : null
}
/>
))}
</PanelSection>
)}
<div className="module-conversation-details__pending--info">
{i18n('PendingInvites--info')}
</div>
</PanelSection>
);
}

View file

@ -0,0 +1,30 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
export const bemGenerator = (block: string) => (
element: string,
modifier?: string | Record<string, boolean>
): string => {
const base = `${block}__${element}`;
const classes = [base];
let conditionals: Record<string, boolean> = {};
if (modifier) {
if (typeof modifier === 'string') {
classes.push(`${base}--${modifier}`);
} else {
conditionals = Object.keys(modifier).reduce(
(acc, key) => ({
...acc,
[`${base}--${key}`]: modifier[key],
}),
{} as Record<string, boolean>
);
}
}
return classNames(classes, conditionals);
};