Moves DraftAttachments into redux

This commit is contained in:
Josh Perez 2021-09-24 16:02:30 -04:00 committed by Josh Perez
parent f81f61af4e
commit 1c3c971cf4
20 changed files with 818 additions and 444 deletions

View file

@ -30,15 +30,19 @@ const micCellEl = new DOMParser().parseFromString(
).body.firstElementChild as HTMLElement;
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationId: '123',
i18n,
micCellEl,
onChooseAttachment: action('onChooseAttachment'),
addAttachment: action('addAttachment'),
addPendingAttachment: action('addPendingAttachment'),
processAttachments: action('processAttachments'),
removeAttachment: action('removeAttachment'),
// AttachmentList
draftAttachments: overrideProps.draftAttachments || [],
onAddAttachment: action('onAddAttachment'),
onClearAttachments: action('onClearAttachments'),
onClickAttachment: action('onClickAttachment'),
onCloseAttachment: action('onCloseAttachment'),
// StagedLinkPreview
linkPreviewLoading: Boolean(overrideProps.linkPreviewLoading),
linkPreviewResult: overrideProps.linkPreviewResult,

View file

@ -1,7 +1,14 @@
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import React, {
MutableRefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { get, noop } from 'lodash';
import classNames from 'classnames';
import { Spinner } from './Spinner';
@ -39,52 +46,61 @@ import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { LinkPreviewWithDomain } from '../types/LinkPreview';
import { ConversationType } from '../state/ducks/conversations';
import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner';
import { CompositionUpload } from './CompositionUpload';
import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing';
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;
acceptedMessageRequest?: boolean;
addAttachment: (
conversationId: string,
attachment: AttachmentType
) => unknown;
addPendingAttachment: (
conversationId: string,
pendingAttachment: AttachmentType
) => unknown;
announcementsOnly?: boolean;
areWeAdmin?: boolean;
areWePending?: boolean;
areWePendingApproval?: boolean;
compositionApi?: MutableRefObject<CompositionAPIType>;
conversationId: string;
draftAttachments: ReadonlyArray<AttachmentType>;
groupAdmins: Array<ConversationType>;
groupVersion?: 1 | 2;
i18n: LocalizerType;
isFetchingUUID?: boolean;
isGroupV1AndDisabled?: boolean;
isMissingMandatoryProfileSharing?: boolean;
isSMSOnly?: boolean;
isFetchingUUID?: boolean;
left?: boolean;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
messageRequestsEnabled?: boolean;
acceptedMessageRequest?: boolean;
compositionApi?: React.MutableRefObject<CompositionAPIType>;
micCellEl?: HTMLElement;
draftAttachments: ReadonlyArray<AttachmentType>;
shouldSendHighQualityAttachments: boolean;
onChooseAttachment(): unknown;
onAddAttachment(): unknown;
onClickAttachment(): unknown;
onCloseAttachment(): unknown;
onClearAttachments(): unknown;
onClickAttachment(): unknown;
onClickQuotedMessage(): unknown;
onCloseLinkPreview(): unknown;
processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown;
onSelectMediaQuality(isHQ: boolean): unknown;
openConversation(conversationId: string): unknown;
quotedMessageProps?: Omit<
QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove'
>;
onClickQuotedMessage(): unknown;
removeAttachment: (conversationId: string, filePath: string) => unknown;
setQuotedMessage(message: undefined): unknown;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
onCloseLinkPreview(): unknown;
openConversation(conversationId: string): unknown;
shouldSendHighQualityAttachments: boolean;
}>;
export type Props = Pick<
@ -129,15 +145,19 @@ const emptyElement = (el: HTMLElement) => {
};
export const CompositionArea = ({
// Base props
addAttachment,
addPendingAttachment,
conversationId,
i18n,
micCellEl,
onChooseAttachment,
processAttachments,
removeAttachment,
// AttachmentList
draftAttachments,
onAddAttachment,
onClearAttachments,
onClickAttachment,
onCloseAttachment,
// StagedLinkPreview
linkPreviewLoading,
linkPreviewResult,
@ -206,21 +226,21 @@ export const CompositionArea = ({
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<InputApi | undefined>();
const [disabled, setDisabled] = useState(false);
const [micActive, setMicActive] = useState(false);
const [dirty, setDirty] = useState(false);
const [large, setLarge] = useState(false);
const inputApiRef = useRef<InputApi | undefined>();
const fileInputRef = useRef<null | HTMLInputElement>(null);
const handleForceSend = React.useCallback(() => {
const handleForceSend = useCallback(() => {
setLarge(false);
if (inputApiRef.current) {
inputApiRef.current.submit();
}
}, [inputApiRef, setLarge]);
const handleSubmit = React.useCallback<typeof onSubmit>(
const handleSubmit = useCallback<typeof onSubmit>(
(...args) => {
setLarge(false);
onSubmit(...args);
@ -228,7 +248,17 @@ export const CompositionArea = ({
[setLarge, onSubmit]
);
const focusInput = React.useCallback(() => {
const launchAttachmentPicker = () => {
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();
}
};
const focusInput = useCallback(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
@ -249,7 +279,6 @@ export const CompositionArea = ({
isDirty: () => dirty,
focusInput,
setDisabled,
setShowMic,
setMicActive,
reset: () => {
if (inputApiRef.current) {
@ -264,7 +293,7 @@ export const CompositionArea = ({
};
}
const insertEmoji = React.useCallback(
const insertEmoji = useCallback(
(e: EmojiPickDataType) => {
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(e);
@ -274,14 +303,16 @@ export const CompositionArea = ({
[inputApiRef, onPickEmoji]
);
const handleToggleLarge = React.useCallback(() => {
const handleToggleLarge = useCallback(() => {
setLarge(l => !l);
}, [setLarge]);
const shouldShowMicrophone = !draftAttachments.length && !draftText;
// 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);
React.useLayoutEffect(() => {
const micCellRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const { current: micCellContainer } = micCellRef;
if (micCellContainer && micCellEl) {
emptyElement(micCellContainer);
@ -289,7 +320,7 @@ export const CompositionArea = ({
}
return noop;
}, [micCellRef, micCellEl, large, dirty, showMic]);
}, [micCellRef, micCellEl, large, dirty, shouldShowMicrophone]);
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
@ -318,7 +349,7 @@ export const CompositionArea = ({
</>
);
const micButtonFragment = showMic ? (
const micButtonFragment = shouldShowMicrophone ? (
<div
className={classNames(
'CompositionArea__button-cell',
@ -338,7 +369,7 @@ export const CompositionArea = ({
<button
type="button"
className="paperclip thumbnail"
onClick={onChooseAttachment}
onClick={launchAttachmentPicker}
aria-label={i18n('CompositionArea--attach-file')}
/>
</div>
@ -384,7 +415,7 @@ export const CompositionArea = ({
) : null;
// Listen for cmd/ctrl-shift-x to toggle large composition mode
React.useEffect(() => {
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'`
@ -557,10 +588,14 @@ export const CompositionArea = ({
<AttachmentList
attachments={draftAttachments}
i18n={i18n}
onAddAttachment={onAddAttachment}
onAddAttachment={launchAttachmentPicker}
onClickAttachment={onClickAttachment}
onClose={onClearAttachments}
onCloseAttachment={onCloseAttachment}
onCloseAttachment={attachment => {
if (attachment.path) {
removeAttachment(conversationId, attachment.path);
}
}}
/>
</div>
) : null}
@ -610,9 +645,19 @@ export const CompositionArea = ({
{stickerButtonFragment}
{attButton}
{!dirty ? micButtonFragment : null}
{dirty || !showMic ? sendButtonFragment : 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>
);
};

View file

@ -0,0 +1,113 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ChangeEventHandler, forwardRef, useState } from 'react';
import { AttachmentType } from '../types/Attachment';
import { AttachmentToastType } from '../types/AttachmentToastType';
import { LocalizerType } from '../types/Util';
import { ToastCannotMixImageAndNonImageAttachments } from './ToastCannotMixImageAndNonImageAttachments';
import { ToastDangerousFileType } from './ToastDangerousFileType';
import { ToastFileSize } from './ToastFileSize';
import { ToastMaxAttachments } from './ToastMaxAttachments';
import { ToastOneNonImageAtATime } from './ToastOneNonImageAtATime';
import { ToastUnableToLoadAttachment } from './ToastUnableToLoadAttachment';
import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing';
export type PropsType = {
addAttachment: (
conversationId: string,
attachment: AttachmentType
) => unknown;
addPendingAttachment: (
conversationId: string,
pendingAttachment: AttachmentType
) => unknown;
conversationId: string;
draftAttachments: ReadonlyArray<AttachmentType>;
i18n: LocalizerType;
processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown;
removeAttachment: (conversationId: string, filePath: string) => unknown;
};
export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
(
{
addAttachment,
addPendingAttachment,
conversationId,
draftAttachments,
i18n,
processAttachments,
removeAttachment,
},
ref
) => {
const [toastType, setToastType] = useState<
AttachmentToastType | undefined
>();
const onFileInputChange: ChangeEventHandler<HTMLInputElement> = async event => {
const files = event.target.files || [];
await processAttachments({
addAttachment,
addPendingAttachment,
conversationId,
files: Array.from(files),
draftAttachments,
onShowToast: setToastType,
removeAttachment,
});
};
function closeToast() {
setToastType(undefined);
}
let toast;
if (toastType === AttachmentToastType.ToastFileSize) {
toast = (
<ToastFileSize
i18n={i18n}
limit={100}
onClose={closeToast}
units="MB"
/>
);
} else if (toastType === AttachmentToastType.ToastDangerousFileType) {
toast = <ToastDangerousFileType i18n={i18n} onClose={closeToast} />;
} else if (toastType === AttachmentToastType.ToastMaxAttachments) {
toast = <ToastMaxAttachments i18n={i18n} onClose={closeToast} />;
} else if (toastType === AttachmentToastType.ToastOneNonImageAtATime) {
toast = <ToastOneNonImageAtATime i18n={i18n} onClose={closeToast} />;
} else if (
toastType ===
AttachmentToastType.ToastCannotMixImageAndNonImageAttachments
) {
toast = (
<ToastCannotMixImageAndNonImageAttachments
i18n={i18n}
onClose={closeToast}
/>
);
} else if (toastType === AttachmentToastType.ToastUnableToLoadAttachment) {
toast = <ToastUnableToLoadAttachment i18n={i18n} onClose={closeToast} />;
}
return (
<>
{toast}
<input
hidden
multiple
onChange={onFileInputChange}
ref={ref}
type="file"
/>
</>
);
}
);

View file

@ -23,7 +23,7 @@ export const Toast = ({
disableCloseOnClick = false,
onClick,
onClose,
timeout = 2000,
timeout = 8000,
}: PropsType): JSX.Element | null => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);

View file

@ -61,7 +61,7 @@ export const AttachmentList = ({
{(attachments || []).map((attachment, index) => {
const url = getUrl(attachment);
const key = url || attachment.fileName || index;
const key = url || attachment.path || attachment.fileName || index;
const isImage = isImageAttachment(attachment);
const isVideo = isVideoAttachment(attachment);