signal-desktop/ts/components/CompositionArea.tsx

500 lines
14 KiB
TypeScript
Raw Normal View History

2020-10-30 20:34:04 +00:00
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { get, noop } from 'lodash';
2019-08-06 19:18:37 +00:00
import classNames from 'classnames';
2020-10-02 20:05:09 +00:00
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
import {
Props as StickerButtonProps,
StickerButton,
} from './stickers/StickerButton';
import {
CompositionInput,
InputApi,
Props as CompositionInputProps,
} from './CompositionInput';
2020-05-27 21:37:06 +00:00
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';
2020-10-02 20:05:09 +00:00
import { EmojiPickDataType } from './emoji/EmojiPicker';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly areWePending?: boolean;
readonly areWePendingApproval?: boolean;
readonly groupVersion?: 1 | 2;
readonly isGroupV1AndDisabled?: boolean;
readonly isMissingMandatoryProfileSharing?: boolean;
readonly left?: boolean;
2020-05-27 21:37:06 +00:00
readonly messageRequestsEnabled?: boolean;
readonly acceptedMessageRequest?: boolean;
readonly compositionApi?: React.MutableRefObject<{
focusInput: () => void;
2019-11-07 21:36:16 +00:00
isDirty: () => boolean;
setDisabled: (disabled: boolean) => void;
2019-08-06 19:18:37 +00:00
setShowMic: (showMic: boolean) => void;
setMicActive: (micActive: boolean) => void;
attSlotRef: React.RefObject<HTMLDivElement>;
reset: InputApi['reset'];
resetEmojiResults: InputApi['resetEmojiResults'];
}>;
2019-08-06 19:18:37 +00:00
readonly micCellEl?: HTMLElement;
readonly attCellEl?: HTMLElement;
readonly attachmentListEl?: HTMLElement;
2019-08-07 00:40:25 +00:00
onChooseAttachment(): unknown;
};
2019-08-06 19:18:37 +00:00
export type Props = Pick<
CompositionInputProps,
| 'sortedGroupMembers'
| 'onSubmit'
| 'onEditorStateChange'
| 'onTextTooLong'
2020-11-03 01:19:52 +00:00
| 'draftText'
| 'draftBodyRanges'
2020-07-01 18:05:41 +00:00
| 'clearQuotedMessage'
| 'getQuotedMessage'
2019-08-06 19:18:37 +00:00
> &
Pick<
EmojiButtonProps,
'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone'
> &
Pick<
StickerButtonProps,
| 'knownPacks'
| 'receivedPacks'
| 'installedPack'
| 'installedPacks'
| 'blessedPacks'
| 'recentStickers'
| 'clearInstalledStickerPack'
| 'onClickAddPack'
| 'onPickSticker'
| 'clearShowIntroduction'
| 'showPickerHint'
| 'clearShowPickerHint'
> &
2020-05-27 21:37:06 +00:00
MessageRequestActionsProps &
Pick<GroupV1DisabledActionsPropsType, 'onStartGroupMigration'> &
Pick<GroupV2PendingApprovalActionsPropsType, 'onCancelJoinRequest'> &
OwnProps;
2019-08-06 19:18:37 +00:00
const emptyElement = (el: HTMLElement) => {
2020-09-12 00:46:52 +00:00
// Necessary to deal with Backbone views
// eslint-disable-next-line no-param-reassign
2019-08-06 19:18:37 +00:00
el.innerHTML = '';
};
export const CompositionArea = ({
i18n,
2019-08-06 19:18:37 +00:00
attachmentListEl,
micCellEl,
2019-08-07 00:40:25 +00:00
onChooseAttachment,
// CompositionInput
onSubmit,
compositionApi,
onEditorStateChange,
onTextTooLong,
2020-11-03 01:19:52 +00:00
draftText,
draftBodyRanges,
2020-05-27 21:37:06 +00:00
clearQuotedMessage,
getQuotedMessage,
sortedGroupMembers,
// EmojiButton
onPickEmoji,
onSetSkinTone,
recentEmojis,
skinTone,
// StickerButton
knownPacks,
receivedPacks,
installedPack,
installedPacks,
blessedPacks,
recentStickers,
clearInstalledStickerPack,
onClickAddPack,
onPickSticker,
clearShowIntroduction,
showPickerHint,
clearShowPickerHint,
2020-05-27 21:37:06 +00:00
// Message Requests
acceptedMessageRequest,
areWePending,
areWePendingApproval,
2020-05-27 21:37:06 +00:00
conversationType,
groupVersion,
2020-05-27 21:37:06 +00:00
isBlocked,
isMissingMandatoryProfileSharing,
left,
2020-07-24 01:35:32 +00:00
messageRequestsEnabled,
2020-05-27 21:37:06 +00:00
name,
onAccept,
onBlock,
onBlockAndDelete,
onDelete,
2020-07-24 01:35:32 +00:00
onUnblock,
2020-05-27 21:37:06 +00:00
phoneNumber,
2020-07-24 01:35:32 +00:00
profileName,
title,
// GroupV1 Disabled Actions
isGroupV1AndDisabled,
onStartGroupMigration,
// GroupV2 Pending Approval Actions
onCancelJoinRequest,
2020-09-12 00:46:52 +00:00
}: Props): JSX.Element => {
const [disabled, setDisabled] = React.useState(false);
2020-11-03 01:19:52 +00:00
const [showMic, setShowMic] = React.useState(!draftText);
2019-08-06 19:18:37 +00:00
const [micActive, setMicActive] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
const [large, setLarge] = React.useState(false);
const inputApiRef = React.useRef<InputApi | undefined>();
2020-01-08 17:44:54 +00:00
const handleForceSend = React.useCallback(() => {
setLarge(false);
if (inputApiRef.current) {
inputApiRef.current.submit();
}
}, [inputApiRef, setLarge]);
2019-08-06 19:18:37 +00:00
const handleSubmit = React.useCallback<typeof onSubmit>(
(...args) => {
setLarge(false);
onSubmit(...args);
},
[setLarge, onSubmit]
);
2020-01-08 17:44:54 +00:00
const focusInput = React.useCallback(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
2020-01-08 17:44:54 +00:00
}
}, [inputApiRef]);
const withStickers =
countStickers({
knownPacks,
blessedPacks,
installedPacks,
receivedPacks,
}) > 0;
2019-08-06 19:18:37 +00:00
// A ref to grab a slot where backbone can insert link previews and attachments
const attSlotRef = React.useRef<HTMLDivElement>(null);
if (compositionApi) {
2020-09-12 00:46:52 +00:00
// Using a React.MutableRefObject, so we need to reassign this prop.
// eslint-disable-next-line no-param-reassign
compositionApi.current = {
2019-11-07 21:36:16 +00:00
isDirty: () => dirty,
focusInput,
setDisabled,
2019-08-06 19:18:37 +00:00
setShowMic,
setMicActive,
attSlotRef,
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]
);
2020-01-08 17:44:54 +00:00
const handleToggleLarge = React.useCallback(() => {
setLarge(l => !l);
}, [setLarge]);
2019-08-06 19:18:37 +00:00
// 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<HTMLDivElement>(null);
2020-01-08 17:44:54 +00:00
React.useLayoutEffect(() => {
const { current: micCellContainer } = micCellRef;
if (micCellContainer && micCellEl) {
emptyElement(micCellContainer);
micCellContainer.appendChild(micCellEl);
}
2019-08-06 19:18:37 +00:00
2020-01-08 17:44:54 +00:00
return noop;
}, [micCellRef, micCellEl, large, dirty, showMic]);
2019-08-06 19:18:37 +00:00
2020-01-08 17:44:54 +00:00
React.useLayoutEffect(() => {
const { current: attSlot } = attSlotRef;
if (attSlot && attachmentListEl) {
attSlot.appendChild(attachmentListEl);
}
2019-08-06 19:18:37 +00:00
2020-01-08 17:44:54 +00:00
return noop;
}, [attSlotRef, attachmentListEl]);
2019-08-06 19:18:37 +00:00
const emojiButtonFragment = (
<div className="module-composition-area__button-cell">
<EmojiButton
i18n={i18n}
doSend={handleForceSend}
onPickEmoji={insertEmoji}
onClose={focusInput}
2019-08-06 19:18:37 +00:00
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</div>
);
const micButtonFragment = showMic ? (
<div
className={classNames(
'module-composition-area__button-cell',
micActive ? 'module-composition-area__button-cell--mic-active' : null,
2019-11-07 21:36:16 +00:00
large ? 'module-composition-area__button-cell--large-right' : null,
micActive && large
? 'module-composition-area__button-cell--large-right-mic-active'
: null
2019-08-06 19:18:37 +00:00
)}
ref={micCellRef}
/>
) : null;
2019-08-07 00:40:25 +00:00
const attButton = (
<div className="module-composition-area__button-cell">
<div className="choose-file">
2020-09-12 00:46:52 +00:00
<button
type="button"
className="paperclip thumbnail"
onClick={onChooseAttachment}
aria-label={i18n('CompositionArea--attach-file')}
/>
2019-08-07 00:40:25 +00:00
</div>
</div>
2019-08-06 19:18:37 +00:00
);
const sendButtonFragment = (
<div
className={classNames(
'module-composition-area__button-cell',
large ? 'module-composition-area__button-cell--large-right' : null
)}
>
<button
2020-09-12 00:46:52 +00:00
type="button"
2019-08-06 19:18:37 +00:00
className="module-composition-area__send-button"
onClick={handleForceSend}
2020-09-12 00:46:52 +00:00
aria-label={i18n('sendMessageToContact')}
2019-08-06 19:18:37 +00:00
/>
</div>
);
const stickerButtonPlacement = large ? 'top-start' : 'top-end';
const stickerButtonFragment = withStickers ? (
<div className="module-composition-area__button-cell">
<StickerButton
i18n={i18n}
knownPacks={knownPacks}
receivedPacks={receivedPacks}
installedPack={installedPack}
2019-08-06 19:18:37 +00:00
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
2020-01-08 17:44:54 +00:00
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;
2020-01-08 17:44:54 +00:00
// cmd/ctrl-shift-x
if (xKey && shiftKey && commandOrCtrl) {
e.preventDefault();
setLarge(x => !x);
}
};
2020-01-08 17:44:54 +00:00
document.addEventListener('keydown', handler);
2020-01-08 17:44:54 +00:00
return () => {
document.removeEventListener('keydown', handler);
};
}, [setLarge]);
if (
2020-11-20 17:30:45 +00:00
isBlocked ||
areWePending ||
(messageRequestsEnabled && !acceptedMessageRequest)
) {
2020-05-27 21:37:06 +00:00
return (
<MessageRequestActions
i18n={i18n}
conversationType={conversationType}
isBlocked={isBlocked}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onUnblock={onUnblock}
onDelete={onDelete}
onAccept={onAccept}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
2020-07-24 01:35:32 +00:00
title={title}
2020-05-27 21:37:06 +00:00
/>
);
}
// 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}
onBlockAndDelete={onBlockAndDelete}
onDelete={onDelete}
onAccept={onAccept}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
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}
/>
);
}
return (
<div className="module-composition-area">
<div className="module-composition-area__toggle-large">
2019-08-06 19:18:37 +00:00
<button
2020-09-12 00:46:52 +00:00
type="button"
2019-08-06 19:18:37 +00:00
className={classNames(
'module-composition-area__toggle-large__button',
large
? 'module-composition-area__toggle-large__button--large-active'
: null
2019-08-06 19:18:37 +00:00
)}
2019-11-07 21:36:16 +00:00
// This prevents the user from tabbing here
tabIndex={-1}
2019-08-06 19:18:37 +00:00
onClick={handleToggleLarge}
2020-09-12 00:46:52 +00:00
aria-label={i18n('CompositionArea--expand')}
/>
</div>
2019-08-06 19:18:37 +00:00
<div
className={classNames(
'module-composition-area__row',
'module-composition-area__row--column'
)}
ref={attSlotRef}
/>
<div
className={classNames(
'module-composition-area__row',
large ? 'module-composition-area__row--padded' : null
)}
>
{!large ? emojiButtonFragment : null}
<div className="module-composition-area__input">
<CompositionInput
i18n={i18n}
2019-08-06 19:18:37 +00:00
disabled={disabled}
large={large}
inputApi={inputApiRef}
onPickEmoji={onPickEmoji}
onSubmit={handleSubmit}
onEditorStateChange={onEditorStateChange}
onTextTooLong={onTextTooLong}
2019-08-06 19:18:37 +00:00
onDirtyChange={setDirty}
skinTone={skinTone}
2020-11-03 01:19:52 +00:00
draftText={draftText}
draftBodyRanges={draftBodyRanges}
clearQuotedMessage={clearQuotedMessage}
getQuotedMessage={getQuotedMessage}
sortedGroupMembers={sortedGroupMembers}
/>
</div>
2019-08-06 19:18:37 +00:00
{!large ? (
<>
{stickerButtonFragment}
{!dirty ? micButtonFragment : null}
2019-08-07 00:40:25 +00:00
{attButton}
2019-08-06 19:18:37 +00:00
</>
) : null}
</div>
{large ? (
<div
className={classNames(
'module-composition-area__row',
'module-composition-area__row--control-row'
)}
>
{emojiButtonFragment}
{stickerButtonFragment}
2019-08-07 00:40:25 +00:00
{attButton}
2019-08-06 19:18:37 +00:00
{!dirty ? micButtonFragment : null}
{dirty || !showMic ? sendButtonFragment : null}
</div>
) : null}
</div>
);
};