Context menu for left pane list items

This commit is contained in:
Fedor Indutny 2023-04-05 13:48:00 -07:00 committed by GitHub
parent 02dedc7157
commit f61d8f38b0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1046 additions and 110 deletions

View file

@ -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'

View file

@ -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 (
<MessageRequestActions
@ -589,6 +593,7 @@ export function CompositionArea({
deleteConversation={deleteConversation}
i18n={i18n}
isBlocked={isBlocked}
isHidden={removalStage !== undefined}
title={title}
/>
);
@ -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
) {

View file

@ -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<T> = Readonly<{
onClick?: (ev: React.MouseEvent) => unknown;
onMenuShowingChanged?: (value: boolean) => unknown;
popperOptions?: Pick<Options, 'placement' | 'strategy'>;
portalToRoot?: boolean;
theme?: Theme;
title?: string;
value?: T;
@ -67,6 +69,7 @@ export function ContextMenu<T>({
onClick,
onMenuShowingChanged,
popperOptions,
portalToRoot,
theme,
title,
value,
@ -118,6 +121,21 @@ export function ContextMenu<T>({
);
}, [isMenuShowing, referenceElement, popperElement]);
const [portalNode, setPortalNode] = React.useState<HTMLElement | null>(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<T>({
>
{children}
</button>
{menuNode}
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
</div>
);
}

View file

@ -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) => (
<MessageSearchResult
body="Lorem ipsum wow"
@ -140,6 +148,24 @@ ContactDirect.story = {
name: 'Contact: direct',
};
export function ContactDirectWithContextMenu(): JSX.Element {
return (
<Wrapper
rows={[
{
type: RowType.Contact,
contact: defaultConversations[0],
hasContextMenu: true,
},
]}
/>
);
}
ContactDirectWithContextMenu.story = {
name: 'Contact: context menu',
};
export function ContactDirectWithShortAbout(): JSX.Element {
return (
<Wrapper

View file

@ -64,6 +64,7 @@ type ContactRowType = {
type: RowType.Contact;
contact: ContactListItemPropsType;
isClickable?: boolean;
hasContextMenu?: boolean;
};
type ContactCheckboxRowType = {
@ -175,12 +176,16 @@ export type PropsType = {
i18n: LocalizerType;
theme: ThemeType;
blockConversation: (conversationId: string) => 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 = (
<ContactListItem
{...row.contact}
@ -274,6 +283,15 @@ export function ConversationList({
onClick={isClickable ? onSelectConversation : undefined}
i18n={i18n}
theme={theme}
hasContextMenu={hasContextMenu}
onAudioCall={
isClickable ? onOutgoingAudioCallInConversation : undefined
}
onVideoCall={
isClickable ? onOutgoingVideoCallInConversation : undefined
}
onBlock={isClickable ? blockConversation : undefined}
onRemove={isClickable ? removeConversation : undefined}
/>
);
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,
]
);

View file

@ -356,6 +356,10 @@ export function ForwardMessagesModal({
showUserNotFoundModal={shouldNeverBeCalled}
setIsFetchingUUID={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled}
blockConversation={shouldNeverBeCalled}
removeConversation={shouldNeverBeCalled}
onOutgoingAudioCallInConversation={shouldNeverBeCalled}
onOutgoingVideoCallInConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;

View file

@ -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: () => <div />,
renderMessageSearchResult: (id: string) => (
<MessageSearchResult
@ -268,9 +281,17 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
};
};
function LeftPaneInContainer(props: PropsType): JSX.Element {
return (
<div style={{ height: '600px' }}>
<LeftPane {...props} />
</div>
);
}
export function InboxNoConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -291,7 +312,7 @@ InboxNoConversations.story = {
export function InboxOnlyPinnedConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -312,7 +333,7 @@ InboxOnlyPinnedConversations.story = {
export function InboxOnlyNonPinnedConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -333,7 +354,7 @@ InboxOnlyNonPinnedConversations.story = {
export function InboxOnlyArchivedConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -354,7 +375,7 @@ InboxOnlyArchivedConversations.story = {
export function InboxPinnedAndArchivedConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -375,7 +396,7 @@ InboxPinnedAndArchivedConversations.story = {
export function InboxNonPinnedAndArchivedConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -396,7 +417,7 @@ InboxNonPinnedAndArchivedConversations.story = {
export function InboxPinnedAndNonPinnedConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -416,7 +437,7 @@ InboxPinnedAndNonPinnedConversations.story = {
};
export function InboxPinnedNonPinnedAndArchivedConversations(): JSX.Element {
return <LeftPane {...useProps()} />;
return <LeftPaneInContainer {...useProps()} />;
}
InboxPinnedNonPinnedAndArchivedConversations.story = {
@ -425,7 +446,7 @@ InboxPinnedNonPinnedAndArchivedConversations.story = {
export function SearchNoResultsWhenSearchingEverywhere(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -446,7 +467,7 @@ SearchNoResultsWhenSearchingEverywhere.story = {
export function SearchNoResultsWhenSearchingEverywhereSms(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -467,7 +488,7 @@ SearchNoResultsWhenSearchingEverywhereSms.story = {
export function SearchNoResultsWhenSearchingInAConversation(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -489,7 +510,7 @@ SearchNoResultsWhenSearchingInAConversation.story = {
export function SearchAllResultsLoading(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -510,7 +531,7 @@ SearchAllResultsLoading.story = {
export function SearchSomeResultsLoading(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -534,7 +555,7 @@ SearchSomeResultsLoading.story = {
export function SearchHasConversationsAndContactsButNotMessages(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -558,7 +579,7 @@ SearchHasConversationsAndContactsButNotMessages.story = {
export function SearchAllResults(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -588,7 +609,7 @@ SearchAllResults.story = {
export function ArchiveNoArchivedConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Archive,
@ -608,7 +629,7 @@ ArchiveNoArchivedConversations.story = {
export function ArchiveArchivedConversations(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Archive,
@ -628,7 +649,7 @@ ArchiveArchivedConversations.story = {
export function ArchiveSearchingAConversation(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Archive,
@ -648,7 +669,7 @@ ArchiveSearchingAConversation.story = {
export function ComposeNoResults(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -670,7 +691,7 @@ ComposeNoResults.story = {
export function ComposeSomeContactsNoSearchTerm(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -692,7 +713,7 @@ ComposeSomeContactsNoSearchTerm.story = {
export function ComposeSomeContactsWithASearchTerm(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -714,7 +735,7 @@ ComposeSomeContactsWithASearchTerm.story = {
export function ComposeSomeGroupsNoSearchTerm(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -736,7 +757,7 @@ ComposeSomeGroupsNoSearchTerm.story = {
export function ComposeSomeGroupsWithSearchTerm(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -758,7 +779,7 @@ ComposeSomeGroupsWithSearchTerm.story = {
export function ComposeSearchIsValidUsername(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -780,7 +801,7 @@ ComposeSearchIsValidUsername.story = {
export function ComposeSearchIsValidUsernameFetchingUsername(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -804,7 +825,7 @@ ComposeSearchIsValidUsernameFetchingUsername.story = {
export function ComposeSearchIsValidUsernameButFlagIsNotEnabled(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -826,7 +847,7 @@ ComposeSearchIsValidUsernameButFlagIsNotEnabled.story = {
export function ComposeSearchIsPartialPhoneNumber(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -848,7 +869,7 @@ ComposeSearchIsPartialPhoneNumber.story = {
export function ComposeSearchIsValidPhoneNumber(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -870,7 +891,7 @@ ComposeSearchIsValidPhoneNumber.story = {
export function ComposeSearchIsValidPhoneNumberFetchingPhoneNumber(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -894,7 +915,7 @@ ComposeSearchIsValidPhoneNumberFetchingPhoneNumber.story = {
export function ComposeAllKindsOfResultsNoSearchTerm(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -916,7 +937,7 @@ ComposeAllKindsOfResultsNoSearchTerm.story = {
export function ComposeAllKindsOfResultsWithASearchTerm(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
@ -938,7 +959,7 @@ ComposeAllKindsOfResultsWithASearchTerm.story = {
export function CaptchaDialogRequired(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -961,7 +982,7 @@ CaptchaDialogRequired.story = {
export function CaptchaDialogPending(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -983,7 +1004,7 @@ CaptchaDialogPending.story = {
};
export const _CrashReportDialog = (): JSX.Element => (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
@ -1005,7 +1026,7 @@ _CrashReportDialog.story = {
export function ChooseGroupMembersPartialPhoneNumber(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.ChooseGroupMembers,
@ -1031,7 +1052,7 @@ ChooseGroupMembersPartialPhoneNumber.story = {
export function ChooseGroupMembersValidPhoneNumber(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.ChooseGroupMembers,
@ -1057,7 +1078,7 @@ ChooseGroupMembersValidPhoneNumber.story = {
export function ChooseGroupMembersUsername(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.ChooseGroupMembers,
@ -1083,7 +1104,7 @@ ChooseGroupMembersUsername.story = {
export function GroupMetadataNoTimer(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.SetGroupMetadata,
@ -1107,7 +1128,7 @@ GroupMetadataNoTimer.story = {
export function GroupMetadataRegularTimer(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.SetGroupMetadata,
@ -1131,7 +1152,7 @@ GroupMetadataRegularTimer.story = {
export function GroupMetadataCustomTimer(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.SetGroupMetadata,
@ -1155,7 +1176,7 @@ GroupMetadataCustomTimer.story = {
export function SearchingConversation(): JSX.Element {
return (
<LeftPane
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,

View file

@ -67,6 +67,7 @@ export type PropsType = {
hasRelinkDialog: boolean;
hasUpdateDialog: boolean;
isUpdateDownloaded: boolean;
isContactManagementEnabled: boolean;
unsupportedOSDialogType: 'error' | 'warning' | undefined;
// These help prevent invalid states. For example, we don't need the list of pinned
@ -104,6 +105,7 @@ export type PropsType = {
theme: ThemeType;
// Action Creators
blockConversation: (conversationId: string) => 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}

View file

@ -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<Element, 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<HTMLButtonElement, Props>(
subtitle,
leading,
trailing,
moduleClassName,
onClick,
onContextMenu,
clickable,
@ -85,6 +85,8 @@ const ListTileImpl = React.forwardRef<HTMLButtonElement, Props>(
) {
const isClickable = clickable ?? Boolean(onClick);
const getClassName = getClassNamesFor('ListTile', moduleClassName);
const rootProps = {
className: classNames(
getClassName(''),

View file

@ -1209,6 +1209,10 @@ export function EditDistributionListModal({
toggleSelectedConversation(conversationId);
}}
onSelectConversation={shouldNeverBeCalled}
blockConversation={shouldNeverBeCalled}
removeConversation={shouldNeverBeCalled}
onOutgoingAudioCallInConversation={shouldNeverBeCalled}
onOutgoingVideoCallInConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;

View file

@ -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: {

View file

@ -134,6 +134,16 @@ export function ToastManager({
);
}
if (toastType === ToastType.ConversationRemoved) {
return (
<Toast onClose={hideToast}>
{i18n('icu:Toast--ConversationRemoved', {
title: toast?.parameters?.title ?? '',
})}
</Toast>
);
}
if (toastType === ToastType.ConversationUnarchived) {
return (
<Toast onClose={hideToast}>

View file

@ -15,6 +15,7 @@ import type { LocalizerType } from '../../types/Util';
export type Props = {
i18n: LocalizerType;
isHidden?: boolean;
} & Omit<ContactNameProps, 'module'> &
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({
</strong>
);
let message: JSX.Element | undefined;
if (conversationType === 'direct') {
if (isBlocked) {
message = (
<Intl
i18n={i18n}
id="icu:MessageRequests--message-direct-blocked"
components={{ name }}
/>
);
} else if (isHidden) {
message = (
<Intl
i18n={i18n}
id="icu:MessageRequests--message-direct-hidden"
components={{ name }}
/>
);
} else {
message = (
<Intl
i18n={i18n}
id="icu:MessageRequests--message-direct"
components={{ name }}
/>
);
}
} else if (conversationType === 'group') {
if (isBlocked) {
message = (
<Intl i18n={i18n} id="icu:MessageRequests--message-group-blocked" />
);
} else {
message = <Intl i18n={i18n} id="icu:MessageRequests--message-group" />;
}
}
return (
<>
{mrState !== MessageRequestState.default ? (
@ -61,28 +100,7 @@ export function MessageRequestActions({
/>
) : null}
<div className="module-message-request-actions">
<p className="module-message-request-actions__message">
{conversationType === 'direct' && isBlocked && (
<Intl
i18n={i18n}
id="icu:MessageRequests--message-direct-blocked"
components={{ name }}
/>
)}
{conversationType === 'direct' && !isBlocked && (
<Intl
i18n={i18n}
id="icu:MessageRequests--message-direct"
components={{ name }}
/>
)}
{conversationType === 'group' && isBlocked && (
<Intl i18n={i18n} id="icu:MessageRequests--message-group-blocked" />
)}
{conversationType === 'group' && !isBlocked && (
<Intl i18n={i18n} id="icu:MessageRequests--message-group" />
)}
</p>
<p className="module-message-request-actions__message">{message}</p>
<div className="module-message-request-actions__buttons">
<Button
onClick={() => {

View file

@ -153,6 +153,10 @@ export function Notification(): JSX.Element {
{
type: 'chatSessionRefreshed',
},
{
type: 'contactRemovedNotification',
data: null,
},
{
type: 'safetyNumberNotification',
data: {

View file

@ -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<PropsType> {
);
} else if (item.type === 'universalTimerNotification') {
notification = renderUniversalTimerNotification();
} else if (item.type === 'contactRemovedNotification') {
notification = (
<SystemMessage
icon="info"
contents={i18n('icu:ContactRemovedNotification__text')}
/>
);
} else if (item.type === 'changeNumberNotification') {
notification = (
<ChangeNumberNotification

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React from 'react';
import React, { useMemo, useState } from 'react';
import { HEADER_CONTACT_NAME_CLASS_NAME } from './BaseConversationListItem';
import type { ConversationType } from '../../state/ducks/conversations';
@ -12,7 +12,11 @@ import { ContactName } from '../conversation/ContactName';
import { About } from '../conversation/About';
import { ListTile } from '../ListTile';
import { Avatar, AvatarSize } from '../Avatar';
import { ContextMenu } from '../ContextMenu';
import { Intl } from '../Intl';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { isSignalConversation } from '../../util/isSignalConversation';
import { isInSystemContacts } from '../../util/isInSystemContacts';
export type ContactListItemConversationType = Pick<
ConversationType,
@ -23,10 +27,13 @@ export type ContactListItemConversationType = Pick<
| 'color'
| 'groupId'
| 'id'
| 'name'
| 'isMe'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'systemGivenName'
| 'systemFamilyName'
| 'title'
| 'type'
| 'unblurredAvatarPath'
@ -42,6 +49,11 @@ type PropsDataType = ContactListItemConversationType & {
type PropsHousekeepingType = {
i18n: LocalizerType;
onClick?: (id: string) => 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<PropsType> = 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 ? (
<ContactName
isMe={isMe}
@ -84,32 +158,138 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
const messageText =
about && !isMe ? <About className="" text={about} /> : undefined;
return (
<ListTile
leading={
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={type}
noteToSelf={Boolean(isMe)}
let trailing: JSX.Element | undefined;
if (hasContextMenu) {
trailing = (
<ContextMenu
i18n={i18n}
menuOptions={menuOptions}
popperOptions={{ placement: 'bottom-start', strategy: 'absolute' }}
moduleClassName="ContactListItem__context-menu"
ariaLabel={i18n('icu:ContactListItem__menu')}
portalToRoot
/>
);
}
let blockConfirmation: JSX.Element | undefined;
let removeConfirmation: JSX.Element | undefined;
if (isConfirmingBlocking) {
blockConfirmation = (
<ConfirmationDialog
dialogName="ContactListItem.blocking"
i18n={i18n}
onClose={() => setConfirmingBlocking(false)}
title={
<Intl
i18n={i18n}
id="icu:MessageRequests--block-direct-confirm-title"
components={{
title: <ContactName key="name" title={title} />,
}}
/>
}
actions={[
{
text: i18n('icu:MessageRequests--block'),
action: () => onBlock?.(id),
style: 'negative',
},
]}
>
{i18n('icu:MessageRequests--block-direct-confirm-body')}
</ConfirmationDialog>
);
}
if (isConfirmingRemoving) {
if (
isInSystemContacts({ type, name, systemGivenName, systemFamilyName })
) {
removeConfirmation = (
<ConfirmationDialog
key="ContactListItem.systemContact"
dialogName="ContactListItem.systemContact"
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarPath={unblurredAvatarPath}
// This is here to appease the type checker.
{...(badge ? { badge, theme } : { badge: undefined })}
/>
}
title={headerName}
subtitle={messageText}
subtitleMaxLines={1}
onClick={onClick ? () => onClick(id) : undefined}
/>
onClose={() => setConfirmingRemoving(false)}
title={
<Intl
i18n={i18n}
id="icu:ContactListItem__remove-system--title"
components={{
title: <ContactName key="name" title={title} />,
}}
/>
}
cancelText={i18n('icu:Confirmation--confirm')}
>
{i18n('icu:ContactListItem__remove-system--body')}
</ConfirmationDialog>
);
} else {
removeConfirmation = (
<ConfirmationDialog
key="ContactListItem.removing"
dialogName="ContactListItem.removing"
i18n={i18n}
onClose={() => setConfirmingRemoving(false)}
title={
<Intl
i18n={i18n}
id="icu:ContactListItem__remove--title"
components={{
title: <ContactName key="name" title={title} />,
}}
/>
}
actions={[
{
text: i18n('icu:ContactListItem__remove--confirm'),
action: () => onRemove?.(id),
style: 'negative',
},
]}
>
{i18n('icu:ContactListItem__remove--body')}
</ConfirmationDialog>
);
}
}
return (
<>
<ListTile
moduleClassName="ContactListItem"
leading={
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={type}
noteToSelf={Boolean(isMe)}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarPath={unblurredAvatarPath}
// This is here to appease the type checker.
{...(badge ? { badge, theme } : { badge: undefined })}
/>
}
trailing={trailing}
title={headerName}
subtitle={messageText}
subtitleMaxLines={1}
onClick={onClick ? () => onClick(id) : undefined}
/>
{blockConfirmation}
{removeConfirmation}
</>
);
}
);

View file

@ -53,6 +53,7 @@ export type PropsData = Pick<
| 'muteExpiresAt'
| 'phoneNumber'
| 'profileName'
| 'removalStage'
| 'sharedGroupNames'
| 'shouldShowDraft'
| 'title'
@ -92,6 +93,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
onClick,
phoneNumber,
profileName,
removalStage,
sharedGroupNames,
shouldShowDraft,
theme,
@ -125,7 +127,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
let messageText: ReactNode = null;
let messageStatusIcon: ReactNode = null;
if (!acceptedMessageRequest) {
if (!acceptedMessageRequest && removalStage !== 'justNotification') {
messageText = (
<span className={`${MESSAGE_TEXT_CLASS_NAME}__message-request`}>
{i18n('icu:ConversationListItem--message-request')}

View file

@ -205,6 +205,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
return {
type: RowType.Contact,
contact,
hasContextMenu: true,
};
}

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

@ -190,6 +190,7 @@ export type MessageAttributesType = {
| 'story'
| 'timer-notification'
| 'universal-timer-notification'
| 'contact-removed-notification'
| 'verified-change';
body?: string;
attachments?: Array<AttachmentType>;
@ -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;

View file

@ -974,6 +974,79 @@ export class ConversationModel extends window.Backbone
return unblocked;
}
async removeContact({
viaStorageServiceSync = false,
shouldSave = true,
} = {}): Promise<void> {
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<void> {
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<void> {
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<void> {
async syncMessageRequestResponse(
response: number,
{ shouldSave = true } = {}
): Promise<void> {
// 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<void> {
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<boolean> {
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(

View file

@ -98,6 +98,7 @@ import {
hasErrors,
isCallHistory,
isChatSessionRefreshed,
isContactRemovedNotification,
isDeliveryIssue,
isEndSession,
isExpirationTimerUpdate,
@ -341,6 +342,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return (
!isCallHistory(attributes) &&
!isChatSessionRefreshed(attributes) &&
!isContactRemovedNotification(attributes) &&
!isConversationMerge(attributes) &&
!isEndSession(attributes) &&
!isExpirationTimerUpdate(attributes) &&

View file

@ -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),
{

View file

@ -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!');
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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

View file

@ -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,
(

View file

@ -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(

View file

@ -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),

View file

@ -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)),

View file

@ -14,6 +14,7 @@ export enum ToastType {
CannotStartGroupCall = 'CannotStartGroupCall',
ConversationArchived = 'ConversationArchived',
ConversationMarkedUnread = 'ConversationMarkedUnread',
ConversationRemoved = 'ConversationRemoved',
ConversationUnarchived = 'ConversationUnarchived',
CopiedUsername = 'CopiedUsername',
CopiedUsernameLink = 'CopiedUsernameLink',

View file

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