// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { MutableRefObject } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { get } from 'lodash'; import classNames from 'classnames'; import type { BodyRangeType, BodyRangesType, LocalizerType, ThemeType, } from '../types/Util'; import type { ErrorDialogAudioRecorderType } from '../state/ducks/audioRecorder'; import { RecordingState } from '../state/ducks/audioRecorder'; import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing'; import { Spinner } from './Spinner'; import type { Props as EmojiButtonProps, EmojiButtonAPI, } from './emoji/EmojiButton'; import { EmojiButton } from './emoji/EmojiButton'; import type { Props as StickerButtonProps } from './stickers/StickerButton'; import { StickerButton } from './stickers/StickerButton'; import type { InputApi, Props as CompositionInputProps, } from './CompositionInput'; import { CompositionInput } from './CompositionInput'; import type { Props as MessageRequestActionsProps } from './conversation/MessageRequestActions'; import { MessageRequestActions } from './conversation/MessageRequestActions'; import type { PropsType as GroupV1DisabledActionsPropsType } from './conversation/GroupV1DisabledActions'; import { GroupV1DisabledActions } from './conversation/GroupV1DisabledActions'; import type { PropsType as GroupV2PendingApprovalActionsPropsType } from './conversation/GroupV2PendingApprovalActions'; import { GroupV2PendingApprovalActions } from './conversation/GroupV2PendingApprovalActions'; import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner'; import { AttachmentList } from './conversation/AttachmentList'; import type { AttachmentDraftType, InMemoryAttachmentDraftType, } from '../types/Attachment'; import { isImageAttachment } from '../types/Attachment'; import { AudioCapture } from './conversation/AudioCapture'; import { CompositionUpload } from './CompositionUpload'; import type { ConversationType } from '../state/ducks/conversations'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions'; import { MediaQualitySelector } from './MediaQualitySelector'; import type { Props as QuoteProps } from './conversation/Quote'; import { Quote } from './conversation/Quote'; import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { countStickers } from './stickers/lib'; import { useAttachFileShortcut, useKeyboardShortcuts, } from '../hooks/useKeyboardShortcuts'; import { MediaEditor } from './MediaEditor'; import { IMAGE_PNG } from '../types/MIME'; import { isImageTypeSupported } from '../util/GoogleChrome'; import * as KeyboardLayout from '../services/keyboardLayout'; export type CompositionAPIType = | { focusInput: () => void; isDirty: () => boolean; setDisabled: (disabled: boolean) => void; reset: InputApi['reset']; resetEmojiResults: InputApi['resetEmojiResults']; } | undefined; export type OwnProps = Readonly<{ acceptedMessageRequest?: boolean; addAttachment: ( conversationId: string, attachment: InMemoryAttachmentDraftType ) => unknown; addPendingAttachment: ( conversationId: string, pendingAttachment: AttachmentDraftType ) => unknown; announcementsOnly?: boolean; areWeAdmin?: boolean; areWePending?: boolean; areWePendingApproval?: boolean; cancelRecording: () => unknown; completeRecording: ( conversationId: string, onSendAudioRecording?: (rec: InMemoryAttachmentDraftType) => unknown ) => unknown; compositionApi?: MutableRefObject<CompositionAPIType>; conversationId: string; draftAttachments: ReadonlyArray<AttachmentDraftType>; errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; errorRecording: (e: ErrorDialogAudioRecorderType) => unknown; groupAdmins: Array<ConversationType>; groupVersion?: 1 | 2; i18n: LocalizerType; isFetchingUUID?: boolean; isGroupV1AndDisabled?: boolean; isMissingMandatoryProfileSharing?: boolean; recordingState: RecordingState; isSMSOnly?: boolean; left?: boolean; linkPreviewLoading: boolean; linkPreviewResult?: LinkPreviewType; messageRequestsEnabled?: boolean; onClearAttachments(): unknown; onClickQuotedMessage(): unknown; onCloseLinkPreview(): unknown; processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown; onSelectMediaQuality(isHQ: boolean): unknown; onSendMessage(options: { draftAttachments?: ReadonlyArray<AttachmentDraftType>; mentions?: BodyRangesType; message?: string; timestamp?: number; voiceNoteAttachment?: InMemoryAttachmentDraftType; }): unknown; openConversation(conversationId: string): unknown; quotedMessageProps?: Omit< QuoteProps, 'i18n' | 'onClick' | 'onClose' | 'withContentAbove' >; removeAttachment: (conversationId: string, filePath: string) => unknown; setQuotedMessage(message: undefined): unknown; shouldSendHighQualityAttachments: boolean; startRecording: () => unknown; theme: ThemeType; }>; export type Props = Pick< CompositionInputProps, | 'sortedGroupMembers' | 'onEditorStateChange' | 'onTextTooLong' | 'draftText' | 'draftBodyRanges' | 'clearQuotedMessage' | 'getPreferredBadge' | 'getQuotedMessage' > & Pick< EmojiButtonProps, 'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone' > & Pick< StickerButtonProps, | 'knownPacks' | 'receivedPacks' | 'installedPack' | 'installedPacks' | 'blessedPacks' | 'recentStickers' | 'clearInstalledStickerPack' | 'onClickAddPack' | 'onPickSticker' | 'clearShowIntroduction' | 'showPickerHint' | 'clearShowPickerHint' > & MessageRequestActionsProps & Pick<GroupV1DisabledActionsPropsType, 'onStartGroupMigration'> & Pick<GroupV2PendingApprovalActionsPropsType, 'onCancelJoinRequest'> & OwnProps; export const CompositionArea = ({ // Base props addAttachment, addPendingAttachment, conversationId, i18n, onSendMessage, processAttachments, removeAttachment, theme, // AttachmentList draftAttachments, onClearAttachments, // AudioCapture cancelRecording, completeRecording, errorDialogAudioRecorderType, errorRecording, recordingState, startRecording, // StagedLinkPreview linkPreviewLoading, linkPreviewResult, onCloseLinkPreview, // Quote quotedMessageProps, onClickQuotedMessage, setQuotedMessage, // MediaQualitySelector onSelectMediaQuality, shouldSendHighQualityAttachments, // CompositionInput compositionApi, onEditorStateChange, onTextTooLong, draftText, draftBodyRanges, clearQuotedMessage, getPreferredBadge, 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] = useState(false); const [dirty, setDirty] = useState(false); const [large, setLarge] = useState(false); const [attachmentToEdit, setAttachmentToEdit] = useState< AttachmentDraftType | undefined >(); const inputApiRef = useRef<InputApi | undefined>(); const emojiButtonRef = useRef<EmojiButtonAPI | undefined>(); const fileInputRef = useRef<null | HTMLInputElement>(null); const handleForceSend = useCallback(() => { setLarge(false); if (inputApiRef.current) { inputApiRef.current.submit(); } }, [inputApiRef, setLarge]); const handleSubmit = useCallback( (message: string, mentions: Array<BodyRangeType>, timestamp: number) => { emojiButtonRef.current?.close(); onSendMessage({ draftAttachments, mentions, message, timestamp, }); setLarge(false); }, [draftAttachments, onSendMessage, setLarge] ); const launchAttachmentPicker = useCallback(() => { const fileInput = fileInputRef.current; if (fileInput) { // Setting the value to empty so that onChange always fires in case // you add multiple photos. fileInput.value = ''; fileInput.click(); } }, []); function maybeEditAttachment(attachment: AttachmentDraftType) { if (!isImageTypeSupported(attachment.contentType)) { return; } setAttachmentToEdit(attachment); } const attachFileShortcut = useAttachFileShortcut(launchAttachmentPicker); useKeyboardShortcuts(attachFileShortcut); const focusInput = 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, reset: () => { if (inputApiRef.current) { inputApiRef.current.reset(); } }, resetEmojiResults: () => { if (inputApiRef.current) { inputApiRef.current.resetEmojiResults(); } }, }; } const insertEmoji = useCallback( (e: EmojiPickDataType) => { if (inputApiRef.current) { inputApiRef.current.insertEmoji(e); onPickEmoji(e); } }, [inputApiRef, onPickEmoji] ); const handleToggleLarge = useCallback(() => { setLarge(l => !l); }, [setLarge]); const shouldShowMicrophone = !large && !draftAttachments.length && !draftText; const showMediaQualitySelector = draftAttachments.some(isImageAttachment); const leftHandSideButtonsFragment = ( <> <div className="CompositionArea__button-cell"> <EmojiButton emojiButtonApi={emojiButtonRef} i18n={i18n} doSend={handleForceSend} onPickEmoji={insertEmoji} onClose={focusInput} recentEmojis={recentEmojis} skinTone={skinTone} onSetSkinTone={onSetSkinTone} /> </div> {showMediaQualitySelector ? ( <div className="CompositionArea__button-cell"> <MediaQualitySelector i18n={i18n} isHighQuality={shouldSendHighQualityAttachments} onSelectQuality={onSelectMediaQuality} /> </div> ) : null} </> ); const micButtonFragment = shouldShowMicrophone ? ( <div className="CompositionArea__button-cell"> <AudioCapture cancelRecording={cancelRecording} completeRecording={completeRecording} conversationId={conversationId} draftAttachments={draftAttachments} errorDialogAudioRecorderType={errorDialogAudioRecorderType} errorRecording={errorRecording} i18n={i18n} recordingState={recordingState} onSendAudioRecording={( voiceNoteAttachment: InMemoryAttachmentDraftType ) => { emojiButtonRef.current?.close(); onSendMessage({ voiceNoteAttachment }); }} startRecording={startRecording} /> </div> ) : null; const isRecording = recordingState === RecordingState.Recording; const attButton = linkPreviewResult || isRecording ? undefined : ( <div className="CompositionArea__button-cell"> <button type="button" className="CompositionArea__attach-file" onClick={launchAttachmentPicker} aria-label={i18n('CompositionArea--attach-file')} /> </div> ); const sendButtonFragment = ( <> <div className="CompositionArea__placeholder" /> <div className="CompositionArea__button-cell"> <button type="button" className="CompositionArea__send-button" onClick={handleForceSend} aria-label={i18n('sendMessageToContact')} /> </div> </> ); const stickerButtonPlacement = large ? 'top-start' : 'top-end'; const stickerButtonFragment = withStickers ? ( <div className="CompositionArea__button-cell"> <StickerButton i18n={i18n} knownPacks={knownPacks} receivedPacks={receivedPacks} installedPack={installedPack} installedPacks={installedPacks} blessedPacks={blessedPacks} recentStickers={recentStickers} clearInstalledStickerPack={clearInstalledStickerPack} onClickAddPack={onClickAddPack} onPickSticker={onPickSticker} clearShowIntroduction={clearShowIntroduction} showPickerHint={showPickerHint} clearShowPickerHint={clearShowPickerHint} position={stickerButtonPlacement} /> </div> ) : null; // Listen for cmd/ctrl-shift-x to toggle large composition mode useEffect(() => { const handler = (e: KeyboardEvent) => { const { shiftKey, ctrlKey, metaKey } = e; const key = KeyboardLayout.lookup(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 ( <MessageRequestActions i18n={i18n} conversationType={conversationType} isBlocked={isBlocked} onBlock={onBlock} onBlockAndReportSpam={onBlockAndReportSpam} onUnblock={onUnblock} onDelete={onDelete} onAccept={onAccept} title={title} /> ); } if (conversationType === 'direct' && isSMSOnly) { return ( <div className={classNames([ 'CompositionArea', 'CompositionArea--sms-only', isFetchingUUID ? 'CompositionArea--pending' : null, ])} > {isFetchingUUID ? ( <Spinner ariaLabel={i18n('CompositionArea--sms-only__spinner-label')} role="presentation" moduleClassName="module-image-spinner" svgSize="small" /> ) : ( <> <h2 className="CompositionArea--sms-only__title"> {i18n('CompositionArea--sms-only__title')} </h2> <p className="CompositionArea--sms-only__body"> {i18n('CompositionArea--sms-only__body')} </p> </> )} </div> ); } // 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 ( <MandatoryProfileSharingActions i18n={i18n} conversationType={conversationType} onBlock={onBlock} onBlockAndReportSpam={onBlockAndReportSpam} onDelete={onDelete} onAccept={onAccept} title={title} /> ); } // If this is a V1 group, now disabled entirely, we show UI to help them upgrade if (!left && isGroupV1AndDisabled) { return ( <GroupV1DisabledActions i18n={i18n} onStartGroupMigration={onStartGroupMigration} /> ); } if (areWePendingApproval) { return ( <GroupV2PendingApprovalActions i18n={i18n} onCancelJoinRequest={onCancelJoinRequest} /> ); } if (announcementsOnly && !areWeAdmin) { return ( <AnnouncementsOnlyGroupBanner groupAdmins={groupAdmins} i18n={i18n} openConversation={openConversation} theme={theme} /> ); } return ( <div className="CompositionArea"> {attachmentToEdit && 'url' in attachmentToEdit && attachmentToEdit.url && ( <MediaEditor i18n={i18n} imageSrc={attachmentToEdit.url} onClose={() => setAttachmentToEdit(undefined)} onDone={data => { const newAttachment = { ...attachmentToEdit, contentType: IMAGE_PNG, data, size: data.byteLength, }; addAttachment(conversationId, newAttachment); setAttachmentToEdit(undefined); }} installedPacks={installedPacks} recentStickers={recentStickers} /> )} <div className="CompositionArea__toggle-large"> <button type="button" className={classNames( 'CompositionArea__toggle-large__button', large ? 'CompositionArea__toggle-large__button--large-active' : null )} // This prevents the user from tabbing here tabIndex={-1} onClick={handleToggleLarge} aria-label={i18n('CompositionArea--expand')} /> </div> <div className={classNames( 'CompositionArea__row', 'CompositionArea__row--column' )} > {quotedMessageProps && ( <div className="quote-wrapper"> <Quote isCompose {...quotedMessageProps} i18n={i18n} onClick={onClickQuotedMessage} onClose={() => { // This one is for redux... setQuotedMessage(undefined); // and this is for conversation_view. clearQuotedMessage?.(); }} /> </div> )} {linkPreviewLoading && linkPreviewResult && ( <div className="preview-wrapper"> <StagedLinkPreview {...linkPreviewResult} i18n={i18n} onClose={onCloseLinkPreview} /> </div> )} {draftAttachments.length ? ( <div className="CompositionArea__attachment-list"> <AttachmentList attachments={draftAttachments} canEditImages i18n={i18n} onAddAttachment={launchAttachmentPicker} onClickAttachment={maybeEditAttachment} onClose={onClearAttachments} onCloseAttachment={attachment => { if (attachment.path) { removeAttachment(conversationId, attachment.path); } }} /> </div> ) : null} </div> <div className={classNames( 'CompositionArea__row', large ? 'CompositionArea__row--padded' : null )} > {!large ? leftHandSideButtonsFragment : null} <div className={classNames( 'CompositionArea__input', large ? 'CompositionArea__input--padded' : null )} > <CompositionInput clearQuotedMessage={clearQuotedMessage} disabled={disabled} draftBodyRanges={draftBodyRanges} draftText={draftText} getPreferredBadge={getPreferredBadge} getQuotedMessage={getQuotedMessage} i18n={i18n} inputApi={inputApiRef} large={large} onDirtyChange={setDirty} onEditorStateChange={onEditorStateChange} onPickEmoji={onPickEmoji} onSubmit={handleSubmit} onTextTooLong={onTextTooLong} skinTone={skinTone} sortedGroupMembers={sortedGroupMembers} theme={theme} /> </div> {!large ? ( <> {stickerButtonFragment} {!dirty ? micButtonFragment : null} {attButton} </> ) : null} </div> {large ? ( <div className={classNames( 'CompositionArea__row', 'CompositionArea__row--control-row' )} > {leftHandSideButtonsFragment} {stickerButtonFragment} {attButton} {!dirty ? micButtonFragment : null} {dirty || !shouldShowMicrophone ? sendButtonFragment : null} </div> ) : null} <CompositionUpload addAttachment={addAttachment} addPendingAttachment={addPendingAttachment} conversationId={conversationId} draftAttachments={draftAttachments} i18n={i18n} processAttachments={processAttachments} removeAttachment={removeAttachment} ref={fileInputRef} /> </div> ); };