ConversationView: Move attachments processing into redux

This commit is contained in:
Scott Nonnenberg 2022-12-07 17:26:59 -08:00 committed by GitHub
parent ff6750e4fd
commit 452e0b7b31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 544 additions and 763 deletions

View file

@ -690,7 +690,12 @@
"description": "An error popup when the user has attempted to add an attachment"
},
"fileSizeWarning": {
"message": "Sorry, the selected file exceeds message size restrictions."
"message": "Sorry, the selected file exceeds message size restrictions.",
"description": "(deleted 2022/12/07) Shown in a toast if the user tries to attach too-large file"
},
"icu:fileSizeWarning": {
"messageformat": "Sorry, the selected file exceeds message size restrictions. {limit}{units}",
"description": "Shown in a toast if the user tries to attach too-large file"
},
"unableToLoadAttachment": {
"message": "Unable to load selected attachment."

View file

@ -33,7 +33,6 @@ export default {
const useProps = (overrideProps: Partial<Props> = {}): Props => ({
addAttachment: action('addAttachment'),
addPendingAttachment: action('addPendingAttachment'),
conversationId: '123',
i18n,
onSendMessage: action('onSendMessage'),

View file

@ -12,7 +12,6 @@ import type {
} from '../types/Util';
import type { ErrorDialogAudioRecorderType } from '../state/ducks/audioRecorder';
import { RecordingState } from '../state/ducks/audioRecorder';
import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing';
import type { imageToBlurHash } from '../util/imageToBlurHash';
import { Spinner } from './Spinner';
import type {
@ -76,10 +75,6 @@ export type OwnProps = Readonly<{
conversationId: string,
attachment: InMemoryAttachmentDraftType
) => unknown;
addPendingAttachment: (
conversationId: string,
pendingAttachment: AttachmentDraftType
) => unknown;
announcementsOnly?: boolean;
areWeAdmin?: boolean;
areWePending?: boolean;
@ -112,7 +107,10 @@ export type OwnProps = Readonly<{
onClearAttachments(): unknown;
onClickQuotedMessage(): unknown;
onCloseLinkPreview(): unknown;
processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown;
processAttachments: (options: {
conversationId: string;
files: ReadonlyArray<File>;
}) => unknown;
onSelectMediaQuality(isHQ: boolean): unknown;
onSendMessage(options: {
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
@ -171,7 +169,6 @@ export type Props = Pick<
export function CompositionArea({
// Base props
addAttachment,
addPendingAttachment,
conversationId,
i18n,
onSendMessage,
@ -733,13 +730,10 @@ export function CompositionArea({
</div>
) : null}
<CompositionUpload
addAttachment={addAttachment}
addPendingAttachment={addPendingAttachment}
conversationId={conversationId}
draftAttachments={draftAttachments}
i18n={i18n}
processAttachments={processAttachments}
removeAttachment={removeAttachment}
ref={fileInputRef}
/>
</div>

View file

@ -2,116 +2,43 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ChangeEventHandler } from 'react';
import React, { forwardRef, useState } from 'react';
import React, { forwardRef } from 'react';
import type {
InMemoryAttachmentDraftType,
AttachmentDraftType,
} from '../types/Attachment';
import type { AttachmentDraftType } from '../types/Attachment';
import { isVideoAttachment, isImageAttachment } from '../types/Attachment';
import { AttachmentToastType } from '../types/AttachmentToastType';
import type { LocalizerType } from '../types/Util';
import { ToastCannotMixMultiAndNonMultiAttachments } from './ToastCannotMixMultiAndNonMultiAttachments';
import { ToastDangerousFileType } from './ToastDangerousFileType';
import { ToastFileSize } from './ToastFileSize';
import { ToastMaxAttachments } from './ToastMaxAttachments';
import { ToastUnsupportedMultiAttachment } from './ToastUnsupportedMultiAttachment';
import { ToastUnableToLoadAttachment } from './ToastUnableToLoadAttachment';
import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing';
import {
getSupportedImageTypes,
getSupportedVideoTypes,
} from '../util/GoogleChrome';
export type PropsType = {
addAttachment: (
conversationId: string,
attachment: InMemoryAttachmentDraftType
) => unknown;
addPendingAttachment: (
conversationId: string,
pendingAttachment: AttachmentDraftType
) => unknown;
conversationId: string;
draftAttachments: ReadonlyArray<AttachmentDraftType>;
i18n: LocalizerType;
processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown;
removeAttachment: (conversationId: string, filePath: string) => unknown;
processAttachments: (options: {
conversationId: string;
files: ReadonlyArray<File>;
}) => unknown;
};
export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
function CompositionUploadInner(
{
addAttachment,
addPendingAttachment,
conversationId,
draftAttachments,
i18n,
processAttachments,
removeAttachment,
},
{ conversationId, draftAttachments, processAttachments },
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.ToastUnsupportedMultiAttachment
) {
toast = (
<ToastUnsupportedMultiAttachment i18n={i18n} onClose={closeToast} />
);
} else if (
toastType ===
AttachmentToastType.ToastCannotMixMultiAndNonMultiAttachments
) {
toast = (
<ToastCannotMixMultiAndNonMultiAttachments
i18n={i18n}
onClose={closeToast}
/>
);
} else if (toastType === AttachmentToastType.ToastUnableToLoadAttachment) {
toast = <ToastUnableToLoadAttachment i18n={i18n} onClose={closeToast} />;
}
const anyVideoOrImageAttachments = draftAttachments.some(attachment => {
return isImageAttachment(attachment) || isVideoAttachment(attachment);
});
@ -121,17 +48,14 @@ export const CompositionUpload = forwardRef<HTMLInputElement, PropsType>(
: null;
return (
<>
{toast}
<input
hidden
multiple
onChange={onFileInputChange}
ref={ref}
type="file"
accept={acceptContentTypes?.join(',')}
/>
</>
<input
hidden
multiple
onChange={onFileInputChange}
ref={ref}
type="file"
accept={acceptContentTypes?.join(',')}
/>
);
}
);

View file

@ -1,28 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { ToastCannotMixMultiAndNonMultiAttachments } from './ToastCannotMixMultiAndNonMultiAttachments';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastCannotMixMultiAndNonMultiAttachments',
};
export const _ToastCannotMixMultiAndNonMultiAttachments = (): JSX.Element => (
<ToastCannotMixMultiAndNonMultiAttachments {...defaultProps} />
);
_ToastCannotMixMultiAndNonMultiAttachments.story = {
name: 'ToastCannotMixMultiAndNonMultiAttachments',
};

View file

@ -1,22 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastCannotMixMultiAndNonMultiAttachments({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>
{i18n('cannotSelectPhotosAndVideosAlongWithFiles')}
</Toast>
);
}

View file

@ -20,7 +20,7 @@ export default {
};
export const _ToastFileSize = (): JSX.Element => (
<ToastFileSize {...defaultProps} limit={100} units="MB" />
<ToastFileSize {...defaultProps} limit="100" units="MB" />
);
_ToastFileSize.story = {

View file

@ -6,7 +6,7 @@ import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type ToastPropsType = {
limit: number;
limit: string;
units: string;
};
@ -23,8 +23,7 @@ export function ToastFileSize({
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>
{i18n('fileSizeWarning')} {limit}
{units}
{i18n('icu:fileSizeWarning', { limit, units })}
</Toast>
);
}

View file

@ -49,6 +49,13 @@ AddingUserToGroup.args = {
},
};
export const CannotMixMultiAndNonMultiAttachments = Template.bind({});
CannotMixMultiAndNonMultiAttachments.args = {
toast: {
toastType: ToastType.CannotMixMultiAndNonMultiAttachments,
},
};
export const CannotStartGroupCall = Template.bind({});
CannotStartGroupCall.args = {
toast: {
@ -70,6 +77,13 @@ CopiedUsernameLink.args = {
},
};
export const DangerousFileType = Template.bind({});
DangerousFileType.args = {
toast: {
toastType: ToastType.DangerousFileType,
},
};
export const DeleteForEveryoneFailed = Template.bind({});
DeleteForEveryoneFailed.args = {
toast: {
@ -91,6 +105,24 @@ FailedToDeleteUsername.args = {
},
};
export const FileSize = Template.bind({});
FileSize.args = {
toast: {
toastType: ToastType.FileSize,
parameters: {
limit: '100',
units: 'MB',
},
},
};
export const MaxAttachments = Template.bind({});
MaxAttachments.args = {
toast: {
toastType: ToastType.MaxAttachments,
},
};
export const MessageBodyTooLong = Template.bind({});
MessageBodyTooLong.args = {
toast: {
@ -105,13 +137,6 @@ PinnedConversationsFull.args = {
},
};
export const StoryMuted = Template.bind({});
StoryMuted.args = {
toast: {
toastType: ToastType.StoryMuted,
},
};
export const ReportedSpamAndBlocked = Template.bind({});
ReportedSpamAndBlocked.args = {
toast: {
@ -119,6 +144,13 @@ ReportedSpamAndBlocked.args = {
},
};
export const StoryMuted = Template.bind({});
StoryMuted.args = {
toast: {
toastType: ToastType.StoryMuted,
},
};
export const StoryReact = Template.bind({});
StoryReact.args = {
toast: {
@ -154,6 +186,20 @@ StoryVideoUnsupported.args = {
},
};
export const UnableToLoadAttachment = Template.bind({});
UnableToLoadAttachment.args = {
toast: {
toastType: ToastType.UnableToLoadAttachment,
},
};
export const UnsupportedMultiAttachment = Template.bind({});
UnsupportedMultiAttachment.args = {
toast: {
toastType: ToastType.UnsupportedMultiAttachment,
},
};
export const UserAddedToGroup = Template.bind({});
UserAddedToGroup.args = {
toast: {

View file

@ -42,6 +42,14 @@ export function ToastManager({
);
}
if (toastType === ToastType.CannotMixMultiAndNonMultiAttachments) {
return (
<Toast onClose={hideToast}>
{i18n('cannotSelectPhotosAndVideosAlongWithFiles')}
</Toast>
);
}
if (toastType === ToastType.CannotStartGroupCall) {
return (
<Toast onClose={hideToast}>
@ -66,6 +74,10 @@ export function ToastManager({
);
}
if (toastType === ToastType.DangerousFileType) {
return <Toast onClose={hideToast}>{i18n('dangerousFileType')}</Toast>;
}
if (toastType === ToastType.DeleteForEveryoneFailed) {
return <Toast onClose={hideToast}>{i18n('deleteForEveryoneFailed')}</Toast>;
}
@ -93,6 +105,18 @@ export function ToastManager({
);
}
if (toastType === ToastType.FileSize) {
return (
<Toast onClose={hideToast}>
{i18n('icu:fileSizeWarning', toast?.parameters)}
</Toast>
);
}
if (toastType === ToastType.MaxAttachments) {
return <Toast onClose={hideToast}>{i18n('maximumAttachments')}</Toast>;
}
if (toastType === ToastType.MessageBodyTooLong) {
return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
}
@ -157,6 +181,18 @@ export function ToastManager({
);
}
if (toastType === ToastType.UnableToLoadAttachment) {
return <Toast onClose={hideToast}>{i18n('unableToLoadAttachment')}</Toast>;
}
if (toastType === ToastType.UnsupportedMultiAttachment) {
return (
<Toast onClose={hideToast}>
{i18n('cannotSelectPhotosAndVideosAlongWithFiles')}
</Toast>
);
}
if (toastType === ToastType.UserAddedToGroup) {
return (
<Toast onClose={hideToast}>

View file

@ -1,28 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { ToastMaxAttachments } from './ToastMaxAttachments';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastMaxAttachments',
};
export const _ToastMaxAttachments = (): JSX.Element => (
<ToastMaxAttachments {...defaultProps} />
);
_ToastMaxAttachments.story = {
name: 'ToastMaxAttachments',
};

View file

@ -1,15 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastMaxAttachments({ i18n, onClose }: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('maximumAttachments')}</Toast>;
}

View file

@ -1,28 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { ToastUnableToLoadAttachment } from './ToastUnableToLoadAttachment';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastUnableToLoadAttachment',
};
export const _ToastUnableToLoadAttachment = (): JSX.Element => (
<ToastUnableToLoadAttachment {...defaultProps} />
);
_ToastUnableToLoadAttachment.story = {
name: 'ToastUnableToLoadAttachment',
};

View file

@ -1,28 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { ToastUnsupportedMultiAttachment } from './ToastUnsupportedMultiAttachment';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastUnsupportedMultiAttachment',
};
export const _ToastUnsupportedMultiAttachment = (): JSX.Element => (
<ToastUnsupportedMultiAttachment {...defaultProps} />
);
_ToastUnsupportedMultiAttachment.story = {
name: 'ToastUnsupportedMultiAttachment',
};

View file

@ -1,22 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastUnsupportedMultiAttachment({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>
{i18n('cannotSelectPhotosAndVideosAlongWithFiles')}
</Toast>
);
}

View file

@ -4,18 +4,82 @@
import React from 'react';
export type PropsType = {
conversationId: string;
processAttachments: (options: {
conversationId: string;
files: ReadonlyArray<File>;
}) => void;
renderCompositionArea: () => JSX.Element;
renderConversationHeader: () => JSX.Element;
renderTimeline: () => JSX.Element;
};
export function ConversationView({
conversationId,
processAttachments,
renderCompositionArea,
renderConversationHeader,
renderTimeline,
}: PropsType): JSX.Element {
const onDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer) {
return;
}
if (event.dataTransfer.types[0] !== 'Files') {
return;
}
event.stopPropagation();
event.preventDefault();
const { files } = event.dataTransfer;
processAttachments({
conversationId,
files: Array.from(files),
});
},
[conversationId, processAttachments]
);
const onPaste = React.useCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => {
if (!event.clipboardData) {
return;
}
const { items } = event.clipboardData;
const anyImages = [...items].some(
item => item.type.split('/')[0] === 'image'
);
if (!anyImages) {
return;
}
event.stopPropagation();
event.preventDefault();
const files: Array<File> = [];
for (let i = 0; i < items.length; i += 1) {
if (items[i].type.split('/')[0] === 'image') {
const file = items[i].getAsFile();
if (file) {
files.push(file);
}
}
}
processAttachments({
conversationId,
files,
});
},
[conversationId, processAttachments]
);
return (
<div className="ConversationView">
<div className="ConversationView" onDrop={onDrop} onPaste={onPaste}>
<div className="ConversationView__header">
{renderConversationHeader()}
</div>

View file

@ -1,6 +1,8 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import path from 'path';
import type { ThunkAction } from 'redux-thunk';
import * as log from '../../logging/log';
@ -25,9 +27,19 @@ import { writeDraftAttachment } from '../../util/writeDraftAttachment';
import { deleteDraftAttachment } from '../../util/deleteDraftAttachment';
import { replaceIndex } from '../../util/replaceIndex';
import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk';
import type { HandleAttachmentsProcessingArgsType } from '../../util/handleAttachmentsProcessing';
import { handleAttachmentsProcessing } from '../../util/handleAttachmentsProcessing';
import { LinkPreviewSourceType } from '../../types/LinkPreview';
import { RecordingState } from './audioRecorder';
import { hasLinkPreviewLoaded } from '../../services/LinkPreview';
import { SHOW_TOAST, ToastType } from './toast';
import type { ShowToastActionType } from './toast';
import { getMaximumAttachmentSize } from '../../util/attachments';
import { isFileDangerous } from '../../util/isFileDangerous';
import { isImage, isVideo, stringToMIMEType } from '../../types/MIME';
import {
getRenderDetailsForLimit,
processAttachment,
} from '../../util/processAttachment';
import type { ReplacementValuesType } from '../../types/Util';
// State
@ -187,11 +199,112 @@ function addPendingAttachment(
};
}
function processAttachments(
options: HandleAttachmentsProcessingArgsType
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
await handleAttachmentsProcessing(options);
function processAttachments({
conversationId,
files,
}: {
conversationId: string;
files: ReadonlyArray<File>;
}): ThunkAction<
void,
RootStateType,
unknown,
NoopActionType | ShowToastActionType
> {
return async (dispatch, getState) => {
if (!files.length) {
return;
}
// If the call came from a conversation we are no longer in we do not
// update the state.
if (getState().conversations.selectedConversationId !== conversationId) {
return;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('processAttachments: Unable to find conv');
}
const state = getState();
const isRecording =
state.audioRecorder.recordingState === RecordingState.Recording;
if (hasLinkPreviewLoaded() || isRecording) {
return;
}
let toastToShow:
| { toastType: ToastType; parameters?: ReplacementValuesType }
| undefined;
const nextDraftAttachments = (
conversation.get('draftAttachments') || []
).slice();
const filesToProcess: Array<File> = [];
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
const processingResult = preProcessAttachment(file, nextDraftAttachments);
if (processingResult != null) {
toastToShow = processingResult;
} else {
const pendingAttachment = getPendingAttachment(file);
if (pendingAttachment) {
addPendingAttachment(conversationId, pendingAttachment)(
dispatch,
getState,
undefined
);
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)(
dispatch,
getState,
undefined
);
return;
}
addAttachment(conversationId, attachment)(
dispatch,
getState,
undefined
);
} catch (err) {
log.error(
'handleAttachmentsProcessing: failed to process attachment:',
err.stack
);
removeAttachment(conversationId, file.path)(
dispatch,
getState,
undefined
);
toastToShow = { toastType: ToastType.UnableToLoadAttachment };
}
})
);
if (toastToShow) {
dispatch({
type: SHOW_TOAST,
payload: toastToShow,
});
return;
}
dispatch({
type: 'NOOP',
payload: null,
@ -199,6 +312,70 @@ function processAttachments(
};
}
function preProcessAttachment(
file: File,
draftAttachments: Array<AttachmentDraftType>
): { toastType: ToastType; parameters?: ReplacementValuesType } | undefined {
if (!file) {
return;
}
const limitKb = getMaximumAttachmentSize();
if (file.size > limitKb) {
return {
toastType: ToastType.FileSize,
parameters: getRenderDetailsForLimit(limitKb),
};
}
if (isFileDangerous(file.name)) {
return { toastType: ToastType.DangerousFileType };
}
if (draftAttachments.length >= 32) {
return { toastType: ToastType.MaxAttachments };
}
const haveNonImageOrVideo = draftAttachments.some(
(attachment: AttachmentDraftType) => {
return (
!isImage(attachment.contentType) && !isVideo(attachment.contentType)
);
}
);
// You can't add another attachment if you already have a non-image staged
if (haveNonImageOrVideo) {
return { toastType: ToastType.UnsupportedMultiAttachment };
}
const fileType = stringToMIMEType(file.type);
const imageOrVideo = isImage(fileType) || isVideo(fileType);
// You can't add a non-image attachment if you already have attachments staged
if (!imageOrVideo && draftAttachments.length > 0) {
return { toastType: ToastType.CannotMixMultiAndNonMultiAttachments };
}
return undefined;
}
function getPendingAttachment(file: File): AttachmentDraftType | undefined {
if (!file) {
return;
}
const fileType = stringToMIMEType(file.type);
const { name: fileName } = path.parse(file.name);
return {
contentType: fileType,
fileName,
size: file.size,
path: file.name,
pending: true,
};
}
function removeAttachment(
conversationId: string,
filePath: string

View file

@ -6,12 +6,16 @@ import type { ReplacementValuesType } from '../../types/Util';
export enum ToastType {
AddingUserToGroup = 'AddingUserToGroup',
CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments',
CannotStartGroupCall = 'CannotStartGroupCall',
CopiedUsername = 'CopiedUsername',
CopiedUsernameLink = 'CopiedUsernameLink',
DangerousFileType = 'DangerousFileType',
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
Error = 'Error',
FailedToDeleteUsername = 'FailedToDeleteUsername',
FileSize = 'FileSize',
MaxAttachments = 'MaxAttachments',
MessageBodyTooLong = 'MessageBodyTooLong',
PinnedConversationsFull = 'PinnedConversationsFull',
ReportedSpamAndBlocked = 'ReportedSpamAndBlocked',
@ -21,6 +25,8 @@ export enum ToastType {
StoryVideoError = 'StoryVideoError',
StoryVideoTooLong = 'StoryVideoTooLong',
StoryVideoUnsupported = 'StoryVideoUnsupported',
UnableToLoadAttachment = 'UnableToLoadAttachment',
UnsupportedMultiAttachment = 'UnsupportedMultiAttachment',
UserAddedToGroup = 'UserAddedToGroup',
}

View file

@ -14,6 +14,7 @@ import type { TimelinePropsType } from './Timeline';
import { SmartTimeline } from './Timeline';
export type PropsType = {
conversationId: string;
compositionAreaProps: Pick<
CompositionAreaPropsType,
| 'clearQuotedMessage'
@ -38,10 +39,15 @@ export type PropsType = {
};
const mapStateToProps = (_state: StateType, props: PropsType) => {
const { compositionAreaProps, conversationHeaderProps, timelineProps } =
props;
const {
compositionAreaProps,
conversationHeaderProps,
conversationId,
timelineProps,
} = props;
return {
conversationId,
renderCompositionArea: () => (
<SmartCompositionArea {...compositionAreaProps} />
),

View file

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

View file

@ -1,90 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
getPendingAttachment,
preProcessAttachment,
processAttachment,
} from './processAttachment';
import type {
AttachmentDraftType,
InMemoryAttachmentDraftType,
} from '../types/Attachment';
import { AttachmentToastType } from '../types/AttachmentToastType';
import * as log from '../logging/log';
export type AddAttachmentActionType = (
conversationId: string,
attachment: InMemoryAttachmentDraftType
) => unknown;
export type AddPendingAttachmentActionType = (
conversationId: string,
pendingAttachment: AttachmentDraftType
) => unknown;
export type RemoveAttachmentActionType = (
conversationId: string,
filePath: string
) => unknown;
export type HandleAttachmentsProcessingArgsType = {
addAttachment: AddAttachmentActionType;
addPendingAttachment: AddPendingAttachmentActionType;
conversationId: string;
draftAttachments: ReadonlyArray<AttachmentDraftType>;
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 != null) {
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) {
log.error(
'handleAttachmentsProcessing: failed to process attachment:',
err.stack
);
removeAttachment(conversationId, file.path);
onShowToast(AttachmentToastType.ToastUnableToLoadAttachment);
}
})
);
}

View file

@ -1,32 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../types/Attachment';
import { getMaximumAttachmentSize } from './attachments';
import { showToast } from './showToast';
import { ToastFileSize } from '../components/ToastFileSize';
export function isAttachmentSizeOkay(
attachment: Readonly<AttachmentType>
): boolean {
const limitKb = getMaximumAttachmentSize();
// this needs to be cast properly
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if ((attachment.data.byteLength / 1024).toFixed(4) >= limitKb) {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * 1000;
do {
limit /= 1000;
u += 1;
} while (limit >= 1000 && u < units.length - 1);
showToast(ToastFileSize, {
limit,
units: units[u],
});
return false;
}
return true;
}

View file

@ -1,85 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import path from 'path';
import * as log from '../logging/log';
import type {
AttachmentDraftType,
AttachmentType,
InMemoryAttachmentDraftType,
} from '../types/Attachment';
import * as Errors from '../types/errors';
import { getMaximumAttachmentSize } from './attachments';
import { AttachmentToastType } from '../types/AttachmentToastType';
import * as Errors from '../types/errors';
import { fileToBytes } from './fileToBytes';
import { handleImageAttachment } from './handleImageAttachment';
import { handleVideoAttachment } from './handleVideoAttachment';
import { isAttachmentSizeOkay } from './isAttachmentSizeOkay';
import { isFileDangerous } from './isFileDangerous';
import { isHeic, isImage, isVideo, stringToMIMEType } from '../types/MIME';
import { isHeic, stringToMIMEType } from '../types/MIME';
import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome';
export function getPendingAttachment(
file: File
): AttachmentDraftType | undefined {
if (!file) {
return;
}
const fileType = stringToMIMEType(file.type);
const { name: fileName } = path.parse(file.name);
return {
contentType: fileType,
fileName,
size: file.size,
path: file.name,
pending: true,
};
}
export function preProcessAttachment(
file: File,
draftAttachments: Array<AttachmentDraftType>
): AttachmentToastType | undefined {
if (!file) {
return;
}
if (file.size > getMaximumAttachmentSize()) {
return AttachmentToastType.ToastFileSize;
}
if (isFileDangerous(file.name)) {
return AttachmentToastType.ToastDangerousFileType;
}
if (draftAttachments.length >= 32) {
return AttachmentToastType.ToastMaxAttachments;
}
const haveNonImageOrVideo = draftAttachments.some(
(attachment: AttachmentDraftType) => {
return (
!isImage(attachment.contentType) && !isVideo(attachment.contentType)
);
}
);
// You can't add another attachment if you already have a non-image staged
if (haveNonImageOrVideo) {
return AttachmentToastType.ToastUnsupportedMultiAttachment;
}
const fileType = stringToMIMEType(file.type);
const imageOrVideo = isImage(fileType) || isVideo(fileType);
// You can't add a non-image attachment if you already have attachments staged
if (!imageOrVideo && draftAttachments.length > 0) {
return AttachmentToastType.ToastCannotMixMultiAndNonMultiAttachments;
}
return undefined;
}
import { showToast } from './showToast';
import { ToastFileSize } from '../components/ToastFileSize';
export async function processAttachment(
file: File
@ -132,3 +67,34 @@ export async function processAttachment(
throw error;
}
}
export function getRenderDetailsForLimit(limitKb: number): {
limit: string;
units: string;
} {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * 1000;
do {
limit /= 1000;
u += 1;
} while (limit >= 1000 && u < units.length - 1);
return {
limit: limit.toFixed(0),
units: units[u],
};
}
function isAttachmentSizeOkay(attachment: Readonly<AttachmentType>): boolean {
const limitKb = getMaximumAttachmentSize();
// this needs to be cast properly
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if ((attachment.data.byteLength / 1024).toFixed(4) >= limitKb) {
showToast(ToastFileSize, getRenderDetailsForLimit(limitKb));
return false;
}
return true;
}

View file

@ -8,7 +8,6 @@ import type { ToastAlreadyGroupMember } from '../components/ToastAlreadyGroupMem
import type { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequestedToJoin';
import type { ToastBlocked } from '../components/ToastBlocked';
import type { ToastBlockedGroup } from '../components/ToastBlockedGroup';
import type { ToastUnsupportedMultiAttachment } from '../components/ToastUnsupportedMultiAttachment';
import type {
ToastCannotOpenGiftBadge,
ToastPropsType as ToastCannotOpenGiftBadgePropsType,
@ -21,7 +20,6 @@ import type {
} from '../components/ToastConversationArchived';
import type { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import type { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
import type { ToastDangerousFileType } from '../components/ToastDangerousFileType';
import type {
ToastInternalError,
ToastPropsType as ToastInternalErrorPropsType,
@ -40,7 +38,6 @@ import type { ToastInvalidConversation } from '../components/ToastInvalidConvers
import type { ToastLeftGroup } from '../components/ToastLeftGroup';
import type { ToastLinkCopied } from '../components/ToastLinkCopied';
import type { ToastLoadingFullLogs } from '../components/ToastLoadingFullLogs';
import type { ToastMaxAttachments } from '../components/ToastMaxAttachments';
import type { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong';
import type { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound';
@ -56,7 +53,6 @@ export function showToast(Toast: typeof ToastAlreadyGroupMember): void;
export function showToast(Toast: typeof ToastAlreadyRequestedToJoin): void;
export function showToast(Toast: typeof ToastBlocked): void;
export function showToast(Toast: typeof ToastBlockedGroup): void;
export function showToast(Toast: typeof ToastUnsupportedMultiAttachment): void;
export function showToast(
Toast: typeof ToastCannotOpenGiftBadge,
props: Omit<ToastCannotOpenGiftBadgePropsType, 'i18n' | 'onClose'>
@ -69,7 +65,6 @@ export function showToast(
): void;
export function showToast(Toast: typeof ToastConversationMarkedUnread): void;
export function showToast(Toast: typeof ToastConversationUnarchived): void;
export function showToast(Toast: typeof ToastDangerousFileType): void;
export function showToast(
Toast: typeof ToastInternalError,
props: ToastInternalErrorPropsType
@ -88,9 +83,7 @@ export function showToast(Toast: typeof ToastInvalidConversation): void;
export function showToast(Toast: typeof ToastLeftGroup): void;
export function showToast(Toast: typeof ToastLinkCopied): void;
export function showToast(Toast: typeof ToastLoadingFullLogs): void;
export function showToast(Toast: typeof ToastMaxAttachments): void;
export function showToast(Toast: typeof ToastMessageBodyTooLong): void;
export function showToast(Toast: typeof ToastUnsupportedMultiAttachment): void;
export function showToast(Toast: typeof ToastOriginalMessageNotFound): void;
export function showToast(Toast: typeof ToastReactionFailed): void;
export function showToast(Toast: typeof ToastStickerPackInstallFailed): void;

View file

@ -19,10 +19,8 @@ import type { ConversationModel } from '../models/conversations';
import type {
GroupV2PendingMemberType,
MessageAttributesType,
QuotedMessageType,
} from '../model-types.d';
import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem';
import type { MessageModel } from '../models/messages';
import { getMessageById } from '../messages/getMessageById';
import { getContactId } from '../messages/helpers';
import { strictAssert } from '../util/assert';
@ -53,22 +51,17 @@ import { ConversationDetailsMembershipList } from '../components/conversation/co
import * as log from '../logging/log';
import type { EmbeddedContactType } from '../types/EmbeddedContact';
import { createConversationView } from '../state/roots/createConversationView';
import { AttachmentToastType } from '../types/AttachmentToastType';
import type { CompositionAPIType } from '../components/CompositionArea';
import { ToastBlocked } from '../components/ToastBlocked';
import { ToastBlockedGroup } from '../components/ToastBlockedGroup';
import { ToastCannotMixMultiAndNonMultiAttachments } from '../components/ToastCannotMixMultiAndNonMultiAttachments';
import { ToastConversationArchived } from '../components/ToastConversationArchived';
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
import { ToastExpired } from '../components/ToastExpired';
import { ToastFileSize } from '../components/ToastFileSize';
import { ToastInvalidConversation } from '../components/ToastInvalidConversation';
import { ToastLeftGroup } from '../components/ToastLeftGroup';
import { ToastMaxAttachments } from '../components/ToastMaxAttachments';
import { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong';
import { ToastUnsupportedMultiAttachment } from '../components/ToastUnsupportedMultiAttachment';
import { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound';
import { ToastReactionFailed } from '../components/ToastReactionFailed';
import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming';
@ -81,7 +74,6 @@ import { isNotNil } from '../util/isNotNil';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
import { resolveAttachmentDraftData } from '../util/resolveAttachmentDraftData';
import { showToast } from '../util/showToast';
import { RecordingState } from '../state/ducks/audioRecorder';
import { UUIDKind } from '../types/UUID';
import type { UUIDStringType } from '../types/UUID';
import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone';
@ -90,7 +82,6 @@ import { MediaGallery } from '../components/conversation/media-gallery/MediaGall
import type { ItemClickEvent } from '../components/conversation/media-gallery/types/ItemClickEvent';
import {
getLinkPreviewForSend,
hasLinkPreviewLoaded,
maybeGrabLinkPreview,
removeLinkPreview,
resetLinkPreview,
@ -200,10 +191,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} = { current: undefined };
private sendStart?: number;
// Quotes
private quote?: QuotedMessageType;
private quotedMessage?: MessageModel;
// Sub-views
private contactModalView?: Backbone.View;
private conversationView?: Backbone.View;
@ -241,8 +228,12 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.model,
'toggle-reply',
(messageId: string | undefined) => {
const target = this.quote || !messageId ? null : messageId;
this.setQuoteMessage(target);
const composerState = window.reduxStore
? window.reduxStore.getState().composer
: undefined;
const quote = composerState?.quotedMessage?.quote;
this.setQuoteMessage(quote ? undefined : messageId);
}
);
this.listenTo(
@ -458,7 +449,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null),
clearQuotedMessage: () => this.setQuoteMessage(undefined),
onStartGroupMigration: () => this.startMigrationToGV2(),
onCancelJoinRequest: async () => {
await window.showConfirmationDialog({
@ -516,6 +507,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// createConversationView root
const JSX = createConversationView(window.reduxStore, {
conversationId: this.model.id,
compositionAreaProps,
conversationHeaderProps,
timelineProps,
@ -750,59 +742,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
});
}
// TODO DESKTOP-2426
async processAttachments(files: Array<File>): Promise<void> {
const state = window.reduxStore.getState();
const isRecording =
state.audioRecorder.recordingState === RecordingState.Recording;
if (hasLinkPreviewLoaded() || isRecording) {
return;
}
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.ToastUnsupportedMultiAttachment
) {
showToast(ToastUnsupportedMultiAttachment);
} else if (
toastType ===
AttachmentToastType.ToastCannotMixMultiAndNonMultiAttachments
) {
showToast(ToastCannotMixMultiAndNonMultiAttachments);
} else if (
toastType === AttachmentToastType.ToastUnableToLoadAttachment
) {
showToast(ToastUnableToLoadAttachment);
}
},
removeAttachment,
});
}
unload(reason: string): void {
log.info(
'unloading conversation',
@ -865,101 +804,10 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.remove();
}
async onDrop(e: JQuery.TriggeredEvent): Promise<void> {
if (!e.originalEvent) {
return;
}
const event = e.originalEvent as DragEvent;
if (!event.dataTransfer) {
return;
}
if (event.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
const { files } = event.dataTransfer;
this.processAttachments(Array.from(files));
}
onPaste(e: JQuery.TriggeredEvent): void {
if (!e.originalEvent) {
return;
}
const event = e.originalEvent as ClipboardEvent;
if (!event.clipboardData) {
return;
}
const { items } = event.clipboardData;
const anyImages = [...items].some(
item => item.type.split('/')[0] === 'image'
);
if (!anyImages) {
return;
}
e.stopPropagation();
e.preventDefault();
const files: Array<File> = [];
for (let i = 0; i < items.length; i += 1) {
if (items[i].type.split('/')[0] === 'image') {
const file = items[i].getAsFile();
if (file) {
files.push(file);
}
}
}
this.processAttachments(files);
}
async saveModel(): Promise<void> {
window.Signal.Data.updateConversation(this.model.attributes);
}
async clearAttachments(): Promise<void> {
const draftAttachments = this.model.get('draftAttachments') || [];
this.model.set({
draftAttachments: [],
draftChanged: true,
});
this.updateAttachmentsView();
// We're fine doing this all at once; at most it should be 32 attachments
await Promise.all([
this.saveModel(),
Promise.all(
draftAttachments.map(attachment => deleteDraftAttachment(attachment))
),
]);
}
hasFiles(options: { includePending: boolean }): boolean {
const draftAttachments = this.model.get('draftAttachments') || [];
if (options.includePending) {
return draftAttachments.length > 0;
}
return draftAttachments.some(item => !item.pending);
}
updateAttachmentsView(): void {
const draftAttachments = this.model.get('draftAttachments') || [];
window.reduxActions.composer.replaceAttachments(
this.model.get('id'),
draftAttachments
);
if (this.hasFiles({ includePending: true })) {
removeLinkPreview();
}
}
async onOpened(messageId: string): Promise<void> {
this.model.onOpenStart();
@ -1219,26 +1067,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
update();
}
focusMessageField(): void {
if (this.panels && this.panels.length) {
return;
}
this.compositionApi.current?.focusInput();
}
disableMessageField(): void {
this.compositionApi.current?.setDisabled(true);
}
enableMessageField(): void {
this.compositionApi.current?.setDisabled(false);
}
resetEmojiResults(): void {
this.compositionApi.current?.resetEmojiResults();
}
showGV1Members(): void {
const { contactCollection, id } = this.model;
@ -1922,74 +1750,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}
}
async setQuoteMessage(messageId: null | string): Promise<void> {
const { model } = this;
const message = messageId ? await getMessageById(messageId) : undefined;
if (
message &&
!canReply(
message.attributes,
window.ConversationController.getOurConversationIdOrThrow(),
findAndFormatContact
)
) {
return;
}
if (message && !message.isNormalBubble()) {
return;
}
this.quote = undefined;
this.quotedMessage = undefined;
const existing = model.get('quotedMessageId');
if (existing !== messageId) {
const now = Date.now();
let active_at = this.model.get('active_at');
let timestamp = this.model.get('timestamp');
if (!active_at && messageId) {
active_at = now;
timestamp = now;
}
this.model.set({
active_at,
draftChanged: true,
quotedMessageId: messageId,
timestamp,
});
await this.saveModel();
}
if (message) {
this.quotedMessage = message;
this.quote = await model.makeQuote(this.quotedMessage);
this.enableMessageField();
this.focusMessageField();
}
this.renderQuotedMessage();
}
renderQuotedMessage(): void {
const { model }: { model: ConversationModel } = this;
if (!this.quotedMessage) {
window.reduxActions.composer.setQuotedMessage(undefined);
return;
}
window.reduxActions.composer.setQuotedMessage({
conversationId: model.id,
quote: this.quote,
});
}
showInvalidMessageToast(messageText?: string): boolean {
const { model }: { model: ConversationModel } = this;
@ -2105,9 +1865,12 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
).filter(isNotNil);
}
const shouldSendHighQualityAttachments = window.reduxStore
? window.reduxStore.getState().composer.shouldSendHighQualityAttachments
const composerState = window.reduxStore
? window.reduxStore.getState().composer
: undefined;
const shouldSendHighQualityAttachments =
composerState?.shouldSendHighQualityAttachments;
const quote = composerState?.quotedMessage?.quote;
const sendHQImages =
shouldSendHighQualityAttachments !== undefined
@ -2122,7 +1885,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
{
body: message,
attachments,
quote: this.quote,
quote,
preview: getLinkPreviewForSend(message),
mentions,
},
@ -2132,7 +1895,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
extraReduxActions: () => {
this.compositionApi.current?.reset();
this.model.setMarkedUnread(false);
this.setQuoteMessage(null);
this.setQuoteMessage(undefined);
resetLinkPreview();
this.clearAttachments();
window.reduxActions.composer.resetComposer();
@ -2149,12 +1912,35 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}
}
focusMessageField(): void {
if (this.panels && this.panels.length) {
return;
}
this.compositionApi.current?.focusInput();
}
disableMessageField(): void {
this.compositionApi.current?.setDisabled(true);
}
enableMessageField(): void {
this.compositionApi.current?.setDisabled(false);
}
resetEmojiResults(): void {
this.compositionApi.current?.resetEmojiResults();
}
onEditorStateChange(
messageText: string,
bodyRanges: DraftBodyRangesType,
caretLocation?: number
): void {
this.maybeBumpTyping(messageText);
if (messageText.length && this.model.throttledBumpTyping) {
this.model.throttledBumpTyping();
}
this.debouncedSaveDraft(messageText, bodyRanges);
// If we have attachments, don't add link preview
@ -2206,11 +1992,95 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}
}
// Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change.
maybeBumpTyping(messageText: string): void {
if (messageText.length && this.model.throttledBumpTyping) {
this.model.throttledBumpTyping();
async setQuoteMessage(messageId: string | undefined): Promise<void> {
const { model } = this;
const message = messageId ? await getMessageById(messageId) : undefined;
if (
message &&
!canReply(
message.attributes,
window.ConversationController.getOurConversationIdOrThrow(),
findAndFormatContact
)
) {
return;
}
if (message && !message.isNormalBubble()) {
return;
}
const existing = model.get('quotedMessageId');
if (existing !== messageId) {
const now = Date.now();
let active_at = this.model.get('active_at');
let timestamp = this.model.get('timestamp');
if (!active_at && messageId) {
active_at = now;
timestamp = now;
}
this.model.set({
active_at,
draftChanged: true,
quotedMessageId: messageId,
timestamp,
});
await this.saveModel();
}
if (message) {
const quote = await model.makeQuote(message);
window.reduxActions.composer.setQuotedMessage({
conversationId: model.id,
quote,
});
this.enableMessageField();
this.focusMessageField();
} else {
window.reduxActions.composer.setQuotedMessage(undefined);
}
}
async clearAttachments(): Promise<void> {
const draftAttachments = this.model.get('draftAttachments') || [];
this.model.set({
draftAttachments: [],
draftChanged: true,
});
this.updateAttachmentsView();
// We're fine doing this all at once; at most it should be 32 attachments
await Promise.all([
this.saveModel(),
Promise.all(
draftAttachments.map(attachment => deleteDraftAttachment(attachment))
),
]);
}
hasFiles(options: { includePending: boolean }): boolean {
const draftAttachments = this.model.get('draftAttachments') || [];
if (options.includePending) {
return draftAttachments.length > 0;
}
return draftAttachments.some(item => !item.pending);
}
updateAttachmentsView(): void {
const draftAttachments = this.model.get('draftAttachments') || [];
window.reduxActions.composer.replaceAttachments(
this.model.get('id'),
draftAttachments
);
if (this.hasFiles({ includePending: true })) {
removeLinkPreview();
}
}
}