diff --git a/package-lock.json b/package-lock.json index cdb3ae7b..65cddf3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7374,9 +7374,9 @@ "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==" }, "fast-diff": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", - "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==" }, "fast-glob": { "version": "3.1.1", @@ -23742,16 +23742,33 @@ "extend": "^3.0.2", "parchment": "^1.1.4", "quill-delta": "^3.6.2" + }, + "dependencies": { + "fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==" + }, + "quill-delta": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", + "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "requires": { + "deep-equal": "^1.0.1", + "extend": "^3.0.2", + "fast-diff": "1.1.2" + } + } } }, "quill-delta": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", - "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-4.2.1.tgz", + "integrity": "sha512-Y2nksOj6Q+4hizre8n0dml76vLNGK4/y86EoI1d7rv6EL1bx7DPDYRmqQMPu1UqFQO/uQuVHQ3fOmm4ZSzWrfA==", "requires": { "deep-equal": "^1.0.1", "extend": "^3.0.2", - "fast-diff": "1.1.2" + "fast-diff": "1.2.0" } }, "randombytes": { diff --git a/package.json b/package.json index 69f22536..a56e1433 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "mimemessage": "github:ProtonMail/mimemessage.js#semver:~v1.1.4", "proton-pack": "github:ProtonMail/proton-pack.git#semver:^3.0.0", "proton-shared": "github:ProtonMail/proton-shared#master", + "quill-delta": "^4.2.1", "react": "^16.8.6", "react-components": "github:ProtonMail/react-components#master", "react-dom": "^16.8.6", diff --git a/src/app/components/composer/Composer.tsx b/src/app/components/composer/Composer.tsx index 01022204..7fa865b1 100644 --- a/src/app/components/composer/Composer.tsx +++ b/src/app/components/composer/Composer.tsx @@ -107,7 +107,8 @@ const Composer = ({ } if (modelMessage.content === undefined) { - setModelMessage({ ...modelMessage, content: syncedMessage.content }); + console.log('loaded content', syncedMessage.content); + setModelMessage({ ...modelMessage, data: syncedMessage.data, content: syncedMessage.content }); } onChange(syncedMessage); @@ -150,17 +151,25 @@ const Composer = ({ autoSave(newModelMessage); }; const save = async (messageToSave = modelMessage) => { + console.log('save', messageToSave.content); await saveDraft(messageToSave); createNotification({ text: c('Info').t`Message saved` }); }; const handleAddAttachments = async (files: File[]) => { - const attachments = await upload(files, modelMessage, ATTACHMENT_ACTION.ATTACHMENT, api); - if (attachments) { + // TODO: Add embedded / attachment pseudo modal + const action = ATTACHMENT_ACTION.INLINE; + + // console.log('handleAddAttachments', files, syncedMessage, action, api); + + const uploads = await upload(files, syncedMessage, action, api); + const attachments = uploads.map(({ attachment }) => attachment); + if (uploads) { const Attachments = [...(modelMessage.data?.Attachments || []), ...attachments]; const newModelMessage = mergeMessages(modelMessage, { data: { Attachments } }); setModelMessage(newModelMessage); save(modelMessage); } + return attachments; }; const handleRemoveAttachment = (attachment: Attachment) => async () => { await api(removeAttachment(attachment.ID || '', modelMessage.data?.ID || '')); @@ -227,6 +236,7 @@ const Composer = ({ message={modelMessage} onChange={handleChange} onFocus={addressesBlurRef.current} + onAddAttachments={handleAddAttachments} onRemoveAttachment={handleRemoveAttachment} contentFocusRef={contentFocusRef} /> diff --git a/src/app/components/composer/ComposerContent.tsx b/src/app/components/composer/ComposerContent.tsx index 94acdf17..2261a6a3 100644 --- a/src/app/components/composer/ComposerContent.tsx +++ b/src/app/components/composer/ComposerContent.tsx @@ -1,50 +1,43 @@ -import React, { MutableRefObject, useRef, useEffect, RefObject } from 'react'; -import ReactQuill from 'react-quill'; -import Quill from 'quill'; -import { noop } from 'proton-shared/lib/helpers/function'; +import React, { MutableRefObject } from 'react'; import { MessageExtended } from '../../models/message'; import { getAttachments } from '../../helpers/message/messages'; import AttachmentsList from './attachments/AttachmensList'; import { Attachment } from '../../models/attachment'; import 'react-quill/dist/quill.snow.css'; - -const Block = Quill.import('blots/block'); -Block.tagName = 'div'; -Quill.register(Block); +import Editor from './editor/Editor'; interface Props { message: MessageExtended; onChange: (message: MessageExtended) => void; onFocus: () => void; + onAddAttachments: (files: File[]) => void; onRemoveAttachment: (attachment: Attachment) => () => void; contentFocusRef: MutableRefObject<() => void>; } -const ComposerContent = ({ message, onChange, onFocus, onRemoveAttachment, contentFocusRef }: Props) => { - const inputRef: RefObject = useRef(null); - - useEffect(() => { - contentFocusRef.current = inputRef.current?.focus || noop; - }, []); +const ComposerContent = ({ + message, + onChange, + onFocus, + onAddAttachments, + onRemoveAttachment, + contentFocusRef +}: Props) => { + const attachments = getAttachments(message.data); - const handleChange = (content: string, delta: any, source: string) => { - if (source === 'user') { - onChange({ content }); - } + const handleChange = (content: string) => { + onChange({ content }); }; - const attachments = getAttachments(message.data); - return (
- {attachments.length > 0 && }
diff --git a/src/app/components/composer/attachments/AttachmentsButton.tsx b/src/app/components/composer/attachments/AttachmentsButton.tsx index 0c51d118..4533a087 100644 --- a/src/app/components/composer/attachments/AttachmentsButton.tsx +++ b/src/app/components/composer/attachments/AttachmentsButton.tsx @@ -1,11 +1,12 @@ -import React, { ChangeEvent } from 'react'; +import React, { ChangeEvent, ReactNode } from 'react'; import { Button } from 'react-components'; interface Props { onAddAttachments: (files: File[]) => void; + children?: ReactNode; } -const AttachmentsButton = ({ onAddAttachments }: Props) => { +const AttachmentsButton = ({ onAddAttachments, children }: Props) => { const handleChange = (event: ChangeEvent) => { const input = event.target; if (input.files) { @@ -17,7 +18,9 @@ const AttachmentsButton = ({ onAddAttachments }: Props) => { return (
-
); }; diff --git a/src/app/components/composer/composer.scss b/src/app/components/composer/composer.scss index 44d7d694..b7788873 100644 --- a/src/app/components/composer/composer.scss +++ b/src/app/components/composer/composer.scss @@ -163,7 +163,7 @@ .composer-quill { height: 100%; - margin-bottom: 42px; // compensate for Quill strange auto sizing + // margin-bottom: 42px; // compensate for Quill strange auto sizing } .composer-attachments-button-wrapper { diff --git a/src/app/components/composer/editor/Editor.tsx b/src/app/components/composer/editor/Editor.tsx new file mode 100644 index 00000000..03dd9be4 --- /dev/null +++ b/src/app/components/composer/editor/Editor.tsx @@ -0,0 +1,93 @@ +import React, { MutableRefObject, useRef, useEffect, RefObject, useState } from 'react'; +import ReactQuill from 'react-quill'; +import Quill, { DeltaStatic } from 'quill'; +import Delta from 'quill-delta'; + +import { generateUID } from 'react-components'; +import { noop } from 'proton-shared/lib/helpers/function'; + +import { Attachment } from '../../../models/attachment'; +import { getCid } from '../../../helpers/attachment/attachments'; +import { getBlob } from '../../../helpers/embedded/embeddedStoreBlobs'; +import EditorToolbar from './EditorToolbar'; + +import '../../../helpers/quill/quillSetup'; + +import 'react-quill/dist/quill.snow.css'; + +// Strangely types from quill and quill-delta are incompatible +const convertDelta = (delta: Delta): DeltaStatic => (delta as any) as DeltaStatic; + +interface Props { + content?: string; + onChange: (content: string) => void; + onFocus: () => void; + onAddAttachments: (files: File[]) => Promise; + contentFocusRef: MutableRefObject<() => void>; +} + +const Editor = ({ content, onChange, onFocus, onAddAttachments, contentFocusRef }: Props) => { + const [uid] = useState(generateUID('quill')); + const reactQuillRef: RefObject = useRef(null); + + const toolbarId = `quill-${uid}-toolbar`; + const getQuill = () => reactQuillRef.current?.getEditor() as Quill; + + useEffect(() => { + contentFocusRef.current = reactQuillRef.current?.focus || noop; + }, []); + + const handleChange = (content: string, delta: any, source: string) => { + if (source === 'user') { + onChange(content); + } + }; + + const handleAddImageUrl = (url: string) => { + const quill = getQuill(); + const range = quill.getSelection(true); + + const delta = new Delta() + .retain(range.index) + .delete(range.length) + .insert({ image: url }); + + quill.updateContents(convertDelta(delta), 'user'); + quill.setSelection(range.index + 1, 0, 'silent'); + }; + + const handleAddAttachments = async (files: File[]) => { + const attachments = await onAddAttachments(files); + + const quill = getQuill(); + const range = quill.getSelection(true); + + const delta = new Delta().retain(range.index).delete(range.length); + + attachments.forEach((attachment) => { + const cid = getCid(attachment); + const { url } = getBlob(cid); + delta.insert({ image: url }, { cid, alt: attachment.Name }); + }); + + quill.updateContents(convertDelta(delta), 'user'); + quill.setSelection(range.index + 1, 0, 'silent'); + }; + + return ( + <> + + + + ); +}; + +export default Editor; diff --git a/src/app/components/composer/editor/EditorImageModal.tsx b/src/app/components/composer/editor/EditorImageModal.tsx new file mode 100644 index 00000000..feadd865 --- /dev/null +++ b/src/app/components/composer/editor/EditorImageModal.tsx @@ -0,0 +1,119 @@ +import React, { useState, ChangeEvent } from 'react'; +import { FormModal, Label, generateUID, Input, Loader, ResetButton, PrimaryButton } from 'react-components'; +import { c } from 'ttag'; + +import EditorReactiveImage from './EditorReactiveImage'; +import AttachmentsButton from '../attachments/AttachmentsButton'; + +// export enum ImageAttachmentType { +// Attachment, +// Embedded, +// Url +// } + +// export interface ImageAttachment { +// type: ImageAttachmentType; +// file?: File; +// url?: string; +// } + +enum ImageState { + Initial, + Loading, + Error, + Ok +} + +interface Props { + onAddUrl: (url: string) => void; + onAddAttachments: (files: File[]) => void; + onClose?: () => void; +} + +const EditorImageModal = ({ onAddUrl, onAddAttachments, onClose, ...rest }: Props) => { + const [uid] = useState(generateUID('editor-image-modal')); + const [imageSrc, setImageSrc] = useState(''); + const [imageState, setImageState] = useState(ImageState.Initial); + + const handleChange = (event: ChangeEvent) => { + setImageSrc(event.target.value); + }; + + const handleLoading = () => setImageState(ImageState.Loading); + const handleSuccess = () => setImageState(ImageState.Ok); + const handleError = () => setImageState(ImageState.Error); + + const handleSubmit = () => { + onAddUrl(imageSrc); + onClose?.(); + }; + + const handleAddAttachments = (files: File[]) => { + onAddAttachments(files); + onClose?.(); + }; + + return ( + + {c('Action').t`Cancel`} + + {c('Action').t`Add file`} + + + {c('Action').t`Insert`} + + + } + {...rest} + > +
+ +
+ +
+
+
+ +
+ + + {imageState !== ImageState.Ok && ( +

+ {c('Info') + .t`If your URL is correct, you'll see an image preview here. Large images may take a few minutes to appear.`} +

+ )} + + {imageState === ImageState.Loading && ( + + + {c('Info').t`Loading image`} + + )} + + {imageState === ImageState.Error && ( + {c('Info').t`Error loading image`} + )} +
+
+
+ ); +}; + +export default EditorImageModal; diff --git a/src/app/components/composer/editor/EditorReactiveImage.tsx b/src/app/components/composer/editor/EditorReactiveImage.tsx new file mode 100644 index 00000000..a1854992 --- /dev/null +++ b/src/app/components/composer/editor/EditorReactiveImage.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, ImgHTMLAttributes, useRef, useState, useCallback } from 'react'; +import { debounce } from 'proton-shared/lib/helpers/function'; + +interface Props extends ImgHTMLAttributes { + onLoading: () => void; + onSuccess: () => void; + onError: () => void; +} + +const EditorReactiveImage = ({ src: inputSrc, onLoading, onSuccess, onError }: Props) => { + const ref = useRef(null); + const [src, setSrc] = useState(); + const [valid, setValid] = useState(false); + + const handleChangeDebounced = useCallback( + debounce(async (inputSrc: string) => { + setSrc(inputSrc); + setValid(false); + onLoading(); + }, 500), + [] + ); + + useEffect(() => handleChangeDebounced(inputSrc), [inputSrc]); + + useEffect(() => { + const handleLoad = () => { + setValid(true); + onSuccess(); + }; + const handleError = () => { + setValid(false); + onError(); + }; + + ref.current?.addEventListener('load', handleLoad); + ref.current?.addEventListener('error', handleError); + + return () => { + ref.current?.removeEventListener('load', handleLoad); + ref.current?.removeEventListener('error', handleError); + }; + }, []); + + return ; +}; + +export default EditorReactiveImage; diff --git a/src/app/components/composer/editor/EditorToolbar.tsx b/src/app/components/composer/editor/EditorToolbar.tsx new file mode 100644 index 00000000..26267245 --- /dev/null +++ b/src/app/components/composer/editor/EditorToolbar.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Icon, useModals } from 'react-components'; + +import EditorImageModal from './EditorImageModal'; + +interface Props { + id: string; + onAddImageUrl: (url: string) => void; + onAddAttachments: (files: File[]) => void; +} + +const EditorToolbar = ({ id, onAddImageUrl, onAddAttachments }: Props) => { + const { createModal } = useModals(); + + const handleImage = () => { + createModal(); + }; + + return ( +
+ + + + + +
+ ); +}; + +export default EditorToolbar; diff --git a/src/app/helpers/attachment/attachmentUploader.ts b/src/app/helpers/attachment/attachmentUploader.ts index 9c16f762..a160b625 100644 --- a/src/app/helpers/attachment/attachmentUploader.ts +++ b/src/app/helpers/attachment/attachmentUploader.ts @@ -6,8 +6,12 @@ import { Api, Binary } from '../../models/utils'; import { getAttachments } from '../message/messages'; import { readFileAsBuffer } from '../file'; import { uploadAttachment } from '../../api/attachments'; -import { isEmbeddable } from './attachments'; +import { isEmbeddable, getCid } from './attachments'; import { Attachment } from '../../models/attachment'; +import { MIME_TYPES } from 'proton-shared/lib/constants'; +import { store } from '../embedded/embeddedStoreBlobs'; +import { generateCid } from '../embedded/embeddedUtils'; +import { generateUID } from '../string'; // Reference: Angular/src/app/attachments/factories/attachmentModel.js @@ -18,13 +22,32 @@ export enum ATTACHMENT_ACTION { INLINE = 'inline' } +interface Packets { + Filename: string; + MIMEType: MIME_TYPES; + FileSize: number; + Inline: boolean; + signature?: Uint8Array; + Preview: Uint8Array; + keys: Uint8Array; + data: Uint8Array; +} + +interface UploadResult { + attachment: Attachment; + // sessionKey: SessionKey; + packets: Packets; + // cid: string; + // REQUEST_ID: string; +} + const encrypt = async ( data: Binary, { name, type, size }: File = {} as File, inline: boolean, publicKeys: PmcryptoKey[], privateKeys: PmcryptoKey[] -) => { +): Promise => { const { message, signature } = await encryptMessage({ filename: name, armor: false, @@ -38,10 +61,10 @@ const encrypt = async ( return { Filename: name, - MIMEType: type, + MIMEType: type as MIME_TYPES, FileSize: size, Inline: inline, - signature: signature ? signature.packets.write() : undefined, + signature: signature ? (signature.packets.write() as Uint8Array) : undefined, Preview: data, keys: asymmetric[0], data: encrypted[0] @@ -66,37 +89,37 @@ const encryptFile = async (file: File, inline: boolean, pubKeys: PmcryptoKey[], /** * Add a new attachment, upload it to the server */ -const uploadFile = async (file: File, message: MessageExtended, inline: boolean, api: Api, total = 1, cid = '') => { +const uploadFile = async ( + file: File, + message: MessageExtended, + inline: boolean, + api: Api, + total = 1, + cid = '' +): Promise => { const titleImage = c('Title').t`Image`; - const tempPacket = { - filename: file.name || `${titleImage} ${getAttachments(message.data).length + 1}`, - uploading: true, - Size: file.size, - ContentID: cid - }; - - // TODO - // force update the embedded counter - // if (file.inline) { - // message.NumEmbedded++; - // // CID doesn't exist when the user add an attachment - // tempPacket.ContentID = cid || embedded.generateCid(file.upload.uuid, message.From.Email); - // tempPacket.Inline = 1; - // } + // const tempPacket = { + // filename: file.name || `${titleImage} ${getAttachments(message.data).length + 1}`, + // uploading: true, + // Size: file.size, + // ContentID: cid + // }; - // const privateKeys = keysModel.getPrivateKeys(message.AddressID); - // message.attachmentsToggle = true; + const filename = file.name || `${titleImage} ${getAttachments(message.data).length + 1}`; + const ContentID = inline ? cid || generateCid(generateUID(), message.data?.Sender?.Address || '') : ''; const publicKeys = message.publicKeys && message.publicKeys.length > 0 ? [message.publicKeys[0]] : []; + // console.log('uploadFile', file, inline, publicKeys, message.privateKeys || []); + const packets = await encryptFile(file, inline, publicKeys, message.privateKeys || []); const { Attachment } = await (api( uploadAttachment({ - Filename: packets.Filename || tempPacket.filename, + Filename: packets.Filename || filename, MessageID: message.data?.ID || '', - ContentID: tempPacket.ContentID, + ContentID, MIMEType: packets.MIMEType, KeyPackets: new Blob([packets.keys] as any), DataPacket: new Blob([packets.data] as any), @@ -116,9 +139,14 @@ const uploadFile = async (file: File, message: MessageExtended, inline: boolean, // const newCid = contentId.replace(/[<>]+/g, ''); // return { attachment, sessionKey, packets, cid: newCid, REQUEST_ID }; - return Attachment; + return { attachment: Attachment, packets }; }; +// const getNewCid = (attachment: Attachment) => { +// const contentId = `${(attachment.Headers || {})['content-id'] || ''}`; +// return contentId.replace(/[<>]+/g, ''); +// }; + /** * Upload a list of attachments [...File] */ @@ -155,6 +183,11 @@ export const upload = async ( // TODO: Embedded // Create embedded and replace theses files from the upload list + uploads.forEach(({ attachment, packets }) => { + // addEmbedded(message, cid, packets.Preview, attachment.MIMEType); + console.log('store', getCid(attachment)); + store(message.data, getCid(attachment))(packets.Preview, attachment.MIMEType); + }); // const embeddedMap = addEmbedded(upload, message); // return _.map(upload, (config) => { // return embeddedMap[config.attachment.ID] || config; diff --git a/src/app/helpers/attachment/attachments.ts b/src/app/helpers/attachment/attachments.ts index ce47065f..7cce9aef 100644 --- a/src/app/helpers/attachment/attachments.ts +++ b/src/app/helpers/attachment/attachments.ts @@ -9,3 +9,5 @@ export const isEmbeddedLocal = ({ }; export const isEmbeddable = (fileType: string) => embeddableTypes.includes(fileType); + +export const getCid = ({ Headers }: Attachment = {}) => `${(Headers || {})['content-id'] || ''}`.replace(/[<>]+/g, ''); diff --git a/src/app/helpers/embedded/embeddedFinder.ts b/src/app/helpers/embedded/embeddedFinder.ts index da39def8..6015bcb9 100644 --- a/src/app/helpers/embedded/embeddedFinder.ts +++ b/src/app/helpers/embedded/embeddedFinder.ts @@ -2,6 +2,7 @@ import { MessageExtended, Message } from '../../models/message'; import { extractEmbedded, getAttachementName, REGEXP_CID_START } from '../embedded/embeddedUtils'; import { addMessageCID, getMessageCIDs } from './embeddedStoreCids'; import { Attachment } from '../../models/attachment'; +import { getAttachments } from '../message/messages'; export const getAttachment = (message: Message = {}, src = '') => { const cid = src.replace(REGEXP_CID_START, ''); @@ -9,13 +10,13 @@ export const getAttachment = (message: Message = {}, src = '') => { }; export const find = (message: MessageExtended) => { - const list = (message.data || {}).Attachments || []; + const attachements = getAttachments(message.data); - if (!list.length || !message.document) { + if (!attachements.length || !message.document) { return []; } - const embeddedAttachments = extractEmbedded(list, message.document); + const embeddedAttachments = extractEmbedded(attachements, message.document); embeddedAttachments.forEach((attachment) => { addMessageCID(message.data || {}, attachment); diff --git a/src/app/helpers/embedded/embeddedParser.ts b/src/app/helpers/embedded/embeddedParser.ts index 83d32cbc..a74b588f 100644 --- a/src/app/helpers/embedded/embeddedParser.ts +++ b/src/app/helpers/embedded/embeddedParser.ts @@ -1,4 +1,4 @@ -import { MessageExtended } from '../../models/message'; +import { MessageExtended, Message } from '../../models/message'; import { escapeSrc, unescapeSrc, wrap } from '../dom'; import { Api } from '../../models/utils'; import { ENCRYPTED_STATUS } from '../../constants'; @@ -48,6 +48,8 @@ export const prepareImages = (message: MessageExtended, show: boolean, isReplyFo image.setAttribute('referrerPolicy', 'no-referrer'); const attachment = getAttachment(message.data, src); + console.log('prepareImages', image, src, attachment); + if (!image.classList.contains(EMBEDDED_CLASSNAME)) { image.classList.add(EMBEDDED_CLASSNAME); } @@ -160,6 +162,7 @@ const triggerSigVerification = ( const actionDirection: { [key: string]: (nodes: Element[], cid: string, url: string) => void } = { blob(nodes: Element[], cid: string, url: string) { nodes.forEach((node) => { + console.log('blob', node, cid, url); // Always remove the `data-` src attribute set by the cid function, otherwise it can get displayed if the user does not auto load embedded images. node.removeAttribute('data-src'); if (node.getAttribute('proton-src')) { @@ -182,18 +185,20 @@ const actionDirection: { [key: string]: (nodes: Element[], cid: string, url: str /** * Parse the content to inject the generated blob src */ -export const mutateHTML = (message: MessageExtended, direction: string) => { - if (!message.document) { +export const mutateHTML = (message: Message = {}, document?: Element, direction: 'blob' | 'cid' = 'blob') => { + if (!document) { return; } - const document = message.document; - document.innerHTML = escapeSrc(document.innerHTML); - Object.keys(getMessageCIDs(message.data)).forEach((cid) => { + console.log('mutateHTML before', getMessageCIDs(message), document.innerHTML); + + Object.keys(getMessageCIDs(message)).forEach((cid) => { const nodes = findEmbedded(cid, document); + console.log('mutateHTML', cid, nodes); + if (nodes.length) { const { url = '' } = getBlob(cid); @@ -201,7 +206,11 @@ export const mutateHTML = (message: MessageExtended, direction: string) => { } }); + console.log('mutateHTML after', document.innerHTML); + document.innerHTML = unescapeSrc(document.innerHTML); + + console.log('mutateHTML after unescape', document.innerHTML); }; export const decrypt = async (message: MessageExtended, api: Api, cache: AttachmentsDataCache) => { diff --git a/src/app/helpers/embedded/embeddedUtils.ts b/src/app/helpers/embedded/embeddedUtils.ts index 88e9e982..6e187699 100644 --- a/src/app/helpers/embedded/embeddedUtils.ts +++ b/src/app/helpers/embedded/embeddedUtils.ts @@ -1,8 +1,9 @@ import mimemessage from 'mimemessage'; -import { ucFirst } from '../string'; +import { ucFirst, toUnsignedString } from '../string'; import { Attachment } from '../../models/attachment'; import { transformEscape } from '../transforms/transformEscape'; +import { hash } from '../string'; export const REGEXP_CID_START = /^cid:/g; @@ -73,6 +74,8 @@ export const extractEmbedded = (attachments: Attachment[] = [], document: Elemen const cid = readCID(Headers); const nodes = findEmbedded(cid, document); + console.log('extractEmbedded', cid, nodes, document.innerHTML); + return nodes.length; }); }; @@ -107,6 +110,15 @@ export const getAttachementName = (Headers: { [key: string]: string } = {}) => { return ''; }; +/** + * Generate CID from input and email + */ +export const generateCid = (input: string, email: string) => { + const hashValue = toUnsignedString(hash(input), 4); + const domain = email.split('@')[1]; + return `${hashValue}@${domain}`; +}; + /** * Get the url for an embedded image */ diff --git a/src/app/helpers/message/messageEncrypt.ts b/src/app/helpers/message/messageEncrypt.ts index 0c4a480f..dd40293b 100644 --- a/src/app/helpers/message/messageEncrypt.ts +++ b/src/app/helpers/message/messageEncrypt.ts @@ -1,5 +1,6 @@ import { encryptMessage, PmcryptoKey } from 'pmcrypto'; import { MessageExtended } from '../../models/message'; +import { getHTML } from './messages'; // Reference: Angular/src/app/message/factories/messageModel.js encryptBody @@ -9,7 +10,7 @@ export const encryptBody = async ( publicKeys: PmcryptoKey[] ): Promise => { const { data } = await encryptMessage({ - data: message.content || '', + data: getHTML(message) || '', publicKeys: [publicKeys[0]], privateKeys, format: 'utf8', diff --git a/src/app/helpers/message/messages.ts b/src/app/helpers/message/messages.ts index 764e9432..fa1a5728 100644 --- a/src/app/helpers/message/messages.ts +++ b/src/app/helpers/message/messages.ts @@ -214,7 +214,8 @@ export const isSentAutoReply = ({ Flags, ParsedHeaders = {} }: Message) => { /** * We NEVER upconvert, if the user wants html: plaintext is actually fine as well */ -export const getHTML = (message: MessageExtended) => (isHTML(message.data) ? message.content : undefined); +export const getHTML = (message: MessageExtended) => + isHTML(message.data) ? message.saveDocument?.innerHTML : undefined; export const exportPlainText = (message: MessageExtended) => { /* diff --git a/src/app/helpers/quill/quillProtonImage.ts b/src/app/helpers/quill/quillProtonImage.ts new file mode 100644 index 00000000..5d42ec93 --- /dev/null +++ b/src/app/helpers/quill/quillProtonImage.ts @@ -0,0 +1,76 @@ +import Quill from 'quill'; + +const Embed = Quill.import('blots/embed'); + +// const ATTRIBUTES = ['alt', 'height', 'width', 'cid']; +const ATTRIBUTES = [ + { + obj: 'alt', + dom: 'alt' + }, + { + obj: 'cid', + dom: 'data-embedded-img' + } +]; + +class ProtonImage extends Embed { + static create(value: string) { + // console.log('ProtonImage.create', value); + const node = super.create(value); + if (typeof value === 'string') { + node.setAttribute('src', value); + } + node.classList.add('proton-embedded'); + return node; + } + + static formats(domNode: Element) { + // console.log('ProtonImage.formats', domNode); + return ATTRIBUTES.reduce((formats, attribute) => { + if (domNode.hasAttribute(attribute.dom)) { + formats[attribute.obj] = domNode.getAttribute(attribute.dom); + } + return formats; + }, {} as { [key: string]: any }); + } + + static match(url: string) { + // console.log('ProtonImage.match', url); + return /\.(jpe?g|gif|png)$/.test(url) || /^data:image\/.+;base64/.test(url); + } + + static register() { + // console.log('ProtonImage.register'); + if (/Firefox/i.test(navigator.userAgent)) { + setTimeout(() => { + // Disable image resizing in Firefox + document.execCommand('enableObjectResizing', false); + }, 1); + } + } + + static value(domNode: Element) { + // console.log('ProtonImage.value', domNode); + return domNode.getAttribute('src'); + } + + format(name: string, value: string) { + // console.log('ProtonImage.format', name, value); + const attribute = ATTRIBUTES.find((attribute) => attribute.obj === name); + if (attribute) { + if (value) { + this.domNode.setAttribute(attribute.dom, value); + } else { + this.domNode.removeAttribute(attribute.dom); + } + } else { + super.format(name, value); + } + } +} + +ProtonImage.blotName = 'image'; +ProtonImage.tagName = 'IMG'; + +export default ProtonImage; diff --git a/src/app/helpers/quill/quillSetup.ts b/src/app/helpers/quill/quillSetup.ts new file mode 100644 index 00000000..0b2e8a86 --- /dev/null +++ b/src/app/helpers/quill/quillSetup.ts @@ -0,0 +1,21 @@ +import Quill from 'quill'; + +import ProtonImage from './quillProtonImage'; + +/** + * Current solution for images is pretty simple yet it has been a long journey to get here. + * There is what I learnt: + * - Never refer to a manual import of Parchment, Quill use another instance and nothing match (especially for classes) + * - Use instead Quill.import to get what you need (log Quill constructor to have a list) + * - There is restrictions of what types of object can be inserted where. By inheritate from the right class, you should be able to be accepted + * - Parent class for images are in Quill.import('blots/embed'); + * - It's also possible to hook on the standard image by importing "formats/image" but a new type felt better + */ + +const Block = Quill.import('blots/block'); +Block.tagName = 'div'; +Quill.register(Block); + +Quill.register(ProtonImage); + +console.log('Quill', Quill); diff --git a/src/app/helpers/send/sendTopPackages.ts b/src/app/helpers/send/sendTopPackages.ts index 0dc2e88e..21ee0230 100644 --- a/src/app/helpers/send/sendTopPackages.ts +++ b/src/app/helpers/send/sendTopPackages.ts @@ -106,6 +106,7 @@ export const generateTopPackages = async ( return; case DEFAULT: packages[DEFAULT] = await generateHTMLPackage(message); + console.log('sendTopPackages', { ...packages[DEFAULT] }); return; default: throw new Error(); // Should never happen. diff --git a/src/app/helpers/string.ts b/src/app/helpers/string.ts index 5cf1a141..966d92d4 100644 --- a/src/app/helpers/string.ts +++ b/src/app/helpers/string.ts @@ -183,3 +183,11 @@ export function generateUID() { export const replaceLineBreaks = (content: string) => { return content.replace(/(?:\r\n|\r|\n)/g, '
'); }; + +/** + * Generate a hash + */ +export const hash = (str = '') => { + // bitwise or with 0 ( | 0) makes sure we are using integer arithmetic and not floating point arithmetic + return str.split('').reduce((prevHash, currVal) => (prevHash * 31 + currVal.charCodeAt(0)) | 0, 0); +}; diff --git a/src/app/helpers/transforms/transformEmbedded.ts b/src/app/helpers/transforms/transformEmbedded.ts index ca0df261..096561de 100644 --- a/src/app/helpers/transforms/transformEmbedded.ts +++ b/src/app/helpers/transforms/transformEmbedded.ts @@ -3,10 +3,11 @@ import { Computation } from '../../hooks/useMessage'; import { find } from '../embedded/embeddedFinder'; import { mutateHTML, decrypt, prepareImages } from '../embedded/embeddedParser'; import { MESSAGE_ACTIONS } from '../../constants'; +import { isDraft } from '../message/messages'; export const transformEmbedded: Computation = async (message, { attachmentsCache, api, mailSettings }) => { const { ShowImages = 0 } = mailSettings as { ShowImages: number }; - const show = message.showEmbeddedImages === true || ShowImages === SHOW_IMAGES.EMBEDDED; + const show = message.showEmbeddedImages === true || ShowImages === SHOW_IMAGES.EMBEDDED || isDraft(message.data); const isReplyForward = message.action === MESSAGE_ACTIONS.REPLY || message.action === MESSAGE_ACTIONS.REPLY_ALL || @@ -16,8 +17,6 @@ export const transformEmbedded: Computation = async (message, { attachmentsCache const attachments = find(message); const showEmbeddedImages = prepareImages(message, show, isReplyForward, isOutside); - const direction = 'blob'; - if (attachments.length === 0 || !show) { /** * cf #5088 we need to escape the body again if we forgot to set the password First. @@ -26,11 +25,11 @@ export const transformEmbedded: Computation = async (message, { attachmentsCache * Don't do it everytime because it's "slow" and we don't want to slow down the process. */ if (isOutside) { - mutateHTML(message, direction); + mutateHTML(message.data, message.document); } } else { await decrypt(message, api, attachmentsCache.data); - mutateHTML(message, direction); + mutateHTML(message.data, message.document); } return { document: message.document, showEmbeddedImages, numEmbedded: attachments.length }; diff --git a/src/app/helpers/transforms/transformEmbeddedSave.ts b/src/app/helpers/transforms/transformEmbeddedSave.ts new file mode 100644 index 00000000..b51ed3a5 --- /dev/null +++ b/src/app/helpers/transforms/transformEmbeddedSave.ts @@ -0,0 +1,11 @@ +import { Computation } from '../../hooks/useMessage'; +import { mutateHTML } from '../embedded/embeddedParser'; +import { find } from '../embedded/embeddedFinder'; + +export const transformEmbeddedSave: Computation = async (message) => { + const saveDocument = message.document?.cloneNode(true) as Element; + find(message); + mutateHTML(message.data, saveDocument, 'cid'); + // console.log('transformEmbeddedSave', message.document?.innerHTML); + return { saveDocument }; +}; diff --git a/src/app/helpers/transforms/transformRemote.js b/src/app/helpers/transforms/transformRemote.js index 49ab8c1b..bcabcc3a 100644 --- a/src/app/helpers/transforms/transformRemote.js +++ b/src/app/helpers/transforms/transformRemote.js @@ -1,6 +1,7 @@ import { flow, filter, reduce } from 'lodash/fp'; import { SHOW_IMAGES } from 'proton-shared/lib/constants'; +import { isDraft } from '../message/messages'; const WHITELIST = ['notify@protonmail.com']; @@ -78,7 +79,8 @@ export const transformRemote = ( const regex = new RegExp(REGEXP_FIXER, 'g'); const showImages = inputShowImages || - !!(mailSettings.ShowImages & SHOW_IMAGES.REMOTE || WHITELIST.includes(message.Sender.Address)); + !!(mailSettings.ShowImages & SHOW_IMAGES.REMOTE || WHITELIST.includes(message.Sender.Address)) || + isDraft(message); const content = document.innerHTML; const hasImages = regex.test(content); diff --git a/src/app/hooks/useMessage.ts b/src/app/hooks/useMessage.ts index 793a6596..285db77b 100644 --- a/src/app/hooks/useMessage.ts +++ b/src/app/hooks/useMessage.ts @@ -13,6 +13,7 @@ import { wait } from 'proton-shared/lib/helpers/promise'; import { transformEscape } from '../helpers/transforms/transformEscape'; import { transformLinks } from '../helpers/transforms/transformLinks'; import { transformEmbedded } from '../helpers/transforms/transformEmbedded'; +import { transformEmbeddedSave } from '../helpers/transforms/transformEmbeddedSave'; import { transformWelcome } from '../helpers/transforms/transformWelcome'; import { transformBlockquotes } from '../helpers/transforms/transformBlockquotes'; import { transformStylesheet } from '../helpers/transforms/transformStylesheet'; @@ -206,7 +207,8 @@ export const useMessage = ( [create, c('Action').t`Creating`], [update, c('Action').t`Saving`], [sendMessage, c('Action').t`Sending`], - [deleteRequest, c('Action').t`Deleting`] + [deleteRequest, c('Action').t`Deleting`], + [transformEmbeddedSave, c('Action').t`Processing`] ]); transforms.forEach((transform) => activities.set(transform, c('Action').t`Processing`)); @@ -299,14 +301,14 @@ export const useMessage = ( const saveDraft = useCallback( async (messageModel: MessageExtended) => { - await run(mergeMessages(message, messageModel), [encrypt, update]); + await run(mergeMessages(message, messageModel), [transformEmbeddedSave, encrypt, update]); }, [message, run, cache] ); const send = useCallback( async (messageModel: MessageExtended) => { - await run(mergeMessages(message, messageModel), [encrypt, update, sendMessage]); + await run(mergeMessages(message, messageModel), [transformEmbeddedSave, encrypt, update, sendMessage]); }, [message, run, cache] ); diff --git a/src/app/models/message.ts b/src/app/models/message.ts index 0b420dc3..b0204b0f 100644 --- a/src/app/models/message.ts +++ b/src/app/models/message.ts @@ -36,7 +36,8 @@ export interface Message { export interface MessageExtended { data?: Message; raw?: string; - document?: Element; + document?: Element; // Document processed based on the message body + saveDocument?: Element; // Document processed to be saved content?: string; verified?: number; publicKeys?: any[];