signal-desktop/ts/components/ForwardMessageModal.tsx
2022-10-04 17:17:15 -06:00

427 lines
14 KiB
TypeScript

// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import { animated } from '@react-spring/web';
import classNames from 'classnames';
import { AttachmentList } from './conversation/AttachmentList';
import type { AttachmentType } from '../types/Attachment';
import { Button } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import type { Row } from './ConversationList';
import { ConversationList, RowType } from './ConversationList';
import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { BodyRangeType, LocalizerType, ThemeType } from '../types/Util';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { ModalHost } from './ModalHost';
import { SearchInput } from './SearchInput';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { useAnimated } from '../hooks/useAnimated';
import {
shouldNeverBeCalled,
asyncShouldNeverBeCalled,
} from '../util/shouldNeverBeCalled';
export type DataPropsType = {
attachments?: Array<AttachmentType>;
candidateConversations: ReadonlyArray<ConversationType>;
doForwardMessage: (
selectedContacts: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
) => void;
getPreferredBadge: PreferredBadgeSelectorType;
hasContact: boolean;
i18n: LocalizerType;
isSticker: boolean;
linkPreview?: LinkPreviewType;
messageBody?: string;
onClose: () => void;
onEditorStateChange: (
messageText: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
) => unknown;
theme: ThemeType;
regionCode: string | undefined;
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
};
type ActionPropsType = {
removeLinkPreview: () => void;
};
export type PropsType = DataPropsType & ActionPropsType;
const MAX_FORWARD = 5;
export const ForwardMessageModal: FunctionComponent<PropsType> = ({
attachments,
candidateConversations,
doForwardMessage,
getPreferredBadge,
hasContact,
i18n,
isSticker,
linkPreview,
messageBody,
onClose,
onEditorStateChange,
removeLinkPreview,
RenderCompositionTextArea,
theme,
regionCode,
}) => {
const inputRef = useRef<null | HTMLInputElement>(null);
const [selectedContacts, setSelectedContacts] = useState<
Array<ConversationType>
>([]);
const [searchTerm, setSearchTerm] = useState('');
const [filteredConversations, setFilteredConversations] = useState(
filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
);
const [attachmentsToForward, setAttachmentsToForward] = useState<
Array<AttachmentType>
>(attachments || []);
const [isEditingMessage, setIsEditingMessage] = useState(false);
const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
const [cannotMessage, setCannotMessage] = useState(false);
const isMessageEditable = !isSticker && !hasContact;
const hasSelectedMaximumNumberOfContacts =
selectedContacts.length >= MAX_FORWARD;
const selectedConversationIdsSet: Set<string> = useMemo(
() => new Set(selectedContacts.map(contact => contact.id)),
[selectedContacts]
);
const hasContactsSelected = Boolean(selectedContacts.length);
const canForwardMessage =
hasContactsSelected &&
(Boolean(messageBodyText) ||
isSticker ||
hasContact ||
(attachmentsToForward && attachmentsToForward.length));
const forwardMessage = React.useCallback(() => {
if (!canForwardMessage) {
return;
}
doForwardMessage(
selectedContacts.map(contact => contact.id),
messageBodyText,
attachmentsToForward,
linkPreview
);
}, [
attachmentsToForward,
canForwardMessage,
doForwardMessage,
linkPreview,
messageBodyText,
selectedContacts,
]);
const normalizedSearchTerm = searchTerm.trim();
useEffect(() => {
const timeout = setTimeout(() => {
setFilteredConversations(
filterAndSortConversationsByRecent(
candidateConversations,
normalizedSearchTerm,
regionCode
)
);
}, 200);
return () => {
clearTimeout(timeout);
};
}, [
candidateConversations,
normalizedSearchTerm,
setFilteredConversations,
regionCode,
]);
const contactLookup = useMemo(() => {
const map = new Map();
candidateConversations.forEach(contact => {
map.set(contact.id, contact);
});
return map;
}, [candidateConversations]);
const toggleSelectedConversation = useCallback(
(conversationId: string) => {
let removeContact = false;
const nextSelectedContacts = selectedContacts.filter(contact => {
if (contact.id === conversationId) {
removeContact = true;
return false;
}
return true;
});
if (removeContact) {
setSelectedContacts(nextSelectedContacts);
return;
}
const selectedContact = contactLookup.get(conversationId);
if (selectedContact) {
if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) {
setCannotMessage(true);
} else {
setSelectedContacts([...nextSelectedContacts, selectedContact]);
}
}
},
[contactLookup, selectedContacts, setSelectedContacts]
);
const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
getTo: isOpen =>
isOpen
? { opacity: 1, transform: 'translateY(0px)' }
: {
opacity: 0,
transform: 'translateY(48px)',
},
});
const handleBackOrClose = useCallback(() => {
if (isEditingMessage) {
setIsEditingMessage(false);
} else {
close();
}
}, [isEditingMessage, close, setIsEditingMessage]);
const rowCount = filteredConversations.length;
const getRow = (index: number): undefined | Row => {
const contact = filteredConversations[index];
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,
};
};
useEffect(() => {
const timeout = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => {
clearTimeout(timeout);
};
}, []);
return (
<>
{cannotMessage && (
<ConfirmationDialog
dialogName="ForwardMessageModal.confirm"
cancelText={i18n('Confirmation--confirm')}
i18n={i18n}
onClose={() => setCannotMessage(false)}
>
{i18n('GroupV2--cannot-send')}
</ConfirmationDialog>
)}
<ModalHost
modalName="ForwardMessageModal"
onEscape={handleBackOrClose}
onClose={close}
overlayStyles={overlayStyles}
useFocusTrap={false}
>
<animated.div
className="module-ForwardMessageModal"
style={modalStyles}
>
<div
className={classNames('module-ForwardMessageModal__header', {
'module-ForwardMessageModal__header--edit': isEditingMessage,
})}
>
{isEditingMessage ? (
<button
aria-label={i18n('back')}
className="module-ForwardMessageModal__header--back"
onClick={() => setIsEditingMessage(false)}
type="button"
>
&nbsp;
</button>
) : (
<button
aria-label={i18n('close')}
className="module-ForwardMessageModal__header--close"
onClick={close}
type="button"
/>
)}
<h1>{i18n('forwardMessage')}</h1>
</div>
{isEditingMessage ? (
<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}
{attachmentsToForward && attachmentsToForward.length ? (
<AttachmentList
attachments={attachmentsToForward}
i18n={i18n}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachmentsToForward.filter(
currentAttachment => currentAttachment !== attachment
);
setAttachmentsToForward(newAttachments);
}}
/>
) : null}
<RenderCompositionTextArea
draftText={messageBodyText}
onChange={(messageText, bodyRanges, caretLocation?) => {
setMessageBodyText(messageText);
onEditorStateChange(messageText, bodyRanges, caretLocation);
}}
onSubmit={forwardMessage}
theme={theme}
/>
</div>
) : (
<div className="module-ForwardMessageModal__main-body">
<SearchInput
disabled={candidateConversations.length === 0}
i18n={i18n}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
ref={inputRef}
value={searchTerm}
/>
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div
className="module-ForwardMessageModal__list-wrapper"
ref={measureRef}
>
<ConversationList
dimensions={contentRect.bounds}
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}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
theme={theme}
/>
</div>
)}
</Measure>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
</div>
)}
<div className="module-ForwardMessageModal__footer">
<div>
{Boolean(selectedContacts.length) &&
selectedContacts.map(contact => contact.title).join(', ')}
</div>
<div>
{isEditingMessage || !isMessageEditable ? (
<Button
aria-label={i18n('ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
disabled={!canForwardMessage}
onClick={forwardMessage}
/>
) : (
<Button
aria-label={i18n('forwardMessage')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
disabled={!hasContactsSelected}
onClick={() => setIsEditingMessage(true)}
/>
)}
</div>
</div>
</animated.div>
</ModalHost>
</>
);
};