Moves DraftAttachments into redux
This commit is contained in:
parent
f81f61af4e
commit
1c3c971cf4
20 changed files with 818 additions and 444 deletions
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
113
ts/components/CompositionUpload.tsx
Normal file
113
ts/components/CompositionUpload.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue