[geary: 1/10] First changes for supporting drag & dropped and pasted images



commit b4e3f196ddcdf3edf8b0934f75fb69550b956c3b
Author: Chris Heywood <chris theheywoods id au>
Date:   Wed Sep 18 12:24:01 2019 +0200

    First changes for supporting drag & dropped and pasted images
    
    See #90 and #304. Work in progress.

 src/client/composer/composer-web-view.vala        |  43 +++++++-
 src/client/composer/composer-widget.vala          | 125 +++++++++++++++++++---
 src/client/web-process/web-process-extension.vala |   3 +-
 src/engine/api/geary-composed-email.vala          |  28 ++---
 src/engine/rfc822/rfc822-message.vala             |  68 ++++++++++--
 ui/composer-web-view.js                           |  24 +++++
 6 files changed, 250 insertions(+), 41 deletions(-)
---
diff --git a/src/client/composer/composer-web-view.vala b/src/client/composer/composer-web-view.vala
index 0f230f7c..0b03b389 100644
--- a/src/client/composer/composer-web-view.vala
+++ b/src/client/composer/composer-web-view.vala
@@ -14,7 +14,7 @@ public class ComposerWebView : ClientWebView {
 
     // WebKit message handler names
     private const string CURSOR_CONTEXT_CHANGED = "cursorContextChanged";
-
+    private const string DRAG_DROP_RECEIVED = "dragDropReceived";
 
     /**
      * Encapsulates editing-related state for a specific DOM node.
@@ -108,6 +108,9 @@ public class ComposerWebView : ClientWebView {
     /** Emitted when the cursor's edit context has changed. */
     public signal void cursor_context_changed(EditContext cursor_context);
 
+    /** Emitted when an image file has been dropped on the composer */
+    public signal void image_file_dropped(string filename, string type, uint8[] contents);
+
     /** Workaround for WebView eating the button event */
     internal signal bool button_release_event_done(Gdk.Event event);
 
@@ -121,6 +124,7 @@ public class ComposerWebView : ClientWebView {
         this.user_content_manager.add_script(ComposerWebView.app_script);
 
         register_message_handler(CURSOR_CONTEXT_CHANGED, on_cursor_context_changed);
+        register_message_handler(DRAG_DROP_RECEIVED, on_drag_drop_received);
 
         // XXX this is a bit of a hack given the docs for is_empty,
         // above
@@ -514,4 +518,41 @@ public class ComposerWebView : ClientWebView {
         }
     }
 
+    /**
+     *  Handle a dropped image
+     */
+    private void on_drag_drop_received(WebKit.JavascriptResult result) {
+        string native_result;
+        try {
+            native_result = Util.JS.to_string(result.get_js_value());
+        } catch (Util.JS.Error err) {
+            warning("Failed to decode drag & drop data: %s", err.message);
+            return;
+        }
+
+        string[] pieces = native_result.split(",");
+
+        if (pieces.length != 4) {
+            warning("Invalid data received in drag & drop: %s", native_result);
+            return;
+        }
+
+        string filename = pieces[0];
+        string filename_unescaped = GLib.Uri.unescape_string(filename);
+        string file_type = pieces[1];
+        string content_base64 = pieces[3];
+        uint8[] image = GLib.Base64.decode(content_base64);
+
+        if (image.length == 0) {
+            warning("%s is empty", filename);
+            return;
+        }
+
+        // A simple check to see if the file looks like an image. A problem here
+        // will be this accepting types which won't be supported by WebKit
+        // or recipients.
+        if (file_type.index_of("image/") == 0) {
+            image_file_dropped(filename_unescaped, file_type, image);
+        }
+    }
 }
diff --git a/src/client/composer/composer-widget.vala b/src/client/composer/composer-widget.vala
index 3cbc21b6..bc867b54 100644
--- a/src/client/composer/composer-widget.vala
+++ b/src/client/composer/composer-widget.vala
@@ -167,6 +167,8 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
     // allowed.
     private const string ATTACHMENT_KEYWORDS_LOCALIZED = 
_("attach|attaching|attaches|attachment|attachments|attached|enclose|enclosed|enclosing|encloses|enclosure|enclosures");
 
+    private const string PASTED_IMAGE_FILENAME_TEMPLATE = "geary-pasted-image-%u.png";
+
     public Geary.Account account { get; private set; }
     private Gee.Map<string, Geary.AccountInformation> accounts;
 
@@ -351,8 +353,8 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
     private AttachPending pending_include = AttachPending.INLINE_ONLY;
     private Gee.Set<File> attached_files = new Gee.HashSet<File>(Geary.Files.nullable_hash,
         Geary.Files.nullable_equal);
-    private Gee.Map<string,File> inline_files = new Gee.HashMap<string,File>();
-    private Gee.Map<string,File> cid_files = new Gee.HashMap<string,File>();
+    private Gee.Map<string,Geary.Memory.Buffer> inline_files = new Gee.HashMap<string,Geary.Memory.Buffer>();
+    private Gee.Map<string,Geary.Memory.Buffer> cid_files = new Gee.HashMap<string,Geary.Memory.Buffer>();
 
     private Geary.App.DraftManager? draft_manager = null;
     private GLib.Cancellable? draft_manager_opening = null;
@@ -504,6 +506,12 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
         this.application.engine.account_unavailable.connect(
             on_account_unavailable
         );
+
+        // Listen for drag and dropped image file
+        this.editor.image_file_dropped.connect(
+            on_image_file_dropped
+        );
+
         // TODO: also listen for account updates to allow adding identities while writing an email
 
         this.from = new Geary.RFC822.MailboxAddresses.single(account.information.primary_mailbox);
@@ -1605,9 +1613,10 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
                         // using a cid: URL anyway, so treat it as an
                         // attachment instead.
                         if (content_id != null) {
-                            this.cid_files[content_id] = file;
+                            Geary.Memory.FileBuffer file_buffer = new Geary.Memory.FileBuffer(file, true);
+                            this.cid_files[content_id] = file_buffer;
                             this.editor.add_internal_resource(
-                                content_id, new Geary.Memory.FileBuffer(file, true)
+                                content_id, file_buffer
                             );
                         } else {
                             type = Geary.Mime.DispositionType.ATTACHMENT;
@@ -1623,7 +1632,10 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
                             !this.attached_files.contains(file) &&
                             !this.inline_files.has_key(content_id)) {
                             if (type == Geary.Mime.DispositionType.INLINE) {
-                                add_inline_part(file, content_id);
+                                check_attachment_file(file);
+                                Geary.Memory.FileBuffer file_buffer = new Geary.Memory.FileBuffer(file, 
true);
+                                string unused;
+                                add_inline_part(file_buffer, content_id, out unused);
                             } else {
                                 add_attachment_part(file);
                             }
@@ -1672,18 +1684,41 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
         update_attachments_view();
     }
 
-    private void add_inline_part(File target, string content_id)
+    private void add_inline_part(Geary.Memory.Buffer target, string content_id, out string unique_contentid)
         throws AttachmentError {
-        check_attachment_file(target);
-        this.inline_files[content_id] = target;
-        try {
-            this.editor.add_internal_resource(
-                content_id, new Geary.Memory.FileBuffer(target, true)
+
+        const string UNIQUE_RENAME_TEMPLATE = "%s_%02u";
+
+        if (target.size == 0)
+            throw new AttachmentError.FILE(
+                _("“%s” is an empty file.").printf(content_id)
             );
-        } catch (Error err) {
-            // unlikely
-            debug("Failed to re-open file for attachment: %s", err.message);
+
+        // Avoid filename conflicts
+        unique_contentid = content_id;
+        int suffix_index = 0;
+        string unsuffixed_filename = "";
+        while (this.inline_files.has_key(unique_contentid)) {
+            string[] filename_parts = unique_contentid.split(".");
+
+            // Handle no file extension
+            int partindex;
+            if (filename_parts.length > 1) {
+                partindex = filename_parts.length-2;
+            } else {
+                partindex = 0;
+            }
+            if (unsuffixed_filename == "")
+                unsuffixed_filename = filename_parts[partindex];
+            filename_parts[partindex] = UNIQUE_RENAME_TEMPLATE.printf(unsuffixed_filename, suffix_index++);
+
+            unique_contentid = string.joinv(".", filename_parts);
         }
+
+        this.inline_files[unique_contentid] = target;
+        this.editor.add_internal_resource(
+            unique_contentid, target
+        );
     }
 
     private FileInfo check_attachment_file(File target)
@@ -1855,7 +1890,14 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
     private void on_paste(SimpleAction action, Variant? param) {
         if (this.container.get_focus() == this.editor) {
             if (this.editor.is_rich_text) {
-                this.editor.paste_rich_text();
+                // Check for pasted image in clipboard
+                Gtk.Clipboard clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD);
+                bool has_image = clipboard.wait_is_image_available();
+                if (has_image) {
+                    paste_image();
+                } else {
+                    this.editor.paste_rich_text();
+                }
             } else {
                 this.editor.paste_plain_text();
             }
@@ -1864,6 +1906,35 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
         }
     }
 
+    /**
+     * Handle a pasted image, adding it as an inline attachment
+     */
+    private void paste_image() {
+        get_clipboard(Gdk.SELECTION_CLIPBOARD).request_image((clipboard, pixbuf) => {
+            if (pixbuf != null) {
+                try {
+                    uint8[] buffer;
+                    pixbuf.save_to_buffer(out buffer, "png");
+                    Geary.Memory.ByteBuffer byte_buffer = new Geary.Memory.ByteBuffer(buffer, buffer.length);
+
+                    // TODO Review placeholder filename generation
+                    GLib.DateTime time_now = new GLib.DateTime.now();
+                    string filename = PASTED_IMAGE_FILENAME_TEMPLATE.printf(time_now.hash());
+
+                    string unique_filename;
+                    add_inline_part(byte_buffer, filename, out unique_filename);
+                    this.editor.insert_image(
+                        ClientWebView.INTERNAL_URL_PREFIX + unique_filename
+                    );
+                } catch (Error error) {
+                    warning("Failed to paste image %s", error.message);
+                }
+            } else {
+                warning("Failed to get image from clipboard");
+            }
+        });
+    }
+
     private void on_paste_without_formatting(SimpleAction action, Variant? param) {
         if (this.container.get_focus() == this.editor)
             this.editor.paste_plain_text();
@@ -2476,10 +2547,13 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
             dialog.hide();
             foreach (File file in dialog.get_files()) {
                 try {
+                    check_attachment_file(file);
+                    Geary.Memory.FileBuffer file_buffer = new Geary.Memory.FileBuffer(file, true);
                     string path = file.get_path();
-                    add_inline_part(file, path);
+                    string unique_filename;
+                    add_inline_part(file_buffer, path, out unique_filename);
                     this.editor.insert_image(
-                        ClientWebView.INTERNAL_URL_PREFIX + path
+                        ClientWebView.INTERNAL_URL_PREFIX + unique_filename
                     );
                 } catch (Error err) {
                     attachment_failed(err.message);
@@ -2535,4 +2609,21 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
         }
     }
 
+    /**
+     * Handle a dropped image file, adding it as an inline attachment
+     */
+    private void on_image_file_dropped(string filename, string file_type, uint8[] contents) {
+        Geary.Memory.ByteBuffer buffer = new Geary.Memory.ByteBuffer(contents, contents.length);
+        string unique_filename;
+        try {
+            add_inline_part(buffer, filename, out unique_filename);
+        } catch (AttachmentError err) {
+            warning("Couldn't attach dropped empty file %s", filename);
+            return;
+        }
+
+        this.editor.insert_image(
+            ClientWebView.INTERNAL_URL_PREFIX + unique_filename
+        );
+    }
 }
diff --git a/src/client/web-process/web-process-extension.vala 
b/src/client/web-process/web-process-extension.vala
index 1644aca2..2074189d 100644
--- a/src/client/web-process/web-process-extension.vala
+++ b/src/client/web-process/web-process-extension.vala
@@ -28,8 +28,7 @@ public void webkit_web_extension_initialize_with_user_data(WebKit.WebExtension e
  */
 public class GearyWebExtension : Object {
 
-
-    private const string[] ALLOWED_SCHEMES = { "cid", "geary", "data" };
+    private const string[] ALLOWED_SCHEMES = { "cid", "geary", "data", "blob" };
 
     private WebKit.WebExtension extension;
 
diff --git a/src/engine/api/geary-composed-email.vala b/src/engine/api/geary-composed-email.vala
index 5b9be5f2..14953e78 100644
--- a/src/engine/api/geary-composed-email.vala
+++ b/src/engine/api/geary-composed-email.vala
@@ -40,10 +40,10 @@ public class Geary.ComposedEmail : BaseObject {
 
     public Gee.Set<File> attached_files { get; private set;
         default = new Gee.HashSet<File>(Geary.Files.nullable_hash, Geary.Files.nullable_equal); }
-    public Gee.Map<string,File> inline_files { get; private set;
-        default = new Gee.HashMap<string,File>(); }
-    public Gee.Map<string,File> cid_files { get; private set;
-        default = new Gee.HashMap<string,File>(); }
+    public Gee.Map<string,Memory.Buffer> inline_files { get; private set;
+        default = new Gee.HashMap<string,Memory.Buffer>(); }
+    public Gee.Map<string,Memory.Buffer> cid_files { get; private set;
+        default = new Gee.HashMap<string,Memory.Buffer>(); }
 
     public string img_src_prefix { get; set; default = ""; }
 
@@ -90,18 +90,18 @@ public class Geary.ComposedEmail : BaseObject {
     public bool replace_inline_img_src(string orig, string replacement) {
         // XXX This and contains_inline_img_src are pretty
         // hacky. Should probably be working with a DOM tree.
-        bool ret = false;
+        bool found = false;
         if (this.body_html != null) {
-            string old_body = this.body_html;
-            this.body_html = old_body.replace(
-                IMG_SRC_TEMPLATE.printf(this.img_src_prefix + orig),
-                IMG_SRC_TEMPLATE.printf(replacement)
-            );
-            // Avoid doing a proper comparison so we don't need to scan
-            // the whole string again.
-            ret = this.body_html.length != old_body.length;
+            string prefixed_orig = IMG_SRC_TEMPLATE.printf(this.img_src_prefix + orig);
+            found = this.body_html.contains(prefixed_orig);
+            if (found) {
+                this.body_html = this.body_html.replace(
+                    prefixed_orig,
+                    IMG_SRC_TEMPLATE.printf(replacement)
+                );
+            }
         }
-        return ret;
+        return found;
     }
 
 }
diff --git a/src/engine/rfc822/rfc822-message.vala b/src/engine/rfc822/rfc822-message.vala
index f600c929..b41d73bb 100644
--- a/src/engine/rfc822/rfc822-message.vala
+++ b/src/engine/rfc822/rfc822-message.vala
@@ -236,7 +236,7 @@ public class Geary.RFC822.Message : BaseObject, EmailHeaderSet {
                 new Gee.LinkedList<GMime.Object>();
 
             // The files that need to have Content IDs assigned
-            Gee.Map<string,File> inline_files = new Gee.HashMap<string,File>();
+            Gee.Map<string,Memory.Buffer> inline_files = new Gee.HashMap<string,Memory.Buffer>();
             inline_files.set_all(email.inline_files);
 
             // Create parts for inline images, if any, and updating
@@ -248,18 +248,18 @@ public class Geary.RFC822.Message : BaseObject, EmailHeaderSet {
             // assigned
             foreach (string cid in email.cid_files.keys) {
                 if (email.contains_inline_img_src(CID_URL_PREFIX + cid)) {
-                    File file = email.cid_files[cid];
                     GMime.Object? inline_part = null;
                     try {
-                        inline_part = yield get_file_part(
-                            file,
+                        inline_part = yield get_buffer_part(
+                            email.cid_files[cid],
+                            GLib.Path.get_basename(cid),
                             Geary.Mime.DispositionType.INLINE,
                             cancellable
                         );
                     } catch (GLib.Error err) {
                         warning(
                             "Error creating CID part %s: %s",
-                            file.get_path(),
+                            cid,
                             err.message
                         );
                     }
@@ -288,15 +288,16 @@ public class Geary.RFC822.Message : BaseObject, EmailHeaderSet {
                                                      CID_URL_PREFIX + cid)) {
                         GMime.Object? inline_part = null;
                         try {
-                            inline_part = yield get_file_part(
+                            inline_part = yield get_buffer_part(
                                 inline_files[name],
+                                GLib.Path.get_basename(name),
                                 Geary.Mime.DispositionType.INLINE,
                                 cancellable
                             );
                         } catch (GLib.Error err) {
                             warning(
                                 "Error creating inline file part %s: %s",
-                                inline_files[name].get_path(),
+                                name,
                                 err.message
                             );
                         }
@@ -445,6 +446,59 @@ public class Geary.RFC822.Message : BaseObject, EmailHeaderSet {
         GMime.StreamGIO stream = new GMime.StreamGIO(file);
         stream.set_owner(false);
 
+        return yield finalise_attachment_part(stream, part, content_type, cancellable);
+    }
+
+    /**
+     * Create a GMime part for the provided attachment buffer
+     */
+    private async GMime.Part? get_buffer_part(Memory.Buffer buffer,
+                                              string basename,
+                                              Geary.Mime.DispositionType disposition,
+                                              GLib.Cancellable cancellable)
+        throws Error {
+
+        Mime.ContentType? mime_type = Mime.ContentType.guess_type(
+            basename,
+            buffer
+        );
+
+        if (mime_type == null) {
+            throw new RFC822Error.INVALID(
+                _("Could not determine mime type for “%s”.").printf(basename)
+                );
+        }
+
+        GMime.ContentType? content_type = new GMime.ContentType.from_string(mime_type.get_mime_type());
+
+        if (content_type == null) {
+            throw new RFC822Error.INVALID(
+                _("Could not determine content type for mime type “%s” on 
“%s”.").printf(mime_type.to_string(), basename)
+                );
+        }
+
+        GMime.Part part = new GMime.Part();
+        part.set_disposition(disposition.serialize());
+        part.set_filename(basename);
+
+        part.set_content_type(content_type);
+
+        // TODO seems inefficient, surely there's a way to create a Glib.Stream using, say Memory.Buffer's 
InputStream.
+        GMime.StreamMem stream = new GMime.StreamMem.with_buffer(buffer.get_uint8_array());
+        stream.set_owner(false);
+
+        return yield finalise_attachment_part(stream, part, content_type, cancellable);
+    }
+
+    /**
+     * Set encoding and content object on GMime part
+     */
+    private async GMime.Part finalise_attachment_part(GMime.Stream stream,
+                                                      GMime.Part part,
+                                                      GMime.ContentType content_type,
+                                                      GLib.Cancellable cancellable)
+        throws Error {
+
         // Text parts should be scanned fully to determine best
         // (i.e. most compact) transport encoding to use, but
         // that's usually fine since they tend to be
diff --git a/ui/composer-web-view.js b/ui/composer-web-view.js
index 524850ca..188db982 100644
--- a/ui/composer-web-view.js
+++ b/ui/composer-web-view.js
@@ -92,6 +92,12 @@ ComposerPageState.prototype = {
             }
         }, true);
 
+        // Handle file drag & drop
+        document.body.addEventListener("drop", state.handleFileDrop, true);
+        document.body.addEventListener("allowDrop", function(e) {
+            ev.preventDefault();
+        }, true);
+
         // Search for and remove a particular styling when we quote
         // text. If that style exists in the quoted text, we alter it
         // slightly so we don't mess with it later.
@@ -396,6 +402,24 @@ ComposerPageState.prototype = {
             }
         }
         return inPart;
+    },
+    handleFileDrop: function(dropEvent) {
+        dropEvent.preventDefault();
+
+        for (var i = 0; i < dropEvent.dataTransfer.files.length; i++) {
+            const file = dropEvent.dataTransfer.files[i];
+
+            if (!file.type.startsWith('image/'))
+                continue;
+
+            const reader = new FileReader();
+            reader.onload = (function(filename, imageType) { return function(loadEvent) {
+                window.webkit.messageHandlers.dragDropReceived.postMessage(
+                    encodeURIComponent(filename) + "," + imageType + "," + loadEvent.target.result
+                );
+            }; })(file.name, file.type);
+            reader.readAsDataURL(file);
+        }
     }
 };
 


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]