make adding of attachment work on react conversation

pull/1387/head
Audric Ackermann 5 years ago
parent 2a155a0f43
commit 6cf69a1337
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -7,7 +7,6 @@ import {
import { Image } from './Image'; import { Image } from './Image';
import { StagedGenericAttachment } from './StagedGenericAttachment'; import { StagedGenericAttachment } from './StagedGenericAttachment';
import { StagedPlaceholderAttachment } from './StagedPlaceholderAttachment'; import { StagedPlaceholderAttachment } from './StagedPlaceholderAttachment';
import { LocalizerType } from '../../types/Util';
import { import {
areAllAttachmentsVisual, areAllAttachmentsVisual,
AttachmentType, AttachmentType,
@ -17,7 +16,6 @@ import {
interface Props { interface Props {
attachments: Array<AttachmentType>; attachments: Array<AttachmentType>;
i18n: LocalizerType;
// onError: () => void; // onError: () => void;
onClickAttachment: (attachment: AttachmentType) => void; onClickAttachment: (attachment: AttachmentType) => void;
onCloseAttachment: (attachment: AttachmentType) => void; onCloseAttachment: (attachment: AttachmentType) => void;
@ -33,7 +31,6 @@ export class AttachmentList extends React.Component<Props> {
public render() { public render() {
const { const {
attachments, attachments,
i18n,
onAddAttachment, onAddAttachment,
onClickAttachment, onClickAttachment,
onCloseAttachment, onCloseAttachment,
@ -72,10 +69,10 @@ export class AttachmentList extends React.Component<Props> {
return ( return (
<Image <Image
key={imageKey} key={imageKey}
alt={i18n('stagedImageAttachment', [ alt={window.i18n('stagedImageAttachment', [
getUrl(attachment) || attachment.fileName, getUrl(attachment) || attachment.fileName,
])} ])}
i18n={i18n} i18n={window.i18n}
attachment={attachment} attachment={attachment}
softCorners={true} softCorners={true}
playIconOverlay={isVideoAttachment(attachment)} playIconOverlay={isVideoAttachment(attachment)}
@ -96,7 +93,7 @@ export class AttachmentList extends React.Component<Props> {
<StagedGenericAttachment <StagedGenericAttachment
key={genericKey} key={genericKey}
attachment={attachment} attachment={attachment}
i18n={i18n} i18n={window.i18n}
onClose={onCloseAttachment} onClose={onCloseAttachment}
/> />
); );

@ -14,19 +14,11 @@ interface Props {
name?: string; name?: string;
disabled: boolean; disabled: boolean;
timespan: string; timespan: string;
i18n: LocalizerType;
} }
export class TimerNotification extends React.Component<Props> { export class TimerNotification extends React.Component<Props> {
public renderContents() { public renderContents() {
const { const { phoneNumber, profileName, timespan, type, disabled } = this.props;
i18n,
phoneNumber,
profileName,
timespan,
type,
disabled,
} = this.props;
const changeKey = disabled const changeKey = disabled
? 'disabledDisappearingMessages' ? 'disabledDisappearingMessages'
: 'theyChangedTheTimer'; : 'theyChangedTheTimer';
@ -39,11 +31,11 @@ export class TimerNotification extends React.Component<Props> {
case 'fromOther': case 'fromOther':
return ( return (
<Intl <Intl
i18n={i18n} i18n={window.i18n}
id={changeKey} id={changeKey}
components={[ components={[
<ContactName <ContactName
i18n={i18n} i18n={window.i18n}
key="external-1" key="external-1"
phoneNumber={displayedPubkey} phoneNumber={displayedPubkey}
profileName={profileName} profileName={profileName}
@ -58,12 +50,12 @@ export class TimerNotification extends React.Component<Props> {
); );
case 'fromMe': case 'fromMe':
return disabled return disabled
? i18n('youDisabledDisappearingMessages') ? window.i18n('youDisabledDisappearingMessages')
: i18n('youChangedTheTimer', [timespan]); : window.i18n('youChangedTheTimer', [timespan]);
case 'fromSync': case 'fromSync':
return disabled return disabled
? i18n('disappearingMessagesDisabled') ? window.i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]); : window.i18n('timerSetOnSync', [timespan]);
default: default:
throw missingCaseError(type); throw missingCaseError(type);
} }

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import _, { debounce } from 'lodash'; import _, { debounce } from 'lodash';
import { Attachment } from '../../../types/Attachment'; import { Attachment, AttachmentType } from '../../../types/Attachment';
import * as MIME from '../../../types/MIME'; import * as MIME from '../../../types/MIME';
import TextareaAutosize from 'react-autosize-textarea'; import TextareaAutosize from 'react-autosize-textarea';
@ -9,6 +9,7 @@ import TextareaAutosize from 'react-autosize-textarea';
import { SessionIconButton, SessionIconSize, SessionIconType } from '../icon'; import { SessionIconButton, SessionIconSize, SessionIconType } from '../icon';
import { SessionEmojiPanel } from './SessionEmojiPanel'; import { SessionEmojiPanel } from './SessionEmojiPanel';
import { SessionRecording } from './SessionRecording'; import { SessionRecording } from './SessionRecording';
import * as GoogleChrome from '../../../util/GoogleChrome';
import { SignalService } from '../../../protobuf'; import { SignalService } from '../../../protobuf';
@ -17,6 +18,7 @@ import { Constants } from '../../../session';
import { toArray } from 'react-emoji-render'; import { toArray } from 'react-emoji-render';
import { SessionQuotedMessageComposition } from './SessionQuotedMessageComposition'; import { SessionQuotedMessageComposition } from './SessionQuotedMessageComposition';
import { Flex } from '../Flex'; import { Flex } from '../Flex';
import { AttachmentList } from '../../conversation/AttachmentList';
export interface ReplyingToMessageProps { export interface ReplyingToMessageProps {
convoId: string; convoId: string;
@ -27,6 +29,10 @@ export interface ReplyingToMessageProps {
attachments?: Array<any>; attachments?: Array<any>;
} }
interface StagedAttachmentType extends AttachmentType {
file: File;
}
interface Props { interface Props {
placeholder?: string; placeholder?: string;
@ -51,7 +57,7 @@ interface State {
mediaSetting: boolean | null; mediaSetting: boolean | null;
showEmojiPanel: boolean; showEmojiPanel: boolean;
attachments: Array<Attachment>; stagedAttachments: Array<StagedAttachmentType>;
voiceRecording?: Blob; voiceRecording?: Blob;
} }
@ -64,7 +70,7 @@ export class SessionCompositionBox extends React.Component<Props, State> {
super(props); super(props);
this.state = { this.state = {
message: '', message: '',
attachments: [], stagedAttachments: [],
voiceRecording: undefined, voiceRecording: undefined,
showRecordingView: false, showRecordingView: false,
mediaSetting: null, mediaSetting: null,
@ -84,6 +90,7 @@ export class SessionCompositionBox extends React.Component<Props, State> {
this.renderRecordingView = this.renderRecordingView.bind(this); this.renderRecordingView = this.renderRecordingView.bind(this);
this.renderCompositionView = this.renderCompositionView.bind(this); this.renderCompositionView = this.renderCompositionView.bind(this);
this.renderQuotedMessage = this.renderQuotedMessage.bind(this); this.renderQuotedMessage = this.renderQuotedMessage.bind(this);
this.renderAttachmentsStaged = this.renderAttachmentsStaged.bind(this);
// Recording view functions // Recording view functions
this.sendVoiceMessage = this.sendVoiceMessage.bind(this); this.sendVoiceMessage = this.sendVoiceMessage.bind(this);
@ -93,6 +100,8 @@ export class SessionCompositionBox extends React.Component<Props, State> {
// Attachments // Attachments
this.onChoseAttachment = this.onChoseAttachment.bind(this); this.onChoseAttachment = this.onChoseAttachment.bind(this);
this.onChooseAttachment = this.onChooseAttachment.bind(this); this.onChooseAttachment = this.onChooseAttachment.bind(this);
this.clearAttachments = this.clearAttachments.bind(this);
this.removeAttachment = this.removeAttachment.bind(this);
// On Sending // On Sending
this.onSendMessage = this.onSendMessage.bind(this); this.onSendMessage = this.onSendMessage.bind(this);
@ -118,6 +127,7 @@ export class SessionCompositionBox extends React.Component<Props, State> {
return ( return (
<Flex flexDirection="column"> <Flex flexDirection="column">
{this.renderQuotedMessage()} {this.renderQuotedMessage()}
{this.renderAttachmentsStaged()}
<div className="composition-container"> <div className="composition-container">
{showRecordingView {showRecordingView
? this.renderRecordingView() ? this.renderRecordingView()
@ -255,38 +265,38 @@ export class SessionCompositionBox extends React.Component<Props, State> {
return <></>; return <></>;
} }
private renderAttachmentsStaged() {
const { stagedAttachments } = this.state;
if (stagedAttachments && stagedAttachments.length) {
return (
<AttachmentList
attachments={stagedAttachments}
// tslint:disable-next-line: no-empty
onClickAttachment={() => {}}
onAddAttachment={this.onChooseAttachment}
onCloseAttachment={this.removeAttachment}
onClose={this.clearAttachments}
/>
);
}
return <></>;
}
private onChooseAttachment() { private onChooseAttachment() {
this.fileInput.current?.click(); this.fileInput.current?.click();
} }
private onChoseAttachment() { private async onChoseAttachment() {
// Build attachments list // Build attachments list
const attachmentsFileList = this.fileInput.current?.files; const attachmentsFileList = this.fileInput.current?.files;
if (!attachmentsFileList) { if (!attachmentsFileList || attachmentsFileList.length === 0) {
return; return;
} }
const attachments: Array<Attachment> = []; // tslint:disable-next-line: prefer-for-of
Array.from(attachmentsFileList).forEach(async (file: File) => { for (let i = 0; i < attachmentsFileList.length; i++) {
const fileBlob = new Blob([file]); await this.maybeAddAttachment(attachmentsFileList[i]);
const fileBuffer = await new Response(fileBlob).arrayBuffer(); }
const attachment = {
fileName: file.name,
flags: undefined,
// FIXME VINCE: Set appropriate type
contentType: MIME.AUDIO_WEBM,
size: file.size,
data: fileBuffer,
};
// Push if size is nonzero
if (attachment.data.byteLength) {
attachments.push(attachment);
}
});
this.setState({ attachments });
} }
private async onKeyDown(event: any) { private async onKeyDown(event: any) {
@ -323,9 +333,6 @@ export class SessionCompositionBox extends React.Component<Props, State> {
if (msgLen === 0 || msgLen > window.CONSTANTS.MAX_MESSAGE_BODY_LENGTH) { if (msgLen === 0 || msgLen > window.CONSTANTS.MAX_MESSAGE_BODY_LENGTH) {
return; return;
} }
// handle Attachments
const { attachments } = this.state;
const { quotedMessageProps } = this.props; const { quotedMessageProps } = this.props;
// Send message // Send message
@ -339,6 +346,7 @@ export class SessionCompositionBox extends React.Component<Props, State> {
); );
try { try {
const attachments = await this.getFiles();
await this.props.sendMessage( await this.props.sendMessage(
messagePlaintext, messagePlaintext,
attachments, attachments,
@ -351,19 +359,30 @@ export class SessionCompositionBox extends React.Component<Props, State> {
// Message sending sucess // Message sending sucess
this.props.onMessageSuccess(); this.props.onMessageSuccess();
// Empty attachments // Empty stagedAttachments
// Empty composition box // Empty composition box
this.setState({ this.setState({
message: '', message: '',
attachments: [],
showEmojiPanel: false, showEmojiPanel: false,
}); });
this.clearAttachments();
} catch (e) { } catch (e) {
// Message sending failed // Message sending failed
window.log.error(e);
this.props.onMessageFailure(); this.props.onMessageFailure();
} }
} }
// this function is called right before sending a message, to gather really files bejind attachments.
private async getFiles() {
const { stagedAttachments } = this.state;
const files = await Promise.all(
stagedAttachments.map(attachment => this.getFile(attachment))
);
this.clearAttachments();
return files;
}
private async sendVoiceMessage(audioBlob: Blob) { private async sendVoiceMessage(audioBlob: Blob) {
if (!this.state.showRecordingView) { if (!this.state.showRecordingView) {
return; return;
@ -467,4 +486,312 @@ export class SessionCompositionBox extends React.Component<Props, State> {
// Focus the textarea when user clicks anywhere in the composition box // Focus the textarea when user clicks anywhere in the composition box
this.textarea.current?.focus(); this.textarea.current?.focus();
} }
// tslint:disable: max-func-body-length cyclomatic-complexity
private async maybeAddAttachment(file: any) {
if (!file) {
return;
}
const fileName = file.name;
const contentType = file.type;
const { stagedAttachments } = this.state;
if (window.Signal.Util.isFileDangerous(fileName)) {
// this.showDangerousError();
return;
}
if (stagedAttachments.length >= 32) {
// this.showMaximumAttachmentsError();
return;
}
const haveNonImage = _.some(
stagedAttachments,
attachment => !MIME.isImage(attachment.contentType)
);
// You can't add another attachment if you already have a non-image staged
if (haveNonImage) {
// this.showMultipleNonImageError();
return;
}
// You can't add a non-image attachment if you already have attachments staged
if (!MIME.isImage(contentType) && stagedAttachments.length > 0) {
// this.showCannotMixError();
return;
}
const { VisualAttachment } = window.Signal.Types;
const renderVideoPreview = async () => {
const objectUrl = URL.createObjectURL(file);
try {
const type = 'image/png';
const thumbnail = await VisualAttachment.makeVideoScreenshot({
objectUrl,
contentType: type,
logger: window.log,
});
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
const url = window.Signal.Util.arrayBufferToObjectURL({
data,
type,
});
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
videoUrl: objectUrl,
url,
isVoiceMessage: false,
});
} catch (error) {
URL.revokeObjectURL(objectUrl);
}
};
const renderImagePreview = async () => {
if (!MIME.isJPEG(contentType)) {
const urlImage = URL.createObjectURL(file);
if (!urlImage) {
throw new Error('Failed to create object url for image!');
}
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
url: urlImage,
isVoiceMessage: false,
});
return;
}
const url = await window.autoOrientImage(file);
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
url,
isVoiceMessage: false,
});
};
try {
const blob = await this.autoScale({
contentType,
file,
});
let limitKb = 10000;
const blobType =
file.type === 'image/gif' ? 'gif' : contentType.split('/')[0];
switch (blobType) {
case 'image':
limitKb = 6000;
break;
case 'gif':
limitKb = 10000;
break;
case 'audio':
limitKb = 10000;
break;
case 'video':
limitKb = 10000;
break;
default:
limitKb = 10000;
}
// if ((blob.file.size / 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);
// // this.showFileSizeError(limit, units[u]);
// return;
// }
} catch (error) {
window.log.error(
'Error ensuring that image is properly sized:',
error && error.stack ? error.stack : error
);
// this.showLoadFailure();
return;
}
try {
if (GoogleChrome.isImageTypeSupported(contentType)) {
await renderImagePreview();
} else if (GoogleChrome.isVideoTypeSupported(contentType)) {
await renderVideoPreview();
} else {
this.addAttachment({
file,
size: file.size,
contentType,
fileName,
url: '',
isVoiceMessage: false,
});
}
} catch (e) {
window.log.error(
`Was unable to generate thumbnail for file type ${contentType}`,
e && e.stack ? e.stack : e
);
this.addAttachment({
file,
size: file.size,
contentType,
fileName,
isVoiceMessage: false,
url: '',
});
}
}
private addAttachment(attachment: StagedAttachmentType) {
const { stagedAttachments } = this.state;
if (attachment.isVoiceMessage && stagedAttachments.length > 0) {
throw new Error('A voice note cannot be sent with other attachments');
}
this.setState({
stagedAttachments: [...stagedAttachments, { ...attachment }],
});
}
private async autoScale<T extends { contentType: string; file: any }>(
attachment: T
): Promise<T> {
const { contentType, file } = attachment;
if (contentType.split('/')[0] !== 'image' || contentType === 'image/tiff') {
// nothing to do
return Promise.resolve(attachment);
}
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.onerror = reject;
img.onload = () => {
URL.revokeObjectURL(url);
const maxSize = 6000 * 1024;
const maxHeight = 4096;
const maxWidth = 4096;
if (
img.naturalWidth <= maxWidth &&
img.naturalHeight <= maxHeight &&
file.size <= maxSize
) {
resolve(attachment);
return;
}
const gifMaxSize = 25000 * 1024;
if (file.type === 'image/gif' && file.size <= gifMaxSize) {
resolve(attachment);
return;
}
if (file.type === 'image/gif') {
reject(new Error('GIF is too large'));
return;
}
const canvas = window.loadImage.scale(img, {
canvas: true,
maxWidth,
maxHeight,
});
let quality = 0.95;
let i = 4;
let blob;
do {
i -= 1;
blob = window.dataURLToBlobSync(
canvas.toDataURL('image/jpeg', quality)
);
quality = (quality * maxSize) / blob.size;
// NOTE: During testing with a large image, we observed the
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax
if (quality < 0.5) {
quality = 0.5;
}
} while (i > 0 && blob.size > maxSize);
resolve({
...attachment,
file: blob,
});
};
img.src = url;
});
}
private clearAttachments() {
this.state.stagedAttachments.forEach(attachment => {
if (attachment.url) {
URL.revokeObjectURL(attachment.url);
}
if (attachment.videoUrl) {
URL.revokeObjectURL(attachment.videoUrl);
}
});
this.setState({ stagedAttachments: [] });
}
private removeAttachment(attachment: AttachmentType) {
const { stagedAttachments } = this.state;
const updatedStagedAttachments = (stagedAttachments || []).filter(
m => m.fileName !== attachment.fileName
);
this.setState({ stagedAttachments: updatedStagedAttachments });
}
private async getFile(attachment: any) {
if (!attachment) {
return Promise.resolve();
}
const attachmentFlags = attachment.isVoiceMessage
? SignalService.AttachmentPointer.Flags.VOICE_MESSAGE
: null;
const scaled = await this.autoScale(attachment);
const fileRead = await this.readFile(scaled);
return {
...fileRead,
url: undefined,
flags: attachmentFlags || null,
};
}
private async readFile(attachment: any): Promise<object> {
return new Promise((resolve, reject) => {
const FR = new FileReader();
FR.onload = e => {
const data = e?.target?.result as ArrayBuffer;
resolve({
...attachment,
data,
size: data.byteLength,
});
};
FR.onerror = reject;
FR.onabort = reject;
FR.readAsArrayBuffer(attachment.file);
});
}
} }

@ -27,6 +27,7 @@ export interface AttachmentType {
isVoiceMessage?: boolean; isVoiceMessage?: boolean;
/** For messages not already on disk, this will be a data url */ /** For messages not already on disk, this will be a data url */
url: string; url: string;
videoUrl?: string;
size?: number; size?: number;
fileSize?: string; fileSize?: string;
pending?: boolean; pending?: boolean;

@ -11,8 +11,8 @@ export const VIDEO_MP4 = 'video/mp4' as MIMEType;
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType; export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg'; export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
export const isImage = (value: MIMEType): boolean => export const isImage = (value?: MIMEType): boolean =>
value && value.startsWith('image/'); !!value && value.startsWith('image/');
export const isVideo = (value: MIMEType): boolean => export const isVideo = (value: MIMEType): boolean =>
value && value.startsWith('video/'); value && value.startsWith('video/');
export const isAudio = (value: MIMEType): boolean => export const isAudio = (value: MIMEType): boolean =>

4
ts/window.d.ts vendored

@ -15,6 +15,7 @@ import { ConfirmationDialogParams } from '../background';
import {} from 'styled-components/cssprop'; import {} from 'styled-components/cssprop';
import { ConversationControllerType } from '../js/ConversationController'; import { ConversationControllerType } from '../js/ConversationController';
import { any } from 'underscore';
/* /*
We declare window stuff here instead of global.d.ts because we are importing other declarations. We declare window stuff here instead of global.d.ts because we are importing other declarations.
If you import anything in global.d.ts, the type system won't work correctly. If you import anything in global.d.ts, the type system won't work correctly.
@ -102,5 +103,8 @@ declare global {
SwarmPolling: SwarmPolling; SwarmPolling: SwarmPolling;
MediaRecorder: any; MediaRecorder: any;
owsDesktopApp: any; owsDesktopApp: any;
loadImage: any;
dataURLToBlobSync: any;
autoOrientImage: any;
} }
} }

Loading…
Cancel
Save