Context menu for left pane list items
This commit is contained in:
parent
02dedc7157
commit
f61d8f38b0
43 changed files with 1046 additions and 110 deletions
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -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 />;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(''),
|
||||
|
|
|
@ -1209,6 +1209,10 @@ export function EditDistributionListModal({
|
|||
toggleSelectedConversation(conversationId);
|
||||
}}
|
||||
onSelectConversation={shouldNeverBeCalled}
|
||||
blockConversation={shouldNeverBeCalled}
|
||||
removeConversation={shouldNeverBeCalled}
|
||||
onOutgoingAudioCallInConversation={shouldNeverBeCalled}
|
||||
onOutgoingVideoCallInConversation={shouldNeverBeCalled}
|
||||
renderMessageSearchResult={() => {
|
||||
shouldNeverBeCalled();
|
||||
return <div />;
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -153,6 +153,10 @@ export function Notification(): JSX.Element {
|
|||
{
|
||||
type: 'chatSessionRefreshed',
|
||||
},
|
||||
{
|
||||
type: 'contactRemovedNotification',
|
||||
data: null,
|
||||
},
|
||||
{
|
||||
type: 'safetyNumberNotification',
|
||||
data: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -205,6 +205,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
|
|||
return {
|
||||
type: RowType.Contact,
|
||||
contact,
|
||||
hasContextMenu: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
8
ts/model-types.d.ts
vendored
8
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) &&
|
||||
|
|
|
@ -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),
|
||||
{
|
||||
|
|
118
ts/sql/migrations/81-contact-removed-notification.ts
Normal file
118
ts/sql/migrations/81-contact-removed-notification.ts
Normal 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!');
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -14,6 +14,7 @@ export enum ToastType {
|
|||
CannotStartGroupCall = 'CannotStartGroupCall',
|
||||
ConversationArchived = 'ConversationArchived',
|
||||
ConversationMarkedUnread = 'ConversationMarkedUnread',
|
||||
ConversationRemoved = 'ConversationRemoved',
|
||||
ConversationUnarchived = 'ConversationUnarchived',
|
||||
CopiedUsername = 'CopiedUsername',
|
||||
CopiedUsernameLink = 'CopiedUsernameLink',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue