From f61d8f38b0f0101f8de9a2e00b6e59831ecb5132 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 5 Apr 2023 13:48:00 -0700 Subject: [PATCH] Context menu for left pane list items --- _locales/en/messages.json | 56 +++++ images/icons/v3/block--compact.svg | 10 + images/icons/v3/chat--compact.svg | 3 + images/icons/v3/minus--circle--compact.svg | 11 + images/icons/v3/phone--compact.svg | 3 + images/icons/v3/video--compact.svg | 3 + protos/SignalStorage.proto | 1 + stylesheets/_modules.scss | 2 +- stylesheets/components/ContactListItem.scss | 129 ++++++++++ stylesheets/components/ContextMenu.scss | 6 +- stylesheets/manifest.scss | 1 + ts/RemoteConfig.ts | 2 + ts/components/CompositionArea.tsx | 9 +- ts/components/ContextMenu.tsx | 20 +- ts/components/ConversationList.stories.tsx | 26 ++ ts/components/ConversationList.tsx | 31 ++- ts/components/ForwardMessagesModal.tsx | 4 + ts/components/LeftPane.stories.tsx | 103 ++++---- ts/components/LeftPane.tsx | 20 ++ ts/components/ListTile.tsx | 6 +- ts/components/StoriesSettingsModal.tsx | 4 + ts/components/ToastManager.stories.tsx | 10 + ts/components/ToastManager.tsx | 10 + .../conversation/MessageRequestActions.tsx | 62 +++-- .../conversation/TimelineItem.stories.tsx | 4 + ts/components/conversation/TimelineItem.tsx | 13 + .../conversationList/ContactListItem.tsx | 232 ++++++++++++++++-- .../conversationList/ConversationListItem.tsx | 4 +- .../leftPane/LeftPaneComposeHelper.tsx | 1 + ts/model-types.d.ts | 8 + ts/models/conversations.ts | 147 ++++++++++- ts/models/messages.ts | 2 + ts/services/storageRecordOps.ts | 13 + .../81-contact-removed-notification.ts | 118 +++++++++ ts/sql/migrations/index.ts | 2 + ts/state/ducks/conversations.ts | 23 ++ ts/state/selectors/conversations.ts | 2 + ts/state/selectors/items.ts | 21 ++ ts/state/selectors/message.ts | 17 ++ ts/state/smart/LeftPane.tsx | 2 + .../leftPane/LeftPaneComposeHelper_test.ts | 8 + ts/types/Toast.tsx | 1 + ts/util/isConversationAccepted.ts | 6 +- 43 files changed, 1046 insertions(+), 110 deletions(-) create mode 100644 images/icons/v3/block--compact.svg create mode 100644 images/icons/v3/chat--compact.svg create mode 100644 images/icons/v3/minus--circle--compact.svg create mode 100644 images/icons/v3/phone--compact.svg create mode 100644 images/icons/v3/video--compact.svg create mode 100644 stylesheets/components/ContactListItem.scss create mode 100644 ts/sql/migrations/81-contact-removed-notification.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 373ce7cd5b2..d559de72cd8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -611,6 +611,50 @@ "messageformat": "Select messages", "description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation" }, + "icu:ContactListItem__menu": { + "messageformat": "Manage Contact", + "description": "Shown as aria label for context menu for a contact" + }, + "icu:ContactListItem__menu__message": { + "messageformat": "Message", + "description": "Shown in a context menu for a contact, allows the user to start the conversation with the contact" + }, + "icu:ContactListItem__menu__audio-call": { + "messageformat": "Audio call", + "description": "Shown in a context menu for a contact, allows the user to start the audio call with the contact" + }, + "icu:ContactListItem__menu__video-call": { + "messageformat": "Video call", + "description": "Shown in a context menu for a contact, allows the user to start the video call with the contact" + }, + "icu:ContactListItem__menu__remove": { + "messageformat": "Remove", + "description": "Shown in a context menu for a contact, allows the user to remove the contact from the contact list" + }, + "icu:ContactListItem__menu__block": { + "messageformat": "Block", + "description": "Shown in a context menu for a contact, allows the user to block the contact" + }, + "icu:ContactListItem__remove--title": { + "messageformat": "Remove {title}?", + "description": "Shown as the title in the confirmation modal for removing a contact from the contact list" + }, + "icu:ContactListItem__remove--body": { + "messageformat": "You won’t see this person when searching. You’ll get a message request if they message you in the future.", + "description": "Shown as the body in the confirmation modal for removing a contact from the contact list" + }, + "icu:ContactListItem__remove--confirm": { + "messageformat": "Remove", + "description": "Shown as the confirmation button text in the confirmation modal for removing a contact from the contact list" + }, + "icu:ContactListItem__remove-system--title": { + "messageformat": "Unable to remove {title}", + "description": "Shown as the title in the confirmation modal for removing a system contact from the contact list" + }, + "icu:ContactListItem__remove-system--body": { + "messageformat": "This person is saved to your device’s Contacts. Delete them from your Contacts on your mobile device and try again.", + "description": "Shown as the body in the confirmation modal for removing a system contact from the contact list" + }, "moveConversationToInbox": { "message": "Unarchive", "description": "(deleted 03/29/2023) Undoes Archive Conversation action, and moves archived conversation back to the main conversation list" @@ -4006,6 +4050,10 @@ "messageformat": "No conversations found", "description": "Label shown when there are no conversations to compose to" }, + "icu:Toast--ConversationRemoved": { + "messageformat": "{title} has been removed.", + "description": "Shown after the contact was removed from the contact list" + }, "Toast--error": { "message": "An error has occurred", "description": "(deleted 03/29/2023) Toast for general errors" @@ -5811,6 +5859,10 @@ "messageformat": "Let {name} message you and share your name and photo with them? They won’t know you’ve seen their messages until you accept.", "description": "Shown as the message for a message request in a direct message" }, + "icu:MessageRequests--message-direct-hidden": { + "messageformat": "Let {name} message you and share your name and photo with them? You have removed this person in the past.", + "description": "Shown as the message for a message request in a hidden conversation" + }, "MessageRequests--message-direct-blocked": { "message": "Let $name$ message you and share your name and photo with them? You won't receive any messages until you unblock them.", "description": "(deleted 03/29/2023) Shown as the message for a message request in a direct message with a blocked account" @@ -9739,6 +9791,10 @@ "messageformat": "The disappearing message time will be set to {timeValue} when you message them.", "description": "A message displayed when default disappearing message timeout is about to be applied" }, + "icu:ContactRemovedNotification__text": { + "messageformat": "You have removed this person, messaging them again will add them back to your list.", + "description": "A message displayed when contact was removed and will be added back on an outgoing message" + }, "ErrorBoundaryNotification__text": { "message": "Couldn't display this message. Click to submit a debug log.", "description": "(deleted 03/29/2023) An error notification displayed when message fails to render due to an internal error" diff --git a/images/icons/v3/block--compact.svg b/images/icons/v3/block--compact.svg new file mode 100644 index 00000000000..91382b94eb5 --- /dev/null +++ b/images/icons/v3/block--compact.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/images/icons/v3/chat--compact.svg b/images/icons/v3/chat--compact.svg new file mode 100644 index 00000000000..101342adea1 --- /dev/null +++ b/images/icons/v3/chat--compact.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/minus--circle--compact.svg b/images/icons/v3/minus--circle--compact.svg new file mode 100644 index 00000000000..fc6fc8efc76 --- /dev/null +++ b/images/icons/v3/minus--circle--compact.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/images/icons/v3/phone--compact.svg b/images/icons/v3/phone--compact.svg new file mode 100644 index 00000000000..79b6a0dbaeb --- /dev/null +++ b/images/icons/v3/phone--compact.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/video--compact.svg b/images/icons/v3/video--compact.svg new file mode 100644 index 00000000000..4477eb00c99 --- /dev/null +++ b/images/icons/v3/video--compact.svg @@ -0,0 +1,3 @@ + + + diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 8d2ce79da76..9b870c80b03 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -96,6 +96,7 @@ message ContactRecord { optional string systemGivenName = 17; optional string systemFamilyName = 18; optional string systemNickname = 19; + optional bool hidden = 20; } message GroupV1Record { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 9f469b5ed5a..b07a0d4cfcb 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5458,7 +5458,7 @@ button.module-image__border-overlay:focus { %module-composition-popper { width: 332px; - border-radius: 8px; + border-radius: 4px; margin-bottom: 6px; z-index: $z-index-context-menu; user-select: none; diff --git a/stylesheets/components/ContactListItem.scss b/stylesheets/components/ContactListItem.scss new file mode 100644 index 00000000000..f566f5e55ff --- /dev/null +++ b/stylesheets/components/ContactListItem.scss @@ -0,0 +1,129 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.ContactListItem { + &__context-menu { + &__chat-icon { + @include dark-theme { + @include color-svg( + '../images/icons/v3/chat--compact.svg', + $color-white + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/chat--compact.svg', + $color-black + ); + } + } + + &__phone-icon { + @include dark-theme { + @include color-svg( + '../images/icons/v3/phone--compact.svg', + $color-white + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/phone--compact.svg', + $color-black + ); + } + } + + &__video-icon { + @include dark-theme { + @include color-svg( + '../images/icons/v3/video--compact.svg', + $color-white + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/video--compact.svg', + $color-black + ); + } + } + + &__delete-icon { + @include dark-theme { + @include color-svg( + '../images/icons/v3/minus--circle--compact.svg', + $color-white + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/minus--circle--compact.svg', + $color-black + ); + } + } + + &__block-icon { + @include dark-theme { + @include color-svg( + '../images/icons/v3/block--compact.svg', + $color-white + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/block--compact.svg', + $color-black + ); + } + } + + // Overrides + &__popper.ContextMenu__popper { + min-width: 240px; + } + + &__button.ContextMenu__button { + opacity: 0; + + .ContactListItem:hover & { + opacity: 1; + } + + &:hover { + @include light-theme { + background-color: $color-gray-20; + } + + @include dark-theme { + background-color: $color-gray-80; + } + } + + width: 28px; + height: 28px; + padding: 4px; + border-radius: 4px; + + &::after { + display: block; + width: 20px; + height: 20px; + content: ''; + + @include dark-theme { + @include color-svg( + '../images/icons/v2/more-horiz-24.svg', + $color-white + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v2/more-horiz-24.svg', + $color-black + ); + } + } + } + } +} diff --git a/stylesheets/components/ContextMenu.scss b/stylesheets/components/ContextMenu.scss index 4967c3a85c4..de2d777c6e6 100644 --- a/stylesheets/components/ContextMenu.scss +++ b/stylesheets/components/ContextMenu.scss @@ -11,7 +11,7 @@ &__popper { @extend %module-composition-popper; margin: 0; - padding: 6px 2px; + padding: 6px 0px; width: auto; &--single-item { @@ -36,10 +36,9 @@ } align-items: center; - border-radius: 6px; display: flex; justify-content: space-between; - padding: 6px; + padding: 7px 12px; min-width: 150px; width: 100%; @@ -88,7 +87,6 @@ &:focus, &:active { @include keyboard-mode { - border-radius: 6px; box-shadow: 0 0 1px 1px $color-ultramarine; outline: none; } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 64be9d28577..1279dc7b4f4 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -55,6 +55,7 @@ @import './components/CompositionRecordingDraft.scss'; @import './components/CompositionInput.scss'; @import './components/CompositionTextArea.scss'; +@import './components/ContactListItem.scss'; @import './components/ContactModal.scss'; @import './components/ContactName.scss'; @import './components/ContactPill.scss'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 179ae6ffff1..a3e3387aeeb 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -16,6 +16,8 @@ export type ConfigKeyType = | 'desktop.announcementGroup' | 'desktop.calling.audioLevelForSpeaking' | 'desktop.cdsi.returnAcisWithoutUaks' + | 'desktop.contactManagement' + | 'desktop.contactManagement.beta' | 'desktop.clientExpiration' | 'desktop.groupCallOutboundRing2' | 'desktop.groupCallOutboundRing2.beta' diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index b8d1a4d77e8..1c061a079e0 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -71,6 +71,7 @@ import type { ShowToastAction } from '../state/ducks/toast'; export type OwnProps = Readonly<{ acceptedMessageRequest?: boolean; + removalStage?: 'justNotification' | 'messageRequest'; addAttachment: ( conversationId: string, attachment: InMemoryAttachmentDraftType @@ -265,6 +266,7 @@ export function CompositionArea({ isMissingMandatoryProfileSharing, left, messageRequestsEnabled, + removalStage, acceptConversation, blockConversation, blockAndReportSpam, @@ -577,7 +579,9 @@ export function CompositionArea({ if ( isBlocked || areWePending || - (messageRequestsEnabled && !acceptedMessageRequest) + (messageRequestsEnabled && + !acceptedMessageRequest && + removalStage !== 'justNotification') ) { return ( ); @@ -627,7 +632,7 @@ export function CompositionArea({ // If no message request, but we haven't shared profile yet, we show profile-sharing UI if ( !left && - (conversationType === 'direct' || + ((conversationType === 'direct' && removalStage !== 'justNotification') || (conversationType === 'group' && groupVersion === 1)) && isMissingMandatoryProfileSharing ) { diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index d399a0ab67c..b9af3906a88 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -4,6 +4,7 @@ import type { KeyboardEvent, ReactNode } from 'react'; import type { Options, VirtualElement } from '@popperjs/core'; import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { usePopper } from 'react-popper'; import { noop } from 'lodash'; @@ -42,6 +43,7 @@ export type PropsType = Readonly<{ onClick?: (ev: React.MouseEvent) => unknown; onMenuShowingChanged?: (value: boolean) => unknown; popperOptions?: Pick; + portalToRoot?: boolean; theme?: Theme; title?: string; value?: T; @@ -67,6 +69,7 @@ export function ContextMenu({ onClick, onMenuShowingChanged, popperOptions, + portalToRoot, theme, title, value, @@ -118,6 +121,21 @@ export function ContextMenu({ ); }, [isMenuShowing, referenceElement, popperElement]); + const [portalNode, setPortalNode] = React.useState(null); + useEffect(() => { + if (!portalToRoot || !isMenuShowing) { + return noop; + } + + const div = document.createElement('div'); + document.body.appendChild(div); + setPortalNode(div); + + return () => { + document.body.removeChild(div); + }; + }, [portalToRoot, isMenuShowing]); + const handleKeyDown = (ev: KeyboardEvent) => { if ((ev.key === 'Enter' || ev.key === 'Space') && !isMenuShowing) { closeCurrentOpenContextMenu?.(); @@ -302,7 +320,7 @@ export function ContextMenu({ > {children} - {menuNode} + {portalNode ? createPortal(menuNode, portalNode) : menuNode} ); } diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index 17d8086dbff..18d86982adb 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -67,9 +67,17 @@ function Wrapper({ getRow={(index: number) => rows[index]} shouldRecomputeRowHeights={false} i18n={i18n} + blockConversation={action('blockConversation')} onSelectConversation={action('onSelectConversation')} + onOutgoingAudioCallInConversation={action( + 'onOutgoingAudioCallInConversation' + )} + onOutgoingVideoCallInConversation={action( + 'onOutgoingVideoCallInConversation' + )} onClickArchiveButton={action('onClickArchiveButton')} onClickContactCheckbox={action('onClickContactCheckbox')} + removeConversation={action('removeConversation')} renderMessageSearchResult={(id: string) => ( + ); +} + +ContactDirectWithContextMenu.story = { + name: 'Contact: context menu', +}; + export function ContactDirectWithShortAbout(): JSX.Element { return ( void; onClickArchiveButton: () => void; onClickContactCheckbox: ( conversationId: string, disabledReason: undefined | ContactCheckboxDisabledReason ) => void; onSelectConversation: (conversationId: string, messageId?: string) => void; + onOutgoingAudioCallInConversation: (conversationId: string) => void; + onOutgoingVideoCallInConversation: (conversationId: string) => void; + removeConversation?: (conversationId: string) => void; renderMessageSearchResult?: (id: string) => JSX.Element; showChooseGroupMembers: () => void; showConversation: ShowConversationType; @@ -195,9 +200,13 @@ export function ConversationList({ getPreferredBadge, getRow, i18n, + blockConversation, onClickArchiveButton, onClickContactCheckbox, onSelectConversation, + onOutgoingAudioCallInConversation, + onOutgoingVideoCallInConversation, + removeConversation, renderMessageSearchResult, rowCount, scrollBehavior = ScrollBehavior.Default, @@ -266,7 +275,7 @@ export function ConversationList({ result = undefined; break; case RowType.Contact: { - const { isClickable = true } = row; + const { isClickable = true, hasContextMenu = false } = row; result = ( ); break; @@ -343,6 +361,7 @@ export function ConversationList({ 'muteExpiresAt', 'phoneNumber', 'profileName', + 'removalStage', 'sharedGroupNames', 'shouldShowDraft', 'title', @@ -452,18 +471,22 @@ export function ConversationList({ ); }, [ + blockConversation, getPreferredBadge, getRow, i18n, + lookupConversationWithoutUuid, onClickArchiveButton, onClickContactCheckbox, + onOutgoingAudioCallInConversation, + onOutgoingVideoCallInConversation, onSelectConversation, - lookupConversationWithoutUuid, - showUserNotFoundModal, - setIsFetchingUUID, + removeConversation, renderMessageSearchResult, + setIsFetchingUUID, showChooseGroupMembers, showConversation, + showUserNotFoundModal, theme, ] ); diff --git a/ts/components/ForwardMessagesModal.tsx b/ts/components/ForwardMessagesModal.tsx index af2ee0a403c..e94ca58fa33 100644 --- a/ts/components/ForwardMessagesModal.tsx +++ b/ts/components/ForwardMessagesModal.tsx @@ -356,6 +356,10 @@ export function ForwardMessagesModal({ showUserNotFoundModal={shouldNeverBeCalled} setIsFetchingUUID={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled} + blockConversation={shouldNeverBeCalled} + removeConversation={shouldNeverBeCalled} + onOutgoingAudioCallInConversation={shouldNeverBeCalled} + onOutgoingVideoCallInConversation={shouldNeverBeCalled} renderMessageSearchResult={() => { shouldNeverBeCalled(); return ; diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index b0371aa24fc..9d5ca9eaa32 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -127,6 +127,10 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { } const isUpdateDownloaded = boolean('isUpdateDownloaded', false); + const isContactManagementEnabled = boolean( + 'isContactManagementEnabled', + true + ); return { clearConversationSearch: action('clearConversationSearch'), @@ -160,12 +164,21 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { undefined ), isUpdateDownloaded, + isContactManagementEnabled, setChallengeStatus: action('setChallengeStatus'), lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(), showUserNotFoundModal: action('showUserNotFoundModal'), setIsFetchingUUID, showConversation: action('showConversation'), + blockConversation: action('blockConversation'), + onOutgoingAudioCallInConversation: action( + 'onOutgoingAudioCallInConversation' + ), + onOutgoingVideoCallInConversation: action( + 'onOutgoingVideoCallInConversation' + ), + removeConversation: action('removeConversation'), renderMainHeader: () => , renderMessageSearchResult: (id: string) => ( { }; }; +function LeftPaneInContainer(props: PropsType): JSX.Element { + return ( + + + + ); +} + export function InboxNoConversations(): JSX.Element { return ( - ; + return ; } InboxPinnedNonPinnedAndArchivedConversations.story = { @@ -425,7 +446,7 @@ InboxPinnedNonPinnedAndArchivedConversations.story = { export function SearchNoResultsWhenSearchingEverywhere(): JSX.Element { return ( - ( - void; clearConversationSearch: () => void; clearGroupCreationError: () => void; clearSearch: () => void; @@ -113,6 +115,9 @@ export type PropsType = { composeReplaceAvatar: ReplaceAvatarActionType; composeSaveAvatarToDisk: SaveAvatarToDiskActionType; createGroup: () => void; + onOutgoingAudioCallInConversation: (conversationId: string) => void; + onOutgoingVideoCallInConversation: (conversationId: string) => void; + removeConversation: (conversationId: string) => void; savePreferredLeftPaneWidth: (_: number) => void; searchInConversation: (conversationId: string) => unknown; setComposeGroupAvatar: (_: undefined | Uint8Array) => void; @@ -151,6 +156,7 @@ export type PropsType = { } & LookupConversationWithoutUuidActionsType; export function LeftPane({ + blockConversation, challengeStatus, clearConversationSearch, clearGroupCreationError, @@ -171,8 +177,12 @@ export function LeftPane({ lookupConversationWithoutUuid, isMacOS, isUpdateDownloaded, + isContactManagementEnabled, modeSpecificProps, + onOutgoingAudioCallInConversation, + onOutgoingVideoCallInConversation, preferredWidthFromStorage, + removeConversation, renderCaptchaDialog, renderCrashReportDialog, renderExpiredBuildDialog, @@ -678,7 +688,17 @@ export function LeftPane({ setIsFetchingUUID={setIsFetchingUUID} lookupConversationWithoutUuid={lookupConversationWithoutUuid} showConversation={showConversation} + blockConversation={blockConversation} onSelectConversation={onSelectConversation} + onOutgoingAudioCallInConversation={ + onOutgoingAudioCallInConversation + } + onOutgoingVideoCallInConversation={ + onOutgoingVideoCallInConversation + } + removeConversation={ + isContactManagementEnabled ? removeConversation : undefined + } renderMessageSearchResult={renderMessageSearchResult} rowCount={helper.getRowCount()} scrollBehavior={scrollBehavior} diff --git a/ts/components/ListTile.tsx b/ts/components/ListTile.tsx index 2b07e33ff96..51b0e5a9253 100644 --- a/ts/components/ListTile.tsx +++ b/ts/components/ListTile.tsx @@ -12,6 +12,7 @@ export type Props = { subtitle?: string | JSX.Element; leading?: string | JSX.Element; trailing?: string | JSX.Element; + moduleClassName?: string; onClick?: () => void; onContextMenu?: (ev: React.MouseEvent) => void; // show hover highlight, @@ -28,8 +29,6 @@ export type Props = { testId?: string; }; -const getClassName = getClassNamesFor('ListTile'); - /** * A single row that typically contains some text and leading/trailing icons/widgets * @@ -72,6 +71,7 @@ const ListTileImpl = React.forwardRef( subtitle, leading, trailing, + moduleClassName, onClick, onContextMenu, clickable, @@ -85,6 +85,8 @@ const ListTileImpl = React.forwardRef( ) { const isClickable = clickable ?? Boolean(onClick); + const getClassName = getClassNamesFor('ListTile', moduleClassName); + const rootProps = { className: classNames( getClassName(''), diff --git a/ts/components/StoriesSettingsModal.tsx b/ts/components/StoriesSettingsModal.tsx index f8551cf1caf..a76648658b6 100644 --- a/ts/components/StoriesSettingsModal.tsx +++ b/ts/components/StoriesSettingsModal.tsx @@ -1209,6 +1209,10 @@ export function EditDistributionListModal({ toggleSelectedConversation(conversationId); }} onSelectConversation={shouldNeverBeCalled} + blockConversation={shouldNeverBeCalled} + removeConversation={shouldNeverBeCalled} + onOutgoingAudioCallInConversation={shouldNeverBeCalled} + onOutgoingVideoCallInConversation={shouldNeverBeCalled} renderMessageSearchResult={() => { shouldNeverBeCalled(); return ; diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 0f7ab99daba..8f7a8666e71 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -127,6 +127,16 @@ ConversationMarkedUnread.args = { }, }; +export const ConversationRemoved = Template.bind({}); +ConversationRemoved.args = { + toast: { + toastType: ToastType.ConversationRemoved, + parameters: { + title: 'Alice', + }, + }, +}; + export const ConversationUnarchived = Template.bind({}); ConversationUnarchived.args = { toast: { diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index 3aea4c4ab63..c64bc7ecc37 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -134,6 +134,16 @@ export function ToastManager({ ); } + if (toastType === ToastType.ConversationRemoved) { + return ( + + {i18n('icu:Toast--ConversationRemoved', { + title: toast?.parameters?.title ?? '', + })} + + ); + } + if (toastType === ToastType.ConversationUnarchived) { return ( diff --git a/ts/components/conversation/MessageRequestActions.tsx b/ts/components/conversation/MessageRequestActions.tsx index a7dd60c8877..0eac3850e25 100644 --- a/ts/components/conversation/MessageRequestActions.tsx +++ b/ts/components/conversation/MessageRequestActions.tsx @@ -15,6 +15,7 @@ import type { LocalizerType } from '../../types/Util'; export type Props = { i18n: LocalizerType; + isHidden?: boolean; } & Omit & Omit< MessageRequestActionsConfirmationProps, @@ -30,6 +31,7 @@ export function MessageRequestActions({ deleteConversation, firstName, i18n, + isHidden, isBlocked, title, }: Props): JSX.Element { @@ -44,6 +46,43 @@ export function MessageRequestActions({ ); + let message: JSX.Element | undefined; + if (conversationType === 'direct') { + if (isBlocked) { + message = ( + + ); + } else if (isHidden) { + message = ( + + ); + } else { + message = ( + + ); + } + } else if (conversationType === 'group') { + if (isBlocked) { + message = ( + + ); + } else { + message = ; + } + } + return ( <> {mrState !== MessageRequestState.default ? ( @@ -61,28 +100,7 @@ export function MessageRequestActions({ /> ) : null} - - {conversationType === 'direct' && isBlocked && ( - - )} - {conversationType === 'direct' && !isBlocked && ( - - )} - {conversationType === 'group' && isBlocked && ( - - )} - {conversationType === 'group' && !isBlocked && ( - - )} - + {message} { diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index d1ae525b09f..843fe4fa7c1 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -153,6 +153,10 @@ export function Notification(): JSX.Element { { type: 'chatSessionRefreshed', }, + { + type: 'contactRemovedNotification', + data: null, + }, { type: 'safetyNumberNotification', data: { diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 2b7e8ba8eb3..578c91b0dbf 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -50,6 +50,7 @@ import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEv import { PaymentEventNotification } from './PaymentEventNotification'; import type { PropsDataType as ConversationMergeNotificationPropsType } from './ConversationMergeNotification'; import { ConversationMergeNotification } from './ConversationMergeNotification'; +import { SystemMessage } from './SystemMessage'; import type { FullJSXType } from '../Intl'; import { TimelineMessage } from './TimelineMessage'; @@ -81,6 +82,10 @@ type UniversalTimerNotificationType = { type: 'universalTimerNotification'; data: null; }; +type ContactRemovedNotificationType = { + type: 'contactRemovedNotification'; + data: null; +}; type ChangeNumberNotificationType = { type: 'changeNumberNotification'; data: ChangeNumberNotificationProps; @@ -137,6 +142,7 @@ export type TimelineItemType = ( | SafetyNumberNotificationType | TimerNotificationType | UniversalTimerNotificationType + | ContactRemovedNotificationType | UnsupportedMessageType | VerificationNotificationType | PaymentEventType @@ -266,6 +272,13 @@ export class TimelineItem extends React.PureComponent { ); } else if (item.type === 'universalTimerNotification') { notification = renderUniversalTimerNotification(); + } else if (item.type === 'contactRemovedNotification') { + notification = ( + + ); } else if (item.type === 'changeNumberNotification') { notification = ( void; + onAudioCall?: (id: string) => void; + onVideoCall?: (id: string) => void; + onRemove?: (id: string) => void; + onBlock?: (id: string) => void; + hasContextMenu: boolean; theme: ThemeType; }; @@ -54,19 +66,81 @@ export const ContactListItem: FunctionComponent = React.memo( avatarPath, badge, color, + hasContextMenu, i18n, id, isMe, + name, onClick, + onAudioCall, + onVideoCall, + onRemove, + onBlock, phoneNumber, profileName, sharedGroupNames, + systemGivenName, + systemFamilyName, theme, title, type, unblurredAvatarPath, uuid, }) { + const [isConfirmingBlocking, setConfirmingBlocking] = useState(false); + const [isConfirmingRemoving, setConfirmingRemoving] = useState(false); + + const menuOptions = useMemo( + () => [ + ...(onClick + ? [ + { + icon: 'ContactListItem__context-menu__chat-icon', + label: i18n('icu:ContactListItem__menu__message'), + onClick: () => onClick(id), + }, + ] + : []), + ...(onAudioCall + ? [ + { + icon: 'ContactListItem__context-menu__phone-icon', + label: i18n('icu:ContactListItem__menu__audio-call'), + onClick: () => onAudioCall(id), + }, + ] + : []), + ...(onVideoCall + ? [ + { + icon: 'ContactListItem__context-menu__video-icon', + label: i18n('icu:ContactListItem__menu__video-call'), + onClick: () => onVideoCall(id), + }, + ] + : []), + ...(onRemove + ? [ + { + icon: 'ContactListItem__context-menu__delete-icon', + label: i18n('icu:ContactListItem__menu__remove'), + onClick: () => setConfirmingRemoving(true), + }, + ] + : []), + ...(onBlock + ? [ + { + icon: 'ContactListItem__context-menu__block-icon', + label: i18n('icu:ContactListItem__menu__block'), + onClick: () => setConfirmingBlocking(true), + }, + ] + : []), + ], + [id, i18n, onClick, onAudioCall, onVideoCall, onRemove, onBlock] + ); + const headerName = isMe ? ( = React.memo( const messageText = about && !isMe ? : undefined; - return ( - + ); + } + + let blockConfirmation: JSX.Element | undefined; + let removeConfirmation: JSX.Element | undefined; + + if (isConfirmingBlocking) { + blockConfirmation = ( + setConfirmingBlocking(false)} + title={ + , + }} + /> + } + actions={[ + { + text: i18n('icu:MessageRequests--block'), + action: () => onBlock?.(id), + style: 'negative', + }, + ]} + > + {i18n('icu:MessageRequests--block-direct-confirm-body')} + + ); + } + + if (isConfirmingRemoving) { + if ( + isInSystemContacts({ type, name, systemGivenName, systemFamilyName }) + ) { + removeConfirmation = ( + - } - title={headerName} - subtitle={messageText} - subtitleMaxLines={1} - onClick={onClick ? () => onClick(id) : undefined} - /> + onClose={() => setConfirmingRemoving(false)} + title={ + , + }} + /> + } + cancelText={i18n('icu:Confirmation--confirm')} + > + {i18n('icu:ContactListItem__remove-system--body')} + + ); + } else { + removeConfirmation = ( + setConfirmingRemoving(false)} + title={ + , + }} + /> + } + actions={[ + { + text: i18n('icu:ContactListItem__remove--confirm'), + action: () => onRemove?.(id), + style: 'negative', + }, + ]} + > + {i18n('icu:ContactListItem__remove--body')} + + ); + } + } + + return ( + <> + + } + trailing={trailing} + title={headerName} + subtitle={messageText} + subtitleMaxLines={1} + onClick={onClick ? () => onClick(id) : undefined} + /> + + {blockConfirmation} + {removeConfirmation} + > ); } ); diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index 6e85678e094..564349245b5 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -53,6 +53,7 @@ export type PropsData = Pick< | 'muteExpiresAt' | 'phoneNumber' | 'profileName' + | 'removalStage' | 'sharedGroupNames' | 'shouldShowDraft' | 'title' @@ -92,6 +93,7 @@ export const ConversationListItem: FunctionComponent = React.memo( onClick, phoneNumber, profileName, + removalStage, sharedGroupNames, shouldShowDraft, theme, @@ -125,7 +127,7 @@ export const ConversationListItem: FunctionComponent = React.memo( let messageText: ReactNode = null; let messageStatusIcon: ReactNode = null; - if (!acceptedMessageRequest) { + if (!acceptedMessageRequest && removalStage !== 'justNotification') { messageText = ( {i18n('icu:ConversationListItem--message-request')} diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx index b0b37a94078..670509bb485 100644 --- a/ts/components/leftPane/LeftPaneComposeHelper.tsx +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -205,6 +205,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper; @@ -301,6 +302,12 @@ export type ConversationAttributesType = { draftTimestamp?: number | null; hideStory?: boolean; inbox_position?: number; + // When contact is removed - it is initially placed into `justNotification` + // removal stage. In this stage user can still send messages (which will + // set `removalStage` to `undefined`), but if a new incoming message arrives - + // the stage will progress to `messageRequest` and composition area will be + // replaced with a message request. + removalStage?: 'justNotification' | 'messageRequest'; isPinned?: boolean; lastMessageDeletedForEveryone?: boolean; lastMessageStatus?: LastMessageStatus | null; @@ -361,6 +368,7 @@ export type ConversationAttributesType = { verified?: number; profileLastFetchedAt?: number; pendingUniversalTimer?: string; + pendingRemovedContactNotification?: string; username?: string; shareMyPhoneNumber?: boolean; previousIdentityKey?: string; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index feffe5a90b1..6d8a28e01b4 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -974,6 +974,79 @@ export class ConversationModel extends window.Backbone return unblocked; } + async removeContact({ + viaStorageServiceSync = false, + shouldSave = true, + } = {}): Promise { + const logId = `removeContact(${this.idForLogging()}) storage? ${viaStorageServiceSync}`; + + if (!isDirectConversation(this.attributes)) { + log.warn(`${logId}: not direct conversation`); + return; + } + + if (this.get('removalStage')) { + log.warn(`${logId}: already removed`); + return; + } + + // Don't show message request state until first incoming message. + log.info(`${logId}: updating`); + this.set({ removalStage: 'justNotification' }); + + if (!viaStorageServiceSync) { + this.captureChange('removeContact'); + } + + this.disableProfileSharing({ viaStorageServiceSync }); + + // Drop existing message request state to avoid sending receipts and + // display MR actions. + const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; + await this.applyMessageRequestResponse(messageRequestEnum.UNKNOWN, { + viaStorageServiceSync, + shouldSave: false, + }); + + // Add notification + drop(this.queueJob('removeContact', () => this.maybeSetContactRemoved())); + + if (shouldSave) { + await window.Signal.Data.updateConversation(this.attributes); + } + } + + async restoreContact({ + viaStorageServiceSync = false, + shouldSave = true, + } = {}): Promise { + const logId = `restoreContact(${this.idForLogging()}) storage? ${viaStorageServiceSync}`; + + if (!isDirectConversation(this.attributes)) { + log.warn(`${logId}: not direct conversation`); + return; + } + + if (this.get('removalStage') === undefined) { + log.warn(`${logId}: not removed`); + return; + } + + log.info(`${logId}: updating`); + this.set({ removalStage: undefined }); + + if (!viaStorageServiceSync) { + this.captureChange('restoreContact'); + } + + // Remove notification since the conversation isn't hidden anymore + await this.maybeClearContactRemoved(); + + if (shouldSave) { + await window.Signal.Data.updateConversation(this.attributes); + } + } + enableProfileSharing({ viaStorageServiceSync = false } = {}): void { log.info( `enableProfileSharing: ${this.idForLogging()} storage? ${viaStorageServiceSync}` @@ -1377,6 +1450,17 @@ export class ConversationModel extends window.Backbone return; } + // Change to message request state if contact was removed and sent message. + if ( + this.get('removalStage') === 'justNotification' && + isIncoming(message.attributes) + ) { + this.set({ + removalStage: 'messageRequest', + }); + window.Signal.Data.updateConversation(this.attributes); + } + void this.addSingleMessage(message); } @@ -1506,7 +1590,12 @@ export class ConversationModel extends window.Backbone // oldest messages, to ensure that the ConversationHero is shown. We don't want to // scroll directly to the oldest message, because that could scroll the hero off // the screen. - if (!newestMessageId && !this.getAccepted() && metrics.oldest) { + if ( + !newestMessageId && + !this.getAccepted() && + this.get('removalStage') !== 'justNotification' && + metrics.oldest + ) { void this.loadAndScroll(metrics.oldest.id, { disableScroll: true }); return; } @@ -1899,6 +1988,7 @@ export class ConversationModel extends window.Backbone inboxPosition, isArchived: this.get('isArchived'), isBlocked: this.isBlocked(), + removalStage: this.get('removalStage'), isMe: isMe(this.attributes), isGroupV1AndDisabled: this.isGroupV1AndDisabled(), isPinned: this.get('isPinned'), @@ -2259,7 +2349,7 @@ export class ConversationModel extends window.Backbone async applyMessageRequestResponse( response: number, - { fromSync = false, viaStorageServiceSync = false } = {} + { fromSync = false, viaStorageServiceSync = false, shouldSave = true } = {} ): Promise { try { const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; @@ -2276,6 +2366,9 @@ export class ConversationModel extends window.Backbone if (response === messageRequestEnum.ACCEPT) { this.unblock({ viaStorageServiceSync }); + if (!viaStorageServiceSync) { + await this.restoreContact({ shouldSave: false }); + } this.enableProfileSharing({ viaStorageServiceSync }); // We really don't want to call this if we don't have to. It can take a lot of @@ -2382,7 +2475,9 @@ export class ConversationModel extends window.Backbone } } } finally { - window.Signal.Data.updateConversation(this.attributes); + if (shouldSave) { + window.Signal.Data.updateConversation(this.attributes); + } } } @@ -2628,10 +2723,13 @@ export class ConversationModel extends window.Backbone } } - async syncMessageRequestResponse(response: number): Promise { + async syncMessageRequestResponse( + response: number, + { shouldSave = true } = {} + ): Promise { // In GroupsV2, this may modify the server. We only want to continue if those // server updates were successful. - await this.applyMessageRequestResponse(response); + await this.applyMessageRequestResponse(response, { shouldSave }); const groupId = this.getGroupIdBuffer(); @@ -3555,6 +3653,44 @@ export class ConversationModel extends window.Backbone return true; } + async maybeSetContactRemoved(): Promise { + if (!isDirectConversation(this.attributes)) { + return; + } + + if (this.get('pendingRemovedContactNotification')) { + return; + } + + log.info( + `maybeSetContactRemoved(${this.idForLogging()}): added notification` + ); + const notificationId = await this.addNotification( + 'contact-removed-notification' + ); + this.set('pendingRemovedContactNotification', notificationId); + await window.Signal.Data.updateConversation(this.attributes); + } + + async maybeClearContactRemoved(): Promise { + const notificationId = this.get('pendingRemovedContactNotification'); + if (!notificationId) { + return false; + } + + this.set('pendingRemovedContactNotification', undefined); + log.info( + `maybeClearContactRemoved(${this.idForLogging()}): removed notification` + ); + + const message = window.MessageController.getById(notificationId); + if (message) { + await window.Signal.Data.removeMessage(message.id); + } + + return true; + } + async addChangeNumberNotification( oldValue: string, newValue: string @@ -4010,6 +4146,7 @@ export class ConversationModel extends window.Backbone if (!storyId || isDirectConversation(this.attributes)) { await this.maybeApplyUniversalTimer(); expireTimer = this.get('expireTimer'); + await this.restoreContact(); } const recipientMaybeConversations = map( diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 68966871b39..f6bd56cea0a 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -98,6 +98,7 @@ import { hasErrors, isCallHistory, isChatSessionRefreshed, + isContactRemovedNotification, isDeliveryIssue, isEndSession, isExpirationTimerUpdate, @@ -341,6 +342,7 @@ export class MessageModel extends window.Backbone.Model { return ( !isCallHistory(attributes) && !isChatSessionRefreshed(attributes) && + !isContactRemovedNotification(attributes) && !isConversationMerge(attributes) && !isEndSession(attributes) && !isExpirationTimerUpdate(attributes) && diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index ee055ac19d9..cf253c4c254 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -203,6 +203,7 @@ export async function toContactRecord( contactRecord.systemNickname = systemNickname; } contactRecord.blocked = conversation.isBlocked(); + contactRecord.hidden = conversation.get('removalStage') !== undefined; contactRecord.whitelisted = Boolean(conversation.get('profileSharing')); contactRecord.archived = Boolean(conversation.get('isArchived')); contactRecord.markedUnread = Boolean(conversation.get('markedUnread')); @@ -1083,6 +1084,18 @@ export async function mergeContactRecord( storageVersion, }); + if (contactRecord.hidden) { + await conversation.removeContact({ + viaStorageServiceSync: true, + shouldSave: false, + }); + } else { + await conversation.restoreContact({ + viaStorageServiceSync: true, + shouldSave: false, + }); + } + conversation.setMuteExpiration( getTimestampFromLong(contactRecord.mutedUntilTimestamp), { diff --git a/ts/sql/migrations/81-contact-removed-notification.ts b/ts/sql/migrations/81-contact-removed-notification.ts new file mode 100644 index 00000000000..91fe81908b7 --- /dev/null +++ b/ts/sql/migrations/81-contact-removed-notification.ts @@ -0,0 +1,118 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion81( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 81) { + return; + } + + db.transaction(() => { + db.exec( + ` + --- These will be re-added below + DROP INDEX messages_preview; + DROP INDEX messages_preview_without_story; + DROP INDEX messages_activity; + DROP INDEX message_user_initiated; + + --- These will also be re-added below + ALTER TABLE messages DROP COLUMN shouldAffectActivity; + ALTER TABLE messages DROP COLUMN shouldAffectPreview; + ALTER TABLE messages DROP COLUMN isUserInitiatedMessage; + + --- Note: These generated columns were previously modified in + --- migration 73, and are mostly the same + + --- (change: added contact-removed-notification) + ALTER TABLE messages + ADD COLUMN shouldAffectActivity INTEGER + GENERATED ALWAYS AS ( + type IS NULL + OR + type NOT IN ( + 'change-number-notification', + 'contact-removed-notification', + 'conversation-merge', + 'group-v1-migration', + 'keychange', + 'message-history-unsynced', + 'profile-change', + 'story', + 'universal-timer-notification', + 'verified-change' + ) + ); + + --- (change: added contact-removed-notification) + ALTER TABLE messages + ADD COLUMN shouldAffectPreview INTEGER + GENERATED ALWAYS AS ( + type IS NULL + OR + type NOT IN ( + 'change-number-notification', + 'contact-removed-notification', + 'conversation-merge', + 'group-v1-migration', + 'keychange', + 'message-history-unsynced', + 'profile-change', + 'story', + 'universal-timer-notification', + 'verified-change' + ) + ); + + --- (change: added contact-removed-notification) + ALTER TABLE messages + ADD COLUMN isUserInitiatedMessage INTEGER + GENERATED ALWAYS AS ( + type IS NULL + OR + type NOT IN ( + 'change-number-notification', + 'contact-removed-notification', + 'conversation-merge', + 'group-v1-migration', + 'group-v2-change', + 'keychange', + 'message-history-unsynced', + 'profile-change', + 'story', + 'universal-timer-notification', + 'verified-change' + ) + ); + + --- From migration 76 + CREATE INDEX messages_preview ON messages + (conversationId, shouldAffectPreview, isGroupLeaveEventFromOther, + received_at, sent_at); + + --- From migration 76 + CREATE INDEX messages_preview_without_story ON messages + (conversationId, shouldAffectPreview, isGroupLeaveEventFromOther, + received_at, sent_at) WHERE storyId IS NULL; + + --- From migration 73 + CREATE INDEX messages_activity ON messages + (conversationId, shouldAffectActivity, isTimerChangeFromSync, isGroupLeaveEventFromOther, received_at, sent_at); + + --- From migration 74 + CREATE INDEX message_user_initiated ON messages (conversationId, isUserInitiatedMessage); + ` + ); + + db.pragma('user_version = 81'); + })(); + + logger.info('updateToSchemaVersion81: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index e0763f3de05..1c38dd9cc1e 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -56,6 +56,7 @@ import updateToSchemaVersion77 from './77-signal-tokenizer'; import updateToSchemaVersion78 from './78-merge-receipt-jobs'; import updateToSchemaVersion79 from './79-paging-lightbox'; import updateToSchemaVersion80 from './80-edited-messages'; +import updateToSchemaVersion81 from './81-contact-removed-notification'; function updateToSchemaVersion1( currentVersion: number, @@ -1982,6 +1983,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion79, updateToSchemaVersion80, + updateToSchemaVersion81, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index ae8c3f96ddf..6f08e72134f 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -226,6 +226,7 @@ export type ConversationType = ReadonlyDeep< hideStory?: boolean; isArchived?: boolean; isBlocked?: boolean; + removalStage?: 'justNotification' | 'messageRequest'; isGroupV1AndDisabled?: boolean; isPinned?: boolean; isUntrusted?: boolean; @@ -1009,6 +1010,7 @@ export const actions = { popPanelForConversation, pushPanelForConversation, removeAllConversations, + removeConversation, removeCustomColorOnConversations, removeMember, removeMemberFromGroup, @@ -3064,6 +3066,27 @@ function acceptConversation(conversationId: string): NoopActionType { }; } +function removeConversation(conversationId: string): ShowToastActionType { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error( + 'acceptConversation: Expected a conversation to be found. Doing nothing' + ); + } + + drop(conversation.removeContact()); + + return { + type: SHOW_TOAST, + payload: { + toastType: ToastType.ConversationRemoved, + parameters: { + title: conversation.getTitle(), + }, + }, + }; +} + function blockConversation(conversationId: string): NoopActionType { const conversation = window.ConversationController.get(conversationId); if (!conversation) { diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index c585ca93945..97c94bb6077 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -471,6 +471,7 @@ function canComposeConversation(conversation: ConversationType): boolean { return Boolean( !isSignalConversation(conversation) && !conversation.isBlocked && + !conversation.removalStage && !isConversationUnregistered(conversation) && hasDisplayInfo(conversation) && isTrusted(conversation) @@ -484,6 +485,7 @@ export const getAllComposableConversations = createSelector( conversation => !isSignalConversation(conversation) && !conversation.isBlocked && + !conversation.removalStage && !conversation.isGroupV1AndDisabled && !isConversationUnregistered(conversation) && // All conversation should have a title except in weird cases where diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index b8aaeca2ac8..ba65c2e1fcd 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -124,6 +124,27 @@ export const getStoriesEnabled = createSelector( } ); +export const getContactManagementEnabled = createSelector( + getRemoteConfig, + (remoteConfig: ConfigMapType): boolean => { + if (isRemoteConfigFlagEnabled(remoteConfig, 'desktop.contactManagement')) { + return true; + } + + if ( + isRemoteConfigFlagEnabled( + remoteConfig, + 'desktop.contactManagement.beta' + ) && + isBeta(window.getVersion()) + ) { + return true; + } + + return false; + } +); + export const getDefaultConversationColor = createSelector( getItems, ( diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 715c7f332d4..4203bad0fe6 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -871,6 +871,13 @@ export function getPropsForBubble( timestamp, }; } + if (isContactRemovedNotification(message)) { + return { + type: 'contactRemovedNotification', + data: null, + timestamp, + }; + } if (isChangeNumberNotification(message)) { return { type: 'changeNumberNotification', @@ -1374,6 +1381,16 @@ export function isUniversalTimerNotification( return message.type === 'universal-timer-notification'; } +// Contact Removed Notification + +// Note: smart, so props not generated here + +export function isContactRemovedNotification( + message: MessageWithUIFieldsType +): boolean { + return message.type === 'contact-removed-notification'; +} + // Change Number Notification export function isChangeNumberNotification( diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 2b8990d3536..6ba918ab356 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -40,6 +40,7 @@ import { hasNetworkDialog } from '../selectors/network'; import { getPreferredLeftPaneWidth, getUsernamesEnabled, + getContactManagementEnabled, } from '../selectors/items'; import { getComposeAvatarData, @@ -233,6 +234,7 @@ const mapStateToProps = (state: StateType) => { targetedMessageId: getTargetedMessage(state)?.id, showArchived: getShowArchived(state), getPreferredBadge: getPreferredBadgeSelector(state), + isContactManagementEnabled: getContactManagementEnabled(state), i18n: getIntl(state), isMacOS: getIsMacOS(state), regionCode: getRegionCode(state), diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts index 1a493da198b..fb047ff0b23 100644 --- a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts @@ -227,10 +227,12 @@ describe('LeftPaneComposeHelper', () => { assert.deepEqual(helper.getRow(2), { type: RowType.Contact, contact: composeContacts[0], + hasContextMenu: true, }); assert.deepEqual(helper.getRow(3), { type: RowType.Contact, contact: composeContacts[1], + hasContextMenu: true, }); }); @@ -259,10 +261,12 @@ describe('LeftPaneComposeHelper', () => { assert.deepEqual(helper.getRow(2), { type: RowType.Contact, contact: composeContacts[0], + hasContextMenu: true, }); assert.deepEqual(helper.getRow(3), { type: RowType.Contact, contact: composeContacts[1], + hasContextMenu: true, }); assert.deepEqual(_testHeaderText(helper.getRow(4)), 'icu:groupsHeader'); assert.deepEqual(helper.getRow(5), { @@ -306,10 +310,12 @@ describe('LeftPaneComposeHelper', () => { assert.deepEqual(helper.getRow(1), { type: RowType.Contact, contact: composeContacts[0], + hasContextMenu: true, }); assert.deepEqual(helper.getRow(2), { type: RowType.Contact, contact: composeContacts[1], + hasContextMenu: true, }); }); @@ -383,10 +389,12 @@ describe('LeftPaneComposeHelper', () => { assert.deepEqual(helper.getRow(1), { type: RowType.Contact, contact: composeContacts[0], + hasContextMenu: true, }); assert.deepEqual(helper.getRow(2), { type: RowType.Contact, contact: composeContacts[1], + hasContextMenu: true, }); assert.deepEqual( _testHeaderText(helper.getRow(3)), diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index c8ddf2bfe62..ffcbd5e8449 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -14,6 +14,7 @@ export enum ToastType { CannotStartGroupCall = 'CannotStartGroupCall', ConversationArchived = 'ConversationArchived', ConversationMarkedUnread = 'ConversationMarkedUnread', + ConversationRemoved = 'ConversationRemoved', ConversationUnarchived = 'ConversationUnarchived', CopiedUsername = 'CopiedUsername', CopiedUsernameLink = 'CopiedUsernameLink', diff --git a/ts/util/isConversationAccepted.ts b/ts/util/isConversationAccepted.ts index d17bf372335..5366a52a789 100644 --- a/ts/util/isConversationAccepted.ts +++ b/ts/util/isConversationAccepted.ts @@ -28,7 +28,11 @@ export function isConversationAccepted( const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; - const { messageRequestResponseType } = conversationAttrs; + const { messageRequestResponseType, removalStage } = conversationAttrs; + if (removalStage !== undefined) { + return false; + } + if (messageRequestResponseType === messageRequestEnum.ACCEPT) { return true; }
- {conversationType === 'direct' && isBlocked && ( - - )} - {conversationType === 'direct' && !isBlocked && ( - - )} - {conversationType === 'group' && isBlocked && ( - - )} - {conversationType === 'group' && !isBlocked && ( - - )} -
{message}