Files
Desktop/ts/components/CompositionArea.dom.tsx
automated-signal e751592fe2 Remove poll feature flag gates
Co-authored-by: yash-signal <yash@signal.org>
2026-02-27 08:52:00 +10:00

1336 lines
41 KiB
TypeScript

// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest';
import type {
DraftBodyRanges,
HydratedBodyRangesType,
} from '../types/BodyRange.std.js';
import type { LocalizerType, ThemeType } from '../types/Util.std.js';
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder.std.js';
import { RecordingState } from '../types/AudioRecorder.std.js';
import type { imageToBlurHash } from '../util/imageToBlurHash.dom.js';
import { dropNull } from '../util/dropNull.std.js';
import { Spinner } from './Spinner.dom.js';
import type {
InputApi,
Props as CompositionInputProps,
} from './CompositionInput.dom.js';
import { CompositionInput } from './CompositionInput.dom.js';
import type { Props as MessageRequestActionsProps } from './conversation/MessageRequestActions.dom.js';
import { MessageRequestActions } from './conversation/MessageRequestActions.dom.js';
import type { PropsType as GroupV1DisabledActionsPropsType } from './conversation/GroupV1DisabledActions.dom.js';
import { GroupV1DisabledActions } from './conversation/GroupV1DisabledActions.dom.js';
import type { PropsType as GroupV2PendingApprovalActionsPropsType } from './conversation/GroupV2PendingApprovalActions.dom.js';
import { GroupV2PendingApprovalActions } from './conversation/GroupV2PendingApprovalActions.dom.js';
import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner.dom.js';
import { AttachmentList } from './conversation/AttachmentList.dom.js';
import type {
AttachmentDraftType,
InMemoryAttachmentDraftType,
} from '../types/Attachment.std.js';
import { isImageAttachment, isVoiceMessage } from '../util/Attachment.std.js';
import { isViewOnceEligible } from '../util/viewOnceEligibility.std.js';
import type { AciString } from '../types/ServiceId.std.js';
import { AudioCapture } from './conversation/AudioCapture.dom.js';
import { CompositionUpload } from './CompositionUpload.dom.js';
import type {
ConversationRemovalStage,
ConversationType,
PushPanelForConversationActionType,
ShowConversationType,
} from '../state/ducks/conversations.preload.js';
import type { GetConversationByIdType } from '../state/selectors/conversations.dom.js';
import type { GetSharedGroupNamesType } from '../util/sharedGroupNames.dom.js';
import type { LinkPreviewForUIType } from '../types/message/LinkPreviews.std.js';
import { isSameLinkPreview } from '../types/message/LinkPreviews.std.js';
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions.dom.js';
import { MediaQualitySelector } from './MediaQualitySelector.dom.js';
import type { Props as QuoteProps } from './conversation/Quote.dom.js';
import { Quote } from './conversation/Quote.dom.js';
import {
useAttachFileShortcut,
useEditLastMessageSent,
} from '../hooks/useKeyboardShortcuts.dom.js';
import { MediaEditor } from './MediaEditor.dom.js';
import { isImageTypeSupported } from '../util/GoogleChrome.std.js';
import * as KeyboardLayout from '../services/keyboardLayout.dom.js';
import { usePrevious } from '../hooks/usePrevious.std.js';
import { PanelType } from '../types/Panels.std.js';
import type { SmartCompositionRecordingDraftProps } from '../state/smart/CompositionRecordingDraft.preload.js';
import { useEscapeHandling } from '../hooks/useEscapeHandling.dom.js';
import SelectModeActions from './conversation/SelectModeActions.dom.js';
import type { ShowToastAction } from '../state/ducks/toast.preload.js';
import type { DraftEditMessageType } from '../model-types.d.ts';
import type { ForwardMessagesPayload } from '../state/ducks/globalModals.preload.js';
import { ForwardMessagesModalType } from './ForwardMessagesModal.dom.js';
import { SignalConversationMuteToggle } from './conversation/SignalConversationMuteToggle.dom.js';
import { FunPicker } from './fun/FunPicker.dom.js';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.js';
import type { FunStickerSelection } from './fun/panels/FunPanelStickers.dom.js';
import type { FunGifSelection } from './fun/panels/FunPanelGifs.dom.js';
import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGifMessageSendModal.preload.js';
import { strictAssert } from '../util/assert.std.js';
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
import type { EmojiSkinTone } from './fun/data/emojis.std.js';
import { FunPickerButton } from './fun/FunButton.dom.js';
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.dom.js';
import { AxoIconButton } from '../axo/AxoIconButton.dom.js';
import { tw } from '../axo/tw.dom.js';
import type { PollCreateType } from '../types/Polls.dom.js';
import { PollCreateModal } from './PollCreateModal.dom.js';
import { useDocumentKeyDown } from '../hooks/useDocumentKeyDown.dom.js';
export type OwnProps = Readonly<{
acceptedMessageRequest: boolean | null;
removalStage: ConversationRemovalStage | null;
addAttachment: (
conversationId: string,
attachment: InMemoryAttachmentDraftType
) => unknown;
announcementsOnly: boolean | null;
areWeAdmin: boolean | null;
areWePending: boolean | null;
areWePendingApproval: boolean | null;
getSharedGroupNames: GetSharedGroupNamesType;
cancelRecording: () => unknown;
completeRecording: (
conversationId: string,
onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown
) => unknown;
convertDraftBodyRangesIntoHydrated: (
bodyRanges: DraftBodyRanges | undefined
) => HydratedBodyRangesType | undefined;
conversationId: string;
conversationSelector: GetConversationByIdType;
discardEditMessage: (id: string) => unknown;
draftEditMessage: DraftEditMessageType | null;
draftAttachments: ReadonlyArray<AttachmentDraftType>;
errorDialogAudioRecorderType: ErrorDialogAudioRecorderType | null;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
focusCounter: number;
groupAdmins: Array<{
member: ConversationType;
labelEmoji: string | undefined;
labelString: string | undefined;
}>;
groupVersion: 1 | 2 | null;
i18n: LocalizerType;
imageToBlurHash: typeof imageToBlurHash;
isDisabled: boolean;
isFetchingUUID: boolean | null;
isFormattingEnabled: boolean;
isGroupV1AndDisabled: boolean | null;
isMissingMandatoryProfileSharing: boolean | null;
isPollSend1to1Enabled: boolean;
isSignalConversation: boolean | null;
isActive: boolean;
lastEditableMessageId: string | null;
recordingState: RecordingState;
messageCompositionId: string;
memberColors: Map<string, string>;
shouldHidePopovers: boolean | null;
isMuted: boolean;
isSmsOnlyOrUnregistered: boolean | null;
left: boolean | null;
linkPreviewLoading: boolean;
linkPreviewResult: LinkPreviewForUIType | null;
onClearAttachments(conversationId: string): unknown;
onCloseLinkPreview(conversationId: string): unknown;
platform: string;
showToast: ShowToastAction;
processAttachments: (options: {
conversationId: string;
files: ReadonlyArray<File>;
flags: number | null;
}) => unknown;
setMuteExpiration(conversationId: string, muteExpiresAt: number): unknown;
setMediaQualitySetting(conversationId: string, isHQ: boolean): unknown;
sendStickerMessage(
id: string,
opts: { packId: string; stickerId: number }
): unknown;
sendEditedMessage(
conversationId: string,
options: {
bodyRanges?: DraftBodyRanges;
message?: string;
quoteAuthorAci?: AciString;
quoteSentAt?: number;
targetMessageId: string;
}
): unknown;
sendMultiMediaMessage(
conversationId: string,
options: {
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
bodyRanges?: DraftBodyRanges;
isViewOnce?: boolean;
message?: string;
timestamp?: number;
voiceNoteAttachment?: InMemoryAttachmentDraftType;
}
): unknown;
sendPoll(conversationId: string, poll: PollCreateType): unknown;
quotedMessageId: string | null;
quotedMessageProps: null | ReadonlyDeep<
Omit<
QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose'
>
>;
quotedMessageAuthorAci: AciString | null;
quotedMessageSentAt: number | null;
removeAttachment: (
conversationId: string,
attachment: AttachmentDraftType
) => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
setComposerFocus: (conversationId: string) => unknown;
setMessageToEdit(conversationId: string, messageId: string): unknown;
setQuoteByMessageId(
conversationId: string,
messageId: string | undefined
): unknown;
isViewOnce: boolean;
setViewOnce(options: {
conversationId: string;
value: boolean;
toastNotify: boolean;
}): unknown;
shouldSendHighQualityAttachments: boolean;
showConversation: ShowConversationType;
startRecording: (id: string) => unknown;
theme: ThemeType;
renderSmartCompositionRecording: () => React.JSX.Element;
renderSmartCompositionRecordingDraft: (
props: SmartCompositionRecordingDraftProps
) => React.JSX.Element | null;
selectedMessageIds: ReadonlyArray<string> | undefined;
areSelectedMessagesForwardable: boolean | undefined;
toggleSelectMode: (on: boolean) => void;
toggleForwardMessagesModal: (
payload: ForwardMessagesPayload,
onForward: () => void
) => void;
toggleDraftGifMessageSendModal: (
props: SmartDraftGifMessageSendModalProps | null
) => void;
onSelectEmoji: (emojiSelection: FunEmojiSelection) => void;
emojiSkinToneDefault: EmojiSkinTone | null;
}>;
export type Props = Pick<
CompositionInputProps,
| 'draftText'
| 'draftBodyRanges'
| 'getPreferredBadge'
| 'onEditorStateChange'
| 'onTextTooLong'
| 'ourConversationId'
| 'quotedMessageId'
| 'sendCounter'
| 'sortedGroupMembers'
> &
MessageRequestActionsProps &
Pick<GroupV1DisabledActionsPropsType, 'showGV2MigrationDialog'> &
Pick<GroupV2PendingApprovalActionsPropsType, 'cancelJoinRequest'> & {
pushPanelForConversation: PushPanelForConversationActionType;
} & OwnProps;
export const CompositionArea = memo(function CompositionArea({
// Base props
addAttachment,
conversationId,
convertDraftBodyRangesIntoHydrated,
discardEditMessage,
draftEditMessage,
focusCounter,
i18n,
imageToBlurHash,
isDisabled,
isPollSend1to1Enabled,
isSignalConversation,
isMuted,
isActive,
lastEditableMessageId,
messageCompositionId,
pushPanelForConversation,
platform,
processAttachments,
removeAttachment,
sendEditedMessage,
sendMultiMediaMessage,
sendPoll,
setComposerFocus,
setMessageToEdit,
setQuoteByMessageId,
shouldHidePopovers,
showToast,
theme,
setMuteExpiration,
// AttachmentList
draftAttachments,
onClearAttachments,
// AudioCapture
recordingState,
startRecording,
// StagedLinkPreview
linkPreviewLoading,
linkPreviewResult,
onCloseLinkPreview,
// Quote
quotedMessageId,
quotedMessageProps,
quotedMessageAuthorAci,
quotedMessageSentAt,
scrollToMessage,
// View Once
isViewOnce,
setViewOnce,
// MediaQualitySelector
setMediaQualitySetting,
shouldSendHighQualityAttachments,
// CompositionInput
draftBodyRanges,
draftText,
getPreferredBadge,
isFormattingEnabled,
onEditorStateChange,
onTextTooLong,
ourConversationId,
sendCounter,
sortedGroupMembers,
// FunPicker
onSelectEmoji,
emojiSkinToneDefault,
sendStickerMessage,
// Message Requests
acceptedMessageRequest,
areWePending,
areWePendingApproval,
conversationType,
getSharedGroupNames,
groupVersion,
isBlocked,
isHidden,
isReported,
isMissingMandatoryProfileSharing,
left,
removalStage,
acceptConversation,
blockConversation,
reportSpam,
blockAndReportSpam,
deleteConversation,
conversationName,
addedByName,
// GroupV1 Disabled Actions
isGroupV1AndDisabled,
showGV2MigrationDialog,
// GroupV2
announcementsOnly,
areWeAdmin,
groupAdmins,
memberColors,
cancelJoinRequest,
showConversation,
// SMS-only contacts
isSmsOnlyOrUnregistered,
isFetchingUUID,
renderSmartCompositionRecording,
renderSmartCompositionRecordingDraft,
// Selected messages
selectedMessageIds,
areSelectedMessagesForwardable,
toggleSelectMode,
toggleForwardMessagesModal,
// DraftGifMessageSendModal
toggleDraftGifMessageSendModal,
}: Props): React.JSX.Element | null {
const [dirty, setDirty] = useState(false);
const [large, setLarge] = useState(false);
const [attachmentToEdit, setAttachmentToEdit] = useState<
AttachmentDraftType | undefined
>();
const [isPollModalOpen, setIsPollModalOpen] = useState(false);
const inputApiRef = useRef<InputApi | undefined>();
const fileInputRef = useRef<null | HTMLInputElement>(null);
const photoVideoInputRef = useRef<null | HTMLInputElement>(null);
const handleForceSend = useCallback(() => {
setLarge(false);
if (inputApiRef.current) {
inputApiRef.current.submit();
}
}, [inputApiRef, setLarge]);
const draftEditMessageBody = draftEditMessage?.body;
const editedMessageId = draftEditMessage?.targetMessageId;
let canSend =
// Text or link preview edited
dirty ||
// Quote of edited message changed
(draftEditMessage != null &&
dropNull(draftEditMessage.quote?.messageId) !==
dropNull(quotedMessageId)) ||
// Link preview of edited message changed
(draftEditMessage != null &&
!isSameLinkPreview(linkPreviewResult, draftEditMessage?.preview)) ||
// Not edit message, but has attachments
(draftEditMessage == null && draftAttachments.length !== 0);
// Draft attachments should finish loading
if (draftAttachments.some(attachment => attachment.pending)) {
canSend = false;
}
const handleSubmit = useCallback(
(
message: string,
bodyRanges: DraftBodyRanges,
timestamp: number
): boolean => {
if (!canSend) {
return false;
}
if (editedMessageId) {
sendEditedMessage(conversationId, {
bodyRanges,
message,
// sent timestamp for the quote
quoteSentAt: quotedMessageSentAt ?? undefined,
quoteAuthorAci: quotedMessageAuthorAci ?? undefined,
targetMessageId: editedMessageId,
});
} else {
sendMultiMediaMessage(conversationId, {
draftAttachments,
bodyRanges,
message,
timestamp,
isViewOnce,
});
}
setLarge(false);
return true;
},
[
conversationId,
canSend,
draftAttachments,
editedMessageId,
isViewOnce,
quotedMessageSentAt,
quotedMessageAuthorAci,
sendEditedMessage,
sendMultiMediaMessage,
setLarge,
]
);
const launchAttachmentPicker = useCallback((type?: 'media' | 'file') => {
const inputRef = type === 'media' ? photoVideoInputRef : fileInputRef;
const fileInput = inputRef.current;
if (fileInput) {
// Setting the value to empty so that onChange always fires in case
// you add multiple photos.
fileInput.value = '';
fileInput.click();
}
}, []);
const launchMediaPicker = useCallback(
() => launchAttachmentPicker('media'),
[launchAttachmentPicker]
);
const launchFilePicker = useCallback(
() => launchAttachmentPicker('file'),
[launchAttachmentPicker]
);
const handleOpenPollModal = useCallback(() => {
setIsPollModalOpen(true);
}, []);
const handleClosePollModal = useCallback(() => {
setIsPollModalOpen(false);
}, []);
const handleSendPoll = useCallback(
(poll: PollCreateType) => {
sendPoll(conversationId, poll);
handleClosePollModal();
},
[conversationId, sendPoll, handleClosePollModal]
);
function maybeEditAttachment(attachment: AttachmentDraftType) {
if (!isImageTypeSupported(attachment.contentType)) {
return;
}
setAttachmentToEdit(attachment);
}
const isComposerEmpty =
!draftAttachments.length && !draftText && !draftEditMessage;
const maybeEditMessage = useCallback(() => {
if (!isComposerEmpty || !lastEditableMessageId) {
return false;
}
setMessageToEdit(conversationId, lastEditableMessageId);
return true;
}, [
conversationId,
isComposerEmpty,
lastEditableMessageId,
setMessageToEdit,
]);
const attachFileShortcut = useAttachFileShortcut(launchFilePicker);
const editLastMessageSent = useEditLastMessageSent(maybeEditMessage);
useDocumentKeyDown(event => {
const hasFocus = inputApiRef.current?.hasFocus() ?? false;
if (hasFocus) {
attachFileShortcut(event);
editLastMessageSent(event);
}
});
// Focus input on first mount
const previousFocusCounter = usePrevious<number | undefined>(
focusCounter,
focusCounter
);
useEffect(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, []);
// Focus input whenever explicitly requested
useEffect(() => {
if (focusCounter !== previousFocusCounter && inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef, focusCounter, previousFocusCounter]);
const previousMessageCompositionId = usePrevious(
messageCompositionId,
messageCompositionId
);
const previousSendCounter = usePrevious(sendCounter, sendCounter);
useEffect(() => {
if (!inputApiRef.current) {
return;
}
if (
previousMessageCompositionId !== messageCompositionId ||
previousSendCounter !== sendCounter
) {
inputApiRef.current.reset();
}
}, [
messageCompositionId,
sendCounter,
previousMessageCompositionId,
previousSendCounter,
]);
// We want to reset the state of Quill only if:
//
// - Our other device edits the message (edit history length would change)
// - User begins editing another message.
const editHistoryLength = draftEditMessage?.editHistoryLength;
const hasEditHistoryChanged =
usePrevious(editHistoryLength, editHistoryLength) !== editHistoryLength;
const hasEditedMessageChanged =
usePrevious(editedMessageId, editedMessageId) !== editedMessageId;
const hasEditDraftChanged = hasEditHistoryChanged || hasEditedMessageChanged;
useEffect(() => {
if (!hasEditDraftChanged) {
return;
}
inputApiRef.current?.setContents(
draftEditMessageBody ?? '',
draftBodyRanges ?? undefined,
true
);
}, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]);
const previousConversationId = usePrevious(conversationId, conversationId);
useEffect(() => {
if (conversationId === previousConversationId) {
return;
}
if (!draftText) {
inputApiRef.current?.setContents('');
return;
}
inputApiRef.current?.setContents(
draftText,
draftBodyRanges ?? undefined,
true
);
}, [conversationId, draftBodyRanges, draftText, previousConversationId]);
const handleToggleLarge = useCallback(() => {
setLarge(l => !l);
}, [setLarge]);
const shouldShowMicrophone = !large && isComposerEmpty;
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
const showViewOnceToggle = isViewOnceEligible(
draftAttachments,
Boolean(quotedMessageId)
);
const isViewOnceActive = isViewOnce && showViewOnceToggle;
let draftEditMessageForInput = draftEditMessage;
let largeForInput = large;
let linkPreviewLoadingForInput = linkPreviewLoading;
let linkPreviewResultForInput = linkPreviewResult;
let quotedMessageIdForInput = quotedMessageId;
if (isViewOnceActive) {
draftEditMessageForInput = null;
largeForInput = false;
linkPreviewLoadingForInput = false;
linkPreviewResultForInput = null;
quotedMessageIdForInput = null;
}
const [funPickerOpen, setFunPickerOpen] = useState(false);
const handleToggleViewOnce = useCallback(() => {
setFunPickerOpen(false);
setViewOnce({
conversationId,
value: !isViewOnce,
toastNotify: true,
});
}, [conversationId, isViewOnce, setViewOnce]);
const handleFunPickerOpenChange = useCallback(
(open: boolean) => {
setFunPickerOpen(open);
if (!open) {
setComposerFocus(conversationId);
}
},
[conversationId, setComposerFocus]
);
const handleFunPickerSelectEmoji = useCallback(
(emojiSelection: FunEmojiSelection) => {
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(emojiSelection);
}
},
[]
);
const handleFunPickerSelectSticker = useCallback(
(stickerSelection: FunStickerSelection) => {
sendStickerMessage(conversationId, {
packId: stickerSelection.stickerPackId,
stickerId: stickerSelection.stickerId,
});
},
[sendStickerMessage, conversationId]
);
const [confirmGifSelection, setConfirmGifSelection] =
useState<FunGifSelection | null>(null);
const handleFunPickerSelectGif = useCallback(
async (gifSelection: FunGifSelection) => {
if (draftAttachments.length > 0) {
setConfirmGifSelection(gifSelection);
} else {
toggleDraftGifMessageSendModal({
conversationId,
previousComposerDraftText: draftText ?? '',
previousComposerDraftBodyRanges: draftBodyRanges ?? [],
gifSelection,
});
}
},
[
conversationId,
toggleDraftGifMessageSendModal,
draftText,
draftBodyRanges,
draftAttachments,
]
);
const handleConfirmGifSelection = useCallback(() => {
strictAssert(confirmGifSelection != null, 'Need selected gif to confirm');
onClearAttachments(conversationId);
toggleDraftGifMessageSendModal({
conversationId,
previousComposerDraftText: draftText ?? '',
previousComposerDraftBodyRanges: draftBodyRanges ?? [],
gifSelection: confirmGifSelection,
});
}, [
confirmGifSelection,
conversationId,
toggleDraftGifMessageSendModal,
draftText,
draftBodyRanges,
onClearAttachments,
]);
const handleCancelGifSelection = useCallback(() => {
setConfirmGifSelection(null);
}, []);
const handleFunPickerAddStickerPack = useCallback(() => {
pushPanelForConversation({
type: PanelType.StickerManager,
});
}, [pushPanelForConversation]);
const mediaQualitySelectorFragment = useMemo(
() =>
showMediaQualitySelector ? (
<div className="CompositionArea__button-cell">
<MediaQualitySelector
conversationId={conversationId}
i18n={i18n}
isHighQuality={shouldSendHighQualityAttachments}
onSelectQuality={setMediaQualitySetting}
/>
</div>
) : null,
[
conversationId,
i18n,
setMediaQualitySetting,
shouldSendHighQualityAttachments,
showMediaQualitySelector,
]
);
const leftHandSideButtonsFragment = (
<>
{confirmGifSelection && (
<ConfirmationDialog
i18n={i18n}
dialogName="CompositionArea.ConfirmGifSelection"
hasXButton={false}
onClose={handleCancelGifSelection}
onCancel={handleCancelGifSelection}
title={i18n('icu:CompositionArea__ConfirmGifSelection__Title')}
actions={[
{
action: handleConfirmGifSelection,
style: 'affirmative',
text: i18n(
'icu:CompositionArea__ConfirmGifSelection__ReplaceButton'
),
},
]}
>
{i18n('icu:CompositionArea__ConfirmGifSelection__Body')}
</ConfirmationDialog>
)}
<div
aria-hidden={isViewOnceActive || undefined}
className={classNames(
'CompositionArea__button-cell',
isViewOnceActive ? tw('invisible') : null
)}
>
<FunPicker
placement="top start"
open={funPickerOpen}
onOpenChange={handleFunPickerOpenChange}
onSelectEmoji={handleFunPickerSelectEmoji}
onSelectSticker={handleFunPickerSelectSticker}
onSelectGif={handleFunPickerSelectGif}
onAddStickerPack={handleFunPickerAddStickerPack}
>
<FunPickerButton i18n={i18n} />
</FunPicker>
</div>
{mediaQualitySelectorFragment}
</>
);
const micButtonFragment = shouldShowMicrophone ? (
<div className="CompositionArea__button-cell">
<AudioCapture
conversationId={conversationId}
draftAttachments={draftAttachments}
i18n={i18n}
showToast={showToast}
startRecording={startRecording}
/>
</div>
) : null;
const editMessageFragment = draftEditMessage ? (
<>
{large && <div className="CompositionArea__placeholder" />}
<div className="CompositionArea__button-cell CompositionArea__button-edit">
<button
aria-label={i18n('icu:CompositionArea__edit-action--discard')}
className="CompositionArea__edit-button CompositionArea__edit-button--discard"
onClick={() => discardEditMessage(conversationId)}
type="button"
/>
<button
aria-label={i18n('icu:CompositionArea__edit-action--send')}
className="CompositionArea__edit-button CompositionArea__edit-button--accept"
disabled={!canSend}
onClick={() => inputApiRef.current?.submit()}
type="button"
/>
</div>
</>
) : null;
const isRecording = recordingState === RecordingState.Recording;
const actionSlotClassName = tw(
'flex size-8 shrink-0 items-center justify-center'
);
const composerAddMenuButton =
draftEditMessage || linkPreviewResult || isRecording ? null : (
<div className="CompositionArea__button-cell">
<AxoDropdownMenu.Root>
<div className={actionSlotClassName}>
<AxoDropdownMenu.Trigger>
<AxoIconButton.Root
variant="borderless-secondary"
size="md"
label={i18n('icu:CompositionArea--attach-plus')}
tooltip={false}
symbol="plus"
/>
</AxoDropdownMenu.Trigger>
</div>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item symbol="photo" onSelect={launchMediaPicker}>
{i18n('icu:CompositionArea__AttachMenu__PhotosAndVideos')}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item symbol="file" onSelect={launchFilePicker}>
{i18n('icu:CompositionArea__AttachMenu__File')}
</AxoDropdownMenu.Item>
{(conversationType === 'group' || isPollSend1to1Enabled) && (
<AxoDropdownMenu.Item
symbol="poll"
onSelect={handleOpenPollModal}
>
{i18n('icu:CompositionArea__AttachMenu__Poll')}
</AxoDropdownMenu.Item>
)}
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</div>
);
const sendButtonFragment = !draftEditMessage ? (
<>
<div className="CompositionArea__placeholder" />
<div className="CompositionArea__button-cell">
<div className={actionSlotClassName}>
<button
type="button"
className="CompositionArea__send-button"
onClick={handleForceSend}
aria-label={i18n('icu:sendMessageToContact')}
/>
</div>
</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 `'K'`. When using the cmd key, `key` is `'k'`
const targetKey = key === 'k' || key === 'K';
const commandKey = platform === 'darwin' && metaKey;
const controlKey = platform !== 'darwin' && ctrlKey;
const commandOrCtrl = commandKey || controlKey;
// cmd/ctrl-shift-k
if (targetKey && shiftKey && commandOrCtrl) {
e.preventDefault();
setLarge(x => !x);
}
};
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler);
};
}, [platform, setLarge]);
const handleEscape = useCallback(() => {
if (linkPreviewResult) {
onCloseLinkPreview(conversationId);
} else if (quotedMessageId) {
setQuoteByMessageId(conversationId, undefined);
} else if (draftEditMessage) {
discardEditMessage(conversationId);
}
}, [
conversationId,
discardEditMessage,
draftEditMessage,
linkPreviewResult,
onCloseLinkPreview,
quotedMessageId,
setQuoteByMessageId,
]);
useEscapeHandling(handleEscape);
if (isSignalConversation) {
return (
<SignalConversationMuteToggle
conversationId={conversationId}
isMuted={isMuted}
i18n={i18n}
setMuteExpiration={setMuteExpiration}
/>
);
}
if (selectedMessageIds != null) {
return (
<SelectModeActions
i18n={i18n}
selectedMessageIds={selectedMessageIds}
areSelectedMessagesForwardable={areSelectedMessagesForwardable === true}
onExitSelectMode={() => {
toggleSelectMode(false);
}}
onDeleteMessages={() => {
window.reduxActions.globalModals.toggleDeleteMessagesModal({
conversationId,
messageIds: selectedMessageIds,
onDelete() {
toggleSelectMode(false);
},
});
}}
onForwardMessages={() => {
if (selectedMessageIds.length > 0) {
toggleForwardMessagesModal(
{
type: ForwardMessagesModalType.Forward,
messageIds: selectedMessageIds,
},
() => {
toggleSelectMode(false);
}
);
}
}}
showToast={showToast}
/>
);
}
if (
isBlocked ||
areWePending ||
(!acceptedMessageRequest && removalStage !== 'justNotification')
) {
return (
<MessageRequestActions
addedByName={addedByName}
conversationType={conversationType}
conversationId={conversationId}
conversationName={conversationName}
getSharedGroupNames={getSharedGroupNames}
i18n={i18n}
isBlocked={isBlocked}
isHidden={isHidden}
isReported={isReported}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
/>
);
}
if (conversationType === 'direct' && isSmsOnlyOrUnregistered) {
return (
<div
className={classNames([
'CompositionArea',
'CompositionArea--sms-only',
isFetchingUUID ? 'CompositionArea--pending' : null,
])}
>
{isFetchingUUID ? (
<Spinner
ariaLabel={i18n('icu:CompositionArea--sms-only__spinner-label')}
role="presentation"
moduleClassName="module-image-spinner"
svgSize="small"
/>
) : (
<>
<h2 className="CompositionArea--sms-only__title">
{i18n('icu:CompositionArea--sms-only__title')}
</h2>
<p className="CompositionArea--sms-only__body">
{i18n('icu: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
addedByName={addedByName}
conversationId={conversationId}
conversationType={conversationType}
conversationName={conversationName}
i18n={i18n}
isBlocked={isBlocked}
isReported={isReported}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
/>
);
}
// If this is a V1 group, now disabled entirely, we show UI to help them upgrade
if (!left && isGroupV1AndDisabled) {
return (
<GroupV1DisabledActions
conversationId={conversationId}
i18n={i18n}
showGV2MigrationDialog={showGV2MigrationDialog}
/>
);
}
if (areWePendingApproval) {
return (
<GroupV2PendingApprovalActions
cancelJoinRequest={cancelJoinRequest}
conversationId={conversationId}
i18n={i18n}
/>
);
}
if (announcementsOnly && !areWeAdmin) {
return (
<AnnouncementsOnlyGroupBanner
getPreferredBadge={getPreferredBadge}
groupAdmins={groupAdmins}
i18n={i18n}
memberColors={memberColors}
showConversation={showConversation}
theme={theme}
/>
);
}
if (isRecording) {
return renderSmartCompositionRecording();
}
if (draftAttachments.length === 1 && isVoiceMessage(draftAttachments[0])) {
const voiceNoteAttachment = draftAttachments[0];
if (!voiceNoteAttachment.pending && voiceNoteAttachment.url) {
return renderSmartCompositionRecordingDraft({ voiceNoteAttachment });
}
}
return (
<div className="CompositionArea">
{attachmentToEdit &&
'url' in attachmentToEdit &&
attachmentToEdit.url && (
<MediaEditor
draftBodyRanges={draftBodyRanges}
draftText={draftText}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
imageSrc={attachmentToEdit.url}
imageToBlurHash={imageToBlurHash}
isCreatingStory={false}
isFormattingEnabled={isFormattingEnabled}
isSending={false}
isHighQuality={shouldSendHighQualityAttachments}
isViewOnce={isViewOnce}
showViewOnceToggle={showViewOnceToggle}
convertDraftBodyRangesIntoHydrated={
convertDraftBodyRangesIntoHydrated
}
onClose={() => setAttachmentToEdit(undefined)}
onDone={({
caption,
captionBodyRanges,
data,
contentType,
blurHash,
isViewOnce: editorIsViewOnce,
isHighQuality: editorIsHighQuality,
}) => {
const newAttachment = {
...attachmentToEdit,
contentType,
blurHash,
data,
size: data.byteLength,
};
addAttachment(conversationId, newAttachment);
setAttachmentToEdit(undefined);
if (
editorIsViewOnce !== undefined &&
editorIsViewOnce !== isViewOnce
) {
setViewOnce({
conversationId,
value: editorIsViewOnce,
toastNotify: false,
});
}
if (
editorIsHighQuality !== undefined &&
editorIsHighQuality !== shouldSendHighQualityAttachments
) {
setMediaQualitySetting(conversationId, editorIsHighQuality);
}
onEditorStateChange?.({
bodyRanges: captionBodyRanges ?? [],
conversationId,
messageText: caption ?? '',
sendCounter,
});
inputApiRef.current?.setContents(
caption ?? '',
convertDraftBodyRangesIntoHydrated(captionBodyRanges),
true
);
}}
onSelectEmoji={onSelectEmoji}
onTextTooLong={onTextTooLong}
ourConversationId={ourConversationId}
platform={platform}
emojiSkinToneDefault={emojiSkinToneDefault}
sortedGroupMembers={sortedGroupMembers}
/>
)}
{isViewOnceActive ? null : (
<div className="CompositionArea__toggle-large">
<button
type="button"
className={classNames(
'CompositionArea__toggle-large__button',
large
? 'CompositionArea__toggle-large__button--large-active'
: null
)}
onClick={handleToggleLarge}
aria-label={i18n('icu:CompositionArea--expand')}
/>
</div>
)}
<div
className={classNames(
'CompositionArea__row',
'CompositionArea__row--column'
)}
>
{isViewOnceActive
? null
: quotedMessageProps && (
<div className="quote-wrapper">
<Quote
isCompose
{...quotedMessageProps}
i18n={i18n}
onClick={
quotedMessageId
? () => scrollToMessage(conversationId, quotedMessageId)
: undefined
}
onClose={() => {
setQuoteByMessageId(conversationId, undefined);
}}
/>
</div>
)}
{draftAttachments.length ? (
<div className="CompositionArea__attachment-list">
<AttachmentList
attachments={draftAttachments}
canEditImages
i18n={i18n}
onAddAttachment={launchFilePicker}
onClickAttachment={maybeEditAttachment}
onClose={() => onClearAttachments(conversationId)}
onCloseAttachment={attachment => {
removeAttachment(conversationId, attachment);
}}
/>
</div>
) : null}
</div>
<div
className={classNames('CompositionArea__row', {
'CompositionArea__row--padded': !isViewOnceActive && large,
})}
>
{!large ? leftHandSideButtonsFragment : null}
<div
className={classNames('CompositionArea__input', {
'CompositionArea__input--padded': !isViewOnceActive && large,
})}
>
<CompositionInput
conversationId={conversationId}
disabled={isDisabled}
draftBodyRanges={draftBodyRanges}
draftText={draftText}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
isFormattingEnabled={isFormattingEnabled}
isActive={isActive}
draftEditMessage={draftEditMessageForInput}
large={largeForInput}
linkPreviewLoading={linkPreviewLoadingForInput}
linkPreviewResult={linkPreviewResultForInput}
quotedMessageId={quotedMessageIdForInput}
onCloseLinkPreview={onCloseLinkPreview}
onDirtyChange={setDirty}
onEditorStateChange={onEditorStateChange}
onSelectEmoji={onSelectEmoji}
onSubmit={handleSubmit}
onTextTooLong={onTextTooLong}
ourConversationId={ourConversationId}
platform={platform}
sendCounter={sendCounter}
shouldHidePopovers={shouldHidePopovers}
emojiSkinToneDefault={emojiSkinToneDefault ?? null}
sortedGroupMembers={sortedGroupMembers}
theme={theme}
showViewOnceButton={showViewOnceToggle}
isViewOnceActive={isViewOnceActive}
onToggleViewOnce={handleToggleViewOnce}
/>
</div>
{isViewOnceActive && (
<div className="CompositionArea__button-cell">
<div className={actionSlotClassName}>
<button
type="button"
className="CompositionArea__send-button"
onClick={handleForceSend}
aria-label={i18n('icu:sendMessageToContact')}
/>
</div>
</div>
)}
{!isViewOnceActive && !large && (
<>
{!dirty ? micButtonFragment : null}
{editMessageFragment}
{composerAddMenuButton}
</>
)}
</div>
{!isViewOnceActive && large ? (
<div
className={classNames(
'CompositionArea__row',
'CompositionArea__row--control-row'
)}
>
{leftHandSideButtonsFragment}
{composerAddMenuButton}
{!dirty ? micButtonFragment : null}
{editMessageFragment}
{dirty || !shouldShowMicrophone ? sendButtonFragment : null}
</div>
) : null}
<CompositionUpload
conversationId={conversationId}
draftAttachments={draftAttachments}
i18n={i18n}
processAttachments={processAttachments}
ref={fileInputRef}
/>
<CompositionUpload
conversationId={conversationId}
draftAttachments={draftAttachments}
i18n={i18n}
processAttachments={processAttachments}
ref={photoVideoInputRef}
acceptMediaOnly
testId="attachfile-input-media"
/>
{isPollModalOpen && (
<PollCreateModal
i18n={i18n}
onClose={handleClosePollModal}
onSendPoll={handleSendPoll}
/>
)}
</div>
);
});