Moves parts of conversation view into redux

This commit is contained in:
Josh Perez 2022-12-05 17:56:23 -05:00 committed by GitHub
parent a49a6f2057
commit 9348940ecf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 693 additions and 461 deletions

View file

@ -1,28 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { ToastDeleteForEveryoneFailed } from './ToastDeleteForEveryoneFailed';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastDeleteForEveryoneFailed',
};
export const _ToastDeleteForEveryoneFailed = (): JSX.Element => (
<ToastDeleteForEveryoneFailed {...defaultProps} />
);
_ToastDeleteForEveryoneFailed.story = {
name: 'ToastDeleteForEveryoneFailed',
};

View file

@ -1,18 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastDeleteForEveryoneFailed({
i18n,
onClose,
}: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('deleteForEveryoneFailed')}</Toast>;
}

View file

@ -143,5 +143,9 @@ export function ToastManager({
); );
} }
if (toastType === ToastType.DeleteForEveryoneFailed) {
return <Toast onClose={hideToast}>{i18n('deleteForEveryoneFailed')}</Toast>;
}
throw missingCaseError(toastType); throw missingCaseError(toastType);
} }

View file

@ -38,8 +38,8 @@ const commonProps = {
i18n, i18n,
onShowConversationDetails: action('onShowConversationDetails'), onShowConversationDetails: action('onShowConversationDetails'),
onSetDisappearingMessages: action('onSetDisappearingMessages'), setDisappearingMessages: action('setDisappearingMessages'),
onDeleteMessages: action('onDeleteMessages'), destroyMessages: action('destroyMessages'),
onSearchInConversation: action('onSearchInConversation'), onSearchInConversation: action('onSearchInConversation'),
onSetMuteNotifications: action('onSetMuteNotifications'), onSetMuteNotifications: action('onSetMuteNotifications'),
onOutgoingAudioCallInConversation: action( onOutgoingAudioCallInConversation: action(

View file

@ -28,6 +28,7 @@ import * as expirationTimer from '../../util/expirationTimer';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isInSystemContacts } from '../../util/isInSystemContacts';
import { isConversationMuted } from '../../util/isConversationMuted'; import { isConversationMuted } from '../../util/isConversationMuted';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { DurationInSeconds } from '../../util/durations'; import { DurationInSeconds } from '../../util/durations';
import { import {
useStartCallShortcuts, useStartCallShortcuts,
@ -80,8 +81,7 @@ export type PropsDataType = {
export type PropsActionsType = { export type PropsActionsType = {
onSetMuteNotifications: (seconds: number) => void; onSetMuteNotifications: (seconds: number) => void;
onSetDisappearingMessages: (seconds: DurationInSeconds) => void; destroyMessages: (conversationId: string) => void;
onDeleteMessages: () => void;
onSearchInConversation: () => void; onSearchInConversation: () => void;
onOutgoingAudioCallInConversation: () => void; onOutgoingAudioCallInConversation: () => void;
onOutgoingVideoCallInConversation: () => void; onOutgoingVideoCallInConversation: () => void;
@ -95,6 +95,10 @@ export type PropsActionsType = {
onArchive: () => void; onArchive: () => void;
onMarkUnread: () => void; onMarkUnread: () => void;
onMoveToInbox: () => void; onMoveToInbox: () => void;
setDisappearingMessages: (
conversationId: string,
seconds: DurationInSeconds
) => void;
viewUserStories: ViewUserStoriesActionCreatorType; viewUserStories: ViewUserStoriesActionCreatorType;
}; };
@ -112,6 +116,7 @@ enum ModalState {
} }
type StateType = { type StateType = {
hasDeleteMessagesConfirmation: boolean;
isNarrow: boolean; isNarrow: boolean;
modalState: ModalState; modalState: ModalState;
}; };
@ -130,7 +135,11 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
public constructor(props: PropsType) { public constructor(props: PropsType) {
super(props); super(props);
this.state = { isNarrow: false, modalState: ModalState.NothingOpen }; this.state = {
hasDeleteMessagesConfirmation: false,
isNarrow: false,
modalState: ModalState.NothingOpen,
};
this.menuTriggerRef = React.createRef(); this.menuTriggerRef = React.createRef();
this.headerRef = React.createRef(); this.headerRef = React.createRef();
@ -329,6 +338,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
expireTimer, expireTimer,
groupVersion, groupVersion,
i18n, i18n,
id,
isArchived, isArchived,
isMissingMandatoryProfileSharing, isMissingMandatoryProfileSharing,
isPinned, isPinned,
@ -337,15 +347,14 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
markedUnread, markedUnread,
muteExpiresAt, muteExpiresAt,
onArchive, onArchive,
onDeleteMessages,
onMarkUnread, onMarkUnread,
onMoveToInbox, onMoveToInbox,
onSetDisappearingMessages,
onSetMuteNotifications, onSetMuteNotifications,
onSetPin, onSetPin,
onShowAllMedia, onShowAllMedia,
onShowConversationDetails, onShowConversationDetails,
onShowGroupMembers, onShowGroupMembers,
setDisappearingMessages,
type, type,
} = this.props; } = this.props;
@ -425,7 +434,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
modalState: ModalState.CustomDisappearingTimeout, modalState: ModalState.CustomDisappearingTimeout,
}); });
} else { } else {
onSetDisappearingMessages(seconds); setDisappearingMessages(id, seconds);
} }
}; };
@ -487,7 +496,11 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
) : ( ) : (
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem> <MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
)} )}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem> <MenuItem
onClick={() => this.setState({ hasDeleteMessagesConfirmation: true })}
>
{i18n('deleteMessages')}
</MenuItem>
{isPinned ? ( {isPinned ? (
<MenuItem onClick={() => onSetPin(false)}> <MenuItem onClick={() => onSetPin(false)}>
{i18n('unpinConversation')} {i18n('unpinConversation')}
@ -501,6 +514,37 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
); );
} }
private renderConfirmationDialog(): ReactNode {
const { hasDeleteMessagesConfirmation } = this.state;
const { destroyMessages, i18n, id } = this.props;
if (!hasDeleteMessagesConfirmation) {
return;
}
return (
<ConfirmationDialog
dialogName="ConversationHeader.destroyMessages"
actions={[
{
action: () => {
this.setState({ hasDeleteMessagesConfirmation: false });
destroyMessages(id);
},
style: 'negative',
text: i18n('delete'),
},
]}
i18n={i18n}
onClose={() => {
this.setState({ hasDeleteMessagesConfirmation: false });
}}
>
{i18n('deleteConversationConfirmation')}
</ConfirmationDialog>
);
}
private renderHeader(): ReactNode { private renderHeader(): ReactNode {
const { conversationTitle, groupVersion, onShowConversationDetails, type } = const { conversationTitle, groupVersion, onShowConversationDetails, type } =
this.props; this.props;
@ -579,8 +623,8 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
isSignalConversation, isSignalConversation,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
onSetDisappearingMessages,
outgoingCallButtonStyle, outgoingCallButtonStyle,
setDisappearingMessages,
showBackButton, showBackButton,
} = this.props; } = this.props;
const { isNarrow, modalState } = this.state; const { isNarrow, modalState } = this.state;
@ -596,7 +640,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
initialValue={expireTimer} initialValue={expireTimer}
onSubmit={value => { onSubmit={value => {
this.setState({ modalState: ModalState.NothingOpen }); this.setState({ modalState: ModalState.NothingOpen });
onSetDisappearingMessages(value); setDisappearingMessages(id, value);
}} }}
onClose={() => this.setState({ modalState: ModalState.NothingOpen })} onClose={() => this.setState({ modalState: ModalState.NothingOpen })}
/> />
@ -608,6 +652,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
return ( return (
<> <>
{modalNode} {modalNode}
{this.renderConfirmationDialog()}
<Measure <Measure
bounds bounds
onResize={({ bounds }) => { onResize={({ bounds }) => {

View file

@ -326,7 +326,7 @@ export class MessageDetail extends React.Component<Props> {
displayLimit={Number.MAX_SAFE_INTEGER} displayLimit={Number.MAX_SAFE_INTEGER}
displayTapToViewMessage={displayTapToViewMessage} displayTapToViewMessage={displayTapToViewMessage}
downloadAttachment={() => downloadAttachment={() =>
log.warn('MessageDetail: deleteMessageForEveryone called!') log.warn('MessageDetail: downloadAttachment called!')
} }
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference} doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
@ -355,7 +355,7 @@ export class MessageDetail extends React.Component<Props> {
showExpiredOutgoingTapToViewToast showExpiredOutgoingTapToViewToast
} }
showMessageDetail={() => { showMessageDetail={() => {
log.warn('MessageDetail: deleteMessageForEveryone called!'); log.warn('MessageDetail: showMessageDetail called!');
}} }}
showVisualAttachment={showVisualAttachment} showVisualAttachment={showVisualAttachment}
startConversation={startConversation} startConversation={startConversation}

View file

@ -26,6 +26,7 @@ import type {
} from './Message'; } from './Message';
import { doesMessageBodyOverflow } from './MessageBodyReadMore'; import { doesMessageBodyOverflow } from './MessageBodyReadMore';
import type { Props as ReactionPickerProps } from './ReactionPicker'; import type { Props as ReactionPickerProps } from './ReactionPicker';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts'; import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts';
export type PropsData = { export type PropsData = {
@ -237,6 +238,8 @@ export function TimelineMessage(props: Props): JSX.Element {
const handleReact = canReact ? () => toggleReactionPicker() : undefined; const handleReact = canReact ? () => toggleReactionPicker() : undefined;
const [hasDOEConfirmation, setHasDOEConfirmation] = useState(false);
const toggleReactionPickerKeyboard = useToggleReactionPicker( const toggleReactionPickerKeyboard = useToggleReactionPicker(
handleReact || noop handleReact || noop
); );
@ -253,6 +256,22 @@ export function TimelineMessage(props: Props): JSX.Element {
return ( return (
<> <>
{hasDOEConfirmation && canDeleteForEveryone && (
<ConfirmationDialog
actions={[
{
action: () => deleteMessageForEveryone(id),
style: 'negative',
text: i18n('delete'),
},
]}
dialogName="TimelineMessage/deleteMessageForEveryone"
i18n={i18n}
onClose={() => setHasDOEConfirmation(false)}
>
{i18n('deleteForEveryoneWarning')}
</ConfirmationDialog>
)}
<Message <Message
{...props} {...props}
renderingContext="conversation/TimelineItem" renderingContext="conversation/TimelineItem"
@ -319,7 +338,7 @@ export function TimelineMessage(props: Props): JSX.Element {
onForward={canForward ? () => showForwardMessageModal(id) : undefined} onForward={canForward ? () => showForwardMessageModal(id) : undefined}
onDeleteForMe={() => deleteMessage(id)} onDeleteForMe={() => deleteMessage(id)}
onDeleteForEveryone={ onDeleteForEveryone={
canDeleteForEveryone ? () => deleteMessageForEveryone(id) : undefined canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined
} }
onMoreInfo={() => showMessageDetail(id)} onMoreInfo={() => showMessageDetail(id)}
/> />

View file

@ -73,14 +73,12 @@ export type StateProps = {
i18n: LocalizerType; i18n: LocalizerType;
isAdmin: boolean; isAdmin: boolean;
isGroup: boolean; isGroup: boolean;
loadRecentMediaItems: (limit: number) => void;
groupsInCommon: Array<ConversationType>; groupsInCommon: Array<ConversationType>;
maxGroupSize: number; maxGroupSize: number;
maxRecommendedGroupSize: number; maxRecommendedGroupSize: number;
memberships: Array<GroupV2Membership>; memberships: Array<GroupV2Membership>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>; pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>; pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
setDisappearingMessages: (seconds: DurationInSeconds) => void;
showAllMedia: () => void; showAllMedia: () => void;
showChatColorEditor: () => void; showChatColorEditor: () => void;
showGroupLinkManagement: () => void; showGroupLinkManagement: () => void;
@ -116,13 +114,15 @@ export type StateProps = {
type ActionProps = { type ActionProps = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
loadRecentMediaItems: (id: string, limit: number) => void;
replaceAvatar: ReplaceAvatarActionType; replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType; saveAvatarToDisk: SaveAvatarToDiskActionType;
searchInConversation: (id: string) => unknown;
setDisappearingMessages: (id: string, seconds: DurationInSeconds) => void;
showContactModal: (contactId: string, conversationId?: string) => void; showContactModal: (contactId: string, conversationId?: string) => void;
showConversation: ShowConversationType; showConversation: ShowConversationType;
toggleSafetyNumberModal: (conversationId: string) => unknown;
searchInConversation: (id: string) => unknown;
toggleAddUserToAnotherGroupModal: (contactId?: string) => void; toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
toggleSafetyNumberModal: (conversationId: string) => unknown;
}; };
export type Props = StateProps & ActionProps; export type Props = StateProps & ActionProps;
@ -412,7 +412,9 @@ export function ConversationDetails({
<DisappearingTimerSelect <DisappearingTimerSelect
i18n={i18n} i18n={i18n}
value={conversation.expireTimer || DurationInSeconds.ZERO} value={conversation.expireTimer || DurationInSeconds.ZERO}
onChange={setDisappearingMessages} onChange={value =>
setDisappearingMessages(conversation.id, value)
}
/> />
} }
/> />

View file

@ -15,7 +15,7 @@ import { MediaGridItem } from '../media-gallery/MediaGridItem';
export type Props = { export type Props = {
conversation: ConversationType; conversation: ConversationType;
i18n: LocalizerType; i18n: LocalizerType;
loadRecentMediaItems: (limit: number) => void; loadRecentMediaItems: (id: string, limit: number) => void;
showAllMedia: () => void; showAllMedia: () => void;
showLightboxForMedia: ( showLightboxForMedia: (
selectedMediaItem: MediaItemType, selectedMediaItem: MediaItemType,
@ -39,8 +39,8 @@ export function ConversationDetailsMediaList({
const mediaItemsLength = mediaItems.length; const mediaItemsLength = mediaItems.length;
React.useEffect(() => { React.useEffect(() => {
loadRecentMediaItems(MEDIA_ITEM_LIMIT); loadRecentMediaItems(conversation.id, MEDIA_ITEM_LIMIT);
}, [loadRecentMediaItems, mediaItemsLength]); }, [conversation.id, loadRecentMediaItems, mediaItemsLength]);
if (mediaItemsLength === 0) { if (mediaItemsLength === 0) {
return null; return null;

View file

@ -13,14 +13,19 @@ import { PanelSection } from './PanelSection';
import { Select } from '../../Select'; import { Select } from '../../Select';
import { useUniqueId } from '../../../hooks/useUniqueId'; import { useUniqueId } from '../../../hooks/useUniqueId';
export type PropsType = { export type PropsDataType = {
conversation?: ConversationType; conversation?: ConversationType;
i18n: LocalizerType; i18n: LocalizerType;
setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void;
setAnnouncementsOnly: (value: boolean) => void;
}; };
type PropsActionType = {
setAccessControlAttributesSetting: (id: string, value: number) => void;
setAccessControlMembersSetting: (id: string, value: number) => void;
setAnnouncementsOnly: (id: string, value: boolean) => void;
};
export type PropsType = PropsDataType & PropsActionType;
export function GroupV2Permissions({ export function GroupV2Permissions({
conversation, conversation,
i18n, i18n,
@ -37,14 +42,17 @@ export function GroupV2Permissions({
} }
const updateAccessControlAttributes = (value: string) => { const updateAccessControlAttributes = (value: string) => {
setAccessControlAttributesSetting(Number(value)); setAccessControlAttributesSetting(conversation.id, Number(value));
}; };
const updateAccessControlMembers = (value: string) => { const updateAccessControlMembers = (value: string) => {
setAccessControlMembersSetting(Number(value)); setAccessControlMembersSetting(conversation.id, Number(value));
}; };
const AccessControlEnum = Proto.AccessControl.AccessRequired; const AccessControlEnum = Proto.AccessControl.AccessRequired;
const updateAnnouncementsOnly = (value: string) => { const updateAnnouncementsOnly = (value: string) => {
setAnnouncementsOnly(Number(value) === AccessControlEnum.ADMINISTRATOR); setAnnouncementsOnly(
conversation.id,
Number(value) === AccessControlEnum.ADMINISTRATOR
);
}; };
const accessControlOptions = getAccessControlOptions(i18n); const accessControlOptions = getAccessControlOptions(i18n);
const announcementsOnlyValue = String( const announcementsOnlyValue = String(

View file

@ -48,7 +48,9 @@ const conversation: ConversationType = {
const OUR_UUID = UUID.generate().toString(); const OUR_UUID = UUID.generate().toString();
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
approvePendingMembership: action('approvePendingMembership'), approvePendingMembershipFromGroupV2: action(
'approvePendingMembershipFromGroupV2'
),
conversation, conversation,
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
i18n, i18n,
@ -70,7 +72,9 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
}, },
})), })),
], ],
revokePendingMemberships: action('revokePendingMemberships'), revokePendingMembershipsFromGroupV2: action(
'revokePendingMembershipsFromGroupV2'
),
theme: React.useContext(StorybookThemeContext), theme: React.useContext(StorybookThemeContext),
...overrideProps, ...overrideProps,
}); });

View file

@ -17,18 +17,29 @@ import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon';
import { isAccessControlEnabled } from '../../../groups/util'; import { isAccessControlEnabled } from '../../../groups/util';
import { assertDev } from '../../../util/assert'; import { assertDev } from '../../../util/assert';
export type PropsType = { export type PropsDataType = {
readonly conversation?: ConversationType; readonly conversation?: ConversationType;
readonly getPreferredBadge: PreferredBadgeSelectorType; readonly getPreferredBadge: PreferredBadgeSelectorType;
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly ourUuid: UUIDStringType; readonly ourUuid: UUIDStringType;
readonly pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>; readonly pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
readonly pendingMemberships: ReadonlyArray<GroupV2PendingMembership>; readonly pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
readonly approvePendingMembership: (conversationId: string) => void;
readonly revokePendingMemberships: (conversationIds: Array<string>) => void;
readonly theme: ThemeType; readonly theme: ThemeType;
}; };
type PropsActionType = {
readonly approvePendingMembershipFromGroupV2: (
conversationId: string,
memberId: string
) => void;
readonly revokePendingMembershipsFromGroupV2: (
conversationId: string,
memberIds: Array<string>
) => void;
};
export type PropsType = PropsDataType & PropsActionType;
export type GroupV2PendingMembership = { export type GroupV2PendingMembership = {
metadata: { metadata: {
addedByUserId?: UUIDStringType; addedByUserId?: UUIDStringType;
@ -57,14 +68,14 @@ type StagedMembershipType = {
}; };
export function PendingInvites({ export function PendingInvites({
approvePendingMembership, approvePendingMembershipFromGroupV2,
conversation, conversation,
getPreferredBadge, getPreferredBadge,
i18n, i18n,
ourUuid, ourUuid,
pendingMemberships, pendingMemberships,
pendingApprovalMemberships, pendingApprovalMemberships,
revokePendingMemberships, revokePendingMembershipsFromGroupV2,
theme, theme,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
if (!conversation || !ourUuid) { if (!conversation || !ourUuid) {
@ -148,13 +159,17 @@ export function PendingInvites({
{stagedMemberships && stagedMemberships.length && ( {stagedMemberships && stagedMemberships.length && (
<MembershipActionConfirmation <MembershipActionConfirmation
approvePendingMembership={approvePendingMembership} approvePendingMembershipFromGroupV2={
approvePendingMembershipFromGroupV2
}
conversation={conversation} conversation={conversation}
i18n={i18n} i18n={i18n}
members={conversation.sortedGroupMembers || []} members={conversation.sortedGroupMembers || []}
onClose={() => setStagedMemberships(null)} onClose={() => setStagedMemberships(null)}
ourUuid={ourUuid} ourUuid={ourUuid}
revokePendingMemberships={revokePendingMemberships} revokePendingMembershipsFromGroupV2={
revokePendingMembershipsFromGroupV2
}
stagedMemberships={stagedMemberships} stagedMemberships={stagedMemberships}
/> />
)} )}
@ -163,29 +178,36 @@ export function PendingInvites({
} }
function MembershipActionConfirmation({ function MembershipActionConfirmation({
approvePendingMembership, approvePendingMembershipFromGroupV2,
conversation, conversation,
i18n, i18n,
members, members,
onClose, onClose,
ourUuid, ourUuid,
revokePendingMemberships, revokePendingMembershipsFromGroupV2,
stagedMemberships, stagedMemberships,
}: { }: {
approvePendingMembership: (conversationId: string) => void; approvePendingMembershipFromGroupV2: (
conversationId: string,
memberId: string
) => void;
conversation: ConversationType; conversation: ConversationType;
i18n: LocalizerType; i18n: LocalizerType;
members: Array<ConversationType>; members: Array<ConversationType>;
onClose: () => void; onClose: () => void;
ourUuid: string; ourUuid: string;
revokePendingMemberships: (conversationIds: Array<string>) => void; revokePendingMembershipsFromGroupV2: (
conversationId: string,
memberIds: Array<string>
) => void;
stagedMemberships: Array<StagedMembershipType>; stagedMemberships: Array<StagedMembershipType>;
}) { }) {
const revokeStagedMemberships = () => { const revokeStagedMemberships = () => {
if (!stagedMemberships) { if (!stagedMemberships) {
return; return;
} }
revokePendingMemberships( revokePendingMembershipsFromGroupV2(
conversation.id,
stagedMemberships.map(({ membership }) => membership.member.id) stagedMemberships.map(({ membership }) => membership.member.id)
); );
}; };
@ -194,7 +216,10 @@ function MembershipActionConfirmation({
if (!stagedMemberships) { if (!stagedMemberships) {
return; return;
} }
approvePendingMembership(stagedMemberships[0].membership.member.id); approvePendingMembershipFromGroupV2(
conversation.id,
stagedMemberships[0].membership.member.id
);
}; };
const membershipType = stagedMemberships[0].type; const membershipType = stagedMemberships[0].type;

View file

@ -150,6 +150,9 @@ import { getSendTarget } from '../util/getSendTarget';
import { getRecipients } from '../util/getRecipients'; import { getRecipients } from '../util/getRecipients';
import { validateConversation } from '../util/validateConversation'; import { validateConversation } from '../util/validateConversation';
import { isSignalConversation } from '../util/isSignalConversation'; import { isSignalConversation } from '../util/isSignalConversation';
import { isMemberRequestingToJoin } from '../util/isMemberRequestingToJoin';
import { removePendingMember } from '../util/removePendingMember';
import { isMemberPending } from '../util/isMemberPending';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -436,29 +439,11 @@ export class ConversationModel extends window.Backbone
} }
private isMemberRequestingToJoin(uuid: UUID): boolean { private isMemberRequestingToJoin(uuid: UUID): boolean {
if (!isGroupV2(this.attributes)) { return isMemberRequestingToJoin(this.attributes, uuid);
return false;
}
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
return false;
}
return pendingAdminApprovalV2.some(item => item.uuid === uuid.toString());
} }
isMemberPending(uuid: UUID): boolean { isMemberPending(uuid: UUID): boolean {
if (!isGroupV2(this.attributes)) { return isMemberPending(this.attributes, uuid);
return false;
}
const pendingMembersV2 = this.get('pendingMembersV2');
if (!pendingMembersV2 || !pendingMembersV2.length) {
return false;
}
return pendingMembersV2.some(item => item.uuid === uuid.toString());
} }
private isMemberBanned(uuid: UUID): boolean { private isMemberBanned(uuid: UUID): boolean {
@ -569,28 +554,6 @@ export class ConversationModel extends window.Backbone
}); });
} }
private async approvePendingApprovalRequest(
uuid: UUID
): Promise<Proto.GroupChange.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(uuid)) {
log.warn(
`approvePendingApprovalRequest/${idLog}: ${uuid} is not requesting ` +
'to join the group. Returning early.'
);
return undefined;
}
return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange({
group: this.attributes,
uuid,
});
}
private async denyPendingApprovalRequest( private async denyPendingApprovalRequest(
uuid: UUID uuid: UUID
): Promise<Proto.GroupChange.Actions | undefined> { ): Promise<Proto.GroupChange.Actions | undefined> {
@ -712,32 +675,7 @@ export class ConversationModel extends window.Backbone
private async removePendingMember( private async removePendingMember(
uuids: ReadonlyArray<UUID> uuids: ReadonlyArray<UUID>
): Promise<Proto.GroupChange.Actions | undefined> { ): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = this.idForLogging(); return removePendingMember(this.attributes, uuids);
const pendingUuids = uuids
.map(uuid => {
// 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(uuid)) {
log.warn(
`removePendingMember/${idLog}: ${uuid} is not a pending member of group. Returning early.`
);
return undefined;
}
return uuid;
})
.filter(isNotNil);
if (!uuids.length) {
return undefined;
}
return window.Signal.Groups.buildDeletePendingMemberChange({
group: this.attributes,
uuids: pendingUuids,
});
} }
private async removeMember( private async removeMember(
@ -2631,85 +2569,6 @@ export class ConversationModel extends window.Backbone
}); });
} }
async approvePendingMembershipFromGroupV2(
conversationId: string
): Promise<void> {
const logId = this.idForLogging();
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
throw new Error(
`approvePendingMembershipFromGroupV2/${logId}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.getCheckedUuid(
`approvePendingMembershipFromGroupV2/${logId}`
);
if (isGroupV2(this.attributes) && this.isMemberRequestingToJoin(uuid)) {
await this.modifyGroupV2({
name: 'approvePendingApprovalRequest',
usingCredentialsFrom: [pendingMember],
createGroupChange: () => this.approvePendingApprovalRequest(uuid),
});
}
}
async revokePendingMembershipsFromGroupV2(
conversationIds: Array<string>
): Promise<void> {
if (!isGroupV2(this.attributes)) {
return;
}
// Only pending memberships can be revoked for multiple members at once
if (conversationIds.length > 1) {
const uuids = conversationIds.map(id => {
const uuid = window.ConversationController.get(id)?.getUuid();
strictAssert(uuid, `UUID does not exist for ${id}`);
return uuid;
});
await this.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () => this.removePendingMember(uuids),
extraConversationsForSend: conversationIds,
});
return;
}
const [conversationId] = conversationIds;
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
const logId = this.idForLogging();
throw new Error(
`revokePendingMembershipsFromGroupV2/${logId}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.getCheckedUuid(
'revokePendingMembershipsFromGroupV2'
);
if (this.isMemberRequestingToJoin(uuid)) {
await this.modifyGroupV2({
name: 'denyPendingApprovalRequest',
usingCredentialsFrom: [],
createGroupChange: () => this.denyPendingApprovalRequest(uuid),
extraConversationsForSend: [conversationId],
});
} else if (this.isMemberPending(uuid)) {
await this.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () => this.removePendingMember([uuid]),
extraConversationsForSend: [conversationId],
});
}
}
async removeFromGroupV2(conversationId: string): Promise<void> { async removeFromGroupV2(conversationId: string): Promise<void> {
if (!isGroupV2(this.attributes)) { if (!isGroupV2(this.attributes)) {
return; return;

View file

@ -12,6 +12,7 @@ import {
without, without,
} from 'lodash'; } from 'lodash';
import type { AttachmentType } from '../../types/Attachment';
import type { StateType as RootStateType } from '../reducer'; import type { StateType as RootStateType } from '../reducer';
import * as groups from '../../groups'; import * as groups from '../../groups';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
@ -90,12 +91,19 @@ import type { TimelineMessageLoadingState } from '../../util/timelineUtil';
import { import {
isDirectConversation, isDirectConversation,
isGroup, isGroup,
isGroupV2,
} from '../../util/whatTypeOfConversation'; } from '../../util/whatTypeOfConversation';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
import { isIncoming } from '../selectors/message'; import { isIncoming } from '../selectors/message';
import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage';
import type { ShowToastActionType } from './toast';
import { SHOW_TOAST, ToastType } from './toast';
import { isMemberRequestingToJoin } from '../../util/isMemberRequestingToJoin';
import { removePendingMember } from '../../util/removePendingMember';
import { denyPendingApprovalRequest } from '../../util/denyPendingApprovalRequest';
// State // State
@ -833,6 +841,7 @@ export type ConversationActionType =
export const actions = { export const actions = {
addMemberToGroup, addMemberToGroup,
approvePendingMembershipFromGroupV2,
cancelConversationVerification, cancelConversationVerification,
changeHasGroupLink, changeHasGroupLink,
clearCancelledConversationVerification, clearCancelledConversationVerification,
@ -854,9 +863,12 @@ export const actions = {
conversationUnloaded, conversationUnloaded,
createGroup, createGroup,
deleteAvatarFromDisk, deleteAvatarFromDisk,
deleteMessageForEveryone,
destroyMessages,
discardMessages, discardMessages,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
generateNewGroupLink, generateNewGroupLink,
loadRecentMediaItems,
messageChanged, messageChanged,
messageDeleted, messageDeleted,
messageExpanded, messageExpanded,
@ -872,31 +884,35 @@ export const actions = {
resetAllChatColors, resetAllChatColors,
reviewGroupMemberNameCollision, reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision, reviewMessageRequestNameCollision,
revokePendingMembershipsFromGroupV2,
saveAvatarToDisk, saveAvatarToDisk,
scrollToMessage, scrollToMessage,
selectMessage, selectMessage,
setAccessControlAddFromInviteLinkSetting, setAccessControlAddFromInviteLinkSetting,
setAccessControlAttributesSetting,
setAccessControlMembersSetting,
setAnnouncementsOnly,
setComposeGroupAvatar, setComposeGroupAvatar,
setComposeGroupExpireTimer, setComposeGroupExpireTimer,
setComposeGroupName, setComposeGroupName,
setComposeSearchTerm, setComposeSearchTerm,
setDisappearingMessages,
setIsFetchingUUID, setIsFetchingUUID,
setIsNearBottom, setIsNearBottom,
setMessageLoadingState, setMessageLoadingState,
setPreJoinConversation, setPreJoinConversation,
setRecentMediaItems,
setSelectedConversationHeaderTitle, setSelectedConversationHeaderTitle,
setSelectedConversationPanelDepth, setSelectedConversationPanelDepth,
setVoiceNotePlaybackRate, setVoiceNotePlaybackRate,
showArchivedConversations, showArchivedConversations,
showChooseGroupMembers, showChooseGroupMembers,
showInbox,
showConversation, showConversation,
showInbox,
startComposing, startComposing,
startSettingGroupMetadata, startSettingGroupMetadata,
toggleAdmin, toggleAdmin,
toggleConversationInChooseMembers,
toggleComposeEditingAvatar, toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
toggleGroupsForStorySend, toggleGroupsForStorySend,
toggleHideStories, toggleHideStories,
updateConversationModelSharedGroups, updateConversationModelSharedGroups,
@ -1003,6 +1019,125 @@ function changeHasGroupLink(
}; };
} }
function setAnnouncementsOnly(
conversationId: string,
value: boolean
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
await longRunningTaskWrapper({
name: 'updateAnnouncementsOnly',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAnnouncementsOnly(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setAccessControlMembersSetting(
conversationId: string,
value: number
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
await longRunningTaskWrapper({
name: 'updateAccessControlMembers',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAccessControlMembers(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setAccessControlAttributesSetting(
conversationId: string,
value: number
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
await longRunningTaskWrapper({
name: 'updateAccessControlAttributes',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAccessControlAttributes(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setDisappearingMessages(
conversationId: string,
seconds: DurationInSeconds
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
const valueToSet = seconds > 0 ? seconds : undefined;
await longRunningTaskWrapper({
name: 'updateExpirationTimer',
idForLogging: conversation.idForLogging(),
task: async () =>
conversation.updateExpirationTimer(valueToSet, {
reason: 'setDisappearingMessages',
}),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function destroyMessages(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
await longRunningTaskWrapper({
name: 'destroymessages',
idForLogging: conversation.idForLogging(),
task: async () => {
conversation.trigger('unload', 'delete messages');
await conversation.destroyMessages();
conversation.updateLastMessage();
},
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function generateNewGroupLink( function generateNewGroupLink(
conversationId: string conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
@ -1845,15 +1980,248 @@ function setSelectedConversationPanelDepth(
payload: { panelDepth }, payload: { panelDepth },
}; };
} }
function setRecentMediaItems(
id: string, function deleteMessageForEveryone(
recentMediaItems: Array<MediaItemType> messageId: string
): SetRecentMediaItemsActionType { ): ThunkAction<
return { void,
type: 'SET_RECENT_MEDIA_ITEMS', RootStateType,
payload: { id, recentMediaItems }, unknown,
NoopActionType | ShowToastActionType
> {
return async dispatch => {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(
`deleteMessageForEveryone: Message ${messageId} missing!`
);
}
const conversation = message.getConversation();
if (!conversation) {
throw new Error('deleteMessageForEveryone: no conversation');
}
try {
await sendDeleteForEveryoneMessage(conversation.attributes, {
id: message.id,
timestamp: message.get('sent_at'),
});
dispatch({
type: 'NOOP',
payload: null,
});
} catch (error) {
log.error(
'Error sending delete-for-everyone',
Errors.toLogFormat(error),
messageId
);
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.DeleteForEveryoneFailed,
},
});
}
}; };
} }
function approvePendingMembershipFromGroupV2(
conversationId: string,
memberId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`approvePendingMembershipFromGroupV2: No conversation found for conversation ${conversationId}`
);
}
const logId = conversation.idForLogging();
const pendingMember = window.ConversationController.get(memberId);
if (!pendingMember) {
throw new Error(
`approvePendingMembershipFromGroupV2/${logId}: No member found for conversation ${conversationId}`
);
}
const uuid = pendingMember.getCheckedUuid(
`approvePendingMembershipFromGroupV2/${logId}`
);
if (
isGroupV2(conversation.attributes) &&
isMemberRequestingToJoin(conversation.attributes, uuid)
) {
await window.Signal.Groups.modifyGroupV2({
conversation,
usingCredentialsFrom: [pendingMember],
createGroupChange: async () => {
// 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 (!isMemberRequestingToJoin(conversation.attributes, uuid)) {
log.warn(
`approvePendingMembershipFromGroupV2/${logId}: ${uuid} is not requesting ` +
'to join the group. Returning early.'
);
return undefined;
}
return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange(
{
group: conversation.attributes,
uuid,
}
);
},
name: 'approvePendingMembershipFromGroupV2',
});
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function revokePendingMembershipsFromGroupV2(
conversationId: string,
memberIds: Array<string>
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`approvePendingMembershipFromGroupV2: No conversation found for conversation ${conversationId}`
);
}
if (!isGroupV2(conversation.attributes)) {
return;
}
// Only pending memberships can be revoked for multiple members at once
if (memberIds.length > 1) {
const uuids = memberIds.map(id => {
const uuid = window.ConversationController.get(id)?.getUuid();
strictAssert(uuid, `UUID does not exist for ${id}`);
return uuid;
});
await conversation.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () =>
removePendingMember(conversation.attributes, uuids),
extraConversationsForSend: memberIds,
});
return;
}
const [memberId] = memberIds;
const pendingMember = window.ConversationController.get(memberId);
if (!pendingMember) {
const logId = conversation.idForLogging();
throw new Error(
`revokePendingMembershipsFromGroupV2/${logId}: No conversation found for conversation ${memberId}`
);
}
const uuid = pendingMember.getCheckedUuid(
'revokePendingMembershipsFromGroupV2'
);
if (isMemberRequestingToJoin(conversation.attributes, uuid)) {
await conversation.modifyGroupV2({
name: 'denyPendingApprovalRequest',
usingCredentialsFrom: [],
createGroupChange: () =>
denyPendingApprovalRequest(conversation.attributes, uuid),
extraConversationsForSend: [memberId],
});
} else if (conversation.isMemberPending(uuid)) {
await conversation.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () =>
removePendingMember(conversation.attributes, [uuid]),
extraConversationsForSend: [memberId],
});
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function loadRecentMediaItems(
conversationId: string,
limit: number
): ThunkAction<void, RootStateType, unknown, SetRecentMediaItemsActionType> {
return async dispatch => {
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
const messages: Array<MessageAttributesType> =
await window.Signal.Data.getMessagesWithVisualMediaAttachments(
conversationId,
{
limit,
}
);
// Cache these messages in memory to ensure Lightbox can find them
messages.forEach(message => {
window.MessageController.register(message.id, message);
});
const recentMediaItems = 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?.path
? getAbsoluteAttachmentPath(thumbnail.path)
: '',
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(message.sourceUuid)?.id ||
message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
),
],
[] as Array<MediaItemType>
);
dispatch({
type: 'SET_RECENT_MEDIA_ITEMS',
payload: { id: conversationId, recentMediaItems },
});
};
}
function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreatedGroupActionType { function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreatedGroupActionType {
return { type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP' }; return { type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP' };
} }

View file

@ -18,6 +18,7 @@ export enum ToastType {
FailedToDeleteUsername = 'FailedToDeleteUsername', FailedToDeleteUsername = 'FailedToDeleteUsername',
CopiedUsername = 'CopiedUsername', CopiedUsername = 'CopiedUsername',
CopiedUsernameLink = 'CopiedUsernameLink', CopiedUsernameLink = 'CopiedUsernameLink',
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
} }
// State // State
@ -32,13 +33,13 @@ export type ToastStateType = {
// Actions // Actions
const HIDE_TOAST = 'toast/HIDE_TOAST'; const HIDE_TOAST = 'toast/HIDE_TOAST';
const SHOW_TOAST = 'toast/SHOW_TOAST'; export const SHOW_TOAST = 'toast/SHOW_TOAST';
type HideToastActionType = { type HideToastActionType = {
type: typeof HIDE_TOAST; type: typeof HIDE_TOAST;
}; };
type ShowToastActionType = { export type ShowToastActionType = {
type: typeof SHOW_TOAST; type: typeof SHOW_TOAST;
payload: { payload: {
toastType: ToastType; toastType: ToastType;

View file

@ -24,7 +24,6 @@ import {
getPreferredBadgeSelector, getPreferredBadgeSelector,
} from '../selectors/badges'; } from '../selectors/badges';
import { assertDev } from '../../util/assert'; import { assertDev } from '../../util/assert';
import type { DurationInSeconds } from '../../util/durations';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes';
import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembersModal'; import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembersModal';
@ -39,8 +38,6 @@ import {
export type SmartConversationDetailsProps = { export type SmartConversationDetailsProps = {
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>; addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
conversationId: string; conversationId: string;
loadRecentMediaItems: (limit: number) => void;
setDisappearingMessages: (seconds: DurationInSeconds) => void;
showAllMedia: () => void; showAllMedia: () => void;
showChatColorEditor: () => void; showChatColorEditor: () => void;
showGroupLinkManagement: () => void; showGroupLinkManagement: () => void;

View file

@ -25,20 +25,17 @@ import { mapDispatchToProps } from '../actions';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { isSignalConversation } from '../../util/isSignalConversation'; import { isSignalConversation } from '../../util/isSignalConversation';
import type { DurationInSeconds } from '../../util/durations';
export type OwnProps = { export type OwnProps = {
id: string; id: string;
onArchive: () => void; onArchive: () => void;
onDeleteMessages: () => void;
onGoBack: () => void; onGoBack: () => void;
onMarkUnread: () => void; onMarkUnread: () => void;
onMoveToInbox: () => void; onMoveToInbox: () => void;
onOutgoingAudioCallInConversation: () => void; onOutgoingAudioCallInConversation: () => void;
onOutgoingVideoCallInConversation: () => void; onOutgoingVideoCallInConversation: () => void;
onSearchInConversation: () => void; onSearchInConversation: () => void;
onSetDisappearingMessages: (seconds: DurationInSeconds) => void;
onSetMuteNotifications: (seconds: number) => void; onSetMuteNotifications: (seconds: number) => void;
onSetPin: (value: boolean) => void; onSetPin: (value: boolean) => void;
onShowAllMedia: () => void; onShowAllMedia: () => void;

View file

@ -4,22 +4,20 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import type { PropsType } from '../../components/conversation/conversation-details/GroupV2Permissions'; import type { PropsDataType } from '../../components/conversation/conversation-details/GroupV2Permissions';
import { mapDispatchToProps } from '../actions';
import { GroupV2Permissions } from '../../components/conversation/conversation-details/GroupV2Permissions'; import { GroupV2Permissions } from '../../components/conversation/conversation-details/GroupV2Permissions';
import { getConversationSelector } from '../selectors/conversations'; import { getConversationSelector } from '../selectors/conversations';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
export type SmartGroupV2PermissionsProps = { export type SmartGroupV2PermissionsProps = {
conversationId: string; conversationId: string;
setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void;
setAnnouncementsOnly: (value: boolean) => void;
}; };
const mapStateToProps = ( const mapStateToProps = (
state: StateType, state: StateType,
props: SmartGroupV2PermissionsProps props: SmartGroupV2PermissionsProps
): PropsType => { ): PropsDataType => {
const conversation = getConversationSelector(state)(props.conversationId); const conversation = getConversationSelector(state)(props.conversationId);
return { return {
@ -29,6 +27,6 @@ const mapStateToProps = (
}; };
}; };
const smart = connect(mapStateToProps); const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartGroupV2Permissions = smart(GroupV2Permissions); export const SmartGroupV2Permissions = smart(GroupV2Permissions);

View file

@ -3,7 +3,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import type { PropsType } from '../../components/conversation/conversation-details/PendingInvites'; import type { PropsDataType } from '../../components/conversation/conversation-details/PendingInvites';
import { PendingInvites } from '../../components/conversation/conversation-details/PendingInvites'; import { PendingInvites } from '../../components/conversation/conversation-details/PendingInvites';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
@ -20,14 +20,12 @@ import type { UUIDStringType } from '../../types/UUID';
export type SmartPendingInvitesProps = { export type SmartPendingInvitesProps = {
conversationId: string; conversationId: string;
ourUuid: UUIDStringType; ourUuid: UUIDStringType;
readonly approvePendingMembership: (conversationid: string) => void;
readonly revokePendingMemberships: (membershipIds: Array<string>) => void;
}; };
const mapStateToProps = ( const mapStateToProps = (
state: StateType, state: StateType,
props: SmartPendingInvitesProps props: SmartPendingInvitesProps
): PropsType => { ): PropsDataType => {
const conversationSelector = getConversationByIdSelector(state); const conversationSelector = getConversationByIdSelector(state);
const conversationByUuidSelector = getConversationByUuidSelector(state); const conversationByUuidSelector = getConversationByUuidSelector(state);

View file

@ -66,7 +66,6 @@ export type TimelinePropsType = ExternalProps &
| 'contactSupport' | 'contactSupport'
| 'blockGroupLinkRequests' | 'blockGroupLinkRequests'
| 'deleteMessage' | 'deleteMessage'
| 'deleteMessageForEveryone'
| 'displayTapToViewMessage' | 'displayTapToViewMessage'
| 'downloadAttachment' | 'downloadAttachment'
| 'downloadNewVersion' | 'downloadNewVersion'

View file

@ -0,0 +1,36 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import type { SignalService as Proto } from '../protobuf';
import type { UUID } from '../types/UUID';
import * as log from '../logging/log';
import { UUIDKind } from '../types/UUID';
import { getConversationIdForLogging } from './idForLogging';
import { isMemberRequestingToJoin } from './isMemberRequestingToJoin';
export async function denyPendingApprovalRequest(
conversationAttributes: ConversationAttributesType,
uuid: UUID
): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = getConversationIdForLogging(conversationAttributes);
// 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 (!isMemberRequestingToJoin(conversationAttributes, uuid)) {
log.warn(
`denyPendingApprovalRequest/${idLog}: ${uuid} is not requesting ` +
'to join the group. Returning early.'
);
return undefined;
}
const ourUuid = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI);
return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({
group: conversationAttributes,
ourUuid,
uuid,
});
}

View file

@ -0,0 +1,25 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UUID } from '../types/UUID';
import type { ConversationAttributesType } from '../model-types.d';
import { isGroupV2 } from './whatTypeOfConversation';
export function isMemberPending(
conversationAttrs: Pick<
ConversationAttributesType,
'groupId' | 'groupVersion' | 'pendingMembersV2'
>,
uuid: UUID
): boolean {
if (!isGroupV2(conversationAttrs)) {
return false;
}
const { pendingMembersV2 } = conversationAttrs;
if (!pendingMembersV2 || !pendingMembersV2.length) {
return false;
}
return pendingMembersV2.some(item => item.uuid === uuid.toString());
}

View file

@ -0,0 +1,25 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UUID } from '../types/UUID';
import type { ConversationAttributesType } from '../model-types.d';
import { isGroupV2 } from './whatTypeOfConversation';
export function isMemberRequestingToJoin(
conversationAttrs: Pick<
ConversationAttributesType,
'groupId' | 'groupVersion' | 'pendingAdminApprovalV2'
>,
uuid: UUID
): boolean {
if (!isGroupV2(conversationAttrs)) {
return false;
}
const { pendingAdminApprovalV2 } = conversationAttrs;
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
return false;
}
return pendingAdminApprovalV2.some(item => item.uuid === uuid.toString());
}

View file

@ -0,0 +1,42 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import type { SignalService as Proto } from '../protobuf';
import type { UUID } from '../types/UUID';
import * as log from '../logging/log';
import { getConversationIdForLogging } from './idForLogging';
import { isMemberPending } from './isMemberPending';
import { isNotNil } from './isNotNil';
export async function removePendingMember(
conversationAttributes: ConversationAttributesType,
uuids: ReadonlyArray<UUID>
): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = getConversationIdForLogging(conversationAttributes);
const pendingUuids = uuids
.map(uuid => {
// 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 (!isMemberPending(conversationAttributes, uuid)) {
log.warn(
`removePendingMember/${idLog}: ${uuid} is not a pending member of group. Returning early.`
);
return undefined;
}
return uuid;
})
.filter(isNotNil);
if (!uuids.length) {
return undefined;
}
return window.Signal.Groups.buildDeletePendingMemberChange({
group: conversationAttributes,
uuids: pendingUuids,
});
}

View file

@ -27,7 +27,6 @@ import type {
ToastInternalError, ToastInternalError,
ToastPropsType as ToastInternalErrorPropsType, ToastPropsType as ToastInternalErrorPropsType,
} from '../components/ToastInternalError'; } from '../components/ToastInternalError';
import type { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
import type { ToastExpired } from '../components/ToastExpired'; import type { ToastExpired } from '../components/ToastExpired';
import type { import type {
ToastFileSaved, ToastFileSaved,
@ -79,7 +78,6 @@ export function showToast(
Toast: typeof ToastInternalError, Toast: typeof ToastInternalError,
props: ToastInternalErrorPropsType props: ToastInternalErrorPropsType
): void; ): void;
export function showToast(Toast: typeof ToastDeleteForEveryoneFailed): void;
export function showToast(Toast: typeof ToastExpired): void; export function showToast(Toast: typeof ToastExpired): void;
export function showToast( export function showToast(
Toast: typeof ToastFileSaved, Toast: typeof ToastFileSaved,

View file

@ -0,0 +1,19 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UUIDStringType } from '../types/UUID';
import { strictAssert } from './assert';
export function startConversation(e164: string, uuid: UUIDStringType): void {
const conversation = window.ConversationController.lookupOrCreate({
e164,
uuid,
reason: 'util/startConversation',
});
strictAssert(
conversation,
`startConversation failed given ${e164}/${uuid} combination`
);
window.Whisper.events.trigger('showConversation', conversation.id);
}

View file

@ -36,7 +36,6 @@ import {
isGroupV1, isGroupV1,
} from '../util/whatTypeOfConversation'; } from '../util/whatTypeOfConversation';
import { findAndFormatContact } from '../util/findAndFormatContact'; import { findAndFormatContact } from '../util/findAndFormatContact';
import type { DurationInSeconds } from '../util/durations';
import { getPreferredBadgeSelector } from '../state/selectors/badges'; import { getPreferredBadgeSelector } from '../state/selectors/badges';
import { import {
canReply, canReply,
@ -67,7 +66,6 @@ import { ToastConversationArchived } from '../components/ToastConversationArchiv
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
import { ToastDangerousFileType } from '../components/ToastDangerousFileType'; import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
import { ToastExpired } from '../components/ToastExpired'; import { ToastExpired } from '../components/ToastExpired';
import { ToastFileSize } from '../components/ToastFileSize'; import { ToastFileSize } from '../components/ToastFileSize';
import { ToastInvalidConversation } from '../components/ToastInvalidConversation'; import { ToastInvalidConversation } from '../components/ToastInvalidConversation';
@ -107,13 +105,13 @@ import {
import { LinkPreviewSourceType } from '../types/LinkPreview'; import { LinkPreviewSourceType } from '../types/LinkPreview';
import { closeLightbox, showLightbox } from '../util/showLightbox'; import { closeLightbox, showLightbox } from '../util/showLightbox';
import { saveAttachment } from '../util/saveAttachment'; import { saveAttachment } from '../util/saveAttachment';
import { sendDeleteForEveryoneMessage } from '../util/sendDeleteForEveryoneMessage';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import { blockSendUntilConversationsAreVerified } from '../util/blockSendUntilConversationsAreVerified'; import { blockSendUntilConversationsAreVerified } from '../util/blockSendUntilConversationsAreVerified';
import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog'; import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
import { getOwn } from '../util/getOwn'; import { getOwn } from '../util/getOwn';
import { CallMode } from '../types/Calling'; import { CallMode } from '../types/Calling';
import { isAnybodyElseInGroupCall } from '../state/ducks/calling'; import { isAnybodyElseInGroupCall } from '../state/ducks/calling';
import { startConversation } from '../util/startConversation';
type AttachmentOptions = { type AttachmentOptions = {
messageId: string; messageId: string;
@ -136,7 +134,6 @@ const { getMessagesBySentAt } = window.Signal.Data;
type MessageActionsType = { type MessageActionsType = {
deleteMessage: (messageId: string) => unknown; deleteMessage: (messageId: string) => unknown;
deleteMessageForEveryone: (messageId: string) => unknown;
displayTapToViewMessage: (messageId: string) => unknown; displayTapToViewMessage: (messageId: string) => unknown;
downloadAttachment: (options: { downloadAttachment: (options: {
attachment: AttachmentType; attachment: AttachmentType;
@ -337,9 +334,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const conversationHeaderProps = { const conversationHeaderProps = {
id: this.model.id, id: this.model.id,
onSetDisappearingMessages: (seconds: DurationInSeconds) =>
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onSearchInConversation: () => { onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search; const { searchInConversation } = window.reduxActions.search;
searchInConversation(this.model.id); searchInConversation(this.model.id);
@ -737,9 +731,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const deleteMessage = (messageId: string) => { const deleteMessage = (messageId: string) => {
this.deleteMessage(messageId); this.deleteMessage(messageId);
}; };
const deleteMessageForEveryone = (messageId: string) => {
this.deleteMessageForEveryone(messageId);
};
const showMessageDetail = (messageId: string) => { const showMessageDetail = (messageId: string) => {
this.showMessageDetail(messageId); this.showMessageDetail(messageId);
}; };
@ -826,11 +817,9 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}; };
const showForwardMessageModal = this.showForwardMessageModal.bind(this); const showForwardMessageModal = this.showForwardMessageModal.bind(this);
const startConversation = this.startConversation.bind(this);
return { return {
deleteMessage, deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage, displayTapToViewMessage,
downloadAttachment, downloadAttachment,
downloadNewVersion, downloadNewVersion,
@ -1661,38 +1650,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}); });
} }
deleteMessageForEveryone(messageId: string): void {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(
`deleteMessageForEveryone: Message ${messageId} missing!`
);
}
window.showConfirmationDialog({
dialogName: 'deleteMessageForEveryone',
confirmStyle: 'negative',
message: window.i18n('deleteForEveryoneWarning'),
okText: window.i18n('delete'),
resolve: async () => {
try {
await sendDeleteForEveryoneMessage(this.model.attributes, {
id: message.id,
timestamp: message.get('sent_at'),
});
} catch (error) {
log.error(
'Error sending delete-for-everyone',
Errors.toLogFormat(error),
messageId
);
showToast(ToastDeleteForEveryoneFailed);
}
this.resetPanel();
},
});
}
showStickerPackPreview(packId: string, packKey: string): void { showStickerPackPreview(packId: string, packKey: string): void {
Stickers.downloadEphemeralPack(packId, packKey); Stickers.downloadEphemeralPack(packId, packKey);
@ -1873,11 +1830,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.reduxStore, window.reduxStore,
{ {
conversationId: this.model.id, conversationId: this.model.id,
setAccessControlAttributesSetting:
this.setAccessControlAttributesSetting.bind(this),
setAccessControlMembersSetting:
this.setAccessControlMembersSetting.bind(this),
setAnnouncementsOnly: this.setAnnouncementsOnly.bind(this),
} }
), ),
}); });
@ -1893,12 +1845,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, { JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, {
conversationId: this.model.id, conversationId: this.model.id,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
approvePendingMembership: (conversationId: string) => {
this.model.approvePendingMembershipFromGroupV2(conversationId);
},
revokePendingMemberships: conversationIds => {
this.model.revokePendingMembershipsFromGroupV2(conversationIds);
},
}), }),
}); });
const headerTitle = window.i18n( const headerTitle = window.i18n(
@ -1971,8 +1917,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const props = { const props = {
addMembers: this.model.addMembersV2.bind(this.model), addMembers: this.model.addMembersV2.bind(this.model),
conversationId: this.model.get('id'), conversationId: this.model.get('id'),
loadRecentMediaItems: this.loadRecentMediaItems.bind(this),
setDisappearingMessages: this.setDisappearingMessages.bind(this),
showAllMedia: this.showAllMedia.bind(this), showAllMedia: this.showAllMedia.bind(this),
showContactModal: this.showContactModal.bind(this), showContactModal: this.showContactModal.bind(this),
showChatColorEditor: this.showChatColorEditor.bind(this), showChatColorEditor: this.showChatColorEditor.bind(this),
@ -2092,10 +2036,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
hasSignalAccount={Boolean(signalAccount)} hasSignalAccount={Boolean(signalAccount)}
onSendMessage={() => { onSendMessage={() => {
if (signalAccount) { if (signalAccount) {
this.startConversation( startConversation(signalAccount.phoneNumber, signalAccount.uuid);
signalAccount.phoneNumber,
signalAccount.uuid
);
} }
}} }}
/> />
@ -2108,20 +2049,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.addPanel({ view }); this.addPanel({ view });
} }
startConversation(e164: string, uuid: UUIDStringType): void {
const conversation = window.ConversationController.lookupOrCreate({
e164,
uuid,
reason: 'conversation_view.startConversation',
});
strictAssert(
conversation,
`startConversation failed given ${e164}/${uuid} combination`
);
this.openConversation(conversation.id);
}
async openConversation( async openConversation(
conversationId: string, conversationId: string,
messageId?: string messageId?: string
@ -2201,124 +2128,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
); );
} }
async loadRecentMediaItems(limit: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
const messages: Array<MessageAttributesType> =
await window.Signal.Data.getMessagesWithVisualMediaAttachments(model.id, {
limit,
});
// Cache these messages in memory to ensure Lightbox can find them
messages.forEach(message => {
window.MessageController.register(message.id, message);
});
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?.path
? getAbsoluteAttachmentPath(thumbnail.path)
: '',
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(message.sourceUuid)?.id ||
message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
),
],
[] as Array<MediaItemType>
);
window.reduxActions.conversations.setRecentMediaItems(
model.id,
loadedRecentMediaItems
);
}
async setDisappearingMessages(seconds: DurationInSeconds): Promise<void> {
const { model }: { model: ConversationModel } = this;
const valueToSet = seconds > 0 ? seconds : undefined;
await this.longRunningTaskWrapper({
name: 'updateExpirationTimer',
task: async () =>
model.updateExpirationTimer(valueToSet, {
reason: 'setDisappearingMessages',
}),
});
}
async setAccessControlAttributesSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlAttributes',
task: async () => model.updateAccessControlAttributes(value),
});
}
async setAccessControlMembersSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlMembers',
task: async () => model.updateAccessControlMembers(value),
});
}
async setAnnouncementsOnly(value: boolean): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAnnouncementsOnly',
task: async () => model.updateAnnouncementsOnly(value),
});
}
async destroyMessages(): Promise<void> {
const { model }: { model: ConversationModel } = this;
window.showConfirmationDialog({
dialogName: 'destroyMessages',
confirmStyle: 'negative',
message: window.i18n('deleteConversationConfirmation'),
okText: window.i18n('delete'),
resolve: () => {
this.longRunningTaskWrapper({
name: 'destroymessages',
task: async () => {
model.trigger('unload', 'delete messages');
await model.destroyMessages();
model.updateLastMessage();
},
});
},
reject: () => {
log.info('destroyMessages: User canceled delete');
},
});
}
async isCallSafe(): Promise<boolean> { async isCallSafe(): Promise<boolean> {
const recipientsByConversation = { const recipientsByConversation = {
[this.model.id]: { [this.model.id]: {