// 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; candidateConversations: ReadonlyArray; doForwardMessage: ( selectedContacts: Array, messageBody?: string, attachments?: Array, linkPreview?: LinkPreviewType ) => void; getPreferredBadge: PreferredBadgeSelectorType; hasContact: boolean; i18n: LocalizerType; isSticker: boolean; linkPreview?: LinkPreviewType; messageBody?: string; onClose: () => void; onEditorStateChange: ( messageText: string, bodyRanges: Array, 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 = ({ attachments, candidateConversations, doForwardMessage, getPreferredBadge, hasContact, i18n, isSticker, linkPreview, messageBody, onClose, onEditorStateChange, removeLinkPreview, RenderCompositionTextArea, theme, regionCode, }) => { const inputRef = useRef(null); const [selectedContacts, setSelectedContacts] = useState< Array >([]); const [searchTerm, setSearchTerm] = useState(''); const [filteredConversations, setFilteredConversations] = useState( filterAndSortConversationsByRecent(candidateConversations, '', regionCode) ); const [attachmentsToForward, setAttachmentsToForward] = useState< Array >(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 = 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 && ( setCannotMessage(false)} > {i18n('GroupV2--cannot-send')} )}
{isEditingMessage ? ( ) : (
{isEditingMessage ? (
{linkPreview ? (
removeLinkPreview()} title={linkPreview.title} url={linkPreview.url} />
) : null} {attachmentsToForward && attachmentsToForward.length ? ( { const newAttachments = attachmentsToForward.filter( currentAttachment => currentAttachment !== attachment ); setAttachmentsToForward(newAttachments); }} /> ) : null} { setMessageBodyText(messageText); onEditorStateChange(messageText, bodyRanges, caretLocation); }} onSubmit={forwardMessage} theme={theme} />
) : (
{ 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) && selectedContacts.map(contact => contact.title).join(', ')}
{isEditingMessage || !isMessageEditable ? (
); };