From 9348940ecf6d717b86433d6bcec1678a6971aae2 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Mon, 5 Dec 2022 17:56:23 -0500 Subject: [PATCH] Moves parts of conversation view into redux --- .../ToastDeleteForEveryoneFailed.stories.tsx | 28 -- .../ToastDeleteForEveryoneFailed.tsx | 18 - ts/components/ToastManager.tsx | 4 + .../ConversationHeader.stories.tsx | 4 +- .../conversation/ConversationHeader.tsx | 63 ++- ts/components/conversation/MessageDetail.tsx | 4 +- .../conversation/TimelineMessage.tsx | 21 +- .../ConversationDetails.tsx | 12 +- .../ConversationDetailsMediaList.tsx | 6 +- .../GroupV2Permissions.tsx | 22 +- .../PendingInvites.stories.tsx | 8 +- .../conversation-details/PendingInvites.tsx | 51 ++- ts/models/conversations.ts | 153 +------ ts/state/ducks/conversations.ts | 388 +++++++++++++++++- ts/state/ducks/toast.ts | 5 +- ts/state/smart/ConversationDetails.tsx | 3 - ts/state/smart/ConversationHeader.tsx | 3 - ts/state/smart/GroupV2Permissions.tsx | 10 +- ts/state/smart/PendingInvites.tsx | 6 +- ts/state/smart/Timeline.tsx | 1 - ts/util/denyPendingApprovalRequest.ts | 36 ++ ts/util/isMemberPending.ts | 25 ++ ts/util/isMemberRequestingToJoin.ts | 25 ++ ts/util/removePendingMember.ts | 42 ++ ts/util/showToast.tsx | 2 - ts/util/startConversation.ts | 19 + ts/views/conversation_view.tsx | 195 +-------- 27 files changed, 693 insertions(+), 461 deletions(-) delete mode 100644 ts/components/ToastDeleteForEveryoneFailed.stories.tsx delete mode 100644 ts/components/ToastDeleteForEveryoneFailed.tsx create mode 100644 ts/util/denyPendingApprovalRequest.ts create mode 100644 ts/util/isMemberPending.ts create mode 100644 ts/util/isMemberRequestingToJoin.ts create mode 100644 ts/util/removePendingMember.ts create mode 100644 ts/util/startConversation.ts diff --git a/ts/components/ToastDeleteForEveryoneFailed.stories.tsx b/ts/components/ToastDeleteForEveryoneFailed.stories.tsx deleted file mode 100644 index 150750458c..0000000000 --- a/ts/components/ToastDeleteForEveryoneFailed.stories.tsx +++ /dev/null @@ -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.story = { - name: 'ToastDeleteForEveryoneFailed', -}; diff --git a/ts/components/ToastDeleteForEveryoneFailed.tsx b/ts/components/ToastDeleteForEveryoneFailed.tsx deleted file mode 100644 index 3696d16c7a..0000000000 --- a/ts/components/ToastDeleteForEveryoneFailed.tsx +++ /dev/null @@ -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 {i18n('deleteForEveryoneFailed')}; -} diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index e4676e7d81..339ad003eb 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -143,5 +143,9 @@ export function ToastManager({ ); } + if (toastType === ToastType.DeleteForEveryoneFailed) { + return {i18n('deleteForEveryoneFailed')}; + } + throw missingCaseError(toastType); } diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 2847029e57..9b700b2252 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -38,8 +38,8 @@ const commonProps = { i18n, onShowConversationDetails: action('onShowConversationDetails'), - onSetDisappearingMessages: action('onSetDisappearingMessages'), - onDeleteMessages: action('onDeleteMessages'), + setDisappearingMessages: action('setDisappearingMessages'), + destroyMessages: action('destroyMessages'), onSearchInConversation: action('onSearchInConversation'), onSetMuteNotifications: action('onSetMuteNotifications'), onOutgoingAudioCallInConversation: action( diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index db9fc0f516..95f6bd092e 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -28,6 +28,7 @@ import * as expirationTimer from '../../util/expirationTimer'; import { missingCaseError } from '../../util/missingCaseError'; import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isConversationMuted } from '../../util/isConversationMuted'; +import { ConfirmationDialog } from '../ConfirmationDialog'; import { DurationInSeconds } from '../../util/durations'; import { useStartCallShortcuts, @@ -80,8 +81,7 @@ export type PropsDataType = { export type PropsActionsType = { onSetMuteNotifications: (seconds: number) => void; - onSetDisappearingMessages: (seconds: DurationInSeconds) => void; - onDeleteMessages: () => void; + destroyMessages: (conversationId: string) => void; onSearchInConversation: () => void; onOutgoingAudioCallInConversation: () => void; onOutgoingVideoCallInConversation: () => void; @@ -95,6 +95,10 @@ export type PropsActionsType = { onArchive: () => void; onMarkUnread: () => void; onMoveToInbox: () => void; + setDisappearingMessages: ( + conversationId: string, + seconds: DurationInSeconds + ) => void; viewUserStories: ViewUserStoriesActionCreatorType; }; @@ -112,6 +116,7 @@ enum ModalState { } type StateType = { + hasDeleteMessagesConfirmation: boolean; isNarrow: boolean; modalState: ModalState; }; @@ -130,7 +135,11 @@ export class ConversationHeader extends React.Component { public constructor(props: PropsType) { super(props); - this.state = { isNarrow: false, modalState: ModalState.NothingOpen }; + this.state = { + hasDeleteMessagesConfirmation: false, + isNarrow: false, + modalState: ModalState.NothingOpen, + }; this.menuTriggerRef = React.createRef(); this.headerRef = React.createRef(); @@ -329,6 +338,7 @@ export class ConversationHeader extends React.Component { expireTimer, groupVersion, i18n, + id, isArchived, isMissingMandatoryProfileSharing, isPinned, @@ -337,15 +347,14 @@ export class ConversationHeader extends React.Component { markedUnread, muteExpiresAt, onArchive, - onDeleteMessages, onMarkUnread, onMoveToInbox, - onSetDisappearingMessages, onSetMuteNotifications, onSetPin, onShowAllMedia, onShowConversationDetails, onShowGroupMembers, + setDisappearingMessages, type, } = this.props; @@ -425,7 +434,7 @@ export class ConversationHeader extends React.Component { modalState: ModalState.CustomDisappearingTimeout, }); } else { - onSetDisappearingMessages(seconds); + setDisappearingMessages(id, seconds); } }; @@ -487,7 +496,11 @@ export class ConversationHeader extends React.Component { ) : ( {i18n('archiveConversation')} )} - {i18n('deleteMessages')} + this.setState({ hasDeleteMessagesConfirmation: true })} + > + {i18n('deleteMessages')} + {isPinned ? ( onSetPin(false)}> {i18n('unpinConversation')} @@ -501,6 +514,37 @@ export class ConversationHeader extends React.Component { ); } + private renderConfirmationDialog(): ReactNode { + const { hasDeleteMessagesConfirmation } = this.state; + const { destroyMessages, i18n, id } = this.props; + + if (!hasDeleteMessagesConfirmation) { + return; + } + + return ( + { + this.setState({ hasDeleteMessagesConfirmation: false }); + destroyMessages(id); + }, + style: 'negative', + text: i18n('delete'), + }, + ]} + i18n={i18n} + onClose={() => { + this.setState({ hasDeleteMessagesConfirmation: false }); + }} + > + {i18n('deleteConversationConfirmation')} + + ); + } + private renderHeader(): ReactNode { const { conversationTitle, groupVersion, onShowConversationDetails, type } = this.props; @@ -579,8 +623,8 @@ export class ConversationHeader extends React.Component { isSignalConversation, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, - onSetDisappearingMessages, outgoingCallButtonStyle, + setDisappearingMessages, showBackButton, } = this.props; const { isNarrow, modalState } = this.state; @@ -596,7 +640,7 @@ export class ConversationHeader extends React.Component { initialValue={expireTimer} onSubmit={value => { this.setState({ modalState: ModalState.NothingOpen }); - onSetDisappearingMessages(value); + setDisappearingMessages(id, value); }} onClose={() => this.setState({ modalState: ModalState.NothingOpen })} /> @@ -608,6 +652,7 @@ export class ConversationHeader extends React.Component { return ( <> {modalNode} + {this.renderConfirmationDialog()} { diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index c74599d8d2..779986ac62 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -326,7 +326,7 @@ export class MessageDetail extends React.Component { displayLimit={Number.MAX_SAFE_INTEGER} displayTapToViewMessage={displayTapToViewMessage} downloadAttachment={() => - log.warn('MessageDetail: deleteMessageForEveryone called!') + log.warn('MessageDetail: downloadAttachment called!') } doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference} getPreferredBadge={getPreferredBadge} @@ -355,7 +355,7 @@ export class MessageDetail extends React.Component { showExpiredOutgoingTapToViewToast } showMessageDetail={() => { - log.warn('MessageDetail: deleteMessageForEveryone called!'); + log.warn('MessageDetail: showMessageDetail called!'); }} showVisualAttachment={showVisualAttachment} startConversation={startConversation} diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index 29d1575631..8ae71ccaae 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -26,6 +26,7 @@ import type { } from './Message'; import { doesMessageBodyOverflow } from './MessageBodyReadMore'; import type { Props as ReactionPickerProps } from './ReactionPicker'; +import { ConfirmationDialog } from '../ConfirmationDialog'; import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts'; export type PropsData = { @@ -237,6 +238,8 @@ export function TimelineMessage(props: Props): JSX.Element { const handleReact = canReact ? () => toggleReactionPicker() : undefined; + const [hasDOEConfirmation, setHasDOEConfirmation] = useState(false); + const toggleReactionPickerKeyboard = useToggleReactionPicker( handleReact || noop ); @@ -253,6 +256,22 @@ export function TimelineMessage(props: Props): JSX.Element { return ( <> + {hasDOEConfirmation && canDeleteForEveryone && ( + deleteMessageForEveryone(id), + style: 'negative', + text: i18n('delete'), + }, + ]} + dialogName="TimelineMessage/deleteMessageForEveryone" + i18n={i18n} + onClose={() => setHasDOEConfirmation(false)} + > + {i18n('deleteForEveryoneWarning')} + + )} showForwardMessageModal(id) : undefined} onDeleteForMe={() => deleteMessage(id)} onDeleteForEveryone={ - canDeleteForEveryone ? () => deleteMessageForEveryone(id) : undefined + canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined } onMoreInfo={() => showMessageDetail(id)} /> diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 5c503d3723..0322342889 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -73,14 +73,12 @@ export type StateProps = { i18n: LocalizerType; isAdmin: boolean; isGroup: boolean; - loadRecentMediaItems: (limit: number) => void; groupsInCommon: Array; maxGroupSize: number; maxRecommendedGroupSize: number; memberships: Array; pendingApprovalMemberships: ReadonlyArray; pendingMemberships: ReadonlyArray; - setDisappearingMessages: (seconds: DurationInSeconds) => void; showAllMedia: () => void; showChatColorEditor: () => void; showGroupLinkManagement: () => void; @@ -116,13 +114,15 @@ export type StateProps = { type ActionProps = { deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; + loadRecentMediaItems: (id: string, limit: number) => void; replaceAvatar: ReplaceAvatarActionType; saveAvatarToDisk: SaveAvatarToDiskActionType; + searchInConversation: (id: string) => unknown; + setDisappearingMessages: (id: string, seconds: DurationInSeconds) => void; showContactModal: (contactId: string, conversationId?: string) => void; showConversation: ShowConversationType; - toggleSafetyNumberModal: (conversationId: string) => unknown; - searchInConversation: (id: string) => unknown; toggleAddUserToAnotherGroupModal: (contactId?: string) => void; + toggleSafetyNumberModal: (conversationId: string) => unknown; }; export type Props = StateProps & ActionProps; @@ -412,7 +412,9 @@ export function ConversationDetails({ + setDisappearingMessages(conversation.id, value) + } /> } /> diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx index d2792e73c0..d316e30311 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx @@ -15,7 +15,7 @@ import { MediaGridItem } from '../media-gallery/MediaGridItem'; export type Props = { conversation: ConversationType; i18n: LocalizerType; - loadRecentMediaItems: (limit: number) => void; + loadRecentMediaItems: (id: string, limit: number) => void; showAllMedia: () => void; showLightboxForMedia: ( selectedMediaItem: MediaItemType, @@ -39,8 +39,8 @@ export function ConversationDetailsMediaList({ const mediaItemsLength = mediaItems.length; React.useEffect(() => { - loadRecentMediaItems(MEDIA_ITEM_LIMIT); - }, [loadRecentMediaItems, mediaItemsLength]); + loadRecentMediaItems(conversation.id, MEDIA_ITEM_LIMIT); + }, [conversation.id, loadRecentMediaItems, mediaItemsLength]); if (mediaItemsLength === 0) { return null; diff --git a/ts/components/conversation/conversation-details/GroupV2Permissions.tsx b/ts/components/conversation/conversation-details/GroupV2Permissions.tsx index 7113dcafb0..e3f48d582d 100644 --- a/ts/components/conversation/conversation-details/GroupV2Permissions.tsx +++ b/ts/components/conversation/conversation-details/GroupV2Permissions.tsx @@ -13,14 +13,19 @@ import { PanelSection } from './PanelSection'; import { Select } from '../../Select'; import { useUniqueId } from '../../../hooks/useUniqueId'; -export type PropsType = { +export type PropsDataType = { conversation?: ConversationType; 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({ conversation, i18n, @@ -37,14 +42,17 @@ export function GroupV2Permissions({ } const updateAccessControlAttributes = (value: string) => { - setAccessControlAttributesSetting(Number(value)); + setAccessControlAttributesSetting(conversation.id, Number(value)); }; const updateAccessControlMembers = (value: string) => { - setAccessControlMembersSetting(Number(value)); + setAccessControlMembersSetting(conversation.id, Number(value)); }; const AccessControlEnum = Proto.AccessControl.AccessRequired; const updateAnnouncementsOnly = (value: string) => { - setAnnouncementsOnly(Number(value) === AccessControlEnum.ADMINISTRATOR); + setAnnouncementsOnly( + conversation.id, + Number(value) === AccessControlEnum.ADMINISTRATOR + ); }; const accessControlOptions = getAccessControlOptions(i18n); const announcementsOnlyValue = String( diff --git a/ts/components/conversation/conversation-details/PendingInvites.stories.tsx b/ts/components/conversation/conversation-details/PendingInvites.stories.tsx index 6a75baad74..6cf8d1c4be 100644 --- a/ts/components/conversation/conversation-details/PendingInvites.stories.tsx +++ b/ts/components/conversation/conversation-details/PendingInvites.stories.tsx @@ -48,7 +48,9 @@ const conversation: ConversationType = { const OUR_UUID = UUID.generate().toString(); const useProps = (overrideProps: Partial = {}): PropsType => ({ - approvePendingMembership: action('approvePendingMembership'), + approvePendingMembershipFromGroupV2: action( + 'approvePendingMembershipFromGroupV2' + ), conversation, getPreferredBadge: () => undefined, i18n, @@ -70,7 +72,9 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ }, })), ], - revokePendingMemberships: action('revokePendingMemberships'), + revokePendingMembershipsFromGroupV2: action( + 'revokePendingMembershipsFromGroupV2' + ), theme: React.useContext(StorybookThemeContext), ...overrideProps, }); diff --git a/ts/components/conversation/conversation-details/PendingInvites.tsx b/ts/components/conversation/conversation-details/PendingInvites.tsx index b5ab7b0475..ca5ecd1a97 100644 --- a/ts/components/conversation/conversation-details/PendingInvites.tsx +++ b/ts/components/conversation/conversation-details/PendingInvites.tsx @@ -17,18 +17,29 @@ import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon'; import { isAccessControlEnabled } from '../../../groups/util'; import { assertDev } from '../../../util/assert'; -export type PropsType = { +export type PropsDataType = { readonly conversation?: ConversationType; readonly getPreferredBadge: PreferredBadgeSelectorType; readonly i18n: LocalizerType; readonly ourUuid: UUIDStringType; readonly pendingApprovalMemberships: ReadonlyArray; readonly pendingMemberships: ReadonlyArray; - readonly approvePendingMembership: (conversationId: string) => void; - readonly revokePendingMemberships: (conversationIds: Array) => void; readonly theme: ThemeType; }; +type PropsActionType = { + readonly approvePendingMembershipFromGroupV2: ( + conversationId: string, + memberId: string + ) => void; + readonly revokePendingMembershipsFromGroupV2: ( + conversationId: string, + memberIds: Array + ) => void; +}; + +export type PropsType = PropsDataType & PropsActionType; + export type GroupV2PendingMembership = { metadata: { addedByUserId?: UUIDStringType; @@ -57,14 +68,14 @@ type StagedMembershipType = { }; export function PendingInvites({ - approvePendingMembership, + approvePendingMembershipFromGroupV2, conversation, getPreferredBadge, i18n, ourUuid, pendingMemberships, pendingApprovalMemberships, - revokePendingMemberships, + revokePendingMembershipsFromGroupV2, theme, }: PropsType): JSX.Element { if (!conversation || !ourUuid) { @@ -148,13 +159,17 @@ export function PendingInvites({ {stagedMemberships && stagedMemberships.length && ( setStagedMemberships(null)} ourUuid={ourUuid} - revokePendingMemberships={revokePendingMemberships} + revokePendingMembershipsFromGroupV2={ + revokePendingMembershipsFromGroupV2 + } stagedMemberships={stagedMemberships} /> )} @@ -163,29 +178,36 @@ export function PendingInvites({ } function MembershipActionConfirmation({ - approvePendingMembership, + approvePendingMembershipFromGroupV2, conversation, i18n, members, onClose, ourUuid, - revokePendingMemberships, + revokePendingMembershipsFromGroupV2, stagedMemberships, }: { - approvePendingMembership: (conversationId: string) => void; + approvePendingMembershipFromGroupV2: ( + conversationId: string, + memberId: string + ) => void; conversation: ConversationType; i18n: LocalizerType; members: Array; onClose: () => void; ourUuid: string; - revokePendingMemberships: (conversationIds: Array) => void; + revokePendingMembershipsFromGroupV2: ( + conversationId: string, + memberIds: Array + ) => void; stagedMemberships: Array; }) { const revokeStagedMemberships = () => { if (!stagedMemberships) { return; } - revokePendingMemberships( + revokePendingMembershipsFromGroupV2( + conversation.id, stagedMemberships.map(({ membership }) => membership.member.id) ); }; @@ -194,7 +216,10 @@ function MembershipActionConfirmation({ if (!stagedMemberships) { return; } - approvePendingMembership(stagedMemberships[0].membership.member.id); + approvePendingMembershipFromGroupV2( + conversation.id, + stagedMemberships[0].membership.member.id + ); }; const membershipType = stagedMemberships[0].type; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 6e06400351..a1a93101c8 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -150,6 +150,9 @@ import { getSendTarget } from '../util/getSendTarget'; import { getRecipients } from '../util/getRecipients'; import { validateConversation } from '../util/validateConversation'; 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 */ window.Whisper = window.Whisper || {}; @@ -436,29 +439,11 @@ export class ConversationModel extends window.Backbone } private isMemberRequestingToJoin(uuid: UUID): boolean { - if (!isGroupV2(this.attributes)) { - return false; - } - const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2'); - - if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) { - return false; - } - - return pendingAdminApprovalV2.some(item => item.uuid === uuid.toString()); + return isMemberRequestingToJoin(this.attributes, uuid); } isMemberPending(uuid: UUID): boolean { - if (!isGroupV2(this.attributes)) { - return false; - } - const pendingMembersV2 = this.get('pendingMembersV2'); - - if (!pendingMembersV2 || !pendingMembersV2.length) { - return false; - } - - return pendingMembersV2.some(item => item.uuid === uuid.toString()); + return isMemberPending(this.attributes, uuid); } private isMemberBanned(uuid: UUID): boolean { @@ -569,28 +554,6 @@ export class ConversationModel extends window.Backbone }); } - private async approvePendingApprovalRequest( - uuid: UUID - ): Promise { - 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( uuid: UUID ): Promise { @@ -712,32 +675,7 @@ export class ConversationModel extends window.Backbone private async removePendingMember( uuids: ReadonlyArray ): Promise { - const idLog = this.idForLogging(); - - 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, - }); + return removePendingMember(this.attributes, uuids); } private async removeMember( @@ -2631,85 +2569,6 @@ export class ConversationModel extends window.Backbone }); } - async approvePendingMembershipFromGroupV2( - conversationId: string - ): Promise { - 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 - ): Promise { - 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 { if (!isGroupV2(this.attributes)) { return; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 9318e77fb6..d58f268096 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -12,6 +12,7 @@ import { without, } from 'lodash'; +import type { AttachmentType } from '../../types/Attachment'; import type { StateType as RootStateType } from '../reducer'; import * as groups from '../../groups'; import * as log from '../../logging/log'; @@ -90,12 +91,19 @@ import type { TimelineMessageLoadingState } from '../../util/timelineUtil'; import { isDirectConversation, isGroup, + isGroupV2, } from '../../util/whatTypeOfConversation'; import { missingCaseError } from '../../util/missingCaseError'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { ReadStatus } from '../../messages/MessageReadStatus'; 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 @@ -833,6 +841,7 @@ export type ConversationActionType = export const actions = { addMemberToGroup, + approvePendingMembershipFromGroupV2, cancelConversationVerification, changeHasGroupLink, clearCancelledConversationVerification, @@ -854,9 +863,12 @@ export const actions = { conversationUnloaded, createGroup, deleteAvatarFromDisk, + deleteMessageForEveryone, + destroyMessages, discardMessages, doubleCheckMissingQuoteReference, generateNewGroupLink, + loadRecentMediaItems, messageChanged, messageDeleted, messageExpanded, @@ -872,31 +884,35 @@ export const actions = { resetAllChatColors, reviewGroupMemberNameCollision, reviewMessageRequestNameCollision, + revokePendingMembershipsFromGroupV2, saveAvatarToDisk, scrollToMessage, selectMessage, setAccessControlAddFromInviteLinkSetting, + setAccessControlAttributesSetting, + setAccessControlMembersSetting, + setAnnouncementsOnly, setComposeGroupAvatar, setComposeGroupExpireTimer, setComposeGroupName, setComposeSearchTerm, + setDisappearingMessages, setIsFetchingUUID, setIsNearBottom, setMessageLoadingState, setPreJoinConversation, - setRecentMediaItems, setSelectedConversationHeaderTitle, setSelectedConversationPanelDepth, setVoiceNotePlaybackRate, showArchivedConversations, showChooseGroupMembers, - showInbox, showConversation, + showInbox, startComposing, startSettingGroupMetadata, toggleAdmin, - toggleConversationInChooseMembers, toggleComposeEditingAvatar, + toggleConversationInChooseMembers, toggleGroupsForStorySend, toggleHideStories, updateConversationModelSharedGroups, @@ -1003,6 +1019,125 @@ function changeHasGroupLink( }; } +function setAnnouncementsOnly( + conversationId: string, + value: boolean +): ThunkAction { + 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 { + 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 { + 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 { + 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 { + 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( conversationId: string ): ThunkAction { @@ -1845,15 +1980,248 @@ function setSelectedConversationPanelDepth( payload: { panelDepth }, }; } -function setRecentMediaItems( - id: string, - recentMediaItems: Array -): SetRecentMediaItemsActionType { - return { - type: 'SET_RECENT_MEDIA_ITEMS', - payload: { id, recentMediaItems }, + +function deleteMessageForEveryone( + messageId: string +): ThunkAction< + void, + RootStateType, + 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 { + 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 +): ThunkAction { + 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 { + return async dispatch => { + const { getAbsoluteAttachmentPath } = window.Signal.Migrations; + + const messages: Array = + 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 + ); + + dispatch({ + type: 'SET_RECENT_MEDIA_ITEMS', + payload: { id: conversationId, recentMediaItems }, + }); + }; +} + function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreatedGroupActionType { return { type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP' }; } diff --git a/ts/state/ducks/toast.ts b/ts/state/ducks/toast.ts index 62377add32..afc0d433e1 100644 --- a/ts/state/ducks/toast.ts +++ b/ts/state/ducks/toast.ts @@ -18,6 +18,7 @@ export enum ToastType { FailedToDeleteUsername = 'FailedToDeleteUsername', CopiedUsername = 'CopiedUsername', CopiedUsernameLink = 'CopiedUsernameLink', + DeleteForEveryoneFailed = 'DeleteForEveryoneFailed', } // State @@ -32,13 +33,13 @@ export type ToastStateType = { // Actions const HIDE_TOAST = 'toast/HIDE_TOAST'; -const SHOW_TOAST = 'toast/SHOW_TOAST'; +export const SHOW_TOAST = 'toast/SHOW_TOAST'; type HideToastActionType = { type: typeof HIDE_TOAST; }; -type ShowToastActionType = { +export type ShowToastActionType = { type: typeof SHOW_TOAST; payload: { toastType: ToastType; diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 79656d681b..ec8be18634 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -24,7 +24,6 @@ import { getPreferredBadgeSelector, } from '../selectors/badges'; import { assertDev } from '../../util/assert'; -import type { DurationInSeconds } from '../../util/durations'; import { SignalService as Proto } from '../../protobuf'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembersModal'; @@ -39,8 +38,6 @@ import { export type SmartConversationDetailsProps = { addMembers: (conversationIds: ReadonlyArray) => Promise; conversationId: string; - loadRecentMediaItems: (limit: number) => void; - setDisappearingMessages: (seconds: DurationInSeconds) => void; showAllMedia: () => void; showChatColorEditor: () => void; showGroupLinkManagement: () => void; diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 10e1afbd7d..5b5afa1a28 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -25,20 +25,17 @@ import { mapDispatchToProps } from '../actions'; import { missingCaseError } from '../../util/missingCaseError'; import { strictAssert } from '../../util/assert'; import { isSignalConversation } from '../../util/isSignalConversation'; -import type { DurationInSeconds } from '../../util/durations'; export type OwnProps = { id: string; onArchive: () => void; - onDeleteMessages: () => void; onGoBack: () => void; onMarkUnread: () => void; onMoveToInbox: () => void; onOutgoingAudioCallInConversation: () => void; onOutgoingVideoCallInConversation: () => void; onSearchInConversation: () => void; - onSetDisappearingMessages: (seconds: DurationInSeconds) => void; onSetMuteNotifications: (seconds: number) => void; onSetPin: (value: boolean) => void; onShowAllMedia: () => void; diff --git a/ts/state/smart/GroupV2Permissions.tsx b/ts/state/smart/GroupV2Permissions.tsx index fd95f44492..6e726e32f0 100644 --- a/ts/state/smart/GroupV2Permissions.tsx +++ b/ts/state/smart/GroupV2Permissions.tsx @@ -4,22 +4,20 @@ import { connect } from 'react-redux'; 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 { getConversationSelector } from '../selectors/conversations'; import { getIntl } from '../selectors/user'; export type SmartGroupV2PermissionsProps = { conversationId: string; - setAccessControlAttributesSetting: (value: number) => void; - setAccessControlMembersSetting: (value: number) => void; - setAnnouncementsOnly: (value: boolean) => void; }; const mapStateToProps = ( state: StateType, props: SmartGroupV2PermissionsProps -): PropsType => { +): PropsDataType => { const conversation = getConversationSelector(state)(props.conversationId); return { @@ -29,6 +27,6 @@ const mapStateToProps = ( }; }; -const smart = connect(mapStateToProps); +const smart = connect(mapStateToProps, mapDispatchToProps); export const SmartGroupV2Permissions = smart(GroupV2Permissions); diff --git a/ts/state/smart/PendingInvites.tsx b/ts/state/smart/PendingInvites.tsx index 40753ff2c1..a297d6ac90 100644 --- a/ts/state/smart/PendingInvites.tsx +++ b/ts/state/smart/PendingInvites.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; 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 type { StateType } from '../reducer'; @@ -20,14 +20,12 @@ import type { UUIDStringType } from '../../types/UUID'; export type SmartPendingInvitesProps = { conversationId: string; ourUuid: UUIDStringType; - readonly approvePendingMembership: (conversationid: string) => void; - readonly revokePendingMemberships: (membershipIds: Array) => void; }; const mapStateToProps = ( state: StateType, props: SmartPendingInvitesProps -): PropsType => { +): PropsDataType => { const conversationSelector = getConversationByIdSelector(state); const conversationByUuidSelector = getConversationByUuidSelector(state); diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index d4c360d965..744a20fb59 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -66,7 +66,6 @@ export type TimelinePropsType = ExternalProps & | 'contactSupport' | 'blockGroupLinkRequests' | 'deleteMessage' - | 'deleteMessageForEveryone' | 'displayTapToViewMessage' | 'downloadAttachment' | 'downloadNewVersion' diff --git a/ts/util/denyPendingApprovalRequest.ts b/ts/util/denyPendingApprovalRequest.ts new file mode 100644 index 0000000000..625b82193a --- /dev/null +++ b/ts/util/denyPendingApprovalRequest.ts @@ -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 { + 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, + }); +} diff --git a/ts/util/isMemberPending.ts b/ts/util/isMemberPending.ts new file mode 100644 index 0000000000..ffb0c68cdf --- /dev/null +++ b/ts/util/isMemberPending.ts @@ -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()); +} diff --git a/ts/util/isMemberRequestingToJoin.ts b/ts/util/isMemberRequestingToJoin.ts new file mode 100644 index 0000000000..80a4070975 --- /dev/null +++ b/ts/util/isMemberRequestingToJoin.ts @@ -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()); +} diff --git a/ts/util/removePendingMember.ts b/ts/util/removePendingMember.ts new file mode 100644 index 0000000000..7cb9345ca7 --- /dev/null +++ b/ts/util/removePendingMember.ts @@ -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 +): Promise { + 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, + }); +} diff --git a/ts/util/showToast.tsx b/ts/util/showToast.tsx index 866b65c201..09797dfc83 100644 --- a/ts/util/showToast.tsx +++ b/ts/util/showToast.tsx @@ -27,7 +27,6 @@ import type { ToastInternalError, ToastPropsType as ToastInternalErrorPropsType, } from '../components/ToastInternalError'; -import type { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed'; import type { ToastExpired } from '../components/ToastExpired'; import type { ToastFileSaved, @@ -79,7 +78,6 @@ export function showToast( Toast: typeof ToastInternalError, props: ToastInternalErrorPropsType ): void; -export function showToast(Toast: typeof ToastDeleteForEveryoneFailed): void; export function showToast(Toast: typeof ToastExpired): void; export function showToast( Toast: typeof ToastFileSaved, diff --git a/ts/util/startConversation.ts b/ts/util/startConversation.ts new file mode 100644 index 0000000000..23fea087ec --- /dev/null +++ b/ts/util/startConversation.ts @@ -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); +} diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 92b81195dd..0e89d58b6c 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -36,7 +36,6 @@ import { isGroupV1, } from '../util/whatTypeOfConversation'; import { findAndFormatContact } from '../util/findAndFormatContact'; -import type { DurationInSeconds } from '../util/durations'; import { getPreferredBadgeSelector } from '../state/selectors/badges'; import { canReply, @@ -67,7 +66,6 @@ import { ToastConversationArchived } from '../components/ToastConversationArchiv import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; import { ToastDangerousFileType } from '../components/ToastDangerousFileType'; -import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed'; import { ToastExpired } from '../components/ToastExpired'; import { ToastFileSize } from '../components/ToastFileSize'; import { ToastInvalidConversation } from '../components/ToastInvalidConversation'; @@ -107,13 +105,13 @@ import { import { LinkPreviewSourceType } from '../types/LinkPreview'; import { closeLightbox, showLightbox } from '../util/showLightbox'; import { saveAttachment } from '../util/saveAttachment'; -import { sendDeleteForEveryoneMessage } from '../util/sendDeleteForEveryoneMessage'; import { SECOND } from '../util/durations'; import { blockSendUntilConversationsAreVerified } from '../util/blockSendUntilConversationsAreVerified'; import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog'; import { getOwn } from '../util/getOwn'; import { CallMode } from '../types/Calling'; import { isAnybodyElseInGroupCall } from '../state/ducks/calling'; +import { startConversation } from '../util/startConversation'; type AttachmentOptions = { messageId: string; @@ -136,7 +134,6 @@ const { getMessagesBySentAt } = window.Signal.Data; type MessageActionsType = { deleteMessage: (messageId: string) => unknown; - deleteMessageForEveryone: (messageId: string) => unknown; displayTapToViewMessage: (messageId: string) => unknown; downloadAttachment: (options: { attachment: AttachmentType; @@ -337,9 +334,6 @@ export class ConversationView extends window.Backbone.View { const conversationHeaderProps = { id: this.model.id, - onSetDisappearingMessages: (seconds: DurationInSeconds) => - this.setDisappearingMessages(seconds), - onDeleteMessages: () => this.destroyMessages(), onSearchInConversation: () => { const { searchInConversation } = window.reduxActions.search; searchInConversation(this.model.id); @@ -737,9 +731,6 @@ export class ConversationView extends window.Backbone.View { const deleteMessage = (messageId: string) => { this.deleteMessage(messageId); }; - const deleteMessageForEveryone = (messageId: string) => { - this.deleteMessageForEveryone(messageId); - }; const showMessageDetail = (messageId: string) => { this.showMessageDetail(messageId); }; @@ -826,11 +817,9 @@ export class ConversationView extends window.Backbone.View { }; const showForwardMessageModal = this.showForwardMessageModal.bind(this); - const startConversation = this.startConversation.bind(this); return { deleteMessage, - deleteMessageForEveryone, displayTapToViewMessage, downloadAttachment, downloadNewVersion, @@ -1661,38 +1650,6 @@ export class ConversationView extends window.Backbone.View { }); } - 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 { Stickers.downloadEphemeralPack(packId, packKey); @@ -1873,11 +1830,6 @@ export class ConversationView extends window.Backbone.View { window.reduxStore, { 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 { JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, { conversationId: this.model.id, ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), - approvePendingMembership: (conversationId: string) => { - this.model.approvePendingMembershipFromGroupV2(conversationId); - }, - revokePendingMemberships: conversationIds => { - this.model.revokePendingMembershipsFromGroupV2(conversationIds); - }, }), }); const headerTitle = window.i18n( @@ -1971,8 +1917,6 @@ export class ConversationView extends window.Backbone.View { const props = { addMembers: this.model.addMembersV2.bind(this.model), conversationId: this.model.get('id'), - loadRecentMediaItems: this.loadRecentMediaItems.bind(this), - setDisappearingMessages: this.setDisappearingMessages.bind(this), showAllMedia: this.showAllMedia.bind(this), showContactModal: this.showContactModal.bind(this), showChatColorEditor: this.showChatColorEditor.bind(this), @@ -2092,10 +2036,7 @@ export class ConversationView extends window.Backbone.View { hasSignalAccount={Boolean(signalAccount)} onSendMessage={() => { if (signalAccount) { - this.startConversation( - signalAccount.phoneNumber, - signalAccount.uuid - ); + startConversation(signalAccount.phoneNumber, signalAccount.uuid); } }} /> @@ -2108,20 +2049,6 @@ export class ConversationView extends window.Backbone.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( conversationId: string, messageId?: string @@ -2201,124 +2128,6 @@ export class ConversationView extends window.Backbone.View { ); } - async loadRecentMediaItems(limit: number): Promise { - const { model }: { model: ConversationModel } = this; - - const messages: Array = - 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 - ); - - window.reduxActions.conversations.setRecentMediaItems( - model.id, - loadedRecentMediaItems - ); - } - - async setDisappearingMessages(seconds: DurationInSeconds): Promise { - 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 { - const { model }: { model: ConversationModel } = this; - - await this.longRunningTaskWrapper({ - name: 'updateAccessControlAttributes', - task: async () => model.updateAccessControlAttributes(value), - }); - } - - async setAccessControlMembersSetting(value: number): Promise { - const { model }: { model: ConversationModel } = this; - - await this.longRunningTaskWrapper({ - name: 'updateAccessControlMembers', - task: async () => model.updateAccessControlMembers(value), - }); - } - - async setAnnouncementsOnly(value: boolean): Promise { - const { model }: { model: ConversationModel } = this; - - await this.longRunningTaskWrapper({ - name: 'updateAnnouncementsOnly', - task: async () => model.updateAnnouncementsOnly(value), - }); - } - - async destroyMessages(): Promise { - 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 { const recipientsByConversation = { [this.model.id]: {