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; ).body.firstElementChild as HTMLElement;
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationId: '123',
i18n, i18n,
micCellEl, micCellEl,
onChooseAttachment: action('onChooseAttachment'),
addAttachment: action('addAttachment'),
addPendingAttachment: action('addPendingAttachment'),
processAttachments: action('processAttachments'),
removeAttachment: action('removeAttachment'),
// AttachmentList // AttachmentList
draftAttachments: overrideProps.draftAttachments || [], draftAttachments: overrideProps.draftAttachments || [],
onAddAttachment: action('onAddAttachment'),
onClearAttachments: action('onClearAttachments'), onClearAttachments: action('onClearAttachments'),
onClickAttachment: action('onClickAttachment'), onClickAttachment: action('onClickAttachment'),
onCloseAttachment: action('onCloseAttachment'),
// StagedLinkPreview // StagedLinkPreview
linkPreviewLoading: Boolean(overrideProps.linkPreviewLoading), linkPreviewLoading: Boolean(overrideProps.linkPreviewLoading),
linkPreviewResult: overrideProps.linkPreviewResult, linkPreviewResult: overrideProps.linkPreviewResult,

View file

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

View file

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

8
ts/model-types.d.ts vendored
View file

@ -22,11 +22,7 @@ import {
} from './messages/MessageSendState'; } from './messages/MessageSendState';
import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions'; import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
import { ConversationColorType } from './types/Colors'; import { ConversationColorType } from './types/Colors';
import { import { AttachmentType, ThumbnailType } from './types/Attachment';
AttachmentType,
ThumbnailType,
OnDiskAttachmentDraftType,
} from './types/Attachment';
import { EmbeddedContactType } from './types/EmbeddedContact'; import { EmbeddedContactType } from './types/EmbeddedContact';
import { SignalService as Proto } from './protobuf'; import { SignalService as Proto } from './protobuf';
import { AvatarDataType } from './types/Avatar'; import { AvatarDataType } from './types/Avatar';
@ -214,7 +210,7 @@ export type ConversationAttributesType = {
customColorId?: string; customColorId?: string;
discoveredUnregisteredAt?: number; discoveredUnregisteredAt?: number;
draftChanged?: boolean; draftChanged?: boolean;
draftAttachments?: Array<OnDiskAttachmentDraftType>; draftAttachments?: Array<AttachmentType>;
draftBodyRanges?: Array<BodyRangeType>; draftBodyRanges?: Array<BodyRangeType>;
draftTimestamp?: number | null; draftTimestamp?: number | null;
inbox_position: number; inbox_position: number;

View file

@ -3,6 +3,8 @@
import { ThunkAction } from 'redux-thunk'; import { ThunkAction } from 'redux-thunk';
import * as log from '../../logging/log';
import { NoopActionType } from './noop';
import { StateType as RootStateType } from '../reducer'; import { StateType as RootStateType } from '../reducer';
import { AttachmentType } from '../../types/Attachment'; import { AttachmentType } from '../../types/Attachment';
import { MessageAttributesType } from '../../model-types.d'; import { MessageAttributesType } from '../../model-types.d';
@ -12,6 +14,13 @@ import {
REMOVE_PREVIEW as REMOVE_LINK_PREVIEW, REMOVE_PREVIEW as REMOVE_LINK_PREVIEW,
RemoveLinkPreviewActionType, RemoveLinkPreviewActionType,
} from './linkPreviews'; } from './linkPreviews';
import { writeDraftAttachment } from '../../util/writeDraftAttachment';
import { replaceIndex } from '../../util/replaceIndex';
import { resolveAttachmentOnDisk } from '../../util/resolveAttachmentOnDisk';
import {
handleAttachmentsProcessing,
HandleAttachmentsProcessingArgsType,
} from '../../util/handleAttachmentsProcessing';
// State // State
@ -25,12 +34,18 @@ export type ComposerStateType = {
// Actions // Actions
const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT';
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS'; const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
const RESET_COMPOSER = 'composer/RESET_COMPOSER'; const RESET_COMPOSER = 'composer/RESET_COMPOSER';
const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING'; const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING';
const SET_LINK_PREVIEW_RESULT = 'composer/SET_LINK_PREVIEW_RESULT'; const SET_LINK_PREVIEW_RESULT = 'composer/SET_LINK_PREVIEW_RESULT';
const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE'; const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE';
type AddPendingAttachmentActionType = {
type: typeof ADD_PENDING_ATTACHMENT;
payload: AttachmentType;
};
type ReplaceAttachmentsActionType = { type ReplaceAttachmentsActionType = {
type: typeof REPLACE_ATTACHMENTS; type: typeof REPLACE_ATTACHMENTS;
payload: ReadonlyArray<AttachmentType>; payload: ReadonlyArray<AttachmentType>;
@ -59,16 +74,21 @@ type SetQuotedMessageActionType = {
}; };
type ComposerActionType = type ComposerActionType =
| AddPendingAttachmentActionType
| RemoveLinkPreviewActionType
| ReplaceAttachmentsActionType | ReplaceAttachmentsActionType
| ResetComposerActionType | ResetComposerActionType
| SetHighQualitySettingActionType | SetHighQualitySettingActionType
| SetLinkPreviewResultActionType | SetLinkPreviewResultActionType
| RemoveLinkPreviewActionType
| SetQuotedMessageActionType; | SetQuotedMessageActionType;
// Action Creators // Action Creators
export const actions = { export const actions = {
addAttachment,
addPendingAttachment,
processAttachments,
removeAttachment,
replaceAttachments, replaceAttachments,
resetComposer, resetComposer,
setLinkPreviewResult, setLinkPreviewResult,
@ -76,9 +96,137 @@ export const actions = {
setQuotedMessage, setQuotedMessage,
}; };
// Not cool that we have to pull from ConversationModel here
// but if the current selected conversation isn't the one that we're operating
// on then we won't be able to grab attachments from state so we resort to the
// next in-memory store.
function getAttachmentsFromConversationModel(
conversationId: string
): Array<AttachmentType> {
const conversation = window.ConversationController.get(conversationId);
return conversation?.get('draftAttachments') || [];
}
function addAttachment(
conversationId: string,
attachment: AttachmentType
): ThunkAction<void, RootStateType, unknown, ReplaceAttachmentsActionType> {
return async (dispatch, getState) => {
const isSelectedConversation =
getState().conversations.selectedConversationId === conversationId;
const draftAttachments = isSelectedConversation
? getState().composer.attachments
: getAttachmentsFromConversationModel(conversationId);
const hasDraftAttachmentPending = draftAttachments.some(
draftAttachment =>
draftAttachment.pending && draftAttachment.path === attachment.path
);
// User has canceled the draft so we don't need to continue processing
if (!hasDraftAttachmentPending) {
return;
}
const onDisk = await writeDraftAttachment(attachment);
// Remove any pending attachments that were transcoding
const index = draftAttachments.findIndex(
draftAttachment => draftAttachment.path === attachment.path
);
let nextAttachments = draftAttachments;
if (index < 0) {
log.warn(
`addAttachment: Failed to find pending attachment with path ${attachment.path}`
);
nextAttachments = [...draftAttachments, onDisk];
} else {
nextAttachments = replaceIndex(draftAttachments, index, onDisk);
}
replaceAttachments(conversationId, nextAttachments)(
dispatch,
getState,
null
);
const conversation = window.ConversationController.get(conversationId);
if (conversation) {
conversation.attributes.draftAttachments = nextAttachments;
window.Signal.Data.updateConversation(conversation.attributes);
}
};
}
function addPendingAttachment(
conversationId: string,
pendingAttachment: AttachmentType
): ThunkAction<void, RootStateType, unknown, ReplaceAttachmentsActionType> {
return (dispatch, getState) => {
const isSelectedConversation =
getState().conversations.selectedConversationId === conversationId;
const draftAttachments = isSelectedConversation
? getState().composer.attachments
: getAttachmentsFromConversationModel(conversationId);
const nextAttachments = [...draftAttachments, pendingAttachment];
dispatch({
type: REPLACE_ATTACHMENTS,
payload: nextAttachments,
});
const conversation = window.ConversationController.get(conversationId);
if (conversation) {
conversation.attributes.draftAttachments = nextAttachments;
window.Signal.Data.updateConversation(conversation.attributes);
}
};
}
function processAttachments(
options: HandleAttachmentsProcessingArgsType
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
await handleAttachmentsProcessing(options);
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function removeAttachment(
conversationId: string,
filePath: string
): ThunkAction<void, RootStateType, unknown, ReplaceAttachmentsActionType> {
return (dispatch, getState) => {
const { attachments } = getState().composer;
const nextAttachments = attachments.filter(
attachment => attachment.path !== filePath
);
const conversation = window.ConversationController.get(conversationId);
if (conversation) {
conversation.attributes.draftAttachments = nextAttachments;
conversation.attributes.draftChanged = true;
window.Signal.Data.updateConversation(conversation.attributes);
}
replaceAttachments(conversationId, nextAttachments)(
dispatch,
getState,
null
);
};
}
function replaceAttachments( function replaceAttachments(
conversationId: string, conversationId: string,
payload: ReadonlyArray<AttachmentType> attachments: ReadonlyArray<AttachmentType>
): ThunkAction<void, RootStateType, unknown, ReplaceAttachmentsActionType> { ): ThunkAction<void, RootStateType, unknown, ReplaceAttachmentsActionType> {
return (dispatch, getState) => { return (dispatch, getState) => {
// If the call came from a conversation we are no longer in we do not // If the call came from a conversation we are no longer in we do not
@ -89,7 +237,7 @@ function replaceAttachments(
dispatch({ dispatch({
type: REPLACE_ATTACHMENTS, type: REPLACE_ATTACHMENTS,
payload, payload: attachments.map(resolveAttachmentOnDisk),
}); });
}; };
} }
@ -189,5 +337,12 @@ export function reducer(
}); });
} }
if (action.type === ADD_PENDING_ATTACHMENT) {
return {
...state,
attachments: [...state.attachments, action.payload],
};
}
return state; return state;
} }

View file

@ -78,6 +78,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return { return {
// Base // Base
conversationId: id,
i18n: getIntl(state), i18n: getIntl(state),
// AttachmentsList // AttachmentsList
draftAttachments, draftAttachments,

View file

@ -39,7 +39,9 @@ describe('both/state/ducks/composer', () => {
const { replaceAttachments } = actions; const { replaceAttachments } = actions;
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const attachments: Array<AttachmentType> = [{ contentType: IMAGE_JPEG }]; const attachments: Array<AttachmentType> = [
{ contentType: IMAGE_JPEG, pending: false, url: '' },
];
replaceAttachments('123', attachments)( replaceAttachments('123', attachments)(
dispatch, dispatch,
getRootStateFunction('123'), getRootStateFunction('123'),

View file

@ -55,6 +55,7 @@ export type AttachmentType = {
contentType: MIME.MIMEType; contentType: MIME.MIMEType;
path: string; path: string;
}; };
screenshotData?: Uint8Array;
screenshotPath?: string; screenshotPath?: string;
flags?: number; flags?: number;
thumbnail?: ThumbnailType; thumbnail?: ThumbnailType;
@ -96,19 +97,6 @@ export type InMemoryAttachmentDraftType =
pending: true; pending: true;
}; };
export type OnDiskAttachmentDraftType =
| ({
caption?: string;
pending: false;
screenshotPath?: string;
} & BaseAttachmentDraftType)
| {
contentType: MIME.MIMEType;
fileName: string;
path: string;
pending: true;
};
export type AttachmentDraftType = export type AttachmentDraftType =
| ({ | ({
url: string; url: string;

View file

@ -0,0 +1,11 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export enum AttachmentToastType {
ToastCannotMixImageAndNonImageAttachments,
ToastDangerousFileType,
ToastFileSize,
ToastMaxAttachments,
ToastOneNonImageAtATime,
ToastUnableToLoadAttachment,
}

View file

@ -0,0 +1,15 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { AttachmentType } from '../types/Attachment';
export async function deleteDraftAttachment(
attachment: Pick<AttachmentType, 'screenshotPath' | 'path'>
): Promise<void> {
if (attachment.screenshotPath) {
await window.Signal.Migrations.deleteDraftFile(attachment.screenshotPath);
}
if (attachment.path) {
await window.Signal.Migrations.deleteDraftFile(attachment.path);
}
}

18
ts/util/fileToBytes.ts Normal file
View file

@ -0,0 +1,18 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function fileToBytes(file: Blob): Promise<Uint8Array> {
return new Promise((resolve, rejectPromise) => {
const FR = new FileReader();
FR.onload = () => {
if (!FR.result || typeof FR.result === 'string') {
rejectPromise(new Error('bytesFromFile: No result!'));
return;
}
resolve(new Uint8Array(FR.result));
};
FR.onerror = rejectPromise;
FR.onabort = rejectPromise;
FR.readAsArrayBuffer(file);
});
}

View file

@ -0,0 +1,82 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
getPendingAttachment,
preProcessAttachment,
processAttachment,
} from './processAttachment';
import { AttachmentType } from '../types/Attachment';
import { AttachmentToastType } from '../types/AttachmentToastType';
export type AddAttachmentActionType = (
conversationId: string,
attachment: AttachmentType
) => unknown;
export type AddPendingAttachmentActionType = (
conversationId: string,
pendingAttachment: AttachmentType
) => unknown;
export type RemoveAttachmentActionType = (
conversationId: string,
filePath: string
) => unknown;
export type HandleAttachmentsProcessingArgsType = {
addAttachment: AddAttachmentActionType;
addPendingAttachment: AddPendingAttachmentActionType;
conversationId: string;
draftAttachments: ReadonlyArray<AttachmentType>;
files: ReadonlyArray<File>;
onShowToast: (toastType: AttachmentToastType) => unknown;
removeAttachment: RemoveAttachmentActionType;
};
export async function handleAttachmentsProcessing({
addAttachment,
addPendingAttachment,
conversationId,
draftAttachments,
files,
onShowToast,
removeAttachment,
}: HandleAttachmentsProcessingArgsType): Promise<void> {
if (!files.length) {
return;
}
const nextDraftAttachments = [...draftAttachments];
const filesToProcess: Array<File> = [];
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
const processingResult = preProcessAttachment(file, nextDraftAttachments);
if (processingResult) {
onShowToast(processingResult);
} else {
const pendingAttachment = getPendingAttachment(file);
if (pendingAttachment) {
addPendingAttachment(conversationId, pendingAttachment);
filesToProcess.push(file);
// we keep a running count of the draft attachments so we can show a
// toast in case we add too many attachments at once
nextDraftAttachments.push(pendingAttachment);
}
}
}
await Promise.all(
filesToProcess.map(async file => {
try {
const attachment = await processAttachment(file);
if (!attachment) {
removeAttachment(conversationId, file.path);
return;
}
addAttachment(conversationId, attachment);
} catch (err) {
removeAttachment(conversationId, file.path);
onShowToast(AttachmentToastType.ToastUnableToLoadAttachment);
}
})
);
}

View file

@ -0,0 +1,43 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { blobToArrayBuffer } from 'blob-util';
import * as log from '../logging/log';
import { makeVideoScreenshot } from '../types/VisualAttachment';
import { IMAGE_PNG, stringToMIMEType } from '../types/MIME';
import { InMemoryAttachmentDraftType } from '../types/Attachment';
import { fileToBytes } from './fileToBytes';
export async function handleVideoAttachment(
file: Readonly<File>
): Promise<InMemoryAttachmentDraftType> {
const objectUrl = URL.createObjectURL(file);
if (!objectUrl) {
throw new Error('Failed to create object url for video!');
}
try {
const screenshotContentType = IMAGE_PNG;
const screenshotBlob = await makeVideoScreenshot({
objectUrl,
contentType: screenshotContentType,
logger: log,
});
const screenshotData = await blobToArrayBuffer(screenshotBlob);
const data = await fileToBytes(file);
return {
contentType: stringToMIMEType(file.type),
data,
fileName: file.name,
path: file.name,
pending: false,
screenshotContentType,
screenshotData: new Uint8Array(screenshotData),
screenshotSize: screenshotData.byteLength,
size: data.byteLength,
};
} finally {
URL.revokeObjectURL(objectUrl);
}
}

View file

@ -12289,22 +12289,6 @@
"updated": "2020-05-20T20:10:43.540Z", "updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Our code, no user input, only clearing out the dom" "reasonDetail": "Our code, no user input, only clearing out the dom"
}, },
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.js",
"line": " const inputApiRef = React.useRef();",
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.js",
"line": " const micCellRef = React.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Needed for the composition area."
},
{ {
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx", "path": "ts/components/CompositionArea.tsx",
@ -12316,16 +12300,23 @@
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CompositionArea.tsx", "path": "ts/components/CompositionArea.tsx",
"line": " const inputApiRef = React.useRef<InputApi | undefined>();", "line": " const micCellRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z" "updated": "2021-07-30T16:57:33.618Z"
}, },
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CompositionArea.tsx", "path": "ts/components/CompositionArea.tsx",
"line": " const micCellRef = React.useRef<HTMLDivElement>(null);", "line": " const inputApiRef = useRef<InputApi | undefined>();",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z" "updated": "2021-09-23T00:07:11.885Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.tsx",
"line": " const fileInputRef = useRef<null | HTMLInputElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-09-23T00:07:11.885Z"
}, },
{ {
"rule": "React-useRef", "rule": "React-useRef",
@ -14283,4 +14274,4 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z" "updated": "2021-09-17T21:02:59.414Z"
} }
] ]

View file

@ -0,0 +1,122 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import path from 'path';
import * as log from '../logging/log';
import { AttachmentType } from '../types/Attachment';
import { AttachmentToastType } from '../types/AttachmentToastType';
import { fileToBytes } from './fileToBytes';
import { handleImageAttachment } from './handleImageAttachment';
import { handleVideoAttachment } from './handleVideoAttachment';
import { isAttachmentSizeOkay } from './isAttachmentSizeOkay';
import { isFileDangerous } from './isFileDangerous';
import { isHeic, isImage, stringToMIMEType } from '../types/MIME';
import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome';
export function getPendingAttachment(file: File): AttachmentType | undefined {
if (!file) {
return;
}
const fileType = stringToMIMEType(file.type);
const { name: fileName } = path.parse(file.name);
return {
contentType: fileType,
fileName,
path: file.name,
pending: true,
};
}
export function preProcessAttachment(
file: File,
draftAttachments: Array<AttachmentType>
): AttachmentToastType | undefined {
if (!file) {
return;
}
const MB = 1000 * 1024;
if (file.size > 100 * MB) {
return AttachmentToastType.ToastFileSize;
}
if (isFileDangerous(file.name)) {
return AttachmentToastType.ToastDangerousFileType;
}
if (draftAttachments.length >= 32) {
return AttachmentToastType.ToastMaxAttachments;
}
const haveNonImage = draftAttachments.some(
(attachment: AttachmentType) => !isImage(attachment.contentType)
);
// You can't add another attachment if you already have a non-image staged
if (haveNonImage) {
return AttachmentToastType.ToastOneNonImageAtATime;
}
const fileType = stringToMIMEType(file.type);
// You can't add a non-image attachment if you already have attachments staged
if (!isImage(fileType) && draftAttachments.length > 0) {
return AttachmentToastType.ToastCannotMixImageAndNonImageAttachments;
}
return undefined;
}
export async function processAttachment(
file: File
): Promise<AttachmentType | void> {
const fileType = stringToMIMEType(file.type);
let attachment: AttachmentType;
try {
if (isImageTypeSupported(fileType) || isHeic(fileType)) {
attachment = await handleImageAttachment(file);
} else if (isVideoTypeSupported(fileType)) {
attachment = await handleVideoAttachment(file);
} else {
const data = await fileToBytes(file);
attachment = {
contentType: fileType,
data,
fileName: file.name,
path: file.name,
pending: false,
size: data.byteLength,
};
}
} catch (e) {
log.error(
`Was unable to generate thumbnail for fileType ${fileType}`,
e && e.stack ? e.stack : e
);
const data = await fileToBytes(file);
attachment = {
contentType: fileType,
data,
fileName: file.name,
path: file.name,
pending: false,
size: data.byteLength,
};
}
try {
if (isAttachmentSizeOkay(attachment)) {
return attachment;
}
} catch (error) {
log.error(
'Error ensuring that image is properly sized:',
error && error.stack ? error.stack : error
);
throw error;
}
}

View file

@ -0,0 +1,40 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { pick } from 'lodash';
import * as log from '../logging/log';
import { AttachmentType } from '../types/Attachment';
export function resolveAttachmentOnDisk(
attachment: AttachmentType
): AttachmentType {
let url = '';
if (attachment.pending) {
return attachment;
}
if (attachment.screenshotPath) {
url = window.Signal.Migrations.getAbsoluteDraftPath(
attachment.screenshotPath
);
} else if (attachment.path) {
url = window.Signal.Migrations.getAbsoluteDraftPath(attachment.path);
} else {
log.warn(
'resolveOnDiskAttachment: Attachment was missing both screenshotPath and path fields'
);
}
return {
...pick(attachment, [
'blurHash',
'caption',
'contentType',
'fileName',
'path',
'size',
]),
pending: false,
url,
};
}

View file

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { omit } from 'lodash';
import { AttachmentType } from '../types/Attachment';
export async function writeDraftAttachment(
attachment: AttachmentType
): Promise<AttachmentType> {
if (attachment.pending) {
throw new Error('writeDraftAttachment: Cannot write pending attachment');
}
const result: AttachmentType = {
...omit(attachment, ['data', 'screenshotData']),
pending: false,
};
if (attachment.data) {
result.path = await window.Signal.Migrations.writeNewDraftData(
attachment.data
);
}
if (attachment.screenshotData) {
result.screenshotPath = await window.Signal.Migrations.writeNewDraftData(
attachment.screenshotData
);
}
return result;
}

View file

@ -1,16 +1,13 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import nodePath from 'path';
import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'; import { unstable_batchedUpdates as batchedUpdates } from 'react-dom';
import { debounce, flatten, omit, pick, reject, throttle } from 'lodash'; import { debounce, flatten, omit, throttle } from 'lodash';
import { render } from 'mustache'; import { render } from 'mustache';
import { import {
AttachmentDraftType, AttachmentDraftType,
AttachmentType, AttachmentType,
InMemoryAttachmentDraftType,
OnDiskAttachmentDraftType,
isGIF, isGIF,
} from '../types/Attachment'; } from '../types/Attachment';
import * as Attachment from '../types/Attachment'; import * as Attachment from '../types/Attachment';
@ -20,8 +17,6 @@ import { BodyRangeType, BodyRangesType } from '../types/Util';
import { import {
IMAGE_JPEG, IMAGE_JPEG,
IMAGE_WEBP, IMAGE_WEBP,
IMAGE_PNG,
isHeic,
MIMEType, MIMEType,
stringToMIMEType, stringToMIMEType,
} from '../types/MIME'; } from '../types/MIME';
@ -41,7 +36,6 @@ import {
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { maybeParseUrl } from '../util/url'; import { maybeParseUrl } from '../util/url';
import { replaceIndex } from '../util/replaceIndex';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue'; import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
@ -72,17 +66,13 @@ import {
} from '../types/LinkPreview'; } from '../types/LinkPreview';
import * as LinkPreview from '../types/LinkPreview'; import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import { import { autoScale } from '../util/handleImageAttachment';
autoScale,
handleImageAttachment,
} from '../util/handleImageAttachment';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import { markViewed } from '../services/MessageUpdater'; import { markViewed } from '../services/MessageUpdater';
import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue'; import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue';
import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue'; import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue';
import type { EmbeddedContactType } from '../types/EmbeddedContact'; import type { EmbeddedContactType } from '../types/EmbeddedContact';
import * as VisualAttachment from '../types/VisualAttachment'; import * as VisualAttachment from '../types/VisualAttachment';
import * as MIME from '../types/MIME';
import type { AnyViewClass, BasicReactWrapperViewClass } from '../window.d'; import type { AnyViewClass, BasicReactWrapperViewClass } from '../window.d';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
@ -93,7 +83,6 @@ import { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCa
import { showToast } from '../util/showToast'; import { showToast } from '../util/showToast';
import { ToastBlocked } from '../components/ToastBlocked'; import { ToastBlocked } from '../components/ToastBlocked';
import { ToastBlockedGroup } from '../components/ToastBlockedGroup'; import { ToastBlockedGroup } from '../components/ToastBlockedGroup';
import { ToastCannotMixImageAndNonImageAttachments } from '../components/ToastCannotMixImageAndNonImageAttachments';
import { ToastConversationArchived } from '../components/ToastConversationArchived'; import { ToastConversationArchived } from '../components/ToastConversationArchived';
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
@ -101,23 +90,26 @@ import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed'; import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
import { ToastExpired } from '../components/ToastExpired'; import { ToastExpired } from '../components/ToastExpired';
import { ToastFileSaved } from '../components/ToastFileSaved'; import { ToastFileSaved } from '../components/ToastFileSaved';
import { ToastFileSize } from '../components/ToastFileSize';
import { ToastInvalidConversation } from '../components/ToastInvalidConversation'; import { ToastInvalidConversation } from '../components/ToastInvalidConversation';
import { ToastLeftGroup } from '../components/ToastLeftGroup'; import { ToastLeftGroup } from '../components/ToastLeftGroup';
import { ToastMaxAttachments } from '../components/ToastMaxAttachments';
import { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong'; import { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong';
import { ToastOneNonImageAtATime } from '../components/ToastOneNonImageAtATime';
import { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound'; import { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound';
import { ToastPinnedConversationsFull } from '../components/ToastPinnedConversationsFull'; import { ToastPinnedConversationsFull } from '../components/ToastPinnedConversationsFull';
import { ToastReactionFailed } from '../components/ToastReactionFailed'; import { ToastReactionFailed } from '../components/ToastReactionFailed';
import { ToastReportedSpamAndBlocked } from '../components/ToastReportedSpamAndBlocked'; import { ToastReportedSpamAndBlocked } from '../components/ToastReportedSpamAndBlocked';
import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming'; import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming';
import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing'; import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing';
import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment';
import { ToastVoiceNoteLimit } from '../components/ToastVoiceNoteLimit'; import { ToastVoiceNoteLimit } from '../components/ToastVoiceNoteLimit';
import { ToastVoiceNoteMustBeOnlyAttachment } from '../components/ToastVoiceNoteMustBeOnlyAttachment'; import { ToastVoiceNoteMustBeOnlyAttachment } from '../components/ToastVoiceNoteMustBeOnlyAttachment';
import { copyGroupLink } from '../util/copyGroupLink'; import { copyGroupLink } from '../util/copyGroupLink';
import { isAttachmentSizeOkay } from '../util/isAttachmentSizeOkay'; import { fileToBytes } from '../util/fileToBytes';
import { AttachmentToastType } from '../types/AttachmentToastType';
import { ToastCannotMixImageAndNonImageAttachments } from '../components/ToastCannotMixImageAndNonImageAttachments';
import { ToastFileSize } from '../components/ToastFileSize';
import { ToastMaxAttachments } from '../components/ToastMaxAttachments';
import { ToastOneNonImageAtATime } from '../components/ToastOneNonImageAtATime';
import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment';
import { deleteDraftAttachment } from '../util/deleteDraftAttachment';
type AttachmentOptions = { type AttachmentOptions = {
messageId: string; messageId: string;
@ -134,10 +126,8 @@ const { Message } = window.Signal.Types;
const { const {
copyIntoTempDirectory, copyIntoTempDirectory,
deleteDraftFile,
deleteTempFile, deleteTempFile,
getAbsoluteAttachmentPath, getAbsoluteAttachmentPath,
getAbsoluteDraftPath,
getAbsoluteTempPath, getAbsoluteTempPath,
loadAttachmentData, loadAttachmentData,
loadPreviewData, loadPreviewData,
@ -147,7 +137,6 @@ const {
readDraftData, readDraftData,
saveAttachmentToDisk, saveAttachmentToDisk,
upgradeMessageSchema, upgradeMessageSchema,
writeNewDraftData,
} = window.Signal.Migrations; } = window.Signal.Migrations;
const { const {
@ -543,7 +532,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
caretLocation?: number caretLocation?: number
) => this.onEditorStateChange(msg, bodyRanges, caretLocation), ) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => showToast(ToastMessageBodyTooLong), onTextTooLong: () => showToast(ToastMessageBodyTooLong),
onChooseAttachment: this.onChooseAttachment.bind(this),
getQuotedMessage: () => this.model.get('quotedMessageId'), getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null), clearQuotedMessage: () => this.setQuoteMessage(null),
micCellEl, micCellEl,
@ -595,9 +583,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}); });
}, },
onAddAttachment: this.onChooseAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this), onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.removeDraftAttachment.bind(this),
onClearAttachments: this.clearAttachments.bind(this), onClearAttachments: this.clearAttachments.bind(this),
onSelectMediaQuality: (isHQ: boolean) => { onSelectMediaQuality: (isHQ: boolean) => {
window.reduxActions.composer.setMediaQualitySetting(isHQ); window.reduxActions.composer.setMediaQualitySetting(isHQ);
@ -1320,20 +1306,59 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} }
onChooseAttachment(): void { onChooseAttachment(): void {
// TODO: DESKTOP-2425
this.$('input.file-input').click(); this.$('input.file-input').click();
} }
async onChoseAttachment(): Promise<void> { async onChoseAttachment(): Promise<void> {
const fileField = this.$('input.file-input'); const fileField = this.$('input.file-input');
const files = fileField.prop('files'); const files: Array<File> = Array.from(fileField.prop('files'));
for (let i = 0, max = files.length; i < max; i += 1) {
const file = files[i];
// eslint-disable-next-line no-await-in-loop
await this.maybeAddAttachment(file);
this.toggleMicrophone();
}
fileField.val([]); fileField.val([]);
await this.processAttachments(files);
}
// TODO DESKTOP-2426
async processAttachments(files: Array<File>): Promise<void> {
const {
addAttachment,
addPendingAttachment,
processAttachments,
removeAttachment,
} = window.reduxActions.composer;
await processAttachments({
addAttachment,
addPendingAttachment,
conversationId: this.model.id,
draftAttachments: this.model.get('draftAttachments') || [],
files,
onShowToast: (toastType: AttachmentToastType) => {
if (toastType === AttachmentToastType.ToastFileSize) {
showToast(ToastFileSize, {
limit: 100,
units: 'MB',
});
} else if (toastType === AttachmentToastType.ToastDangerousFileType) {
showToast(ToastDangerousFileType);
} else if (toastType === AttachmentToastType.ToastMaxAttachments) {
showToast(ToastMaxAttachments);
} else if (toastType === AttachmentToastType.ToastOneNonImageAtATime) {
showToast(ToastOneNonImageAtATime);
} else if (
toastType ===
AttachmentToastType.ToastCannotMixImageAndNonImageAttachments
) {
showToast(ToastCannotMixImageAndNonImageAttachments);
} else if (
toastType === AttachmentToastType.ToastUnableToLoadAttachment
) {
showToast(ToastUnableToLoadAttachment);
}
},
removeAttachment,
});
} }
unload(reason: string): void { unload(reason: string): void {
@ -1419,10 +1444,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
e.preventDefault(); e.preventDefault();
const { files } = event.dataTransfer; const { files } = event.dataTransfer;
for (let i = 0, max = files.length; i < max; i += 1) { this.processAttachments(Array.from(files));
const file = files[i];
this.maybeAddAttachment(file);
}
} }
onPaste(e: JQuery.TriggeredEvent): void { onPaste(e: JQuery.TriggeredEvent): void {
@ -1445,14 +1467,17 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
const files: Array<File> = [];
for (let i = 0; i < items.length; i += 1) { for (let i = 0; i < items.length; i += 1) {
if (items[i].type.split('/')[0] === 'image') { if (items[i].type.split('/')[0] === 'image') {
const file = items[i].getAsFile(); const file = items[i].getAsFile();
if (file) { if (file) {
this.maybeAddAttachment(file); files.push(file);
} }
} }
} }
this.processAttachments(files);
} }
syncMessageRequestResponse( syncMessageRequestResponse(
@ -1511,7 +1536,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const onSave = (caption?: string) => { const onSave = (caption?: string) => {
const attachments = this.model.get('draftAttachments') || []; const attachments = this.model.get('draftAttachments') || [];
this.model.set({ this.model.set({
draftAttachments: attachments.map((item: OnDiskAttachmentDraftType) => { draftAttachments: attachments.map((item: AttachmentType) => {
if (item.pending || attachment.pending) { if (item.pending || attachment.pending) {
return item; return item;
} }
@ -1551,98 +1576,10 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el); window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
} }
// eslint-disable-next-line class-methods-use-this
async deleteDraftAttachment(
attachment: Pick<AttachmentType, 'screenshotPath' | 'path'>
): Promise<void> {
if (attachment.screenshotPath) {
await deleteDraftFile(attachment.screenshotPath);
}
if (attachment.path) {
await deleteDraftFile(attachment.path);
}
}
async saveModel(): Promise<void> { async saveModel(): Promise<void> {
window.Signal.Data.updateConversation(this.model.attributes); window.Signal.Data.updateConversation(this.model.attributes);
} }
async addAttachment(attachment: InMemoryAttachmentDraftType): Promise<void> {
const onDisk = await this.writeDraftAttachment(attachment);
// Remove any pending attachments that were transcoding
const draftAttachments = this.model.get('draftAttachments') || [];
const index = draftAttachments.findIndex(
draftAttachment => draftAttachment.path === attachment.path
);
if (index < 0) {
log.warn(
`addAttachment: Failed to find pending attachment with path ${attachment.path}`
);
this.model.set({
draftAttachments: [...draftAttachments, onDisk],
});
} else {
this.model.set({
draftAttachments: replaceIndex(draftAttachments, index, onDisk),
});
}
this.updateAttachmentsView();
await this.saveModel();
}
// eslint-disable-next-line class-methods-use-this
resolveOnDiskAttachment(
attachment: OnDiskAttachmentDraftType
): AttachmentDraftType {
let url = '';
if (attachment.pending) {
return attachment;
}
if (attachment.screenshotPath) {
url = getAbsoluteDraftPath(attachment.screenshotPath);
} else if (attachment.path) {
url = getAbsoluteDraftPath(attachment.path);
} else {
log.warn(
'resolveOnDiskAttachment: Attachment was missing both screenshotPath and path fields'
);
}
return {
...pick(attachment, [
'blurHash',
'caption',
'contentType',
'fileName',
'path',
'size',
]),
pending: false,
url,
};
}
async removeDraftAttachment(
attachment: Pick<AttachmentType, 'path' | 'screenshotPath'>
): Promise<void> {
const draftAttachments = this.model.get('draftAttachments') || [];
this.model.set({
draftAttachments: reject(
draftAttachments,
item => item.path === attachment.path
),
draftChanged: true,
});
this.updateAttachmentsView();
await this.saveModel();
await this.deleteDraftAttachment(attachment);
}
async clearAttachments(): Promise<void> { async clearAttachments(): Promise<void> {
this.voiceNoteAttachment = undefined; this.voiceNoteAttachment = undefined;
@ -1658,9 +1595,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
await Promise.all([ await Promise.all([
this.saveModel(), this.saveModel(),
Promise.all( Promise.all(
draftAttachments.map(attachment => draftAttachments.map(attachment => deleteDraftAttachment(attachment))
this.deleteDraftAttachment(attachment)
)
), ),
]); ]);
} }
@ -1690,12 +1625,16 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
async getFile( async getFile(
attachment?: OnDiskAttachmentDraftType attachment?: AttachmentType
): Promise<AttachmentType | undefined> { ): Promise<AttachmentType | undefined> {
if (!attachment || attachment.pending) { if (!attachment || attachment.pending) {
return; return;
} }
if (!attachment.path) {
return;
}
const data = await readDraftData(attachment.path); const data = await readDraftData(attachment.path);
if (data.byteLength !== attachment.size) { if (data.byteLength !== attachment.size) {
log.error( log.error(
@ -1710,231 +1649,17 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}; };
} }
// eslint-disable-next-line class-methods-use-this
bytesFromFile(file: Blob): Promise<Uint8Array> {
return new Promise((resolve, rejectPromise) => {
const FR = new FileReader();
FR.onload = () => {
if (!FR.result || typeof FR.result === 'string') {
rejectPromise(new Error('bytesFromFile: No result!'));
return;
}
resolve(new Uint8Array(FR.result));
};
FR.onerror = rejectPromise;
FR.onabort = rejectPromise;
FR.readAsArrayBuffer(file);
});
}
updateAttachmentsView(): void { updateAttachmentsView(): void {
const draftAttachments = this.model.get('draftAttachments') || []; const draftAttachments = this.model.get('draftAttachments') || [];
window.reduxActions.composer.replaceAttachments( window.reduxActions.composer.replaceAttachments(
this.model.get('id'), this.model.get('id'),
draftAttachments.map((att: OnDiskAttachmentDraftType) => draftAttachments
this.resolveOnDiskAttachment(att)
)
); );
this.toggleMicrophone();
if (this.hasFiles({ includePending: true })) { if (this.hasFiles({ includePending: true })) {
this.removeLinkPreview(); this.removeLinkPreview();
} }
} }
// eslint-disable-next-line class-methods-use-this
async writeDraftAttachment(
attachment: InMemoryAttachmentDraftType
): Promise<OnDiskAttachmentDraftType> {
if (attachment.pending) {
throw new Error('writeDraftAttachment: Cannot write pending attachment');
}
const result: OnDiskAttachmentDraftType = {
...omit(attachment, ['data', 'screenshotData']),
pending: false,
};
if (attachment.data) {
result.path = await writeNewDraftData(attachment.data);
}
if (attachment.screenshotData) {
result.screenshotPath = await writeNewDraftData(
attachment.screenshotData
);
}
return result;
}
async maybeAddAttachment(file: File): Promise<void> {
if (!file) {
return;
}
const MB = 1000 * 1024;
if (file.size > 100 * MB) {
showToast(ToastFileSize, {
limit: 100,
units: 'MB',
});
return;
}
if (window.Signal.Util.isFileDangerous(file.name)) {
showToast(ToastDangerousFileType);
return;
}
const draftAttachments = this.model.get('draftAttachments') || [];
if (draftAttachments.length >= 32) {
showToast(ToastMaxAttachments);
return;
}
const haveNonImage = draftAttachments.some(
(attachment: OnDiskAttachmentDraftType) =>
!MIME.isImage(attachment.contentType)
);
// You can't add another attachment if you already have a non-image staged
if (haveNonImage) {
showToast(ToastOneNonImageAtATime);
return;
}
const fileType = stringToMIMEType(file.type);
// You can't add a non-image attachment if you already have attachments staged
if (!MIME.isImage(fileType) && draftAttachments.length > 0) {
showToast(ToastCannotMixImageAndNonImageAttachments);
return;
}
// Add a pending attachment since async processing happens below
const path = file.name;
const fileName = nodePath.parse(file.name).name;
this.model.set({
draftAttachments: [
...draftAttachments,
{
contentType: fileType,
fileName,
path,
pending: true,
},
],
});
this.updateAttachmentsView();
let attachment: InMemoryAttachmentDraftType;
try {
if (
window.Signal.Util.GoogleChrome.isImageTypeSupported(fileType) ||
isHeic(fileType)
) {
attachment = await handleImageAttachment(file);
const hasDraftAttachmentPending = (
this.model.get('draftAttachments') || []
).some(
draftAttachment =>
draftAttachment.pending && draftAttachment.path === path
);
// User has canceled the draft so we don't need to continue processing
if (!hasDraftAttachmentPending) {
return;
}
} else if (
window.Signal.Util.GoogleChrome.isVideoTypeSupported(fileType)
) {
attachment = await this.handleVideoAttachment(file);
} else {
const data = await this.bytesFromFile(file);
attachment = {
contentType: fileType,
data,
fileName: file.name,
path: file.name,
pending: false,
size: data.byteLength,
};
}
} catch (e) {
log.error(
`Was unable to generate thumbnail for fileType ${fileType}`,
e && e.stack ? e.stack : e
);
const data = await this.bytesFromFile(file);
attachment = {
contentType: fileType,
data,
fileName: file.name,
path: file.name,
pending: false,
size: data.byteLength,
};
}
try {
if (!isAttachmentSizeOkay(attachment)) {
this.removeDraftAttachment(attachment);
}
} catch (error) {
log.error(
'Error ensuring that image is properly sized:',
error && error.stack ? error.stack : error
);
this.removeDraftAttachment(attachment);
showToast(ToastUnableToLoadAttachment);
return;
}
try {
await this.addAttachment(attachment);
} catch (error) {
log.error(
'Error saving draft attachment:',
error && error.stack ? error.stack : error
);
showToast(ToastUnableToLoadAttachment);
}
}
async handleVideoAttachment(
file: Readonly<File>
): Promise<InMemoryAttachmentDraftType> {
const objectUrl = URL.createObjectURL(file);
if (!objectUrl) {
throw new Error('Failed to create object url for video!');
}
try {
const screenshotContentType = IMAGE_PNG;
const screenshotBlob = await VisualAttachment.makeVideoScreenshot({
objectUrl,
contentType: screenshotContentType,
logger: log,
});
const screenshotData = await VisualAttachment.blobToArrayBuffer(
screenshotBlob
);
const data = await this.bytesFromFile(file);
return {
contentType: stringToMIMEType(file.type),
data,
fileName: file.name,
path: file.name,
pending: false,
screenshotContentType,
screenshotData: new Uint8Array(screenshotData),
screenshotSize: screenshotData.byteLength,
size: data.byteLength,
};
} finally {
URL.revokeObjectURL(objectUrl);
}
}
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
async markAllAsVerifiedDefault( async markAllAsVerifiedDefault(
unverified: ReadonlyArray<ConversationModel> unverified: ReadonlyArray<ConversationModel>
@ -1957,12 +1682,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
await Promise.all(untrusted.map(contact => contact.setApproved())); await Promise.all(untrusted.map(contact => contact.setApproved()));
} }
toggleMicrophone(): void {
this.compositionApi.current?.setShowMic(
!this.hasFiles({ includePending: true })
);
}
captureAudio(e?: Event): void { captureAudio(e?: Event): void {
if (e) { if (e) {
e.preventDefault(); e.preventDefault();
@ -2019,7 +1738,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
throw new Error('A voice note cannot be sent with other attachments'); throw new Error('A voice note cannot be sent with other attachments');
} }
const data = await this.bytesFromFile(blob); const data = await fileToBytes(blob);
// These aren't persisted to disk; they are meant to be sent immediately // These aren't persisted to disk; they are meant to be sent immediately
this.voiceNoteAttachment = { this.voiceNoteAttachment = {
@ -4097,7 +3816,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
fileName: title, fileName: title,
}); });
const data = await this.bytesFromFile(withBlob.file); const data = await fileToBytes(withBlob.file);
objectUrl = URL.createObjectURL(withBlob.file); objectUrl = URL.createObjectURL(withBlob.file);
const blurHash = await window.imageToBlurHash(withBlob.file); const blurHash = await window.imageToBlurHash(withBlob.file);