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:
parent
1268945840
commit
c0510b08a5
64 changed files with 4699 additions and 81 deletions
|
@ -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'),
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -33,6 +33,7 @@ const commonProps = {
|
|||
|
||||
i18n,
|
||||
|
||||
onShowConversationDetails: action('onShowConversationDetails'),
|
||||
onSetDisappearingMessages: action('onSetDisappearingMessages'),
|
||||
onDeleteMessages: action('onDeleteMessages'),
|
||||
onResetSession: action('onResetSession'),
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 />;
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
});
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 })} />
|
||||
));
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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} />;
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
});
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
});
|
58
ts/components/conversation/conversation-details/PanelRow.tsx
Normal file
58
ts/components/conversation/conversation-details/PanelRow.tsx
Normal 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>;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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>
|
||||
);
|
|
@ -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} />;
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
30
ts/components/conversation/conversation-details/util.ts
Normal file
30
ts/components/conversation/conversation-details/util.ts
Normal 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);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue