Introduce conversation details screen for New Groups
Co-authored-by: Chris Svenningsen <chris@carbonfive.com> Co-authored-by: Sidney Keese <me@sidke.com>
This commit is contained in:
parent
1268945840
commit
c0510b08a5
64 changed files with 4699 additions and 81 deletions
|
@ -10,6 +10,7 @@ type ConfigKeyType =
|
|||
| 'desktop.disableGV1'
|
||||
| 'desktop.groupCalling'
|
||||
| 'desktop.gv2'
|
||||
| 'desktop.gv2Admin'
|
||||
| 'desktop.mandatoryProfileSharing'
|
||||
| 'desktop.messageRequests'
|
||||
| 'desktop.storage'
|
||||
|
|
|
@ -39,7 +39,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
draftText: overrideProps.draftText || undefined,
|
||||
clearQuotedMessage: action('clearQuotedMessage'),
|
||||
getQuotedMessage: action('getQuotedMessage'),
|
||||
members: [],
|
||||
sortedGroupMembers: [],
|
||||
// EmojiButton
|
||||
onPickEmoji: action('onPickEmoji'),
|
||||
onSetSkinTone: action('onSetSkinTone'),
|
||||
|
|
|
@ -54,7 +54,7 @@ export type OwnProps = {
|
|||
|
||||
export type Props = Pick<
|
||||
CompositionInputProps,
|
||||
| 'members'
|
||||
| 'sortedGroupMembers'
|
||||
| 'onSubmit'
|
||||
| 'onEditorStateChange'
|
||||
| 'onTextTooLong'
|
||||
|
@ -106,7 +106,7 @@ export const CompositionArea = ({
|
|||
draftBodyRanges,
|
||||
clearQuotedMessage,
|
||||
getQuotedMessage,
|
||||
members,
|
||||
sortedGroupMembers,
|
||||
// EmojiButton
|
||||
onPickEmoji,
|
||||
onSetSkinTone,
|
||||
|
@ -450,7 +450,7 @@ export const CompositionArea = ({
|
|||
draftBodyRanges={draftBodyRanges}
|
||||
clearQuotedMessage={clearQuotedMessage}
|
||||
getQuotedMessage={getQuotedMessage}
|
||||
members={members}
|
||||
sortedGroupMembers={sortedGroupMembers}
|
||||
/>
|
||||
</div>
|
||||
{!large ? (
|
||||
|
|
|
@ -28,7 +28,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
getQuotedMessage: action('getQuotedMessage'),
|
||||
onPickEmoji: action('onPickEmoji'),
|
||||
large: boolean('large', overrideProps.large || false),
|
||||
members: overrideProps.members || [],
|
||||
sortedGroupMembers: overrideProps.sortedGroupMembers || [],
|
||||
skinTone: select(
|
||||
'skinTone',
|
||||
{
|
||||
|
@ -103,7 +103,7 @@ story.add('Emojis', () => {
|
|||
|
||||
story.add('Mentions', () => {
|
||||
const props = createProps({
|
||||
members: [
|
||||
sortedGroupMembers: [
|
||||
{
|
||||
id: '0',
|
||||
type: 'direct',
|
||||
|
|
|
@ -63,7 +63,7 @@ export type Props = {
|
|||
readonly skinTone?: EmojiPickDataType['skinTone'];
|
||||
readonly draftText?: string;
|
||||
readonly draftBodyRanges?: Array<BodyRangeType>;
|
||||
members?: Array<ConversationType>;
|
||||
sortedGroupMembers?: Array<ConversationType>;
|
||||
onDirtyChange?(dirty: boolean): unknown;
|
||||
onEditorStateChange?(
|
||||
messageText: string,
|
||||
|
@ -92,7 +92,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
draftBodyRanges,
|
||||
getQuotedMessage,
|
||||
clearQuotedMessage,
|
||||
members,
|
||||
sortedGroupMembers,
|
||||
} = props;
|
||||
|
||||
const [emojiCompletionElement, setEmojiCompletionElement] = React.useState<
|
||||
|
@ -459,11 +459,11 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
quill.updateContents(newDelta as any);
|
||||
};
|
||||
|
||||
const memberIds = members ? members.map(m => m.id) : [];
|
||||
const memberIds = sortedGroupMembers ? sortedGroupMembers.map(m => m.id) : [];
|
||||
|
||||
React.useEffect(() => {
|
||||
memberRepositoryRef.current.updateMembers(members || []);
|
||||
removeStaleMentions(members || []);
|
||||
memberRepositoryRef.current.updateMembers(sortedGroupMembers || []);
|
||||
removeStaleMentions(sortedGroupMembers || []);
|
||||
// We are still depending on members, but ESLint can't tell
|
||||
// Comparing the actual members list does not work for a couple reasons:
|
||||
// * Arrays with the same objects are not "equal" to React
|
||||
|
@ -510,7 +510,9 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
skinTone,
|
||||
},
|
||||
mentionCompletion: {
|
||||
me: members ? members.find(foo => foo.isMe) : undefined,
|
||||
me: sortedGroupMembers
|
||||
? sortedGroupMembers.find(foo => foo.isMe)
|
||||
: undefined,
|
||||
memberRepositoryRef,
|
||||
setMentionPickerElement: setMentionCompletionElement,
|
||||
i18n,
|
||||
|
|
|
@ -31,11 +31,13 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),
|
||||
contact: overrideProps.contact || defaultContact,
|
||||
i18n,
|
||||
isAdmin: boolean('isAdmin', overrideProps.isAdmin || false),
|
||||
isMember: boolean('isMember', overrideProps.isMember || true),
|
||||
onClose: action('onClose'),
|
||||
openConversation: action('openConversation'),
|
||||
removeMember: action('removeMember'),
|
||||
showSafetyNumber: action('showSafetyNumber'),
|
||||
toggleAdmin: action('toggleAdmin'),
|
||||
});
|
||||
|
||||
story.add('As non-admin', () => {
|
||||
|
|
|
@ -13,22 +13,26 @@ export type PropsType = {
|
|||
areWeAdmin: boolean;
|
||||
contact?: ConversationType;
|
||||
readonly i18n: LocalizerType;
|
||||
isAdmin: boolean;
|
||||
isMember: boolean;
|
||||
onClose: () => void;
|
||||
openConversation: (conversationId: string) => void;
|
||||
removeMember: (conversationId: string) => void;
|
||||
showSafetyNumber: (conversationId: string) => void;
|
||||
toggleAdmin: (conversationId: string) => void;
|
||||
};
|
||||
|
||||
export const ContactModal = ({
|
||||
areWeAdmin,
|
||||
contact,
|
||||
i18n,
|
||||
isAdmin,
|
||||
isMember,
|
||||
onClose,
|
||||
openConversation,
|
||||
removeMember,
|
||||
showSafetyNumber,
|
||||
toggleAdmin,
|
||||
}: PropsType): ReactPortal | null => {
|
||||
if (!contact) {
|
||||
throw new Error('Contact modal opened without a matching contact');
|
||||
|
@ -143,16 +147,32 @@ export const ContactModal = ({
|
|||
</button>
|
||||
)}
|
||||
{!contact.isMe && areWeAdmin && isMember && (
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__remove-from-group"
|
||||
onClick={() => removeMember(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__remove-from-group__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--remove-from-group')}</span>
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__make-admin"
|
||||
onClick={() => toggleAdmin(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__make-admin__bubble-icon" />
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<span>{i18n('ContactModal--rm-admin')}</span>
|
||||
) : (
|
||||
<span>{i18n('ContactModal--make-admin')}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__remove-from-group"
|
||||
onClick={() => removeMember(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__remove-from-group__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--remove-from-group')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -33,6 +33,7 @@ const commonProps = {
|
|||
|
||||
i18n,
|
||||
|
||||
onShowConversationDetails: action('onShowConversationDetails'),
|
||||
onSetDisappearingMessages: action('onSetDisappearingMessages'),
|
||||
onDeleteMessages: action('onDeleteMessages'),
|
||||
onResetSession: action('onResetSession'),
|
||||
|
|
|
@ -33,6 +33,7 @@ export enum OutgoingCallButtonStyle {
|
|||
}
|
||||
|
||||
export type PropsDataType = {
|
||||
conversationTitle?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
|
||||
|
@ -51,6 +52,7 @@ export type PropsDataType = {
|
|||
isMissingMandatoryProfileSharing?: boolean;
|
||||
left?: boolean;
|
||||
markedUnread?: boolean;
|
||||
groupVersion?: number;
|
||||
|
||||
canChangeTimer?: boolean;
|
||||
expireTimer?: number;
|
||||
|
@ -71,6 +73,7 @@ export type PropsActionsType = {
|
|||
onOutgoingVideoCallInConversation: () => void;
|
||||
onSetPin: (value: boolean) => void;
|
||||
|
||||
onShowConversationDetails: () => void;
|
||||
onShowSafetyNumber: () => void;
|
||||
onShowAllMedia: () => void;
|
||||
onShowGroupMembers: () => void;
|
||||
|
@ -126,7 +129,7 @@ export class ConversationHeader extends React.Component<PropsType> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderTitle(): JSX.Element {
|
||||
public renderTitle(): JSX.Element | null {
|
||||
const {
|
||||
name,
|
||||
phoneNumber,
|
||||
|
@ -352,11 +355,13 @@ export class ConversationHeader extends React.Component<PropsType> {
|
|||
muteExpiresAt,
|
||||
isMissingMandatoryProfileSharing,
|
||||
left,
|
||||
groupVersion,
|
||||
onDeleteMessages,
|
||||
onResetSession,
|
||||
onSetDisappearingMessages,
|
||||
onSetMuteNotifications,
|
||||
onShowAllMedia,
|
||||
onShowConversationDetails,
|
||||
onShowGroupMembers,
|
||||
onShowSafetyNumber,
|
||||
onArchive,
|
||||
|
@ -401,6 +406,11 @@ export class ConversationHeader extends React.Component<PropsType> {
|
|||
isMissingMandatoryProfileSharing
|
||||
);
|
||||
|
||||
const hasGV2AdminEnabled =
|
||||
isGroup &&
|
||||
groupVersion === 2 &&
|
||||
window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin');
|
||||
|
||||
return (
|
||||
<ContextMenu id={triggerId}>
|
||||
{disableTimerChanges ? null : (
|
||||
|
@ -430,7 +440,12 @@ export class ConversationHeader extends React.Component<PropsType> {
|
|||
</MenuItem>
|
||||
))}
|
||||
</SubMenu>
|
||||
{isGroup ? (
|
||||
{hasGV2AdminEnabled ? (
|
||||
<MenuItem onClick={onShowConversationDetails}>
|
||||
{i18n('showConversationDetails')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{isGroup && !hasGV2AdminEnabled ? (
|
||||
<MenuItem onClick={onShowGroupMembers}>
|
||||
{i18n('showMembers')}
|
||||
</MenuItem>
|
||||
|
@ -470,7 +485,23 @@ export class ConversationHeader extends React.Component<PropsType> {
|
|||
}
|
||||
|
||||
private renderHeader(): JSX.Element {
|
||||
const { id, isMe, onShowContactModal, type } = this.props;
|
||||
const {
|
||||
conversationTitle,
|
||||
id,
|
||||
isMe,
|
||||
onShowContactModal,
|
||||
type,
|
||||
} = this.props;
|
||||
|
||||
if (conversationTitle) {
|
||||
return (
|
||||
<div className="module-conversation-header__title-flex">
|
||||
<div className="module-conversation-header__title">
|
||||
{conversationTitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'group' || isMe) {
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
import { ConversationDetails, Props } from './ConversationDetails';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/ConversationDetails',
|
||||
module
|
||||
);
|
||||
|
||||
const conversation: ConversationType = {
|
||||
id: '',
|
||||
lastUpdated: 0,
|
||||
markedUnread: false,
|
||||
memberships: Array.from(Array(32)).map(() => ({
|
||||
isAdmin: false,
|
||||
member: getDefaultConversation({}),
|
||||
metadata: {
|
||||
conversationId: '',
|
||||
joinedAtVersion: 0,
|
||||
role: 2,
|
||||
},
|
||||
})),
|
||||
pendingMemberships: Array.from(Array(16)).map(() => ({
|
||||
member: getDefaultConversation({}),
|
||||
metadata: {
|
||||
conversationId: '',
|
||||
role: 2,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})),
|
||||
title: 'Some Conversation',
|
||||
type: 'group',
|
||||
};
|
||||
|
||||
const createProps = (hasGroupLink = false): Props => ({
|
||||
canEditGroupInfo: false,
|
||||
conversation,
|
||||
hasGroupLink,
|
||||
i18n,
|
||||
isAdmin: false,
|
||||
loadRecentMediaItems: action('loadRecentMediaItems'),
|
||||
setDisappearingMessages: action('setDisappearingMessages'),
|
||||
showAllMedia: action('showAllMedia'),
|
||||
showContactModal: action('showContactModal'),
|
||||
showGroupLinkManagement: action('showGroupLinkManagement'),
|
||||
showGroupV2Permissions: action('showGroupV2Permissions'),
|
||||
showPendingInvites: action('showPendingInvites'),
|
||||
showLightboxForMedia: action('showLightboxForMedia'),
|
||||
onBlockAndDelete: action('onBlockAndDelete'),
|
||||
onDelete: action('onDelete'),
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <ConversationDetails {...props} />;
|
||||
});
|
||||
|
||||
story.add('as Admin', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <ConversationDetails {...props} isAdmin />;
|
||||
});
|
||||
|
||||
story.add('Group Editable', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <ConversationDetails {...props} canEditGroupInfo />;
|
||||
});
|
||||
|
||||
story.add('Group Links On', () => {
|
||||
const props = createProps(true);
|
||||
|
||||
return <ConversationDetails {...props} isAdmin />;
|
||||
});
|
|
@ -0,0 +1,176 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import {
|
||||
ExpirationTimerOptions,
|
||||
TimerOption,
|
||||
} from '../../../util/ExpirationTimerOptions';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { MediaItemType } from '../../LightboxGallery';
|
||||
|
||||
import { PanelRow } from './PanelRow';
|
||||
import { PanelSection } from './PanelSection';
|
||||
import { ConversationDetailsActions } from './ConversationDetailsActions';
|
||||
import { ConversationDetailsHeader } from './ConversationDetailsHeader';
|
||||
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
|
||||
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
|
||||
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
|
||||
|
||||
export type StateProps = {
|
||||
canEditGroupInfo: boolean;
|
||||
conversation?: ConversationType;
|
||||
hasGroupLink: boolean;
|
||||
i18n: LocalizerType;
|
||||
isAdmin: boolean;
|
||||
loadRecentMediaItems: (limit: number) => void;
|
||||
setDisappearingMessages: (seconds: number) => void;
|
||||
showAllMedia: () => void;
|
||||
showContactModal: (conversationId: string) => void;
|
||||
showGroupLinkManagement: () => void;
|
||||
showGroupV2Permissions: () => void;
|
||||
showPendingInvites: () => void;
|
||||
showLightboxForMedia: (
|
||||
selectedMediaItem: MediaItemType,
|
||||
media: Array<MediaItemType>
|
||||
) => void;
|
||||
onBlockAndDelete: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export type Props = StateProps;
|
||||
|
||||
export const ConversationDetails: React.ComponentType<Props> = ({
|
||||
canEditGroupInfo,
|
||||
conversation,
|
||||
hasGroupLink,
|
||||
i18n,
|
||||
isAdmin,
|
||||
loadRecentMediaItems,
|
||||
setDisappearingMessages,
|
||||
showAllMedia,
|
||||
showContactModal,
|
||||
showGroupLinkManagement,
|
||||
showGroupV2Permissions,
|
||||
showPendingInvites,
|
||||
showLightboxForMedia,
|
||||
onBlockAndDelete,
|
||||
onDelete,
|
||||
}) => {
|
||||
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setDisappearingMessages(parseInt(event.target.value, 10));
|
||||
};
|
||||
|
||||
if (conversation === undefined) {
|
||||
throw new Error('ConversationDetails rendered without a conversation');
|
||||
}
|
||||
|
||||
const pendingMemberships = conversation.pendingMemberships || [];
|
||||
const pendingApprovalMemberships =
|
||||
conversation.pendingApprovalMemberships || [];
|
||||
const invitesCount =
|
||||
pendingMemberships.length + pendingApprovalMemberships.length;
|
||||
|
||||
return (
|
||||
<div className="conversation-details-panel">
|
||||
<ConversationDetailsHeader i18n={i18n} conversation={conversation} />
|
||||
|
||||
{canEditGroupInfo ? (
|
||||
<PanelSection>
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n(
|
||||
'ConversationDetails--disappearing-messages-label'
|
||||
)}
|
||||
icon="timer"
|
||||
/>
|
||||
}
|
||||
info={i18n('ConversationDetails--disappearing-messages-info')}
|
||||
label={i18n('ConversationDetails--disappearing-messages-label')}
|
||||
right={
|
||||
<div className="module-conversation-details-select">
|
||||
<select
|
||||
onChange={updateExpireTimer}
|
||||
value={conversation.expireTimer || 0}
|
||||
>
|
||||
{ExpirationTimerOptions.map((item: typeof TimerOption) => (
|
||||
<option
|
||||
value={item.get('seconds')}
|
||||
key={item.get('seconds')}
|
||||
aria-label={item.getName(i18n)}
|
||||
>
|
||||
{item.getName(i18n)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</PanelSection>
|
||||
) : null}
|
||||
|
||||
<ConversationDetailsMembershipList
|
||||
i18n={i18n}
|
||||
showContactModal={showContactModal}
|
||||
memberships={conversation.memberships || []}
|
||||
/>
|
||||
|
||||
<PanelSection>
|
||||
{isAdmin ? (
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('ConversationDetails--group-link')}
|
||||
icon="link"
|
||||
/>
|
||||
}
|
||||
label={i18n('ConversationDetails--group-link')}
|
||||
onClick={showGroupLinkManagement}
|
||||
right={hasGroupLink ? i18n('on') : i18n('off')}
|
||||
/>
|
||||
) : null}
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('ConversationDetails--requests-and-invites')}
|
||||
icon="invites"
|
||||
/>
|
||||
}
|
||||
label={i18n('ConversationDetails--requests-and-invites')}
|
||||
onClick={showPendingInvites}
|
||||
right={invitesCount}
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('permissions')}
|
||||
icon="lock"
|
||||
/>
|
||||
}
|
||||
label={i18n('permissions')}
|
||||
onClick={showGroupV2Permissions}
|
||||
/>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<ConversationDetailsMediaList
|
||||
conversation={conversation}
|
||||
i18n={i18n}
|
||||
loadRecentMediaItems={loadRecentMediaItems}
|
||||
showAllMedia={showAllMedia}
|
||||
showLightboxForMedia={showLightboxForMedia}
|
||||
/>
|
||||
|
||||
<ConversationDetailsActions
|
||||
i18n={i18n}
|
||||
conversationTitle={conversation.title}
|
||||
onDelete={onDelete}
|
||||
onBlockAndDelete={onBlockAndDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
import {
|
||||
ConversationDetailsActions,
|
||||
Props,
|
||||
} from './ConversationDetailsActions';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/ConversationDetailsActions',
|
||||
module
|
||||
);
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
conversationTitle: overrideProps.conversationTitle || '',
|
||||
onBlockAndDelete: action('onBlockAndDelete'),
|
||||
onDelete: action('onDelete'),
|
||||
i18n,
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <ConversationDetailsActions {...props} />;
|
||||
});
|
|
@ -0,0 +1,95 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { ConfirmationModal } from '../../ConfirmationModal';
|
||||
|
||||
import { PanelRow } from './PanelRow';
|
||||
import { PanelSection } from './PanelSection';
|
||||
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
|
||||
|
||||
export type Props = {
|
||||
conversationTitle: string;
|
||||
onBlockAndDelete: () => void;
|
||||
onDelete: () => void;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
export const ConversationDetailsActions: React.ComponentType<Props> = ({
|
||||
conversationTitle,
|
||||
onBlockAndDelete,
|
||||
onDelete,
|
||||
i18n,
|
||||
}) => {
|
||||
const [confirmingLeave, setConfirmingLeave] = React.useState<boolean>(false);
|
||||
const [confirmingBlock, setConfirmingBlock] = React.useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelSection>
|
||||
<PanelRow
|
||||
onClick={() => setConfirmingLeave(true)}
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('ConversationDetailsActions--leave-group')}
|
||||
icon="leave"
|
||||
/>
|
||||
}
|
||||
label={i18n('ConversationDetailsActions--leave-group')}
|
||||
/>
|
||||
<PanelRow
|
||||
onClick={() => setConfirmingBlock(true)}
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('ConversationDetailsActions--block-group')}
|
||||
icon="block"
|
||||
/>
|
||||
}
|
||||
label={i18n('ConversationDetailsActions--block-group')}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{confirmingLeave && (
|
||||
<ConfirmationModal
|
||||
actions={[
|
||||
{
|
||||
text: i18n(
|
||||
'ConversationDetailsActions--leave-group-modal-confirm'
|
||||
),
|
||||
action: onDelete,
|
||||
style: 'affirmative',
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={() => setConfirmingLeave(false)}
|
||||
title={i18n('ConversationDetailsActions--leave-group-modal-title')}
|
||||
>
|
||||
{i18n('ConversationDetailsActions--leave-group-modal-content')}
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
|
||||
{confirmingBlock && (
|
||||
<ConfirmationModal
|
||||
actions={[
|
||||
{
|
||||
text: i18n(
|
||||
'ConversationDetailsActions--block-group-modal-confirm'
|
||||
),
|
||||
action: onBlockAndDelete,
|
||||
style: 'affirmative',
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={() => setConfirmingBlock(false)}
|
||||
title={i18n('ConversationDetailsActions--block-group-modal-title', [
|
||||
conversationTitle,
|
||||
])}
|
||||
>
|
||||
{i18n('ConversationDetailsActions--block-group-modal-content')}
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { number, text } from '@storybook/addon-knobs';
|
||||
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
|
||||
import { ConversationDetailsHeader, Props } from './ConversationDetailsHeader';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/ConversationDetailHeader',
|
||||
module
|
||||
);
|
||||
|
||||
const createConversation = (): ConversationType => ({
|
||||
id: '',
|
||||
markedUnread: false,
|
||||
type: 'group',
|
||||
lastUpdated: 0,
|
||||
title: text('conversation title', 'Some Conversation'),
|
||||
memberships: new Array(number('conversation members length', 0)),
|
||||
});
|
||||
|
||||
const createProps = (): Props => ({
|
||||
conversation: createConversation(),
|
||||
i18n,
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <ConversationDetailsHeader {...props} />;
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Avatar } from '../../Avatar';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { bemGenerator } from './util';
|
||||
|
||||
export type Props = {
|
||||
i18n: LocalizerType;
|
||||
conversation: ConversationType;
|
||||
};
|
||||
|
||||
const bem = bemGenerator('module-conversation-details-header');
|
||||
|
||||
export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
||||
i18n,
|
||||
conversation,
|
||||
}) => {
|
||||
const memberships = conversation.memberships || [];
|
||||
|
||||
return (
|
||||
<div className={bem('root')}>
|
||||
<Avatar
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
size={80}
|
||||
{...conversation}
|
||||
/>
|
||||
<div>
|
||||
<div className={bem('title')}>{conversation.title}</div>
|
||||
<div className={bem('subtitle')}>
|
||||
{i18n('ConversationDetailsHeader--members', [
|
||||
memberships.length.toString(),
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { ConversationDetailsIcon, Props } from './ConversationDetailsIcon';
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/ConversationDetailIcon',
|
||||
module
|
||||
);
|
||||
|
||||
const createProps = (overrideProps: Partial<Props>): Props => ({
|
||||
ariaLabel: overrideProps.ariaLabel || '',
|
||||
icon: overrideProps.icon || '',
|
||||
onClick: overrideProps.onClick,
|
||||
});
|
||||
|
||||
story.add('All', () => {
|
||||
const icons = ['timer', 'trash', 'invites', 'block', 'leave', 'down'];
|
||||
|
||||
return icons.map(icon => (
|
||||
<ConversationDetailsIcon {...createProps({ icon })} />
|
||||
));
|
||||
});
|
||||
|
||||
story.add('Clickable Icons', () => {
|
||||
const icons = ['timer', 'trash', 'invites', 'block', 'leave', 'down'];
|
||||
|
||||
const onClick = action('onClick');
|
||||
|
||||
return icons.map(icon => (
|
||||
<ConversationDetailsIcon {...createProps({ icon, onClick })} />
|
||||
));
|
||||
});
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { bemGenerator } from './util';
|
||||
|
||||
export type Props = {
|
||||
ariaLabel: string;
|
||||
icon: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const bem = bemGenerator('module-conversation-details-icon');
|
||||
|
||||
export const ConversationDetailsIcon: React.ComponentType<Props> = ({
|
||||
ariaLabel,
|
||||
icon,
|
||||
onClick,
|
||||
}) => {
|
||||
const content = <div className={bem('icon', icon)} />;
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={bem('button')}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
|
||||
import {
|
||||
ConversationDetailsMediaList,
|
||||
Props,
|
||||
} from './ConversationDetailsMediaList';
|
||||
import {
|
||||
createPreparedMediaItems,
|
||||
createRandomMedia,
|
||||
} from '../media-gallery/AttachmentSection.stories';
|
||||
import { MediaItemType } from '../../LightboxGallery';
|
||||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/ConversationMediaList',
|
||||
module
|
||||
);
|
||||
|
||||
const createProps = (mediaItems?: Array<MediaItemType>): Props => ({
|
||||
conversation: getDefaultConversation({
|
||||
recentMediaItems: mediaItems || [],
|
||||
}),
|
||||
i18n,
|
||||
loadRecentMediaItems: action('loadRecentMediaItems'),
|
||||
showAllMedia: action('showAllMedia'),
|
||||
showLightboxForMedia: action('showLightboxForMedia'),
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const mediaItems = createPreparedMediaItems(createRandomMedia);
|
||||
const props = createProps(mediaItems);
|
||||
|
||||
return <ConversationDetailsMediaList {...props} />;
|
||||
});
|
|
@ -0,0 +1,73 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
|
||||
import { MediaItemType } from '../../LightboxGallery';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
|
||||
import { PanelSection } from './PanelSection';
|
||||
import { bemGenerator } from './util';
|
||||
import { MediaGridItem } from '../media-gallery/MediaGridItem';
|
||||
|
||||
export type Props = {
|
||||
conversation: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
loadRecentMediaItems: (limit: number) => void;
|
||||
showAllMedia: () => void;
|
||||
showLightboxForMedia: (
|
||||
selectedMediaItem: MediaItemType,
|
||||
media: Array<MediaItemType>
|
||||
) => void;
|
||||
};
|
||||
|
||||
const MEDIA_ITEM_LIMIT = 6;
|
||||
|
||||
const bem = bemGenerator('module-conversation-details-media-list');
|
||||
|
||||
export const ConversationDetailsMediaList: React.ComponentType<Props> = ({
|
||||
conversation,
|
||||
i18n,
|
||||
loadRecentMediaItems,
|
||||
showAllMedia,
|
||||
showLightboxForMedia,
|
||||
}) => {
|
||||
const mediaItems = conversation.recentMediaItems || [];
|
||||
|
||||
React.useEffect(() => {
|
||||
loadRecentMediaItems(MEDIA_ITEM_LIMIT);
|
||||
}, [loadRecentMediaItems]);
|
||||
|
||||
if (mediaItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelSection
|
||||
actions={
|
||||
<button
|
||||
className={bem('show-all')}
|
||||
onClick={showAllMedia}
|
||||
type="button"
|
||||
>
|
||||
{i18n('ConversationDetailsMediaList--show-all')}
|
||||
</button>
|
||||
}
|
||||
borderless
|
||||
title={i18n('ConversationDetailsMediaList--shared-media')}
|
||||
>
|
||||
<div className={bem('root')}>
|
||||
{mediaItems.slice(0, MEDIA_ITEM_LIMIT).map(mediaItem => (
|
||||
<MediaGridItem
|
||||
key={`${mediaItem.message.id}-${mediaItem.index}`}
|
||||
mediaItem={mediaItem}
|
||||
i18n={i18n}
|
||||
onClick={() => showLightboxForMedia(mediaItem, mediaItems)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { number } from '@storybook/addon-knobs';
|
||||
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
|
||||
import {
|
||||
ConversationDetailsMembershipList,
|
||||
Props,
|
||||
GroupV2Membership,
|
||||
} from './ConversationDetailsMembershipList';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/ConversationDetailsMembershipList',
|
||||
module
|
||||
);
|
||||
|
||||
const createMemberships = (
|
||||
numberOfMemberships = 10
|
||||
): Array<GroupV2Membership> => {
|
||||
return Array.from(
|
||||
new Array(number('number of memberships', numberOfMemberships))
|
||||
).map(
|
||||
(_, i): GroupV2Membership => ({
|
||||
isAdmin: i % 3 === 0,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metadata: {} as any,
|
||||
member: getDefaultConversation({}),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const createProps = (overrideProps: Partial<Props>): Props => ({
|
||||
i18n,
|
||||
showContactModal: action('showContactModal'),
|
||||
memberships: overrideProps.memberships || [],
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const memberships = createMemberships(10);
|
||||
|
||||
const props = createProps({ memberships });
|
||||
|
||||
return <ConversationDetailsMembershipList {...props} />;
|
||||
});
|
||||
|
||||
story.add('Few', () => {
|
||||
const memberships = createMemberships(3);
|
||||
|
||||
const props = createProps({ memberships });
|
||||
|
||||
return <ConversationDetailsMembershipList {...props} />;
|
||||
});
|
||||
|
||||
story.add('Many', () => {
|
||||
const memberships = createMemberships(100);
|
||||
|
||||
const props = createProps({ memberships });
|
||||
|
||||
return <ConversationDetailsMembershipList {...props} />;
|
||||
});
|
||||
|
||||
story.add('None', () => {
|
||||
const props = createProps({ memberships: [] });
|
||||
|
||||
return <ConversationDetailsMembershipList {...props} />;
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { Avatar } from '../../Avatar';
|
||||
|
||||
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { GroupV2MemberType } from '../../../model-types.d';
|
||||
import { PanelRow } from './PanelRow';
|
||||
import { PanelSection } from './PanelSection';
|
||||
|
||||
export type GroupV2Membership = {
|
||||
isAdmin: boolean;
|
||||
metadata: GroupV2MemberType;
|
||||
member: ConversationType;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
memberships: Array<GroupV2Membership>;
|
||||
showContactModal: (conversationId: string) => void;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
const INITIAL_MEMBER_COUNT = 5;
|
||||
|
||||
export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
|
||||
memberships,
|
||||
showContactModal,
|
||||
i18n,
|
||||
}) => {
|
||||
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<PanelSection
|
||||
title={i18n('ConversationDetailsMembershipList--title', [
|
||||
memberships.length.toString(),
|
||||
])}
|
||||
>
|
||||
{memberships
|
||||
.slice(0, showAllMembers ? undefined : INITIAL_MEMBER_COUNT)
|
||||
.map(({ isAdmin, member }) => (
|
||||
<PanelRow
|
||||
key={member.id}
|
||||
onClick={() => showContactModal(member.id)}
|
||||
icon={
|
||||
<Avatar
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
size={32}
|
||||
{...member}
|
||||
/>
|
||||
}
|
||||
label={member.title}
|
||||
right={isAdmin ? i18n('GroupV2--admin') : ''}
|
||||
/>
|
||||
))}
|
||||
{showAllMembers === false &&
|
||||
memberships.length > INITIAL_MEMBER_COUNT && (
|
||||
<PanelRow
|
||||
className="module-conversation-details-membership-list--show-all"
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('ConversationDetailsMembershipList--show-all')}
|
||||
icon="down"
|
||||
/>
|
||||
}
|
||||
onClick={() => setShowAllMembers(true)}
|
||||
label={i18n('ConversationDetailsMembershipList--show-all')}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,86 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
import { GroupLinkManagement, PropsType } from './GroupLinkManagement';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/GroupLinkManagement',
|
||||
module
|
||||
);
|
||||
|
||||
class AccessEnum {
|
||||
static ANY = 0;
|
||||
|
||||
static UNKNOWN = 1;
|
||||
|
||||
static MEMBER = 2;
|
||||
|
||||
static ADMINISTRATOR = 3;
|
||||
|
||||
static UNSATISFIABLE = 4;
|
||||
}
|
||||
|
||||
function getConversation(
|
||||
groupLink?: string,
|
||||
accessControlAddFromInviteLink?: number
|
||||
): ConversationType {
|
||||
return {
|
||||
id: '',
|
||||
lastUpdated: 0,
|
||||
markedUnread: false,
|
||||
memberships: Array(32).fill({ member: getDefaultConversation({}) }),
|
||||
pendingMemberships: Array(16).fill({ member: getDefaultConversation({}) }),
|
||||
title: 'Some Conversation',
|
||||
type: 'group',
|
||||
groupLink,
|
||||
accessControlAddFromInviteLink:
|
||||
accessControlAddFromInviteLink !== undefined
|
||||
? accessControlAddFromInviteLink
|
||||
: AccessEnum.UNSATISFIABLE,
|
||||
};
|
||||
}
|
||||
|
||||
const createProps = (conversation?: ConversationType): PropsType => ({
|
||||
accessEnum: AccessEnum,
|
||||
changeHasGroupLink: action('changeHasGroupLink'),
|
||||
conversation: conversation || getConversation(),
|
||||
copyGroupLink: action('copyGroupLink'),
|
||||
generateNewGroupLink: action('generateNewGroupLink'),
|
||||
i18n,
|
||||
setAccessControlAddFromInviteLinkSetting: action(
|
||||
'setAccessControlAddFromInviteLinkSetting'
|
||||
),
|
||||
});
|
||||
|
||||
story.add('Off', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <GroupLinkManagement {...props} />;
|
||||
});
|
||||
|
||||
story.add('On', () => {
|
||||
const props = createProps(
|
||||
getConversation('https://signal.group/1', AccessEnum.ANY)
|
||||
);
|
||||
|
||||
return <GroupLinkManagement {...props} />;
|
||||
});
|
||||
|
||||
story.add('On (Admin Approval Needed)', () => {
|
||||
const props = createProps(
|
||||
getConversation('https://signal.group/1', AccessEnum.ADMINISTRATOR)
|
||||
);
|
||||
|
||||
return <GroupLinkManagement {...props} />;
|
||||
});
|
|
@ -0,0 +1,130 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { PanelRow } from './PanelRow';
|
||||
import { PanelSection } from './PanelSection';
|
||||
import { AccessControlClass } from '../../../textsecure.d';
|
||||
|
||||
export type PropsType = {
|
||||
accessEnum: typeof AccessControlClass.AccessRequired;
|
||||
changeHasGroupLink: (value: boolean) => void;
|
||||
conversation?: ConversationType;
|
||||
copyGroupLink: (groupLink: string) => void;
|
||||
generateNewGroupLink: () => void;
|
||||
i18n: LocalizerType;
|
||||
setAccessControlAddFromInviteLinkSetting: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const GroupLinkManagement: React.ComponentType<PropsType> = ({
|
||||
accessEnum,
|
||||
changeHasGroupLink,
|
||||
conversation,
|
||||
copyGroupLink,
|
||||
generateNewGroupLink,
|
||||
i18n,
|
||||
setAccessControlAddFromInviteLinkSetting,
|
||||
}) => {
|
||||
if (conversation === undefined) {
|
||||
throw new Error('GroupLinkManagement rendered without a conversation');
|
||||
}
|
||||
|
||||
const createEventHandler = (handleEvent: (x: boolean) => void) => {
|
||||
return (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
handleEvent(event.target.value === 'true');
|
||||
};
|
||||
};
|
||||
|
||||
const membersNeedAdminApproval =
|
||||
conversation.accessControlAddFromInviteLink === accessEnum.ADMINISTRATOR;
|
||||
|
||||
const hasGroupLink =
|
||||
conversation.groupLink &&
|
||||
conversation.accessControlAddFromInviteLink !== accessEnum.UNSATISFIABLE;
|
||||
const groupLinkInfo = hasGroupLink ? conversation.groupLink : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelSection>
|
||||
<PanelRow
|
||||
info={groupLinkInfo}
|
||||
label={i18n('ConversationDetails--group-link')}
|
||||
right={
|
||||
<div className="module-conversation-details-select">
|
||||
<select
|
||||
onChange={createEventHandler(changeHasGroupLink)}
|
||||
value={String(Boolean(hasGroupLink))}
|
||||
>
|
||||
<option value="true" aria-label={i18n('on')}>
|
||||
{i18n('on')}
|
||||
</option>
|
||||
<option value="false" aria-label={i18n('off')}>
|
||||
{i18n('off')}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{hasGroupLink ? (
|
||||
<>
|
||||
<PanelSection>
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('GroupLinkManagement--share')}
|
||||
icon="share"
|
||||
/>
|
||||
}
|
||||
label={i18n('GroupLinkManagement--share')}
|
||||
onClick={() => {
|
||||
if (conversation.groupLink) {
|
||||
copyGroupLink(conversation.groupLink);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('GroupLinkManagement--reset')}
|
||||
icon="reset"
|
||||
/>
|
||||
}
|
||||
label={i18n('GroupLinkManagement--reset')}
|
||||
onClick={generateNewGroupLink}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection>
|
||||
<PanelRow
|
||||
info={i18n('GroupLinkManagement--approve-info')}
|
||||
label={i18n('GroupLinkManagement--approve-label')}
|
||||
right={
|
||||
<div className="module-conversation-details-select">
|
||||
<select
|
||||
onChange={createEventHandler(
|
||||
setAccessControlAddFromInviteLinkSetting
|
||||
)}
|
||||
value={String(membersNeedAdminApproval)}
|
||||
>
|
||||
<option value="true" aria-label={i18n('on')}>
|
||||
{i18n('on')}
|
||||
</option>
|
||||
<option value="false" aria-label={i18n('off')}>
|
||||
{i18n('off')}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</PanelSection>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
import { GroupV2Permissions, PropsType } from './GroupV2Permissions';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/GroupV2Permissions',
|
||||
module
|
||||
);
|
||||
|
||||
const conversation: ConversationType = {
|
||||
id: '',
|
||||
lastUpdated: 0,
|
||||
markedUnread: false,
|
||||
memberships: Array(32).fill({ member: getDefaultConversation({}) }),
|
||||
pendingMemberships: Array(16).fill({ member: getDefaultConversation({}) }),
|
||||
title: 'Some Conversation',
|
||||
type: 'group',
|
||||
};
|
||||
|
||||
class AccessEnum {
|
||||
static ANY = 0;
|
||||
|
||||
static UNKNOWN = 1;
|
||||
|
||||
static MEMBER = 2;
|
||||
|
||||
static ADMINISTRATOR = 3;
|
||||
|
||||
static UNSATISFIABLE = 4;
|
||||
}
|
||||
|
||||
const createProps = (): PropsType => ({
|
||||
accessEnum: AccessEnum,
|
||||
conversation,
|
||||
i18n,
|
||||
setAccessControlAttributesSetting: action(
|
||||
'setAccessControlAttributesSetting'
|
||||
),
|
||||
setAccessControlMembersSetting: action('setAccessControlMembersSetting'),
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <GroupV2Permissions {...props} />;
|
||||
});
|
|
@ -0,0 +1,85 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { getAccessControlOptions } from '../../../util/getAccessControlOptions';
|
||||
import { AccessControlClass } from '../../../textsecure.d';
|
||||
|
||||
import { PanelRow } from './PanelRow';
|
||||
import { PanelSection } from './PanelSection';
|
||||
|
||||
export type PropsType = {
|
||||
accessEnum: typeof AccessControlClass.AccessRequired;
|
||||
conversation?: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
setAccessControlAttributesSetting: (value: number) => void;
|
||||
setAccessControlMembersSetting: (value: number) => void;
|
||||
};
|
||||
|
||||
export const GroupV2Permissions: React.ComponentType<PropsType> = ({
|
||||
accessEnum,
|
||||
conversation,
|
||||
i18n,
|
||||
setAccessControlAttributesSetting,
|
||||
setAccessControlMembersSetting,
|
||||
}) => {
|
||||
if (conversation === undefined) {
|
||||
throw new Error('GroupV2Permissions rendered without a conversation');
|
||||
}
|
||||
|
||||
const updateAccessControlAttributes = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
setAccessControlAttributesSetting(Number(event.target.value));
|
||||
};
|
||||
const updateAccessControlMembers = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
setAccessControlMembersSetting(Number(event.target.value));
|
||||
};
|
||||
const accessControlOptions = getAccessControlOptions(accessEnum, i18n);
|
||||
|
||||
return (
|
||||
<PanelSection>
|
||||
<PanelRow
|
||||
label={i18n('ConversationDetails--group-info-label')}
|
||||
info={i18n('ConversationDetails--group-info-info')}
|
||||
right={
|
||||
<div className="module-conversation-details-select">
|
||||
<select
|
||||
onChange={updateAccessControlAttributes}
|
||||
value={conversation.accessControlAttributes}
|
||||
>
|
||||
{accessControlOptions.map(({ name, value }) => (
|
||||
<option aria-label={name} key={name} value={value}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PanelRow
|
||||
label={i18n('ConversationDetails--add-members-label')}
|
||||
info={i18n('ConversationDetails--add-members-info')}
|
||||
right={
|
||||
<div className="module-conversation-details-select">
|
||||
<select
|
||||
onChange={updateAccessControlMembers}
|
||||
value={conversation.accessControlMembers}
|
||||
>
|
||||
{accessControlOptions.map(({ name, value }) => (
|
||||
<option aria-label={name} key={name} value={value}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { boolean, text } from '@storybook/addon-knobs';
|
||||
|
||||
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
|
||||
import { PanelRow, Props } from './PanelRow';
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/PanelRow',
|
||||
module
|
||||
);
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
icon: boolean('with icon', overrideProps.icon !== undefined) ? (
|
||||
<ConversationDetailsIcon ariaLabel="timer" icon="timer" />
|
||||
) : null,
|
||||
label: text('label', overrideProps.label || ''),
|
||||
info: text('info', overrideProps.info || ''),
|
||||
right: text('right', (overrideProps.right as string) || ''),
|
||||
actions: boolean('with action', overrideProps.actions !== undefined) ? (
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel="trash"
|
||||
icon="trash"
|
||||
onClick={action('action onClick')}
|
||||
/>
|
||||
) : null,
|
||||
onClick: boolean('clickable', overrideProps.onClick !== undefined)
|
||||
? overrideProps.onClick || action('onClick')
|
||||
: undefined,
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const props = createProps({
|
||||
label: 'this is a panel row',
|
||||
});
|
||||
|
||||
return <PanelRow {...props} />;
|
||||
});
|
||||
|
||||
story.add('Simple', () => {
|
||||
const props = createProps({
|
||||
label: 'this is a panel row',
|
||||
icon: 'with icon',
|
||||
right: 'side text',
|
||||
});
|
||||
|
||||
return <PanelRow {...props} />;
|
||||
});
|
||||
|
||||
story.add('Full', () => {
|
||||
const props = createProps({
|
||||
label: 'this is a panel row',
|
||||
icon: 'with icon',
|
||||
info: 'this is some info that exists below the main label',
|
||||
right: 'side text',
|
||||
actions: 'with action',
|
||||
});
|
||||
|
||||
return <PanelRow {...props} />;
|
||||
});
|
||||
|
||||
story.add('Button', () => {
|
||||
const props = createProps({
|
||||
label: 'this is a panel row',
|
||||
icon: 'with icon',
|
||||
right: 'side text',
|
||||
onClick: action('onClick'),
|
||||
});
|
||||
|
||||
return <PanelRow {...props} />;
|
||||
});
|
58
ts/components/conversation/conversation-details/PanelRow.tsx
Normal file
58
ts/components/conversation/conversation-details/PanelRow.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { bemGenerator } from './util';
|
||||
|
||||
export type Props = {
|
||||
alwaysShowActions?: boolean;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
info?: string;
|
||||
right?: string | React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const bem = bemGenerator('module-conversation-details-panel-row');
|
||||
|
||||
export const PanelRow: React.ComponentType<Props> = ({
|
||||
alwaysShowActions,
|
||||
className,
|
||||
icon,
|
||||
label,
|
||||
info,
|
||||
right,
|
||||
actions,
|
||||
onClick,
|
||||
}) => {
|
||||
const content = (
|
||||
<>
|
||||
{icon && <div className={bem('icon')}>{icon}</div>}
|
||||
<div className={bem('label')}>
|
||||
<div>{label}</div>
|
||||
{info && <div className={bem('info')}>{info}</div>}
|
||||
</div>
|
||||
{right && <div className={bem('right')}>{right}</div>}
|
||||
{actions && (
|
||||
<div className={alwaysShowActions ? '' : bem('actions')}>{actions}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(bem('root', 'button'), className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classNames(bem('root'), className)}>{content}</div>;
|
||||
};
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { boolean, text } from '@storybook/addon-knobs';
|
||||
|
||||
import { PanelSection, Props } from './PanelSection';
|
||||
import { PanelRow } from './PanelRow';
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/PanelSection',
|
||||
module
|
||||
);
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
title: text('label', overrideProps.title || ''),
|
||||
centerTitle: boolean('centerTitle', overrideProps.centerTitle || false),
|
||||
actions: boolean('with action', overrideProps.actions !== undefined) ? (
|
||||
<button onClick={action('actions onClick')} type="button">
|
||||
action
|
||||
</button>
|
||||
) : null,
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const props = createProps({
|
||||
title: 'panel section header',
|
||||
});
|
||||
|
||||
return <PanelSection {...props} />;
|
||||
});
|
||||
|
||||
story.add('Centered', () => {
|
||||
const props = createProps({
|
||||
title: 'this is a panel row',
|
||||
centerTitle: true,
|
||||
});
|
||||
|
||||
return <PanelSection {...props} />;
|
||||
});
|
||||
|
||||
story.add('With Actions', () => {
|
||||
const props = createProps({
|
||||
title: 'this is a panel row',
|
||||
actions: (
|
||||
<button onClick={action('actions onClick')} type="button">
|
||||
action
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
||||
return <PanelSection {...props} />;
|
||||
});
|
||||
|
||||
story.add('With Content', () => {
|
||||
const props = createProps({
|
||||
title: 'this is a panel row',
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelSection {...props}>
|
||||
<PanelRow label="this is panel row one" />
|
||||
<PanelRow label="this is panel row two" />
|
||||
<PanelRow label="this is panel row three" />
|
||||
<PanelRow label="this is panel row four" />
|
||||
</PanelSection>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { bemGenerator } from './util';
|
||||
|
||||
export type Props = {
|
||||
actions?: React.ReactNode;
|
||||
borderless?: boolean;
|
||||
centerTitle?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const bem = bemGenerator('module-conversation-details-panel-section');
|
||||
const borderlessClass = bem('root', 'borderless');
|
||||
|
||||
export const PanelSection: React.ComponentType<Props> = ({
|
||||
actions,
|
||||
borderless,
|
||||
centerTitle,
|
||||
children,
|
||||
title,
|
||||
}) => (
|
||||
<div className={classNames(bem('root'), borderless ? borderlessClass : null)}>
|
||||
{(title || actions) && (
|
||||
<div className={bem('header', { center: centerTitle || false })}>
|
||||
{title && <div className={bem('title')}>{title}</div>}
|
||||
{actions && <div>{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,87 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
import { PendingInvites, PropsType } from './PendingInvites';
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/Conversation/ConversationDetails/PendingInvites',
|
||||
module
|
||||
);
|
||||
|
||||
const sortedGroupMembers = Array.from(Array(32)).map((_, i) =>
|
||||
i === 0
|
||||
? getDefaultConversation({ id: 'def456' })
|
||||
: getDefaultConversation({})
|
||||
);
|
||||
|
||||
const conversation: ConversationType = {
|
||||
areWeAdmin: true,
|
||||
id: '',
|
||||
lastUpdated: 0,
|
||||
markedUnread: false,
|
||||
memberships: sortedGroupMembers.map(member => ({
|
||||
isAdmin: false,
|
||||
member,
|
||||
metadata: {
|
||||
conversationId: 'abc123',
|
||||
joinedAtVersion: 1,
|
||||
role: 1,
|
||||
},
|
||||
})),
|
||||
pendingMemberships: Array.from(Array(4))
|
||||
.map(() => ({
|
||||
member: getDefaultConversation({}),
|
||||
metadata: {
|
||||
addedByUserId: 'abc123',
|
||||
conversationId: 'xyz789',
|
||||
role: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}))
|
||||
.concat(
|
||||
Array.from(Array(8)).map(() => ({
|
||||
member: getDefaultConversation({}),
|
||||
metadata: {
|
||||
addedByUserId: 'def456',
|
||||
conversationId: 'xyz789',
|
||||
role: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}))
|
||||
),
|
||||
pendingApprovalMemberships: Array.from(Array(5)).map(() => ({
|
||||
member: getDefaultConversation({}),
|
||||
metadata: {
|
||||
conversationId: 'xyz789',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})),
|
||||
sortedGroupMembers,
|
||||
title: 'Some Conversation',
|
||||
type: 'group',
|
||||
};
|
||||
|
||||
const createProps = (): PropsType => ({
|
||||
approvePendingMembership: action('approvePendingMembership'),
|
||||
conversation,
|
||||
i18n,
|
||||
ourConversationId: 'abc123',
|
||||
revokePendingMemberships: action('revokePendingMemberships'),
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <PendingInvites {...props} />;
|
||||
});
|
|
@ -0,0 +1,477 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { Avatar } from '../../Avatar';
|
||||
import { ConfirmationModal } from '../../ConfirmationModal';
|
||||
import { PanelSection } from './PanelSection';
|
||||
import { PanelRow } from './PanelRow';
|
||||
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
|
||||
import {
|
||||
GroupV2PendingAdminApprovalType,
|
||||
GroupV2PendingMemberType,
|
||||
} from '../../../model-types.d';
|
||||
|
||||
export type PropsType = {
|
||||
conversation?: ConversationType;
|
||||
readonly i18n: LocalizerType;
|
||||
ourConversationId?: string;
|
||||
readonly approvePendingMembership: (conversationId: string) => void;
|
||||
readonly revokePendingMemberships: (conversationIds: Array<string>) => void;
|
||||
};
|
||||
|
||||
export type GroupV2PendingMembership = {
|
||||
metadata: GroupV2PendingMemberType;
|
||||
member: ConversationType;
|
||||
};
|
||||
|
||||
export type GroupV2RequestingMembership = {
|
||||
metadata: GroupV2PendingAdminApprovalType;
|
||||
member: ConversationType;
|
||||
};
|
||||
|
||||
enum Tab {
|
||||
Requests = 'Requests',
|
||||
Pending = 'Pending',
|
||||
}
|
||||
|
||||
enum StageType {
|
||||
APPROVE_REQUEST = 'APPROVE_REQUEST',
|
||||
DENY_REQUEST = 'DENY_REQUEST',
|
||||
REVOKE_INVITE = 'REVOKE_INVITE',
|
||||
}
|
||||
|
||||
type StagedMembershipType = {
|
||||
type: StageType;
|
||||
membership: GroupV2PendingMembership | GroupV2RequestingMembership;
|
||||
};
|
||||
|
||||
export const PendingInvites: React.ComponentType<PropsType> = ({
|
||||
approvePendingMembership,
|
||||
conversation,
|
||||
i18n,
|
||||
ourConversationId,
|
||||
revokePendingMemberships,
|
||||
}) => {
|
||||
if (!conversation || !ourConversationId) {
|
||||
throw new Error(
|
||||
'PendingInvites rendered without a conversation or ourConversationId'
|
||||
);
|
||||
}
|
||||
|
||||
const [selectedTab, setSelectedTab] = React.useState(Tab.Requests);
|
||||
const [stagedMemberships, setStagedMemberships] = React.useState<Array<
|
||||
StagedMembershipType
|
||||
> | null>(null);
|
||||
|
||||
const allPendingMemberships = conversation.pendingMemberships || [];
|
||||
const allRequestingMemberships =
|
||||
conversation.pendingApprovalMemberships || [];
|
||||
|
||||
return (
|
||||
<div className="conversation-details-panel">
|
||||
<div className="module-conversation-details__tabs">
|
||||
<div
|
||||
className={classNames({
|
||||
'module-conversation-details__tab': true,
|
||||
'module-conversation-details__tab--selected':
|
||||
selectedTab === Tab.Requests,
|
||||
})}
|
||||
onClick={() => {
|
||||
setSelectedTab(Tab.Requests);
|
||||
}}
|
||||
onKeyUp={(e: React.KeyboardEvent) => {
|
||||
if (e.target === e.currentTarget && e.keyCode === 13) {
|
||||
setSelectedTab(Tab.Requests);
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
>
|
||||
{i18n('PendingInvites--tab-requests', {
|
||||
count: String(allRequestingMemberships.length),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames({
|
||||
'module-conversation-details__tab': true,
|
||||
'module-conversation-details__tab--selected':
|
||||
selectedTab === Tab.Pending,
|
||||
})}
|
||||
onClick={() => {
|
||||
setSelectedTab(Tab.Pending);
|
||||
}}
|
||||
onKeyUp={(e: React.KeyboardEvent) => {
|
||||
if (e.target === e.currentTarget && e.keyCode === 13) {
|
||||
setSelectedTab(Tab.Pending);
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
>
|
||||
{i18n('PendingInvites--tab-invites', {
|
||||
count: String(allPendingMemberships.length),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTab === Tab.Requests ? (
|
||||
<MembersPendingAdminApproval
|
||||
conversation={conversation}
|
||||
i18n={i18n}
|
||||
memberships={allRequestingMemberships}
|
||||
setStagedMemberships={setStagedMemberships}
|
||||
/>
|
||||
) : null}
|
||||
{selectedTab === Tab.Pending ? (
|
||||
<MembersPendingProfileKey
|
||||
conversation={conversation}
|
||||
i18n={i18n}
|
||||
members={conversation.sortedGroupMembers || []}
|
||||
memberships={allPendingMemberships}
|
||||
ourConversationId={ourConversationId}
|
||||
setStagedMemberships={setStagedMemberships}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stagedMemberships && stagedMemberships.length && (
|
||||
<MembershipActionConfirmation
|
||||
approvePendingMembership={approvePendingMembership}
|
||||
i18n={i18n}
|
||||
members={conversation.sortedGroupMembers || []}
|
||||
onClose={() => setStagedMemberships(null)}
|
||||
ourConversationId={ourConversationId}
|
||||
revokePendingMemberships={revokePendingMemberships}
|
||||
stagedMemberships={stagedMemberships}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function MembershipActionConfirmation({
|
||||
approvePendingMembership,
|
||||
i18n,
|
||||
members,
|
||||
onClose,
|
||||
ourConversationId,
|
||||
revokePendingMemberships,
|
||||
stagedMemberships,
|
||||
}: {
|
||||
approvePendingMembership: (conversationId: string) => void;
|
||||
i18n: LocalizerType;
|
||||
members: Array<ConversationType>;
|
||||
onClose: () => void;
|
||||
ourConversationId: string;
|
||||
revokePendingMemberships: (conversationIds: Array<string>) => void;
|
||||
stagedMemberships: Array<StagedMembershipType>;
|
||||
}) {
|
||||
const revokeStagedMemberships = () => {
|
||||
if (!stagedMemberships) {
|
||||
return;
|
||||
}
|
||||
revokePendingMemberships(
|
||||
stagedMemberships.map(({ membership }) => membership.member.id)
|
||||
);
|
||||
};
|
||||
|
||||
const approveStagedMembership = () => {
|
||||
if (!stagedMemberships) {
|
||||
return;
|
||||
}
|
||||
approvePendingMembership(stagedMemberships[0].membership.member.id);
|
||||
};
|
||||
|
||||
const membershipType = stagedMemberships[0].type;
|
||||
|
||||
const modalAction =
|
||||
membershipType === StageType.APPROVE_REQUEST
|
||||
? approveStagedMembership
|
||||
: revokeStagedMemberships;
|
||||
|
||||
let modalActionText = i18n('PendingInvites--revoke');
|
||||
|
||||
if (membershipType === StageType.APPROVE_REQUEST) {
|
||||
modalActionText = i18n('PendingRequests--approve');
|
||||
} else if (membershipType === StageType.DENY_REQUEST) {
|
||||
modalActionText = i18n('PendingRequests--deny');
|
||||
} else if (membershipType === StageType.REVOKE_INVITE) {
|
||||
modalActionText = i18n('PendingInvites--revoke');
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
actions={[
|
||||
{
|
||||
action: modalAction,
|
||||
style: 'affirmative',
|
||||
text: modalActionText,
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
>
|
||||
{getConfirmationMessage({
|
||||
i18n,
|
||||
members,
|
||||
ourConversationId,
|
||||
stagedMemberships,
|
||||
})}
|
||||
</ConfirmationModal>
|
||||
);
|
||||
}
|
||||
|
||||
function getConfirmationMessage({
|
||||
i18n,
|
||||
members,
|
||||
ourConversationId,
|
||||
stagedMemberships,
|
||||
}: {
|
||||
i18n: LocalizerType;
|
||||
members: Array<ConversationType>;
|
||||
ourConversationId: string;
|
||||
stagedMemberships: Array<StagedMembershipType>;
|
||||
}): string {
|
||||
if (!stagedMemberships || !stagedMemberships.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const membershipType = stagedMemberships[0].type;
|
||||
const firstMembership = stagedMemberships[0].membership;
|
||||
|
||||
// Requesting a membership since they weren't added by anyone
|
||||
if (membershipType === StageType.DENY_REQUEST) {
|
||||
return i18n('PendingRequests--deny-for', {
|
||||
name: firstMembership.member.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (membershipType === StageType.APPROVE_REQUEST) {
|
||||
return i18n('PendingRequests--approve-for', {
|
||||
name: firstMembership.member.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (membershipType !== StageType.REVOKE_INVITE) {
|
||||
throw new Error('getConfirmationMessage: Invalid staging type');
|
||||
}
|
||||
|
||||
const firstPendingMembership = firstMembership as GroupV2PendingMembership;
|
||||
|
||||
// Pending invite
|
||||
const invitedByUs =
|
||||
firstPendingMembership.metadata.addedByUserId === ourConversationId;
|
||||
|
||||
if (invitedByUs) {
|
||||
return i18n('PendingInvites--revoke-for', {
|
||||
name: firstPendingMembership.member.title,
|
||||
});
|
||||
}
|
||||
|
||||
const inviter = members.find(
|
||||
({ id }) => id === firstPendingMembership.metadata.addedByUserId
|
||||
);
|
||||
|
||||
if (inviter === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const name = inviter.title;
|
||||
|
||||
if (stagedMemberships.length === 1) {
|
||||
return i18n('PendingInvites--revoke-from-singular', { name });
|
||||
}
|
||||
|
||||
return i18n('PendingInvites--revoke-from-plural', {
|
||||
number: stagedMemberships.length.toString(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
function MembersPendingAdminApproval({
|
||||
conversation,
|
||||
i18n,
|
||||
memberships,
|
||||
setStagedMemberships,
|
||||
}: {
|
||||
conversation: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
memberships: Array<GroupV2RequestingMembership>;
|
||||
setStagedMemberships: (stagedMembership: Array<StagedMembershipType>) => void;
|
||||
}) {
|
||||
return (
|
||||
<PanelSection>
|
||||
{memberships.map(membership => (
|
||||
<PanelRow
|
||||
alwaysShowActions
|
||||
key={membership.member.id}
|
||||
icon={
|
||||
<Avatar
|
||||
conversationType="direct"
|
||||
size={32}
|
||||
i18n={i18n}
|
||||
{...membership.member}
|
||||
/>
|
||||
}
|
||||
label={membership.member.title}
|
||||
actions={
|
||||
conversation.areWeAdmin ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="module-button__small module-conversation-details__action-button"
|
||||
onClick={() => {
|
||||
setStagedMemberships([
|
||||
{
|
||||
type: StageType.DENY_REQUEST,
|
||||
membership,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{i18n('delete')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-button__small module-conversation-details__action-button"
|
||||
onClick={() => {
|
||||
setStagedMemberships([
|
||||
{
|
||||
type: StageType.APPROVE_REQUEST,
|
||||
membership,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{i18n('accept')}
|
||||
</button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className="module-conversation-details__pending--info">
|
||||
{i18n('PendingRequests--info', [conversation.title])}
|
||||
</div>
|
||||
</PanelSection>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersPendingProfileKey({
|
||||
conversation,
|
||||
i18n,
|
||||
members,
|
||||
memberships,
|
||||
ourConversationId,
|
||||
setStagedMemberships,
|
||||
}: {
|
||||
conversation: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
members: Array<ConversationType>;
|
||||
memberships: Array<GroupV2PendingMembership>;
|
||||
ourConversationId: string;
|
||||
setStagedMemberships: (stagedMembership: Array<StagedMembershipType>) => void;
|
||||
}) {
|
||||
const groupedPendingMemberships = _.groupBy(
|
||||
memberships,
|
||||
membership => membership.metadata.addedByUserId
|
||||
);
|
||||
|
||||
const {
|
||||
[ourConversationId]: ourPendingMemberships,
|
||||
...otherPendingMembershipGroups
|
||||
} = groupedPendingMemberships;
|
||||
|
||||
const otherPendingMemberships = Object.keys(otherPendingMembershipGroups)
|
||||
.map(id => members.find(member => member.id === id))
|
||||
.filter((member): member is ConversationType => member !== undefined)
|
||||
.map(member => ({
|
||||
member,
|
||||
pendingMemberships: otherPendingMembershipGroups[member.id],
|
||||
}));
|
||||
|
||||
return (
|
||||
<PanelSection>
|
||||
{ourPendingMemberships && (
|
||||
<PanelSection title={i18n('PendingInvites--invited-by-you')}>
|
||||
{ourPendingMemberships.map(membership => (
|
||||
<PanelRow
|
||||
key={membership.member.id}
|
||||
icon={
|
||||
<Avatar
|
||||
conversationType="direct"
|
||||
size={32}
|
||||
i18n={i18n}
|
||||
{...membership.member}
|
||||
/>
|
||||
}
|
||||
label={membership.member.title}
|
||||
actions={
|
||||
conversation.areWeAdmin ? (
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('PendingInvites--revoke-for-label')}
|
||||
icon="trash"
|
||||
onClick={() => {
|
||||
setStagedMemberships([
|
||||
{
|
||||
type: StageType.REVOKE_INVITE,
|
||||
membership,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</PanelSection>
|
||||
)}
|
||||
{otherPendingMemberships.length > 0 && (
|
||||
<PanelSection title={i18n('PendingInvites--invited-by-others')}>
|
||||
{otherPendingMemberships.map(({ member, pendingMemberships }) => (
|
||||
<PanelRow
|
||||
key={member.id}
|
||||
icon={
|
||||
<Avatar
|
||||
conversationType="direct"
|
||||
size={32}
|
||||
i18n={i18n}
|
||||
{...member}
|
||||
/>
|
||||
}
|
||||
label={member.title}
|
||||
right={i18n('PendingInvites--invited-count', [
|
||||
pendingMemberships.length.toString(),
|
||||
])}
|
||||
actions={
|
||||
conversation.areWeAdmin ? (
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('PendingInvites--revoke-for-label')}
|
||||
icon="trash"
|
||||
onClick={() => {
|
||||
setStagedMemberships(
|
||||
pendingMemberships.map(membership => ({
|
||||
type: StageType.REVOKE_INVITE,
|
||||
membership,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</PanelSection>
|
||||
)}
|
||||
<div className="module-conversation-details__pending--info">
|
||||
{i18n('PendingInvites--info')}
|
||||
</div>
|
||||
</PanelSection>
|
||||
);
|
||||
}
|
30
ts/components/conversation/conversation-details/util.ts
Normal file
30
ts/components/conversation/conversation-details/util.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
export const bemGenerator = (block: string) => (
|
||||
element: string,
|
||||
modifier?: string | Record<string, boolean>
|
||||
): string => {
|
||||
const base = `${block}__${element}`;
|
||||
const classes = [base];
|
||||
|
||||
let conditionals: Record<string, boolean> = {};
|
||||
|
||||
if (modifier) {
|
||||
if (typeof modifier === 'string') {
|
||||
classes.push(`${base}--${modifier}`);
|
||||
} else {
|
||||
conditionals = Object.keys(modifier).reduce(
|
||||
(acc, key) => ({
|
||||
...acc,
|
||||
[`${base}--${key}`]: modifier[key],
|
||||
}),
|
||||
{} as Record<string, boolean>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return classNames(classes, conditionals);
|
||||
};
|
213
ts/groups.ts
213
ts/groups.ts
|
@ -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
2
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
21
ts/state/roots/createConversationDetails.tsx
Normal file
21
ts/state/roots/createConversationDetails.tsx
Normal 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>
|
||||
);
|
21
ts/state/roots/createGroupLinkManagement.tsx
Normal file
21
ts/state/roots/createGroupLinkManagement.tsx
Normal 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>
|
||||
);
|
21
ts/state/roots/createGroupV2Permissions.tsx
Normal file
21
ts/state/roots/createGroupV2Permissions.tsx
Normal 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>
|
||||
);
|
21
ts/state/roots/createPendingInvites.tsx
Normal file
21
ts/state/roots/createPendingInvites.tsx
Normal 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>
|
||||
);
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
56
ts/state/smart/ConversationDetails.tsx
Normal file
56
ts/state/smart/ConversationDetails.tsx
Normal 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);
|
|
@ -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),
|
||||
|
|
39
ts/state/smart/GroupLinkManagement.tsx
Normal file
39
ts/state/smart/GroupLinkManagement.tsx
Normal 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);
|
37
ts/state/smart/GroupV2Permissions.tsx
Normal file
37
ts/state/smart/GroupV2Permissions.tsx
Normal 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);
|
39
ts/state/smart/PendingInvites.tsx
Normal file
39
ts/state/smart/PendingInvites.tsx
Normal 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);
|
|
@ -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
6
ts/textsecure.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
26
ts/util/getAccessControlOptions.ts
Normal file
26
ts/util/getAccessControlOptions.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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 don’t 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
13
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue