// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useEffect, useMemo, useRef, useState, Fragment, } from 'react'; 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 { SearchInput } from './SearchInput'; import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations'; import { shouldNeverBeCalled, asyncShouldNeverBeCalled, } from '../util/shouldNeverBeCalled'; 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'; import { UserText } from './UserText'; import { Modal } from './Modal'; import { SizeObserver } from '../hooks/useSizeObserver'; 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: 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.map(draft => ({ ...draft, // We don't keep @mention bodyRanges in multi-forward scenarios bodyRanges: draft.bodyRanges?.filter(BodyRange.isFormatting), })) ); } }, [ 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 handleBackOrClose = useCallback(() => { if (isEditingMessage) { setIsEditingMessage(false); } else { onClose(); } }, [isEditingMessage, onClose, 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); }; }, []); const footer = (
{selectedContacts.map((contact, index) => { return ( {index < selectedContacts.length - 1 ? ', ' : ''} ); })}
{isEditingMessage || !isLonelyDraftEditable ? (
); return ( <> {cannotMessage && ( setCannotMessage(false)} > {i18n('icu:GroupV2--cannot-send')} )} {isEditingMessage && lonelyDraft != null ? ( { onChange([{ ...lonelyDraft, messageBody, bodyRanges }]); }} onChangeAttachments={attachments => { onChange([{ ...lonelyDraft, attachments }]); }} removeLinkPreview={removeLinkPreview} theme={theme} i18n={i18n} RenderCompositionTextArea={RenderCompositionTextArea} onSubmit={forwardMessages} /> ) : (
{ setSearchTerm(event.target.value); }} ref={inputRef} value={searchTerm} /> {candidateConversations.length ? ( {(ref, size) => (
{ 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
; }} rowCount={rowCount} shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} theme={theme} />
)} ) : (
{i18n('icu:noContactsFound')}
)}
)} ); } 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) => unknown; onSubmit: () => unknown; theme: ThemeType; i18n: LocalizerType; }>; function ForwardMessageEditor({ draft, linkPreview, i18n, RenderCompositionTextArea, removeLinkPreview, onChange, onChangeAttachments, onSubmit, theme, }: ForwardMessageEditorProps): JSX.Element { const { attachments } = draft; return (
{linkPreview ? (
) : null} {attachments != null && attachments.length > 0 ? ( { const newAttachments = attachments.filter( currentAttachment => currentAttachment !== attachment ); onChangeAttachments(newAttachments); }} /> ) : null}
); }