signal-desktop/ts/components/ForwardMessagesModal.tsx

463 lines
14 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2021 Signal Messenger, LLC
2021-04-27 22:35:35 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
2023-04-20 17:03:43 +00:00
Fragment,
2021-04-27 22:35:35 +00:00
} from 'react';
import { AttachmentList } from './conversation/AttachmentList';
2021-12-03 01:05:32 +00:00
import type { AttachmentType } from '../types/Attachment';
2021-04-27 22:35:35 +00:00
import { Button } from './Button';
2021-07-20 20:18:35 +00:00
import { ConfirmationDialog } from './ConfirmationDialog';
2021-04-27 22:35:35 +00:00
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import type { Row } from './ConversationList';
import { ConversationList, RowType } from './ConversationList';
import type { ConversationType } from '../state/ducks/conversations';
2021-11-17 18:38:52 +00:00
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
2023-03-20 22:23:53 +00:00
import type { LocalizerType, ThemeType } from '../types/Util';
2022-10-04 23:17:15 +00:00
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
2021-05-11 00:50:43 +00:00
import { SearchInput } from './SearchInput';
2021-04-27 22:35:35 +00:00
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
2021-04-28 20:44:48 +00:00
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import {
shouldNeverBeCalled,
asyncShouldNeverBeCalled,
} from '../util/shouldNeverBeCalled';
2023-03-20 22:23:53 +00:00
import type { MessageForwardDraft } from '../util/maybeForwardMessages';
import {
isDraftEditable,
isDraftForwardable,
} from '../util/maybeForwardMessages';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { LinkPreviewSourceType } from '../types/LinkPreview';
import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast';
import type { HydratedBodyRangesType } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
2023-04-20 17:03:43 +00:00
import { UserText } from './UserText';
import { Modal } from './Modal';
import { SizeObserver } from '../hooks/useSizeObserver';
2021-04-27 22:35:35 +00:00
export type DataPropsType = {
candidateConversations: ReadonlyArray<ConversationType>;
2023-03-20 22:23:53 +00:00
doForwardMessages: (
conversationIds: ReadonlyArray<string>,
drafts: ReadonlyArray<MessageForwardDraft>
2021-04-27 22:35:35 +00:00
) => void;
2023-03-20 22:23:53 +00:00
drafts: ReadonlyArray<MessageForwardDraft>;
2021-11-17 18:38:52 +00:00
getPreferredBadge: PreferredBadgeSelectorType;
2021-04-27 22:35:35 +00:00
i18n: LocalizerType;
2023-03-20 22:23:53 +00:00
linkPreviewForSource: (
source: LinkPreviewSourceType
) => LinkPreviewType | void;
2021-04-27 22:35:35 +00:00
onClose: () => void;
2023-03-20 22:23:53 +00:00
onChange: (
updatedDrafts: ReadonlyArray<MessageForwardDraft>,
2021-04-27 22:35:35 +00:00
caretLocation?: number
) => unknown;
regionCode: string | undefined;
2022-10-04 23:17:15 +00:00
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
2023-03-20 22:23:53 +00:00
showToast: ShowToastAction;
theme: ThemeType;
2022-10-04 23:17:15 +00:00
};
2021-04-27 22:35:35 +00:00
2022-10-04 23:17:15 +00:00
type ActionPropsType = {
2021-04-27 22:35:35 +00:00
removeLinkPreview: () => void;
};
export type PropsType = DataPropsType & ActionPropsType;
const MAX_FORWARD = 5;
2023-03-20 22:23:53 +00:00
export function ForwardMessagesModal({
drafts,
2021-04-27 22:35:35 +00:00
candidateConversations,
2023-03-20 22:23:53 +00:00
doForwardMessages,
linkPreviewForSource,
2021-11-17 18:38:52 +00:00
getPreferredBadge,
2021-04-27 22:35:35 +00:00
i18n,
onClose,
2023-03-20 22:23:53 +00:00
onChange,
2021-04-27 22:35:35 +00:00
removeLinkPreview,
2022-10-04 23:17:15 +00:00
RenderCompositionTextArea,
2023-03-20 22:23:53 +00:00
showToast,
2021-11-02 23:01:13 +00:00
theme,
regionCode,
2022-11-18 00:45:19 +00:00
}: PropsType): JSX.Element {
2021-04-27 22:35:35 +00:00
const inputRef = useRef<null | HTMLInputElement>(null);
const [selectedContacts, setSelectedContacts] = useState<
Array<ConversationType>
>([]);
const [searchTerm, setSearchTerm] = useState('');
2021-04-28 20:44:48 +00:00
const [filteredConversations, setFilteredConversations] = useState(
filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
2021-04-27 22:35:35 +00:00
);
const [isEditingMessage, setIsEditingMessage] = useState(false);
2021-07-20 20:18:35 +00:00
const [cannotMessage, setCannotMessage] = useState(false);
2021-04-27 22:35:35 +00:00
2023-03-20 22:23:53 +00:00
const isLonelyDraft = drafts.length === 1;
const lonelyDraft = isLonelyDraft ? drafts[0] : null;
const isLonelyDraftEditable =
lonelyDraft != null && isDraftEditable(lonelyDraft);
const lonelyLinkPreview = isLonelyDraft
? linkPreviewForSource(LinkPreviewSourceType.ForwardMessageModal)
: null;
2021-04-27 22:35:35 +00:00
const hasSelectedMaximumNumberOfContacts =
selectedContacts.length >= MAX_FORWARD;
const selectedConversationIdsSet: Set<string> = useMemo(
() => new Set(selectedContacts.map(contact => contact.id)),
[selectedContacts]
);
const hasContactsSelected = Boolean(selectedContacts.length);
2023-03-20 22:23:53 +00:00
const canForwardMessages =
hasContactsSelected && drafts.every(isDraftForwardable);
2023-03-20 22:23:53 +00:00
const forwardMessages = React.useCallback(() => {
if (!canForwardMessages) {
showToast({ toastType: ToastType.CannotForwardEmptyMessage });
2021-04-27 22:35:35 +00:00
return;
}
2023-03-20 22:23:53 +00:00
const conversationIds = selectedContacts.map(contact => contact.id);
if (lonelyDraft != null) {
const previews = lonelyLinkPreview ? [lonelyLinkPreview] : [];
doForwardMessages(conversationIds, [{ ...lonelyDraft, previews }]);
} else {
doForwardMessages(
conversationIds,
drafts.map(draft => ({
...draft,
// We don't keep @mention bodyRanges in multi-forward scenarios
bodyRanges: draft.bodyRanges?.filter(BodyRange.isFormatting),
}))
);
2023-03-20 22:23:53 +00:00
}
2021-04-27 22:35:35 +00:00
}, [
2023-03-20 22:23:53 +00:00
drafts,
lonelyDraft,
lonelyLinkPreview,
doForwardMessages,
2021-04-27 22:35:35 +00:00
selectedContacts,
2023-03-20 22:23:53 +00:00
canForwardMessages,
showToast,
2021-04-27 22:35:35 +00:00
]);
const normalizedSearchTerm = searchTerm.trim();
useEffect(() => {
const timeout = setTimeout(() => {
2021-04-28 20:44:48 +00:00
setFilteredConversations(
filterAndSortConversationsByRecent(
candidateConversations,
normalizedSearchTerm,
regionCode
2021-04-28 20:44:48 +00:00
)
2021-04-27 22:35:35 +00:00
);
}, 200);
return () => {
clearTimeout(timeout);
};
}, [
candidateConversations,
normalizedSearchTerm,
setFilteredConversations,
regionCode,
]);
2021-04-27 22:35:35 +00:00
const contactLookup = useMemo(() => {
const map = new Map();
candidateConversations.forEach(contact => {
map.set(contact.id, contact);
});
return map;
}, [candidateConversations]);
2021-04-28 20:44:48 +00:00
const toggleSelectedConversation = useCallback(
(conversationId: string) => {
2021-04-27 22:35:35 +00:00
let removeContact = false;
const nextSelectedContacts = selectedContacts.filter(contact => {
if (contact.id === conversationId) {
2021-04-27 22:35:35 +00:00
removeContact = true;
return false;
}
return true;
});
if (removeContact) {
setSelectedContacts(nextSelectedContacts);
return;
}
const selectedContact = contactLookup.get(conversationId);
2021-04-27 22:35:35 +00:00
if (selectedContact) {
2021-07-20 20:18:35 +00:00
if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) {
setCannotMessage(true);
} else {
setSelectedContacts([...nextSelectedContacts, selectedContact]);
}
2021-04-27 22:35:35 +00:00
}
},
[contactLookup, selectedContacts, setSelectedContacts]
);
2021-04-28 20:44:48 +00:00
const handleBackOrClose = useCallback(() => {
if (isEditingMessage) {
setIsEditingMessage(false);
} else {
onClose();
2021-04-28 20:44:48 +00:00
}
}, [isEditingMessage, onClose, setIsEditingMessage]);
2021-04-28 20:44:48 +00:00
const rowCount = filteredConversations.length;
2021-04-27 22:35:35 +00:00
const getRow = (index: number): undefined | Row => {
2021-04-28 20:44:48 +00:00
const contact = filteredConversations[index];
2021-04-27 22:35:35 +00:00
if (!contact) {
return undefined;
}
const isSelected = selectedConversationIdsSet.has(contact.id);
let disabledReason: undefined | ContactCheckboxDisabledReason;
if (hasSelectedMaximumNumberOfContacts && !isSelected) {
disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
}
return {
type: RowType.ContactCheckbox,
contact,
isChecked: isSelected,
disabledReason,
};
};
2021-04-28 20:44:48 +00:00
useEffect(() => {
const timeout = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => {
clearTimeout(timeout);
};
}, []);
const footer = (
<div className="module-ForwardMessageModal__footer">
<div>
{selectedContacts.map((contact, index) => {
return (
<Fragment key={contact.id}>
<UserText text={contact.title} />
{index < selectedContacts.length - 1 ? ', ' : ''}
</Fragment>
);
})}
</div>
<div>
{isEditingMessage || !isLonelyDraftEditable ? (
<Button
aria-label={i18n('icu:ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
aria-disabled={!canForwardMessages}
onClick={forwardMessages}
/>
) : (
<Button
aria-label={i18n('icu:forwardMessage')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
disabled={!hasContactsSelected}
onClick={() => setIsEditingMessage(true)}
/>
)}
</div>
</div>
);
2021-04-27 22:35:35 +00:00
return (
2021-07-20 20:18:35 +00:00
<>
{cannotMessage && (
<ConfirmationDialog
2022-09-27 20:24:21 +00:00
dialogName="ForwardMessageModal.confirm"
2023-03-30 00:03:25 +00:00
cancelText={i18n('icu:Confirmation--confirm')}
2021-07-20 20:18:35 +00:00
i18n={i18n}
onClose={() => setCannotMessage(false)}
2021-04-27 22:35:35 +00:00
>
2023-03-30 00:03:25 +00:00
{i18n('icu:GroupV2--cannot-send')}
2021-07-20 20:18:35 +00:00
</ConfirmationDialog>
)}
<Modal
2022-09-27 20:24:21 +00:00
modalName="ForwardMessageModal"
hasXButton
i18n={i18n}
onClose={onClose}
onBackButtonClick={isEditingMessage ? handleBackOrClose : undefined}
moduleClassName="module-ForwardMessageModal"
title={i18n('icu:ForwardMessageModal__title')}
useFocusTrap={false}
padded={false}
modalFooter={footer}
noMouseClose
2021-10-14 16:52:42 +00:00
>
{isEditingMessage && lonelyDraft != null ? (
<ForwardMessageEditor
draft={lonelyDraft}
linkPreview={lonelyLinkPreview}
onChange={(messageBody, bodyRanges) => {
onChange([{ ...lonelyDraft, messageBody, bodyRanges }]);
}}
onChangeAttachments={attachments => {
onChange([{ ...lonelyDraft, attachments }]);
}}
removeLinkPreview={removeLinkPreview}
theme={theme}
i18n={i18n}
RenderCompositionTextArea={RenderCompositionTextArea}
onSubmit={forwardMessages}
/>
) : (
<div className="module-ForwardMessageModal__main-body">
<SearchInput
disabled={candidateConversations.length === 0}
2023-03-20 22:23:53 +00:00
i18n={i18n}
placeholder={i18n('icu:contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
ref={inputRef}
value={searchTerm}
2023-03-20 22:23:53 +00:00
/>
{candidateConversations.length ? (
<SizeObserver>
{(ref, size) => (
<div
className="module-ForwardMessageModal__list-wrapper"
ref={ref}
>
<ConversationList
dimensions={size ?? undefined}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
conversationId: string,
disabledReason:
| undefined
| ContactCheckboxDisabledReason
) => {
if (
disabledReason !==
ContactCheckboxDisabledReason.MaximumContactsSelected
) {
toggleSelectedConversation(conversationId);
}
}}
lookupConversationWithoutUuid={asyncShouldNeverBeCalled}
showConversation={shouldNeverBeCalled}
showUserNotFoundModal={shouldNeverBeCalled}
setIsFetchingUUID={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled}
blockConversation={shouldNeverBeCalled}
removeConversation={shouldNeverBeCalled}
onOutgoingAudioCallInConversation={shouldNeverBeCalled}
onOutgoingVideoCallInConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
theme={theme}
/>
</div>
)}
</SizeObserver>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('icu:noContactsFound')}
</div>
)}
2021-04-27 22:35:35 +00:00
</div>
)}
</Modal>
2021-07-20 20:18:35 +00:00
</>
2021-04-27 22:35:35 +00:00
);
2022-11-18 00:45:19 +00:00
}
2023-03-20 22:23:53 +00:00
type ForwardMessageEditorProps = Readonly<{
draft: MessageForwardDraft;
linkPreview: LinkPreviewType | null | void;
removeLinkPreview(): void;
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
onChange: (
messageText: string,
bodyRanges: HydratedBodyRangesType,
caretLocation?: number
) => unknown;
onChangeAttachments: (attachments: ReadonlyArray<AttachmentType>) => unknown;
2023-03-20 22:23:53 +00:00
onSubmit: () => unknown;
theme: ThemeType;
i18n: LocalizerType;
}>;
function ForwardMessageEditor({
draft,
linkPreview,
i18n,
RenderCompositionTextArea,
removeLinkPreview,
onChange,
onChangeAttachments,
2023-03-20 22:23:53 +00:00
onSubmit,
theme,
}: ForwardMessageEditorProps): JSX.Element {
const { attachments } = draft;
2023-03-20 22:23:53 +00:00
return (
<div className="module-ForwardMessageModal__main-body">
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date}
description={linkPreview.description ?? ''}
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
onClose={removeLinkPreview}
title={linkPreview.title}
url={linkPreview.url}
/>
</div>
) : null}
{attachments != null && attachments.length > 0 ? (
2023-03-20 22:23:53 +00:00
<AttachmentList
attachments={attachments}
2023-03-20 22:23:53 +00:00
i18n={i18n}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachments.filter(
2023-03-20 22:23:53 +00:00
currentAttachment => currentAttachment !== attachment
);
onChangeAttachments(newAttachments);
2023-03-20 22:23:53 +00:00
}}
/>
) : null}
<RenderCompositionTextArea
bodyRanges={draft.bodyRanges}
2023-03-20 22:23:53 +00:00
draftText={draft.messageBody ?? ''}
onChange={onChange}
2023-03-20 22:23:53 +00:00
onSubmit={onSubmit}
theme={theme}
/>
</div>
);
}