// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { get, noop } from 'lodash'; import classNames from 'classnames'; import { Spinner } from './Spinner'; import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton'; import { Props as StickerButtonProps, StickerButton, } from './stickers/StickerButton'; import { CompositionInput, InputApi, Props as CompositionInputProps, } from './CompositionInput'; import { MessageRequestActions, Props as MessageRequestActionsProps, } from './conversation/MessageRequestActions'; import { GroupV1DisabledActions, PropsType as GroupV1DisabledActionsPropsType, } from './conversation/GroupV1DisabledActions'; import { GroupV2PendingApprovalActions, PropsType as GroupV2PendingApprovalActionsPropsType, } from './conversation/GroupV2PendingApprovalActions'; import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions'; import { countStickers } from './stickers/lib'; import { LocalizerType } from '../types/Util'; import { EmojiPickDataType } from './emoji/EmojiPicker'; import { AttachmentType, isImageAttachment } from '../types/Attachment'; import { AttachmentList } from './conversation/AttachmentList'; import { MediaQualitySelector } from './MediaQualitySelector'; import { Quote, Props as QuoteProps } from './conversation/Quote'; import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { LinkPreviewWithDomain } from '../types/LinkPreview'; import { ConversationType } from '../state/ducks/conversations'; import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner'; export type CompositionAPIType = { focusInput: () => void; isDirty: () => boolean; setDisabled: (disabled: boolean) => void; setShowMic: (showMic: boolean) => void; setMicActive: (micActive: boolean) => void; reset: InputApi['reset']; resetEmojiResults: InputApi['resetEmojiResults']; }; export type OwnProps = Readonly<{ i18n: LocalizerType; areWePending?: boolean; areWePendingApproval?: boolean; announcementsOnly?: boolean; areWeAdmin?: boolean; groupAdmins: Array; groupVersion?: 1 | 2; isGroupV1AndDisabled?: boolean; isMissingMandatoryProfileSharing?: boolean; isSMSOnly?: boolean; isFetchingUUID?: boolean; left?: boolean; messageRequestsEnabled?: boolean; acceptedMessageRequest?: boolean; compositionApi?: React.MutableRefObject; micCellEl?: HTMLElement; draftAttachments: ReadonlyArray; shouldSendHighQualityAttachments: boolean; onChooseAttachment(): unknown; onAddAttachment(): unknown; onClickAttachment(): unknown; onCloseAttachment(): unknown; onClearAttachments(): unknown; onSelectMediaQuality(isHQ: boolean): unknown; quotedMessageProps?: Omit< QuoteProps, 'i18n' | 'onClick' | 'onClose' | 'withContentAbove' >; onClickQuotedMessage(): unknown; setQuotedMessage(message: undefined): unknown; linkPreviewLoading: boolean; linkPreviewResult?: LinkPreviewWithDomain; onCloseLinkPreview(): unknown; openConversation(conversationId: string): unknown; }>; export type Props = Pick< CompositionInputProps, | 'sortedGroupMembers' | 'onSubmit' | 'onEditorStateChange' | 'onTextTooLong' | 'draftText' | 'draftBodyRanges' | 'clearQuotedMessage' | 'getQuotedMessage' > & Pick< EmojiButtonProps, 'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone' > & Pick< StickerButtonProps, | 'knownPacks' | 'receivedPacks' | 'installedPack' | 'installedPacks' | 'blessedPacks' | 'recentStickers' | 'clearInstalledStickerPack' | 'onClickAddPack' | 'onPickSticker' | 'clearShowIntroduction' | 'showPickerHint' | 'clearShowPickerHint' > & MessageRequestActionsProps & Pick & Pick & OwnProps; const emptyElement = (el: HTMLElement) => { // Necessary to deal with Backbone views // eslint-disable-next-line no-param-reassign el.innerHTML = ''; }; export const CompositionArea = ({ i18n, micCellEl, onChooseAttachment, // AttachmentList draftAttachments, onAddAttachment, onClearAttachments, onClickAttachment, onCloseAttachment, // StagedLinkPreview linkPreviewLoading, linkPreviewResult, onCloseLinkPreview, // Quote quotedMessageProps, onClickQuotedMessage, setQuotedMessage, // MediaQualitySelector onSelectMediaQuality, shouldSendHighQualityAttachments, // CompositionInput onSubmit, compositionApi, onEditorStateChange, onTextTooLong, draftText, draftBodyRanges, clearQuotedMessage, getQuotedMessage, sortedGroupMembers, // EmojiButton onPickEmoji, onSetSkinTone, recentEmojis, skinTone, // StickerButton knownPacks, receivedPacks, installedPack, installedPacks, blessedPacks, recentStickers, clearInstalledStickerPack, onClickAddPack, onPickSticker, clearShowIntroduction, showPickerHint, clearShowPickerHint, // Message Requests acceptedMessageRequest, areWePending, areWePendingApproval, conversationType, groupVersion, isBlocked, isMissingMandatoryProfileSharing, left, messageRequestsEnabled, onAccept, onBlock, onBlockAndReportSpam, onDelete, onUnblock, title, // GroupV1 Disabled Actions isGroupV1AndDisabled, onStartGroupMigration, // GroupV2 announcementsOnly, areWeAdmin, groupAdmins, onCancelJoinRequest, openConversation, // SMS-only contacts isSMSOnly, isFetchingUUID, }: Props): JSX.Element => { const [disabled, setDisabled] = React.useState(false); const [showMic, setShowMic] = React.useState(!draftText); const [micActive, setMicActive] = React.useState(false); const [dirty, setDirty] = React.useState(false); const [large, setLarge] = React.useState(false); const inputApiRef = React.useRef(); const handleForceSend = React.useCallback(() => { setLarge(false); if (inputApiRef.current) { inputApiRef.current.submit(); } }, [inputApiRef, setLarge]); const handleSubmit = React.useCallback( (...args) => { setLarge(false); onSubmit(...args); }, [setLarge, onSubmit] ); const focusInput = React.useCallback(() => { if (inputApiRef.current) { inputApiRef.current.focus(); } }, [inputApiRef]); const withStickers = countStickers({ knownPacks, blessedPacks, installedPacks, receivedPacks, }) > 0; if (compositionApi) { // Using a React.MutableRefObject, so we need to reassign this prop. // eslint-disable-next-line no-param-reassign compositionApi.current = { isDirty: () => dirty, focusInput, setDisabled, setShowMic, setMicActive, reset: () => { if (inputApiRef.current) { inputApiRef.current.reset(); } }, resetEmojiResults: () => { if (inputApiRef.current) { inputApiRef.current.resetEmojiResults(); } }, }; } const insertEmoji = React.useCallback( (e: EmojiPickDataType) => { if (inputApiRef.current) { inputApiRef.current.insertEmoji(e); onPickEmoji(e); } }, [inputApiRef, onPickEmoji] ); const handleToggleLarge = React.useCallback(() => { setLarge(l => !l); }, [setLarge]); // The following is a work-around to allow react to lay-out backbone-managed // dom nodes until those functions are in React const micCellRef = React.useRef(null); React.useLayoutEffect(() => { const { current: micCellContainer } = micCellRef; if (micCellContainer && micCellEl) { emptyElement(micCellContainer); micCellContainer.appendChild(micCellEl); } return noop; }, [micCellRef, micCellEl, large, dirty, showMic]); const showMediaQualitySelector = draftAttachments.some(isImageAttachment); const leftHandSideButtonsFragment = ( <>
{showMediaQualitySelector ? (
) : null} ); const micButtonFragment = showMic ? (
) : null; const attButton = (
); const sendButtonFragment = (
); const stickerButtonPlacement = large ? 'top-start' : 'top-end'; const stickerButtonFragment = withStickers ? (
) : null; // Listen for cmd/ctrl-shift-x to toggle large composition mode React.useEffect(() => { const handler = (e: KeyboardEvent) => { const { key, shiftKey, ctrlKey, metaKey } = e; // When using the ctrl key, `key` is `'X'`. When using the cmd key, `key` is `'x'` const xKey = key === 'x' || key === 'X'; const commandKey = get(window, 'platform') === 'darwin' && metaKey; const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey; const commandOrCtrl = commandKey || controlKey; // cmd/ctrl-shift-x if (xKey && shiftKey && commandOrCtrl) { e.preventDefault(); setLarge(x => !x); } }; document.addEventListener('keydown', handler); return () => { document.removeEventListener('keydown', handler); }; }, [setLarge]); if ( isBlocked || areWePending || (messageRequestsEnabled && !acceptedMessageRequest) ) { return ( ); } if (conversationType === 'direct' && isSMSOnly) { return (
{isFetchingUUID ? ( ) : ( <>

{i18n('CompositionArea--sms-only__title')}

{i18n('CompositionArea--sms-only__body')}

)}
); } // If no message request, but we haven't shared profile yet, we show profile-sharing UI if ( !left && (conversationType === 'direct' || (conversationType === 'group' && groupVersion === 1)) && isMissingMandatoryProfileSharing ) { return ( ); } // If this is a V1 group, now disabled entirely, we show UI to help them upgrade if (!left && isGroupV1AndDisabled) { return ( ); } if (areWePendingApproval) { return ( ); } if (announcementsOnly && !areWeAdmin) { return ( ); } return (
{quotedMessageProps && (
{ // This one is for redux... setQuotedMessage(undefined); // and this is for conversation_view. clearQuotedMessage(); }} withContentAbove />
)} {linkPreviewLoading && (
)} {draftAttachments.length ? (
) : null}
{!large ? leftHandSideButtonsFragment : null}
{!large ? ( <> {stickerButtonFragment} {!dirty ? micButtonFragment : null} {attButton} ) : null}
{large ? (
{leftHandSideButtonsFragment} {stickerButtonFragment} {attButton} {!dirty ? micButtonFragment : null} {dirty || !showMic ? sendButtonFragment : null}
) : null}
); };