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

@ -10,6 +10,7 @@ type ConfigKeyType =
| 'desktop.disableGV1'
| 'desktop.groupCalling'
| 'desktop.gv2'
| 'desktop.gv2Admin'
| 'desktop.mandatoryProfileSharing'
| 'desktop.messageRequests'
| 'desktop.storage'

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);
};

View file

@ -49,8 +49,10 @@ import {
computeHash,
deriveMasterKeyFromGroupV1,
fromEncodedBinaryToArrayBuffer,
getRandomBytes,
} from './Crypto';
import {
AccessRequiredEnum,
GroupAttributeBlobClass,
GroupChangeClass,
GroupChangesClass,
@ -225,6 +227,35 @@ const TEMPORAL_AUTH_REJECTED_CODE = 401;
const GROUP_ACCESS_DENIED_CODE = 403;
const GROUP_NONEXISTENT_CODE = 404;
const SUPPORTED_CHANGE_EPOCH = 1;
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
// Group Links
export function generateGroupInviteLinkPassword(): ArrayBuffer {
return getRandomBytes(GROUP_INVITE_LINK_PASSWORD_LENGTH);
}
export function toWebSafeBase64(base64: string): string {
return base64.replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '');
}
export function buildGroupLink(conversation: ConversationModel): string {
const { masterKey, groupInviteLinkPassword } = conversation.attributes;
const subProto = new window.textsecure.protobuf.GroupInviteLink.GroupInviteLinkContentsV1();
subProto.groupMasterKey = window.Signal.Crypto.base64ToArrayBuffer(masterKey);
subProto.inviteLinkPassword = window.Signal.Crypto.base64ToArrayBuffer(
groupInviteLinkPassword
);
const proto = new window.textsecure.protobuf.GroupInviteLink();
proto.v1Contents = subProto;
const bytes = proto.toArrayBuffer();
const hash = toWebSafeBase64(window.Signal.Crypto.arrayBufferToBase64(bytes));
return `sgnl://signal.group/#${hash}`;
}
// Group Modifications
@ -457,11 +488,119 @@ export function buildDisappearingMessagesTimerChange({
return actions;
}
export function buildDeletePendingMemberChange({
export function buildInviteLinkPasswordChange(
group: ConversationAttributesType,
inviteLinkPassword: string
): GroupChangeClass.Actions {
const inviteLinkPasswordAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyInviteLinkPasswordAction();
inviteLinkPasswordAction.inviteLinkPassword = base64ToArrayBuffer(
inviteLinkPassword
);
const actions = new window.textsecure.protobuf.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyInviteLinkPassword = inviteLinkPasswordAction;
return actions;
}
export function buildNewGroupLinkChange(
group: ConversationAttributesType,
inviteLinkPassword: string,
addFromInviteLinkAccess: AccessRequiredEnum
): GroupChangeClass.Actions {
const accessControlAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction();
accessControlAction.addFromInviteLinkAccess = addFromInviteLinkAccess;
const inviteLinkPasswordAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyInviteLinkPasswordAction();
inviteLinkPasswordAction.inviteLinkPassword = base64ToArrayBuffer(
inviteLinkPassword
);
const actions = new window.textsecure.protobuf.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyAddFromInviteLinkAccess = accessControlAction;
actions.modifyInviteLinkPassword = inviteLinkPasswordAction;
return actions;
}
export function buildAccessControlAddFromInviteLinkChange(
group: ConversationAttributesType,
value: AccessRequiredEnum
): GroupChangeClass.Actions {
const accessControlAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction();
accessControlAction.addFromInviteLinkAccess = value;
const actions = new window.textsecure.protobuf.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyAddFromInviteLinkAccess = accessControlAction;
return actions;
}
export function buildAccessControlAttributesChange(
group: ConversationAttributesType,
value: AccessRequiredEnum
): GroupChangeClass.Actions {
const accessControlAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyAttributesAccessControlAction();
accessControlAction.attributesAccess = value;
const actions = new window.textsecure.protobuf.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyAttributesAccess = accessControlAction;
return actions;
}
export function buildAccessControlMembersChange(
group: ConversationAttributesType,
value: AccessRequiredEnum
): GroupChangeClass.Actions {
const accessControlAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyMembersAccessControlAction();
accessControlAction.membersAccess = value;
const actions = new window.textsecure.protobuf.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyMemberAccess = accessControlAction;
return actions;
}
// TODO AND-1101
export function buildDeletePendingAdminApprovalMemberChange({
group,
uuid,
}: {
group: ConversationAttributesType;
uuid: string;
}): GroupChangeClass.Actions {
const actions = new window.textsecure.protobuf.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildDeletePendingAdminApprovalMemberChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const deleteMemberPendingAdminApproval = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberPendingAdminApprovalAction();
deleteMemberPendingAdminApproval.deletedUserId = uuidCipherTextBuffer;
actions.version = (group.revision || 0) + 1;
actions.deleteMemberPendingAdminApprovals = [
deleteMemberPendingAdminApproval,
];
return actions;
}
export function buildDeletePendingMemberChange({
uuids,
group,
}: {
uuid: string;
uuids: Array<string>;
group: ConversationAttributesType;
}): GroupChangeClass.Actions {
const actions = new window.textsecure.protobuf.GroupChange.Actions();
@ -472,13 +611,16 @@ export function buildDeletePendingMemberChange({
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberPendingProfileKeyAction();
deletePendingMember.deletedUserId = uuidCipherTextBuffer;
const deletePendingMembers = uuids.map(uuid => {
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberPendingProfileKeyAction();
deletePendingMember.deletedUserId = uuidCipherTextBuffer;
return deletePendingMember;
});
actions.version = (group.revision || 0) + 1;
actions.deletePendingMembers = [deletePendingMember];
actions.deletePendingMembers = deletePendingMembers;
return actions;
}
@ -507,6 +649,63 @@ export function buildDeleteMemberChange({
return actions;
}
export function buildModifyMemberRoleChange({
uuid,
group,
role,
}: {
uuid: string;
group: ConversationAttributesType;
role: number;
}): GroupChangeClass.Actions {
const actions = new window.textsecure.protobuf.GroupChange.Actions();
if (!group.secretParams) {
throw new Error('buildMakeAdminChange: group was missing secretParams!');
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const toggleAdmin = new window.textsecure.protobuf.GroupChange.Actions.ModifyMemberRoleAction();
toggleAdmin.userId = uuidCipherTextBuffer;
toggleAdmin.role = role;
actions.version = (group.revision || 0) + 1;
actions.modifyMemberRoles = [toggleAdmin];
return actions;
}
export function buildPromotePendingAdminApprovalMemberChange({
group,
uuid,
}: {
group: ConversationAttributesType;
uuid: string;
}): GroupChangeClass.Actions {
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const actions = new window.textsecure.protobuf.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const promotePendingMember = new window.textsecure.protobuf.GroupChange.Actions.PromoteMemberPendingAdminApprovalAction();
promotePendingMember.userId = uuidCipherTextBuffer;
promotePendingMember.role = MEMBER_ROLE_ENUM.DEFAULT;
actions.version = (group.revision || 0) + 1;
actions.promoteMemberPendingAdminApprovals = [promotePendingMember];
return actions;
}
export function buildPromoteMemberChange({
group,
profileKeyCredentialBase64,
@ -4300,8 +4499,6 @@ function decryptMemberPendingAdminApproval(
return member;
}
/* eslint-enable no-param-reassign */
export function getMembershipList(
conversationId: string
): Array<{ uuid: string; uuidCiphertext: ArrayBuffer }> {

2
ts/model-types.d.ts vendored
View file

@ -255,12 +255,14 @@ export type GroupV2MemberType = {
joinedFromLink?: boolean;
approvedByAdmin?: boolean;
};
export type GroupV2PendingMemberType = {
addedByUserId?: string;
conversationId: string;
timestamp: number;
role: MemberRoleEnum;
};
export type GroupV2PendingAdminApprovalType = {
conversationId: string;
timestamp: number;

View file

@ -10,6 +10,11 @@ import {
ConversationAttributesType,
VerificationOptions,
} from '../model-types.d';
import {
GroupV2PendingMembership,
GroupV2RequestingMembership,
} from '../components/conversation/conversation-details/PendingInvites';
import { GroupV2Membership } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
import {
@ -295,6 +300,21 @@ export class ConversationModel extends window.Backbone.Model<
}
}
isMemberRequestingToJoin(conversationId: string): boolean {
if (!this.isGroupV2()) {
return false;
}
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
return false;
}
return pendingAdminApprovalV2.some(
item => item.conversationId === conversationId
);
}
isMemberPending(conversationId: string): boolean {
if (!this.isGroupV2()) {
return false;
@ -393,7 +413,7 @@ export class ConversationModel extends window.Backbone.Model<
});
}
async removePendingMember(
async approvePendingApprovalRequest(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
@ -401,9 +421,9 @@ export class ConversationModel extends window.Backbone.Model<
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberPending(conversationId)) {
if (!this.isMemberRequestingToJoin(conversationId)) {
window.log.warn(
`removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
`approvePendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.`
);
return undefined;
}
@ -411,20 +431,101 @@ export class ConversationModel extends window.Backbone.Model<
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
throw new Error(
`removePendingMember/${idLog}: No conversation found for conversation ${conversationId}`
`approvePendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.get('uuid');
if (!uuid) {
throw new Error(
`removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
`approvePendingApprovalRequest/${idLog}: Missing uuid for conversation ${conversationId}`
);
}
return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange({
group: this.attributes,
uuid,
});
}
async denyPendingApprovalRequest(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberRequestingToJoin(conversationId)) {
window.log.warn(
`denyPendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.`
);
return undefined;
}
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
throw new Error(
`denyPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.get('uuid');
if (!uuid) {
throw new Error(
`denyPendingApprovalRequest/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
);
}
return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({
group: this.attributes,
uuid,
});
}
async removePendingMember(
conversationIds: Array<string>
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
const uuids = conversationIds
.map(conversationId => {
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberPending(conversationId)) {
window.log.warn(
`removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
);
return undefined;
}
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
window.log.warn(
`removePendingMember/${idLog}: No conversation found for conversation ${conversationId}`
);
return undefined;
}
const uuid = pendingMember.get('uuid');
if (!uuid) {
window.log.warn(
`removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
);
return undefined;
}
return uuid;
})
.filter((uuid): uuid is string => Boolean(uuid));
if (!uuids.length) {
return undefined;
}
return window.Signal.Groups.buildDeletePendingMemberChange({
group: this.attributes,
uuid,
uuids,
});
}
@ -463,6 +564,49 @@ export class ConversationModel extends window.Backbone.Model<
});
}
async toggleAdminChange(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
if (!this.isGroupV2()) {
return undefined;
}
const idLog = this.idForLogging();
if (!this.isMember(conversationId)) {
window.log.warn(
`toggleAdminChange/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
);
return undefined;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`toggleAdminChange/${idLog}: No conversation found for conversation ${conversationId}`
);
}
const uuid = conversation.get('uuid');
if (!uuid) {
throw new Error(
`toggleAdminChange/${idLog}: Missing uuid for conversation ${conversationId}`
);
}
const MEMBER_ROLES = window.textsecure.protobuf.Member.Role;
const role = this.isAdmin(conversationId)
? MEMBER_ROLES.DEFAULT
: MEMBER_ROLES.ADMINISTRATOR;
return window.Signal.Groups.buildModifyMemberRoleChange({
group: this.attributes,
uuid,
role,
});
}
async modifyGroupV2({
name,
createGroupChange,
@ -1158,7 +1302,7 @@ export class ConversationModel extends window.Backbone.Model<
groupVersion = 2;
}
const members = this.isGroupV2()
const sortedGroupMembers = this.isGroupV2()
? this.getMembers()
.sort((left, right) =>
sortConversationTitles(left, right, this.intlCollator)
@ -1182,6 +1326,7 @@ export class ConversationModel extends window.Backbone.Model<
),
areWeAdmin: this.areWeAdmin(),
canChangeTimer: this.canChangeTimer(),
canEditGroupInfo: this.canEditGroupInfo(),
avatarPath: this.getAvatarPath()!,
color,
draftBodyRanges,
@ -1190,6 +1335,7 @@ export class ConversationModel extends window.Backbone.Model<
firstName: this.get('profileName')!,
groupVersion,
groupId: this.get('groupId'),
groupLink: this.getGroupLink(),
inboxPosition,
isArchived: this.get('isArchived')!,
isBlocked: this.isBlocked(),
@ -1207,11 +1353,17 @@ export class ConversationModel extends window.Backbone.Model<
lastUpdated: this.get('timestamp')!,
left: Boolean(this.get('left')),
markedUnread: this.get('markedUnread')!,
members,
membersCount: this.isPrivate()
? undefined
: (this.get('membersV2')! || this.get('members')! || []).length,
memberships: this.getMemberships(),
pendingMemberships: this.getPendingMemberships(),
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
messageRequestsEnabled,
accessControlAddFromInviteLink: this.get('accessControl')
?.addFromInviteLink,
accessControlAttributes: this.get('accessControl')?.attributes,
accessControlMembers: this.get('accessControl')?.members,
expireTimer: this.get('expireTimer'),
muteExpiresAt: this.get('muteExpiresAt')!,
name: this.get('name')!,
@ -1221,6 +1373,7 @@ export class ConversationModel extends window.Backbone.Model<
secretParams: this.get('secretParams'),
sharedGroupNames: this.get('sharedGroupNames')!,
shouldShowDraft,
sortedGroupMembers,
timestamp,
title: this.getTitle()!,
type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType,
@ -1480,7 +1633,7 @@ export class ConversationModel extends window.Backbone.Model<
) {
await this.modifyGroupV2({
name: 'delete',
createGroupChange: () => this.removePendingMember(ourConversationId),
createGroupChange: () => this.removePendingMember([ourConversationId]),
});
} else if (
ourConversationId &&
@ -1498,11 +1651,76 @@ export class ConversationModel extends window.Backbone.Model<
}
}
async removeFromGroupV2(conversationId: string): Promise<void> {
if (this.isGroupV2() && this.isMemberPending(conversationId)) {
async toggleAdmin(conversationId: string): Promise<void> {
if (!this.isGroupV2()) {
return;
}
if (!this.isMember(conversationId)) {
window.log.error(
`toggleAdmin: Member ${conversationId} is not a member of the group`
);
return;
}
await this.modifyGroupV2({
name: 'toggleAdmin',
createGroupChange: () => this.toggleAdminChange(conversationId),
});
}
async approvePendingMembershipFromGroupV2(
conversationId: string
): Promise<void> {
if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) {
await this.modifyGroupV2({
name: 'approvePendingApprovalRequest',
createGroupChange: () =>
this.approvePendingApprovalRequest(conversationId),
});
}
}
async revokePendingMembershipsFromGroupV2(
conversationIds: Array<string>
): Promise<void> {
if (!this.isGroupV2()) {
return;
}
const [conversationId] = conversationIds;
// Only pending memberships can be revoked for multiple members at once
if (conversationIds.length > 1) {
await this.modifyGroupV2({
name: 'removePendingMember',
createGroupChange: () => this.removePendingMember(conversationId),
createGroupChange: () => this.removePendingMember(conversationIds),
});
} else if (this.isMemberRequestingToJoin(conversationId)) {
await this.modifyGroupV2({
name: 'denyPendingApprovalRequest',
createGroupChange: () =>
this.denyPendingApprovalRequest(conversationId),
});
} else if (this.isMemberPending(conversationId)) {
await this.modifyGroupV2({
name: 'removePendingMember',
createGroupChange: () => this.removePendingMember([conversationId]),
});
}
}
async removeFromGroupV2(conversationId: string): Promise<void> {
if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) {
await this.modifyGroupV2({
name: 'denyPendingApprovalRequest',
createGroupChange: () =>
this.denyPendingApprovalRequest(conversationId),
});
} else if (this.isGroupV2() && this.isMemberPending(conversationId)) {
await this.modifyGroupV2({
name: 'removePendingMember',
createGroupChange: () => this.removePendingMember([conversationId]),
});
} else if (this.isGroupV2() && this.isMember(conversationId)) {
await this.modifyGroupV2({
@ -2274,6 +2492,114 @@ export class ConversationModel extends window.Backbone.Model<
return this.jobQueue.add(taskWithTimeout);
}
isAdmin(conversationId: string): boolean {
if (!this.isGroupV2()) {
return false;
}
const members = this.get('membersV2') || [];
const member = members.find(x => x.conversationId === conversationId);
if (!member) {
return false;
}
const MEMBER_ROLES = window.textsecure.protobuf.Member.Role;
return member.role === MEMBER_ROLES.ADMINISTRATOR;
}
getMemberships(): Array<GroupV2Membership> {
if (!this.isGroupV2()) {
return [];
}
const members = this.get('membersV2') || [];
return members
.map(member => {
const conversationModel = window.ConversationController.get(
member.conversationId
);
if (!conversationModel || conversationModel.isUnregistered()) {
return null;
}
return {
isAdmin:
member.role ===
window.textsecure.protobuf.Member.Role.ADMINISTRATOR,
metadata: member,
member: conversationModel.format(),
};
})
.filter(
(membership): membership is GroupV2Membership => membership !== null
);
}
getGroupLink(): string | undefined {
if (!this.isGroupV2()) {
return undefined;
}
if (!this.get('groupInviteLinkPassword')) {
return undefined;
}
return window.Signal.Groups.buildGroupLink(this);
}
getPendingMemberships(): Array<GroupV2PendingMembership> {
if (!this.isGroupV2()) {
return [];
}
const members = this.get('pendingMembersV2') || [];
return members
.map(member => {
const conversationModel = window.ConversationController.get(
member.conversationId
);
if (!conversationModel || conversationModel.isUnregistered()) {
return null;
}
return {
metadata: member,
member: conversationModel.format(),
};
})
.filter(
(membership): membership is GroupV2PendingMembership =>
membership !== null
);
}
getPendingApprovalMemberships(): Array<GroupV2RequestingMembership> {
if (!this.isGroupV2()) {
return [];
}
const members = this.get('pendingAdminApprovalV2') || [];
return members
.map(member => {
const conversationModel = window.ConversationController.get(
member.conversationId
);
if (!conversationModel || conversationModel.isUnregistered()) {
return null;
}
return {
metadata: member,
member: conversationModel.format(),
};
})
.filter(
(membership): membership is GroupV2RequestingMembership =>
membership !== null
);
}
getMembers(
options: { includePendingMembers?: boolean } = {}
): Array<ConversationModel> {
@ -3199,6 +3525,166 @@ export class ConversationModel extends window.Backbone.Model<
window.Whisper.events.trigger('updateUnreadCount');
}
async refreshGroupLink(): Promise<void> {
if (!this.isGroupV2()) {
return;
}
const groupInviteLinkPassword = arrayBufferToBase64(
window.Signal.Groups.generateGroupInviteLinkPassword()
);
window.log.info('refreshGroupLink for conversation', this.idForLogging());
await this.modifyGroupV2({
name: 'updateInviteLinkPassword',
createGroupChange: async () =>
window.Signal.Groups.buildInviteLinkPasswordChange(
this.attributes,
groupInviteLinkPassword
),
});
this.set({ groupInviteLinkPassword });
}
async toggleGroupLink(value: boolean): Promise<void> {
if (!this.isGroupV2()) {
return;
}
const shouldCreateNewGroupLink =
value && !this.get('groupInviteLinkPassword');
const groupInviteLinkPassword =
this.get('groupInviteLinkPassword') ||
arrayBufferToBase64(
window.Signal.Groups.generateGroupInviteLinkPassword()
);
window.log.info(
'toggleGroupLink for conversation',
this.idForLogging(),
value
);
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const addFromInviteLink = value
? ACCESS_ENUM.ANY
: ACCESS_ENUM.UNSATISFIABLE;
if (shouldCreateNewGroupLink) {
await this.modifyGroupV2({
name: 'updateNewGroupLink',
createGroupChange: async () =>
window.Signal.Groups.buildNewGroupLinkChange(
this.attributes,
groupInviteLinkPassword,
addFromInviteLink
),
});
} else {
await this.modifyGroupV2({
name: 'updateAccessControlAddFromInviteLink',
createGroupChange: async () =>
window.Signal.Groups.buildAccessControlAddFromInviteLinkChange(
this.attributes,
addFromInviteLink
),
});
}
this.set({
accessControl: {
addFromInviteLink,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
if (shouldCreateNewGroupLink) {
this.set({ groupInviteLinkPassword });
}
}
async updateAccessControlAddFromInviteLink(value: boolean): Promise<void> {
if (!this.isGroupV2()) {
return;
}
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const addFromInviteLink = value
? ACCESS_ENUM.ADMINISTRATOR
: ACCESS_ENUM.ANY;
await this.modifyGroupV2({
name: 'updateAccessControlAddFromInviteLink',
createGroupChange: async () =>
window.Signal.Groups.buildAccessControlAddFromInviteLinkChange(
this.attributes,
addFromInviteLink
),
});
this.set({
accessControl: {
addFromInviteLink,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
}
async updateAccessControlAttributes(value: number): Promise<void> {
if (!this.isGroupV2()) {
return;
}
await this.modifyGroupV2({
name: 'updateAccessControlAttributes',
createGroupChange: async () =>
window.Signal.Groups.buildAccessControlAttributesChange(
this.attributes,
value
),
});
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
this.set({
accessControl: {
addFromInviteLink:
this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER,
attributes: value,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
}
async updateAccessControlMembers(value: number): Promise<void> {
if (!this.isGroupV2()) {
return;
}
await this.modifyGroupV2({
name: 'updateAccessControlMembers',
createGroupChange: async () =>
window.Signal.Groups.buildAccessControlMembersChange(
this.attributes,
value
),
});
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
this.set({
accessControl: {
addFromInviteLink:
this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: value,
},
});
}
async updateExpirationTimer(
providedExpireTimer: number | undefined,
providedSource: unknown,
@ -4187,6 +4673,18 @@ export class ConversationModel extends window.Backbone.Model<
return this.areWeAdmin();
}
canEditGroupInfo(): boolean {
if (!this.isGroupV2()) {
return false;
}
return (
this.areWeAdmin() ||
this.get('accessControl')?.attributes ===
window.textsecure.protobuf.AccessControl.AccessRequired.MEMBER
);
}
areWeAdmin(): boolean {
if (!this.isGroupV2()) {
return false;

View file

@ -24,6 +24,12 @@ import { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Colors';
import { BodyRangeType } from '../../types/Util';
import { CallMode, CallHistoryDetailsFromDiskType } from '../../types/Calling';
import {
GroupV2PendingMembership,
GroupV2RequestingMembership,
} from '../../components/conversation/conversation-details/PendingInvites';
import { GroupV2Membership } from '../../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { MediaItemType } from '../../components/LightboxGallery';
// State
@ -56,6 +62,7 @@ export type ConversationType = {
areWeAdmin?: boolean;
areWePending?: boolean;
canChangeTimer?: boolean;
canEditGroupInfo?: boolean;
color?: ColorType;
isAccepted?: boolean;
isArchived?: boolean;
@ -76,12 +83,21 @@ export type ConversationType = {
markedUnread?: boolean;
phoneNumber?: string;
membersCount?: number;
accessControlAddFromInviteLink?: number;
accessControlAttributes?: number;
accessControlMembers?: number;
expireTimer?: number;
members?: Array<ConversationType>;
// This is used by the ConversationDetails set of components, it includes the
// membersV2 data and also has some extra metadata attached to the object
memberships?: Array<GroupV2Membership>;
pendingMemberships?: Array<GroupV2PendingMembership>;
pendingApprovalMemberships?: Array<GroupV2RequestingMembership>;
muteExpiresAt?: number;
type: ConversationTypeType;
isMe?: boolean;
lastUpdated?: number;
// This is used by the CompositionInput for @mentions
sortedGroupMembers?: Array<ConversationType>;
title: string;
unreadCount?: number;
isSelected?: boolean;
@ -92,6 +108,7 @@ export type ConversationType = {
phoneNumber?: string;
profileName?: string;
} | null;
recentMediaItems?: Array<MediaItemType>;
shouldShowDraft?: boolean;
draftText?: string | null;
@ -101,6 +118,7 @@ export type ConversationType = {
sharedGroupNames?: Array<string>;
groupVersion?: 1 | 2;
groupId?: string;
groupLink?: string;
isMissingMandatoryProfileSharing?: boolean;
messageRequestsEnabled?: boolean;
acceptedMessageRequest?: boolean;
@ -198,6 +216,7 @@ export type ConversationsStateType = {
selectedConversation?: string;
selectedMessage?: string;
selectedMessageCounter: number;
selectedConversationTitle?: string;
selectedConversationPanelDepth: number;
showArchived: boolean;
@ -347,6 +366,10 @@ export type SetIsNearBottomActionType = {
isNearBottom: boolean;
};
};
export type SetConversationHeaderTitleActionType = {
type: 'SET_CONVERSATION_HEADER_TITLE';
payload: { title?: string };
};
export type SetSelectedConversationPanelDepthActionType = {
type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH';
payload: { panelDepth: number };
@ -389,6 +412,13 @@ export type ShowArchivedConversationsActionType = {
type: 'SHOW_ARCHIVED_CONVERSATIONS';
payload: null;
};
type SetRecentMediaItemsActionType = {
type: 'SET_RECENT_MEDIA_ITEMS';
payload: {
id: string;
recentMediaItems: Array<MediaItemType>;
};
};
export type ConversationActionType =
| ConversationAddedActionType
@ -411,41 +441,45 @@ export type ConversationActionType =
| ClearSelectedMessageActionType
| ClearUnreadMetricsActionType
| ScrollToMessageActionType
| SetConversationHeaderTitleActionType
| SetSelectedConversationPanelDepthActionType
| SelectedConversationChangedActionType
| MessageDeletedActionType
| SelectedConversationChangedActionType
| SetRecentMediaItemsActionType
| ShowInboxActionType
| ShowArchivedConversationsActionType;
// Action Creators
export const actions = {
clearChangedMessages,
clearSelectedMessage,
clearUnreadMetrics,
conversationAdded,
conversationChanged,
conversationRemoved,
conversationUnloaded,
removeAllConversations,
selectMessage,
messageDeleted,
messageChanged,
messageDeleted,
messageSizeChanged,
messagesAdded,
messagesReset,
setMessagesLoading,
setLoadCountdownStart,
setIsNearBottom,
setSelectedConversationPanelDepth,
clearChangedMessages,
clearSelectedMessage,
clearUnreadMetrics,
scrollToMessage,
openConversationInternal,
openConversationExternal,
showInbox,
showArchivedConversations,
openConversationInternal,
removeAllConversations,
repairNewestMessage,
repairOldestMessage,
scrollToMessage,
selectMessage,
setIsNearBottom,
setLoadCountdownStart,
setMessagesLoading,
setRecentMediaItems,
setSelectedConversationHeaderTitle,
setSelectedConversationPanelDepth,
showArchivedConversations,
showInbox,
};
function conversationAdded(
@ -642,6 +676,14 @@ function setIsNearBottom(
},
};
}
function setSelectedConversationHeaderTitle(
title?: string
): SetConversationHeaderTitleActionType {
return {
type: 'SET_CONVERSATION_HEADER_TITLE',
payload: { title },
};
}
function setSelectedConversationPanelDepth(
panelDepth: number
): SetSelectedConversationPanelDepthActionType {
@ -650,6 +692,15 @@ function setSelectedConversationPanelDepth(
payload: { panelDepth },
};
}
function setRecentMediaItems(
id: string,
recentMediaItems: Array<MediaItemType>
): SetRecentMediaItemsActionType {
return {
type: 'SET_RECENT_MEDIA_ITEMS',
payload: { id, recentMediaItems },
};
}
function clearChangedMessages(
conversationId: string
): ClearChangedMessagesActionType {
@ -743,6 +794,7 @@ export function getEmptyState(): ConversationsStateType {
messagesLookup: {},
selectedMessageCounter: 0,
showArchived: false,
selectedConversationTitle: '',
selectedConversationPanelDepth: 0,
};
}
@ -1547,5 +1599,37 @@ export function reducer(
};
}
if (action.type === 'SET_CONVERSATION_HEADER_TITLE') {
return {
...state,
selectedConversationTitle: action.payload.title,
};
}
if (action.type === 'SET_RECENT_MEDIA_ITEMS') {
const { id, recentMediaItems } = action.payload;
const { conversationLookup } = state;
const conversationData = conversationLookup[id];
if (!conversationData) {
return state;
}
const data = {
...conversationData,
recentMediaItems,
};
return {
...state,
conversationLookup: {
...conversationLookup,
[id]: data,
},
...updateConversationLookups(data, undefined, state),
};
}
return state;
}

View file

@ -0,0 +1,21 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import {
SmartConversationDetails,
SmartConversationDetailsProps,
} from '../smart/ConversationDetails';
export const createConversationDetails = (
store: Store,
props: SmartConversationDetailsProps
): React.ReactElement => (
<Provider store={store}>
<SmartConversationDetails {...props} />
</Provider>
);

View file

@ -0,0 +1,21 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import {
SmartGroupLinkManagement,
SmartGroupLinkManagementProps,
} from '../smart/GroupLinkManagement';
export const createGroupLinkManagement = (
store: Store,
props: SmartGroupLinkManagementProps
): React.ReactElement => (
<Provider store={store}>
<SmartGroupLinkManagement {...props} />
</Provider>
);

View file

@ -0,0 +1,21 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import {
SmartGroupV2Permissions,
SmartGroupV2PermissionsProps,
} from '../smart/GroupV2Permissions';
export const createGroupV2Permissions = (
store: Store,
props: SmartGroupV2PermissionsProps
): React.ReactElement => (
<Provider store={store}>
<SmartGroupV2Permissions {...props} />
</Provider>
);

View file

@ -0,0 +1,21 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import {
SmartPendingInvites,
SmartPendingInvitesProps,
} from '../smart/PendingInvites';
export const createPendingInvites = (
store: Store,
props: SmartPendingInvitesProps
): React.ReactElement => (
<Provider store={store}>
<SmartPendingInvites {...props} />
</Provider>
);

View file

@ -17,8 +17,9 @@ export type SmartContactModalProps = {
currentConversationId: string;
readonly onClose: () => unknown;
readonly openConversation: (conversationId: string) => void;
readonly showSafetyNumber: (conversationId: string) => void;
readonly removeMember: (conversationId: string) => void;
readonly showSafetyNumber: (conversationId: string) => void;
readonly toggleAdmin: (conversationId: string) => void;
};
const mapStateToProps = (
@ -31,21 +32,29 @@ const mapStateToProps = (
currentConversationId
);
const contact = getConversationSelector(state)(contactId);
const isMember =
contact && currentConversation && currentConversation.members
? currentConversation.members.includes(contact)
: false;
const areWeAdmin =
currentConversation && currentConversation.areWeAdmin
? currentConversation.areWeAdmin
: false;
let isMember = false;
let isAdmin = false;
if (contact && currentConversation && currentConversation.memberships) {
currentConversation.memberships.forEach(membership => {
if (membership.member.id === contact.id) {
isMember = true;
isAdmin = membership.isAdmin;
}
});
}
return {
...props,
areWeAdmin,
contact,
i18n: getIntl(state),
isAdmin,
isMember,
};
};

View file

@ -0,0 +1,56 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { StateType } from '../reducer';
import {
ConversationDetails,
StateProps,
} from '../../components/conversation/conversation-details/ConversationDetails';
import { getConversationSelector } from '../selectors/conversations';
import { getIntl } from '../selectors/user';
import { MediaItemType } from '../../components/LightboxGallery';
export type SmartConversationDetailsProps = {
conversationId: string;
hasGroupLink: 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;
};
const mapStateToProps = (
state: StateType,
props: SmartConversationDetailsProps
): StateProps => {
const conversation = getConversationSelector(state)(props.conversationId);
const canEditGroupInfo =
conversation && conversation.canEditGroupInfo
? conversation.canEditGroupInfo
: false;
const isAdmin =
conversation && conversation.areWeAdmin ? conversation.areWeAdmin : false;
return {
...props,
canEditGroupInfo,
conversation,
i18n: getIntl(state),
isAdmin,
};
};
const smart = connect(mapStateToProps);
export const SmartConversationDetails = smart(ConversationDetails);

View file

@ -40,6 +40,7 @@ export type OwnProps = {
onMarkUnread: () => void;
onMoveToInbox: () => void;
onShowSafetyNumber: () => void;
onShowConversationDetails: () => void;
};
const getOutgoingCallButtonStyle = (
@ -102,7 +103,9 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
'profileName',
'title',
'type',
'groupVersion',
]),
conversationTitle: state.conversations.selectedConversationTitle,
i18n: getIntl(state),
showBackButton: state.conversations.selectedConversationPanelDepth > 0,
outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state),

View file

@ -0,0 +1,39 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { StateType } from '../reducer';
import {
GroupLinkManagement,
PropsType,
} from '../../components/conversation/conversation-details/GroupLinkManagement';
import { getConversationSelector } from '../selectors/conversations';
import { getIntl } from '../selectors/user';
import { AccessControlClass } from '../../textsecure.d';
export type SmartGroupLinkManagementProps = {
accessEnum: typeof AccessControlClass.AccessRequired;
changeHasGroupLink: (value: boolean) => void;
conversationId: string;
copyGroupLink: (groupLink: string) => void;
generateNewGroupLink: () => void;
setAccessControlAddFromInviteLinkSetting: (value: boolean) => void;
};
const mapStateToProps = (
state: StateType,
props: SmartGroupLinkManagementProps
): PropsType => {
const conversation = getConversationSelector(state)(props.conversationId);
return {
...props,
conversation,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps);
export const SmartGroupLinkManagement = smart(GroupLinkManagement);

View file

@ -0,0 +1,37 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { StateType } from '../reducer';
import {
GroupV2Permissions,
PropsType,
} from '../../components/conversation/conversation-details/GroupV2Permissions';
import { getConversationSelector } from '../selectors/conversations';
import { getIntl } from '../selectors/user';
import { AccessControlClass } from '../../textsecure.d';
export type SmartGroupV2PermissionsProps = {
accessEnum: typeof AccessControlClass.AccessRequired;
conversationId: string;
setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void;
};
const mapStateToProps = (
state: StateType,
props: SmartGroupV2PermissionsProps
): PropsType => {
const conversation = getConversationSelector(state)(props.conversationId);
return {
...props,
conversation,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps);
export const SmartGroupV2Permissions = smart(GroupV2Permissions);

View file

@ -0,0 +1,39 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import {
PendingInvites,
PropsType,
} from '../../components/conversation/conversation-details/PendingInvites';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations';
export type SmartPendingInvitesProps = {
conversationId: string;
ourConversationId?: string;
readonly approvePendingMembership: (conversationid: string) => void;
readonly revokePendingMemberships: (membershipIds: Array<string>) => void;
};
const mapStateToProps = (
state: StateType,
props: SmartPendingInvitesProps
): PropsType => {
const { conversationId } = props;
const conversation = getConversationSelector(state)(conversationId);
return {
...props,
conversation,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartPendingInvites = smart(PendingInvites);

View file

@ -4,15 +4,327 @@
import { v4 as generateUuid } from 'uuid';
import { ConversationType } from '../../state/ducks/conversations';
const FIRST_NAMES = [
'James',
'John',
'Robert',
'Michael',
'William',
'David',
'Richard',
'Joseph',
'Thomas',
'Charles',
'Christopher',
'Daniel',
'Matthew',
'Anthony',
'Donald',
'Mark',
'Paul',
'Steven',
'Andrew',
'Kenneth',
'Joshua',
'Kevin',
'Brian',
'George',
'Edward',
'Ronald',
'Timothy',
'Jason',
'Jeffrey',
'Ryan',
'Jacob',
'Gary',
'Nicholas',
'Eric',
'Jonathan',
'Stephen',
'Larry',
'Justin',
'Scott',
'Brandon',
'Benjamin',
'Samuel',
'Frank',
'Gregory',
'Raymond',
'Alexander',
'Patrick',
'Jack',
'Dennis',
'Jerry',
'Tyler',
'Aaron',
'Jose',
'Henry',
'Adam',
'Douglas',
'Nathan',
'Peter',
'Zachary',
'Kyle',
'Walter',
'Harold',
'Jeremy',
'Ethan',
'Carl',
'Keith',
'Roger',
'Gerald',
'Christian',
'Terry',
'Sean',
'Arthur',
'Austin',
'Noah',
'Lawrence',
'Jesse',
'Joe',
'Bryan',
'Billy',
'Jordan',
'Albert',
'Dylan',
'Bruce',
'Willie',
'Gabriel',
'Alan',
'Juan',
'Logan',
'Wayne',
'Ralph',
'Roy',
'Eugene',
'Randy',
'Vincent',
'Russell',
'Louis',
'Philip',
'Bobby',
'Johnny',
'Bradley',
'Mary',
'Patricia',
'Jennifer',
'Linda',
'Elizabeth',
'Barbara',
'Susan',
'Jessica',
'Sarah',
'Karen',
'Nancy',
'Lisa',
'Margaret',
'Betty',
'Sandra',
'Ashley',
'Dorothy',
'Kimberly',
'Emily',
'Donna',
'Michelle',
'Carol',
'Amanda',
'Melissa',
'Deborah',
'Stephanie',
'Rebecca',
'Laura',
'Sharon',
'Cynthia',
'Kathleen',
'Amy',
'Shirley',
'Angela',
'Helen',
'Anna',
'Brenda',
'Pamela',
'Nicole',
'Samantha',
'Katherine',
'Emma',
'Ruth',
'Christine',
'Catherine',
'Debra',
'Rachel',
'Carolyn',
'Janet',
'Virginia',
'Maria',
'Heather',
'Diane',
'Julie',
'Joyce',
'Victoria',
'Kelly',
'Christina',
'Lauren',
'Joan',
'Evelyn',
'Olivia',
'Judith',
'Megan',
'Cheryl',
'Martha',
'Andrea',
'Frances',
'Hannah',
'Jacqueline',
'Ann',
'Gloria',
'Jean',
'Kathryn',
'Alice',
'Teresa',
'Sara',
'Janice',
'Doris',
'Madison',
'Julia',
'Grace',
'Judy',
'Abigail',
'Marie',
'Denise',
'Beverly',
'Amber',
'Theresa',
'Marilyn',
'Danielle',
'Diana',
'Brittany',
'Natalie',
'Sophia',
'Rose',
'Isabella',
'Alexis',
'Kayla',
'Charlotte',
];
const LAST_NAMES = [
'Smith',
'Johnson',
'Williams',
'Brown',
'Jones',
'Garcia',
'Miller',
'Davis',
'Rodriguez',
'Martinez',
'Hernandez',
'Lopez',
'Gonzales',
'Wilson',
'Anderson',
'Thomas',
'Taylor',
'Moore',
'Jackson',
'Martin',
'Lee',
'Perez',
'Thompson',
'White',
'Harris',
'Sanchez',
'Clark',
'Ramirez',
'Lewis',
'Robinson',
'Walker',
'Young',
'Allen',
'King',
'Wright',
'Scott',
'Torres',
'Nguyen',
'Hill',
'Flores',
'Green',
'Adams',
'Nelson',
'Baker',
'Hall',
'Rivera',
'Campbell',
'Mitchell',
'Carter',
'Roberts',
'Gomez',
'Phillips',
'Evans',
'Turner',
'Diaz',
'Parker',
'Cruz',
'Edwards',
'Collins',
'Reyes',
'Stewart',
'Morris',
'Morales',
'Murphy',
'Cook',
'Rogers',
'Gutierrez',
'Ortiz',
'Morgan',
'Cooper',
'Peterson',
'Bailey',
'Reed',
'Kelly',
'Howard',
'Ramos',
'Kim',
'Cox',
'Ward',
'Richardson',
'Watson',
'Brooks',
'Chavez',
'Wood',
'James',
'Bennet',
'Gray',
'Mendoza',
'Ruiz',
'Hughes',
'Price',
'Alvarez',
'Castillo',
'Sanders',
'Patel',
'Myers',
'Long',
'Ross',
'Foster',
'Jimenez',
];
export function getRandomTitle(): string {
const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
return `${firstName} ${lastName}`;
}
export function getDefaultConversation(
overrideProps: Partial<ConversationType>
): ConversationType {
return {
id: 'guid-1',
id: generateUuid(),
lastUpdated: Date.now(),
markedUnread: Boolean(overrideProps.markedUnread),
e164: '+1300555000',
title: 'Alice',
title: getRandomTitle(),
type: 'direct' as const,
uuid: generateUuid(),
...overrideProps,

6
ts/textsecure.d.ts vendored
View file

@ -504,6 +504,12 @@ export declare class GroupExternalCredentialClass {
}
export declare class GroupInviteLinkClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => GroupInviteLinkClass;
toArrayBuffer: () => ArrayBuffer;
v1Contents?: GroupInviteLinkClass.GroupInviteLinkContentsV1;
// Note: this isn't part of the proto, but our protobuf library tells us which

View file

@ -34,6 +34,7 @@ export type AttachmentType = {
pending?: boolean;
width?: number;
height?: number;
path?: string;
screenshot?: {
height: number;
width: number;
@ -46,6 +47,7 @@ export type AttachmentType = {
width: number;
url: string;
contentType: MIME.MIMEType;
path: string;
};
};

View file

@ -0,0 +1,26 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { LocalizerType } from '../types/Util';
import { AccessControlClass } from '../textsecure.d';
type AccessControlOption = {
name: string;
value: number;
};
export function getAccessControlOptions(
accessEnum: typeof AccessControlClass.AccessRequired,
i18n: LocalizerType
): Array<AccessControlOption> {
return [
{
name: i18n('GroupV2--all-members'),
value: accessEnum.MEMBER,
},
{
name: i18n('GroupV2--admin'),
value: accessEnum.ADMINISTRATOR,
},
];
}

View file

@ -14770,7 +14770,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 102,
"lineNumber": 105,
"reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Used to reference popup menu"

View file

@ -7,7 +7,10 @@
// use normal import syntax, nor can we use 'import type' syntax, or this will be turned
// into a module, and we'll get the dreaded 'exports is not defined' error.
// see https://github.com/microsoft/TypeScript/issues/41562
type AttachmentType = import('../types/Attachment').AttachmentType;
type GroupV2PendingMemberType = import('../model-types.d').GroupV2PendingMemberType;
type MediaItemType = import('../components/LightboxGallery').MediaItemType;
type MessageType = import('../state/ducks/conversations').MessageType;
type GetLinkPreviewResult = {
title: string;
@ -29,7 +32,7 @@ const LINK_PREVIEW_TIMEOUT = 60 * 1000;
window.Whisper = window.Whisper || {};
const { Whisper } = window;
const { Message, MIME, VisualAttachment } = window.Signal.Types;
const { Message, MIME, VisualAttachment, Attachment } = window.Signal.Types;
const {
copyIntoTempDirectory,
@ -224,6 +227,12 @@ Whisper.ReactionFailedToast = Whisper.ToastView.extend({
},
});
Whisper.GroupLinkCopiedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('GroupLinkManagement--clipboard') };
},
});
Whisper.PinnedConversationsFullToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('pinnedConversationsFull') };
@ -523,6 +532,9 @@ Whisper.ConversationView = Whisper.View.extend({
}
},
onShowConversationDetails: () => {
this.showConversationDetails();
},
onShowSafetyNumber: () => {
this.showSafetyNumber();
},
@ -565,6 +577,7 @@ Whisper.ConversationView = Whisper.View.extend({
),
});
this.$('.conversation-header').append(this.titleView.el);
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
},
setupCompositionArea({ attachmentListEl }: any) {
@ -2124,10 +2137,6 @@ Whisper.ConversationView = Whisper.View.extend({
},
async showAllMedia() {
if (this.panels && this.panels.length > 0) {
return;
}
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const DEFAULT_MEDIA_FETCH_COUNT = 50;
@ -2267,6 +2276,7 @@ Whisper.ConversationView = Whisper.View.extend({
this.stopListening(this.model.messageCollection, 'remove', update);
},
});
view.headerTitle = window.i18n('allMedia');
const update = async () => {
view.update(await getProps());
@ -2570,7 +2580,49 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
showLightbox({ attachment, messageId }: any) {
// TODO: DESKTOP-1133 (DRY up these lightboxes)
showLightboxForMedia(selectedMediaItem: any, media: Array<any> = []) {
const onSave = async (options: any = {}) => {
const fullPath = await window.Signal.Types.Attachment.save({
attachment: options.attachment,
index: options.index + 1,
readAttachmentData,
saveAttachmentToDisk,
timestamp: options.message.get('sent_at'),
});
if (fullPath) {
this.showToast(Whisper.FileSavedToast, { fullPath });
}
};
const selectedIndex = media.findIndex(
mediaItem =>
mediaItem.attachment.path === selectedMediaItem.attachment.path
);
this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: window.Signal.Components.LightboxGallery,
props: {
media,
onSave,
selectedIndex,
},
onClose: () => window.Signal.Backbone.Views.Lightbox.hide(),
});
window.Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el);
},
showLightbox({
attachment,
messageId,
}: {
attachment: typeof Attachment;
messageId: string;
showSingle?: boolean;
}) {
const message = this.model.messageCollection.get(messageId);
if (!message) {
throw new Error(`showLightbox: did not find message for id ${messageId}`);
@ -2686,7 +2738,6 @@ Whisper.ConversationView = Whisper.View.extend({
};
this.contactModalView = new Whisper.ReactWrapperView({
className: 'progress-modal-wrapper',
JSX: window.Signal.State.Roots.createContactModal(window.reduxStore, {
contactId,
currentConversationId: this.model.id,
@ -2695,13 +2746,43 @@ Whisper.ConversationView = Whisper.View.extend({
hideContactModal();
this.openConversation(conversationId);
},
removeMember: (conversationId: string) => {
hideContactModal();
this.model.removeFromGroupV2(conversationId);
},
showSafetyNumber: (conversationId: string) => {
hideContactModal();
this.showSafetyNumber(conversationId);
},
removeMember: (conversationId: string) => {
toggleAdmin: (conversationId: string) => {
hideContactModal();
this.model.removeFromGroupV2(conversationId);
const isAdmin = this.model.isAdmin(conversationId);
const conversationModel = window.ConversationController.get(
conversationId
);
if (!conversationModel) {
window.log.info(
'conversation_view/toggleAdmin: Could not find conversation to toggle admin privileges'
);
return;
}
window.showConfirmationDialog({
cancelText: window.i18n('cancel'),
message: isAdmin
? window.i18n('ContactModal--rm-admin-info', [
conversationModel.getTitle(),
])
: window.i18n('ContactModal--make-admin-info', [
conversationModel.getTitle(),
]),
okText: isAdmin
? window.i18n('ContactModal--rm-admin')
: window.i18n('ContactModal--make-admin'),
resolve: () => this.model.toggleAdmin(conversationId),
});
},
}),
});
@ -2709,6 +2790,136 @@ Whisper.ConversationView = Whisper.View.extend({
this.contactModalView.render();
},
showGroupLinkManagement() {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createGroupLinkManagement(
window.reduxStore,
{
accessEnum: window.textsecure.protobuf.AccessControl.AccessRequired,
changeHasGroupLink: this.changeHasGroupLink.bind(this),
conversationId: this.model.id,
copyGroupLink: this.copyGroupLink.bind(this),
generateNewGroupLink: this.generateNewGroupLink.bind(this),
setAccessControlAddFromInviteLinkSetting: this.setAccessControlAddFromInviteLinkSetting.bind(
this
),
}
),
});
view.headerTitle = window.i18n('ConversationDetails--group-link');
this.listenBack(view);
view.render();
},
showGroupV2Permissions() {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createGroupV2Permissions(
window.reduxStore,
{
accessEnum: window.textsecure.protobuf.AccessControl.AccessRequired,
conversationId: this.model.id,
setAccessControlAttributesSetting: this.setAccessControlAttributesSetting.bind(
this
),
setAccessControlMembersSetting: this.setAccessControlMembersSetting.bind(
this
),
}
),
});
view.headerTitle = window.i18n('permissions');
this.listenBack(view);
view.render();
},
showPendingInvites() {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, {
conversationId: this.model.id,
ourConversationId: window.ConversationController.getOurConversationId(),
approvePendingMembership: (conversationId: string) => {
this.model.approvePendingMembershipFromGroupV2(conversationId);
},
revokePendingMemberships: conversationIds => {
this.model.revokePendingMembershipsFromGroupV2(conversationIds);
},
}),
});
view.headerTitle = window.i18n('ConversationDetails--requests-and-invites');
this.listenBack(view);
view.render();
},
showConversationDetails() {
const conversation = this.model;
const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
// these methods are used in more than one place and should probably be
// dried up and hoisted to methods on ConversationView
const onDelete = () => {
this.longRunningTaskWrapper({
name: 'onDelete',
task: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.DELETE
),
});
};
const onBlockAndDelete = () => {
this.longRunningTaskWrapper({
name: 'onBlockAndDelete',
task: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.BLOCK_AND_DELETE
),
});
};
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const hasGroupLink =
conversation.get('groupInviteLinkPassword') &&
conversation.get('accessControl')?.addFromInviteLink !==
ACCESS_ENUM.UNSATISFIABLE;
const props = {
conversationId: conversation.get('id'),
hasGroupLink,
loadRecentMediaItems: this.loadRecentMediaItems.bind(this),
setDisappearingMessages: this.setDisappearingMessages.bind(this),
showAllMedia: this.showAllMedia.bind(this),
showContactModal: this.showContactModal.bind(this),
showGroupLinkManagement: this.showGroupLinkManagement.bind(this),
showGroupV2Permissions: this.showGroupV2Permissions.bind(this),
showPendingInvites: this.showPendingInvites.bind(this),
showLightboxForMedia: this.showLightboxForMedia.bind(this),
onDelete,
onBlockAndDelete,
};
const view = new Whisper.ReactWrapperView({
className: 'conversation-details-pane panel',
JSX: window.Signal.State.Roots.createConversationDetails(
window.reduxStore,
props
),
});
view.headerTitle = '';
this.listenBack(view);
view.render();
},
showMessageDetail(messageId: any) {
const message = this.model.messageCollection.get(messageId);
if (!message) {
@ -2797,6 +3008,9 @@ Whisper.ConversationView = Whisper.View.extend({
window.reduxActions.conversations.setSelectedConversationPanelDepth(
this.panels.length
);
window.reduxActions.conversations.setSelectedConversationHeaderTitle(
view.headerTitle
);
},
resetPanel() {
if (!this.panels || !this.panels.length) {
@ -2830,12 +3044,56 @@ Whisper.ConversationView = Whisper.View.extend({
window.reduxActions.conversations.setSelectedConversationPanelDepth(
this.panels.length
);
window.reduxActions.conversations.setSelectedConversationHeaderTitle(
this.panels[0]?.headerTitle
);
},
endSession() {
this.model.endSession();
},
async loadRecentMediaItems(limit: number): Promise<void> {
const messages: Array<MessageType> = await window.Signal.Data.getMessagesWithVisualMediaAttachments(
this.model.id,
{
limit,
}
);
const loadedRecentMediaItems = messages
.filter(message => message.attachments !== undefined)
.reduce(
(acc, message) => [
...acc,
...message.attachments.map(
(attachment: AttachmentType, index: number): MediaItemType => {
const { thumbnail } = attachment;
return {
objectURL: getAbsoluteAttachmentPath(attachment.path || ''),
thumbnailObjectUrl: thumbnail
? getAbsoluteAttachmentPath(thumbnail.path)
: '',
contentType: attachment.contentType,
index,
attachment,
// this message is a valid structure, but doesn't work with ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message: message as any,
};
}
),
],
[] as Array<MediaItemType>
);
window.reduxActions.conversations.setRecentMediaItems(
this.model.id,
loadedRecentMediaItems
);
},
async setDisappearingMessages(seconds: any) {
const valueToSet = seconds > 0 ? seconds : null;
@ -2845,6 +3103,53 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
async changeHasGroupLink(value: boolean) {
await this.longRunningTaskWrapper({
name: 'toggleGroupLink',
task: async () => this.model.toggleGroupLink(value),
});
},
async copyGroupLink(groupLink: string) {
await navigator.clipboard.writeText(groupLink);
this.showToast(Whisper.GroupLinkCopiedToast);
},
async generateNewGroupLink() {
window.showConfirmationDialog({
confirmStyle: 'negative',
message: window.i18n('GroupLinkManagement--confirm-reset'),
okText: window.i18n('GroupLinkManagement--reset'),
resolve: async () => {
await this.longRunningTaskWrapper({
name: 'refreshGroupLink',
task: async () => this.model.refreshGroupLink(),
});
},
});
},
async setAccessControlAddFromInviteLinkSetting(value: boolean) {
await this.longRunningTaskWrapper({
name: 'updateAccessControlAddFromInviteLink',
task: async () => this.model.updateAccessControlAddFromInviteLink(value),
});
},
async setAccessControlAttributesSetting(value: number) {
await this.longRunningTaskWrapper({
name: 'updateAccessControlAttributes',
task: async () => this.model.updateAccessControlAttributes(value),
});
},
async setAccessControlMembersSetting(value: number) {
await this.longRunningTaskWrapper({
name: 'updateAccessControlMembers',
task: async () => this.model.updateAccessControlMembers(value),
});
},
setMuteNotifications(ms: number) {
const muteExpiresAt = ms > 0 ? Date.now() + ms : undefined;

13
ts/window.d.ts vendored
View file

@ -43,9 +43,13 @@ import { createStore } from './state/createStore';
import { createCallManager } from './state/roots/createCallManager';
import { createCompositionArea } from './state/roots/createCompositionArea';
import { createContactModal } from './state/roots/createContactModal';
import { createConversationDetails } from './state/roots/createConversationDetails';
import { createConversationHeader } from './state/roots/createConversationHeader';
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
import { createGroupV2Permissions } from './state/roots/createGroupV2Permissions';
import { createLeftPane } from './state/roots/createLeftPane';
import { createPendingInvites } from './state/roots/createPendingInvites';
import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer';
import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal';
import { createStickerManager } from './state/roots/createStickerManager';
@ -85,6 +89,7 @@ import { MessageDetail } from './components/conversation/MessageDetail';
import { ProgressModal } from './components/ProgressModal';
import { Quote } from './components/conversation/Quote';
import { StagedLinkPreview } from './components/conversation/StagedLinkPreview';
import { MIMEType } from './types/MIME';
export { Long } from 'long';
@ -335,8 +340,9 @@ declare global {
path: string;
objectUrl: string;
};
contentType: string;
contentType: MIMEType;
error: unknown;
caption: string;
migrateDataToFileSystem: (
attachment: WhatIsThis,
@ -448,9 +454,13 @@ declare global {
createCallManager: typeof createCallManager;
createCompositionArea: typeof createCompositionArea;
createContactModal: typeof createContactModal;
createConversationDetails: typeof createConversationDetails;
createConversationHeader: typeof createConversationHeader;
createGroupLinkManagement: typeof createGroupLinkManagement;
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
createGroupV2Permissions: typeof createGroupV2Permissions;
createLeftPane: typeof createLeftPane;
createPendingInvites: typeof createPendingInvites;
createSafetyNumberViewer: typeof createSafetyNumberViewer;
createShortcutGuideModal: typeof createShortcutGuideModal;
createStickerManager: typeof createStickerManager;
@ -641,6 +651,7 @@ export type WhisperType = {
BannerView: any;
RecorderView: any;
GroupMemberList: any;
GroupLinkCopiedToast: typeof Backbone.View;
KeyVerificationPanelView: any;
SafetyNumberChangeDialogView: any;
BodyRangesType: BodyRangesType;