// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only 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 { 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'; import { Emojify } from './conversation/Emojify'; 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'; export type DataPropsType = { candidateConversations: ReadonlyArray; doForwardMessages: ( conversationIds: ReadonlyArray, drafts: ReadonlyArray ) => void; drafts: ReadonlyArray; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; linkPreviewForSource: ( source: LinkPreviewSourceType ) => LinkPreviewType | void; onClose: () => void; onChange: ( updatedDrafts: ReadonlyArray, caretLocation?: number ) => unknown; regionCode: string | undefined; RenderCompositionTextArea: ( props: SmartCompositionTextAreaProps ) => JSX.Element; showToast: ShowToastAction; theme: ThemeType; }; type ActionPropsType = { removeLinkPreview: () => void; }; export type PropsType = DataPropsType & ActionPropsType; const MAX_FORWARD = 5; export function ForwardMessagesModal({ drafts, candidateConversations, doForwardMessages, linkPreviewForSource, getPreferredBadge, i18n, onClose, onChange, removeLinkPreview, RenderCompositionTextArea, showToast, theme, regionCode, }: PropsType): JSX.Element { const inputRef = useRef(null); const [selectedContacts, setSelectedContacts] = useState< Array >([]); const [searchTerm, setSearchTerm] = useState(''); const [filteredConversations, setFilteredConversations] = useState( filterAndSortConversationsByRecent(candidateConversations, '', regionCode) ); const [isEditingMessage, setIsEditingMessage] = useState(false); const [cannotMessage, setCannotMessage] = useState(false); const isLonelyDraft = drafts.length === 1; const lonelyDraft = isLonelyDraft ? drafts[0] : null; const isLonelyDraftEditable = lonelyDraft != null && isDraftEditable(lonelyDraft); const lonelyLinkPreview = isLonelyDraft ? linkPreviewForSource(LinkPreviewSourceType.ForwardMessageModal) : null; const hasSelectedMaximumNumberOfContacts = selectedContacts.length >= MAX_FORWARD; const selectedConversationIdsSet: Set = useMemo( () => new Set(selectedContacts.map(contact => contact.id)), [selectedContacts] ); const hasContactsSelected = Boolean(selectedContacts.length); const canForwardMessages = hasContactsSelected && drafts.every(isDraftForwardable); const forwardMessages = React.useCallback(() => { if (!canForwardMessages) { showToast(ToastType.CannotForwardEmptyMessage); return; } const conversationIds = selectedContacts.map(contact => contact.id); if (lonelyDraft != null) { const previews = lonelyLinkPreview ? [lonelyLinkPreview] : []; doForwardMessages(conversationIds, [{ ...lonelyDraft, previews }]); } else { doForwardMessages(conversationIds, drafts); } }, [ drafts, lonelyDraft, lonelyLinkPreview, doForwardMessages, selectedContacts, canForwardMessages, showToast, ]); 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 && ( setCannotMessage(false)} > {i18n('GroupV2--cannot-send')} )}
{isEditingMessage ? ( ) : (
{isEditingMessage && lonelyDraft != null ? ( { onChange([{ ...lonelyDraft, messageBody }]); }} removeLinkPreview={removeLinkPreview} theme={theme} i18n={i18n} RenderCompositionTextArea={RenderCompositionTextArea} onSubmit={forwardMessages} /> ) : (
{ setSearchTerm(event.target.value); }} ref={inputRef} value={searchTerm} /> {candidateConversations.length ? ( {({ contentRect, measureRef }: MeasuredComponentProps) => (
{ if ( disabledReason !== ContactCheckboxDisabledReason.MaximumContactsSelected ) { toggleSelectedConversation(conversationId); } }} lookupConversationWithoutUuid={asyncShouldNeverBeCalled} showConversation={shouldNeverBeCalled} showUserNotFoundModal={shouldNeverBeCalled} setIsFetchingUUID={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled} renderMessageSearchResult={() => { shouldNeverBeCalled(); return
; }} rowCount={rowCount} shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} theme={theme} />
)} ) : (
{i18n('noContactsFound')}
)}
)}
{Boolean(selectedContacts.length) && ( contact.title) .join(', ')} /> )}
{isEditingMessage || !isLonelyDraftEditable ? (
); } type ForwardMessageEditorProps = Readonly<{ draft: MessageForwardDraft; linkPreview: LinkPreviewType | null | void; removeLinkPreview(): void; RenderCompositionTextArea: ( props: SmartCompositionTextAreaProps ) => JSX.Element; onChange: (messageText: string, caretLocation?: number) => unknown; onSubmit: () => unknown; theme: ThemeType; i18n: LocalizerType; }>; function ForwardMessageEditor({ draft, linkPreview, i18n, RenderCompositionTextArea, removeLinkPreview, onChange, onSubmit, theme, }: ForwardMessageEditorProps): JSX.Element { const [attachmentsToForward, setAttachmentsToForward] = useState< ReadonlyArray >(draft.attachments ?? []); return (
{linkPreview ? (
) : null} {attachmentsToForward && attachmentsToForward.length ? ( { const newAttachments = attachmentsToForward.filter( currentAttachment => currentAttachment !== attachment ); setAttachmentsToForward(newAttachments); }} /> ) : null} { onChange(messageText, caretLocation); }} onSubmit={onSubmit} theme={theme} />
); }