diff --git a/src/client/composer/composer-web-view.vala b/src/client/composer/composer-web-view.vala index ee495a5b..642e6a8e 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 @@ -521,4 +525,40 @@ public class ComposerWebView : ClientWebView { } } + /** + * Handle a dropped image + */ + private void on_drag_drop_received(WebKit.JavascriptResult result) { + + try { + JSC.Value object = result.get_js_value(); + string filename = Util.JS.to_string( + Util.JS.get_property(object, "fileName") + ); + string filename_unescaped = GLib.Uri.unescape_string(filename); + + string file_type = Util.JS.to_string( + Util.JS.get_property(object, "fileType") + ); + + string content_base64 = Util.JS.to_string( + Util.JS.get_property(object, "content") + ); + 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); + } + } catch (Util.JS.Error err) { + debug("Could not get deceptive link param: %s", err.message); + } + } } diff --git a/src/client/composer/composer-widget.vala b/src/client/composer/composer-widget.vala index 11ce04f5..44fcbcf8 100644 --- a/src/client/composer/composer-widget.vala +++ b/src/client/composer/composer-widget.vala @@ -179,6 +179,8 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface { _("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 accounts; @@ -367,8 +369,8 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface { private AttachPending pending_include = AttachPending.INLINE_ONLY; private Gee.Set attached_files = new Gee.HashSet(Geary.Files.nullable_hash, Geary.Files.nullable_equal); - private Gee.Map inline_files = new Gee.HashMap(); - private Gee.Map cid_files = new Gee.HashMap(); + private Gee.Map inline_files = new Gee.HashMap(); + private Gee.Map cid_files = new Gee.HashMap(); private Geary.App.DraftManager? draft_manager = null; private GLib.Cancellable? draft_manager_opening = null; @@ -524,6 +526,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); @@ -1623,9 +1631,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; @@ -1641,7 +1650,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); } @@ -1690,18 +1702,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) @@ -1873,7 +1908,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(); } @@ -1882,6 +1924,39 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface { } } + /** + * Handle a pasted image, adding it as an inline attachment + */ + private void paste_image() { + // The slow operations here are creating the PNG and, to a lesser extent, + // requesting the image from the clipboard + this.container.top_window.application.mark_busy(); + + 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); + + 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"); + } + this.container.top_window.application.unmark_busy(); + }); + } + private void on_paste_without_formatting(SimpleAction action, Variant? param) { if (this.container.get_focus() == this.editor) this.editor.paste_plain_text(); @@ -2496,10 +2571,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); @@ -2555,4 +2633,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 2a2266f6..b2b29bf9 100644 --- a/src/client/web-process/web-process-extension.vala +++ b/src/client/web-process/web-process-extension.vala @@ -30,8 +30,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 4c1eb9e4..7bc9b7ea 100644 --- a/src/engine/api/geary-composed-email.vala +++ b/src/engine/api/geary-composed-email.vala @@ -38,10 +38,10 @@ public class Geary.ComposedEmail : BaseObject { public Gee.Set attached_files { get; private set; default = new Gee.HashSet(Geary.Files.nullable_hash, Geary.Files.nullable_equal); } - public Gee.Map inline_files { get; private set; - default = new Gee.HashMap(); } - public Gee.Map cid_files { get; private set; - default = new Gee.HashMap(); } + public Gee.Map inline_files { get; private set; + default = new Gee.HashMap(); } + public Gee.Map cid_files { get; private set; + default = new Gee.HashMap(); } public string img_src_prefix { get; set; default = ""; } @@ -88,18 +88,17 @@ 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; + int index = -1; 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); + index = this.body_html.index_of(prefixed_orig); + if (index != -1) { + this.body_html = this.body_html.substring(0, index) + + IMG_SRC_TEMPLATE.printf(replacement) + + this.body_html.substring(index + prefixed_orig.length); + } } - return ret; + return index != -1; } } diff --git a/src/engine/rfc822/rfc822-message.vala b/src/engine/rfc822/rfc822-message.vala index 834ed98c..bafa431e 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(); // The files that need to have Content IDs assigned - Gee.Map inline_files = new Gee.HashMap(); + Gee.Map inline_files = new Gee.HashMap(); 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 ); } @@ -441,6 +442,56 @@ 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); + + GMime.StreamMem stream = Utils.create_stream_mem(buffer); + + 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/test/data/org.gnome.GearyTest.gresource.xml b/test/data/org.gnome.GearyTest.gresource.xml index e0ef0e06..0f38310f 100644 --- a/test/data/org.gnome.GearyTest.gresource.xml +++ b/test/data/org.gnome.GearyTest.gresource.xml @@ -6,5 +6,6 @@ basic-multipart-alternative.eml basic-multipart-tnef.eml geary-0.6-db.tar.xz + test-attachment-image.png diff --git a/test/data/test-attachment-image.png b/test/data/test-attachment-image.png new file mode 100644 index 00000000..e542ff58 Binary files /dev/null and b/test/data/test-attachment-image.png differ diff --git a/test/engine/api/geary-composed-email-test.vala b/test/engine/api/geary-composed-email-test.vala new file mode 100644 index 00000000..3001beb4 --- /dev/null +++ b/test/engine/api/geary-composed-email-test.vala @@ -0,0 +1,53 @@ +/* + * Copyright 2016-2018 Michael Gratton + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +class Geary.ComposedEmailTest: TestCase { + + private const string IMG_CONTAINING_HTML_BODY = ""; + + public ComposedEmailTest() { + base("Geary.ComposedEmailTest"); + add_test("contains_inline_img_src", contains_inline_img_src); + add_test("replace_inline_img_src", replace_inline_img_src); + } + + public void contains_inline_img_src() throws Error { + ComposedEmail composed = build_composed_with_img_src(); + assert_true(composed.contains_inline_img_src("test.png"), "Expected matched image source"); + assert_false(composed.contains_inline_img_src("missing.png"), "Expected missing image"); + } + + public void replace_inline_img_src() throws Error { + ComposedEmail composed = build_composed_with_img_src(); + assert_true(composed.replace_inline_img_src("test.png", "updated.png"), "Expected replacement success"); + assert_false(composed.replace_inline_img_src("missing.png", "updated.png"), "Expected replacement failure"); + assert_true(composed.contains_inline_img_src("updated.png"), "Expected new image source"); + + assert_true(composed.replace_inline_img_src("updated.png", "1234567.png"), "Expected replacement success for same length filename"); + assert_true(composed.contains_inline_img_src("1234567.png"), "Expected new same length image source"); + } + + private ComposedEmail build_composed_with_img_src() { + RFC822.MailboxAddress to = new RFC822.MailboxAddress( + "Test", "test@example.com" + ); + RFC822.MailboxAddress from = new RFC822.MailboxAddress( + "Sender", "sender@example.com" + ); + + return new Geary.ComposedEmail( + new GLib.DateTime.now_local(), + new Geary.RFC822.MailboxAddresses.single(from), + new Geary.RFC822.MailboxAddresses.single(to), + null, + null, + null, + null, + IMG_CONTAINING_HTML_BODY + ); + } +} diff --git a/test/engine/rfc822-message-test.vala b/test/engine/rfc822-message-test.vala index fe696f6b..d0c2502f 100644 --- a/test/engine/rfc822-message-test.vala +++ b/test/engine/rfc822-message-test.vala @@ -29,6 +29,11 @@ This is the second line. """; + private static string SIMPLE_MULTIRECIPIENT_TO_CC_BCC = "From: Jill Smith \r\nTo: Jane Doe \r\nCc: Jane Doe CC \r\nBcc: Jane Doe BCC \r\nSubject: Re: Saying Hello\r\nDate: Fri, 21 Nov 1997 10:01:10 -0600\r\n\r\nThis is a reply to your hello.\r\n\r\n"; + private static string NETWORK_BUFFER_EXPECTED = "From: Alice \r\nSender: Bob \r\nTo: Charlie \r\nCC: Dave \r\nBCC: Eve \r\nReply-To: \"Alice: Personal Account\" \r\nSubject: Re: Basic text/plain message\r\nDate: Fri, 21 Nov 1997 10:01:10 -0600\r\nMessage-ID: <3456@example.net>\r\nIn-Reply-To: <1234@local.machine.example>\r\nReferences: <1234@local.machine.example>\r\nX-Mailer: Geary Test Suite 1.0\r\n\r\nThis is the first line.\r\n\r\nThis is the second line.\r\n\r\n"; + + private static string TEST_ATTACHMENT_IMAGE_FILENAME = "test-attachment-image.png"; + public MessageTest() { base("Geary.RFC822.MessageTest"); add_test("basic_message_from_buffer", basic_message_from_buffer); @@ -49,6 +54,11 @@ This is the second line. add_test("multipart_alternative_as_html", multipart_alternative_as_html); add_test("get_preview", get_preview); + add_test("get_recipients", get_recipients); + add_test("get_searchable_body", get_searchable_body); + add_test("get_searchable_recipients", get_searchable_recipients); + add_test("get_network_buffer", get_network_buffer); + add_test("from_composed_email_inline_attachments", from_composed_email_inline_attachments); } public void basic_message_from_buffer() throws Error { @@ -169,6 +179,106 @@ This is the second line. assert(multipart_signed.get_preview() == MULTIPART_SIGNED_MESSAGE_PREVIEW); } + public void get_recipients() throws Error { + Message test = string_to_message(SIMPLE_MULTIRECIPIENT_TO_CC_BCC); + + Gee.List? addresses = test.get_recipients(); + + Gee.List verify_list = new Gee.ArrayList(); + verify_list.add("Jane Doe "); + verify_list.add("Jane Doe CC "); + verify_list.add("Jane Doe BCC "); + + assert_addresses_list(addresses, verify_list, "get_recipients"); + } + + public void get_searchable_body() throws Error { + Message test = resource_to_message(BASIC_TEXT_HTML); + string searchable = test.get_searchable_body(); + assert_true(searchable.contains("This is the first line"), "Expected body text"); + assert_false(searchable.contains("

"), "Expected html removed"); + } + + public void get_searchable_recipients() throws Error { + Message test = string_to_message(SIMPLE_MULTIRECIPIENT_TO_CC_BCC); + string searchable = test.get_searchable_recipients(); + assert_true(searchable.contains("Jane Doe "), "Expected to address"); + assert_true(searchable.contains("Jane Doe CC "), "Expected cc address"); + assert_true(searchable.contains("Jane Doe BCC "), "Expected bcc address"); + } + + public void get_network_buffer() throws Error { + Message test = resource_to_message(BASIC_TEXT_PLAIN); + Memory.Buffer buffer = test.get_network_buffer(true); + assert_true(buffer.to_string() == NETWORK_BUFFER_EXPECTED, "Network buffer differs"); + } + + public void from_composed_email_inline_attachments() throws Error { + RFC822.MailboxAddress to = new RFC822.MailboxAddress( + "Test", "test@example.com" + ); + RFC822.MailboxAddress from = new RFC822.MailboxAddress( + "Sender", "sender@example.com" + ); + + Geary.ComposedEmail composed = new Geary.ComposedEmail( + new GLib.DateTime.now_local(), + new Geary.RFC822.MailboxAddresses.single(from), + new Geary.RFC822.MailboxAddresses.single(to), + null, + null, + null, + null, + "" + ); + + GLib.File resource = + GLib.File.new_for_uri(RESOURCE_URI).resolve_relative_path(TEST_ATTACHMENT_IMAGE_FILENAME); + uint8[] contents; + resource.load_contents(null, out contents, null); + Geary.Memory.ByteBuffer buffer = new Geary.Memory.ByteBuffer(contents, contents.length); + composed.cid_files[TEST_ATTACHMENT_IMAGE_FILENAME] = buffer; + Geary.Memory.ByteBuffer buffer2 = new Geary.Memory.ByteBuffer(contents, contents.length); + composed.inline_files["needing_cid.png"] = buffer2; + + this.message_from_composed_email.begin( + composed, + async_complete_full + ); + Geary.RFC822.Message message = message_from_composed_email.end(async_result()); + + Gee.List attachments = message.get_attachments(); + + bool found_first = false; + bool found_second = false; + bool second_id_renamed = false; + foreach (Part part in attachments) { + if (part.get_clean_filename() == TEST_ATTACHMENT_IMAGE_FILENAME) { + found_first = true; + } else if (part.get_clean_filename() == "needing_cid.png") { + found_second = true; + second_id_renamed = part.content_id != "needing_cid.png"; + } + } + assert_true(found_first, "Expected CID attachment"); + assert_true(found_second, "Expected inline attachment"); + assert_true(second_id_renamed, "Expected inline attachment renamed"); + + string html_body = message.get_html_body(null); + assert_false(html_body.contains("src=\"needing_cid.png\""), "Expected updated attachment content ID"); + + Memory.Buffer out_buffer = message.get_native_buffer(); + assert_true(out_buffer.size > (buffer.size+buffer2.size), "Expected sizeable message"); + } + + private async Geary.RFC822.Message message_from_composed_email(Geary.ComposedEmail composed) { + return yield new Geary.RFC822.Message.from_composed_email( + composed, + GMime.utils_generate_message_id(composed.from.get(0).domain), + null + ); + } + private Message resource_to_message(string path) throws Error { GLib.File resource = GLib.File.new_for_uri(RESOURCE_URI).resolve_relative_path(path); @@ -208,6 +318,17 @@ This is the second line. assert_string(expected, addresses.to_rfc822_string()); } + private void assert_addresses_list(Gee.List? addresses, + Gee.List expected, + string context) + throws Error { + assert_non_null(addresses, context + " not null"); + assert_true(addresses.size == expected.size, context + " size"); + foreach (RFC822.MailboxAddress address in addresses) { + assert_true(expected.contains(address.to_rfc822_string()), context + " missing"); + } + } + private void assert_message_id_list(Geary.RFC822.MessageIDList? ids, string expected) throws Error { diff --git a/test/meson.build b/test/meson.build index e774d7aa..95c24de8 100644 --- a/test/meson.build +++ b/test/meson.build @@ -27,6 +27,7 @@ geary_test_engine_sources = [ 'engine/api/geary-engine-test.vala', 'engine/api/geary-folder-path-test.vala', 'engine/api/geary-service-information-test.vala', + 'engine/api/geary-composed-email-test.vala', 'engine/app/app-conversation-test.vala', 'engine/app/app-conversation-monitor-test.vala', 'engine/app/app-conversation-set-test.vala', diff --git a/test/test-engine.vala b/test/test-engine.vala index 28e758e1..a8e262fa 100644 --- a/test/test-engine.vala +++ b/test/test-engine.vala @@ -76,6 +76,7 @@ int main(string[] args) { engine.add_suite(new Geary.RFC822.PartTest().get_suite()); engine.add_suite(new Geary.RFC822.Utils.Test().get_suite()); engine.add_suite(new Geary.String.Test().get_suite()); + engine.add_suite(new Geary.ComposedEmailTest().get_suite()); /* * Run the tests diff --git a/ui/composer-web-view.js b/ui/composer-web-view.js index 848290ed..8de6e5f1 100644 --- a/ui/composer-web-view.js +++ b/ui/composer-web-view.js @@ -93,6 +93,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. @@ -380,6 +386,31 @@ 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) { + // Remove prefixed file type and encoding type + var parts = loadEvent.target.result.split(","); + if (parts.length < 2) + return; + + window.webkit.messageHandlers.dragDropReceived.postMessage({ + fileName: encodeURIComponent(filename), + fileType: imageType, + content: parts[1] + }); + }; })(file.name, file.type); + reader.readAsDataURL(file); + } } };