diff --git a/src/client/application/geary-controller.vala b/src/client/application/geary-controller.vala index cfd78573..70f331da 100644 --- a/src/client/application/geary-controller.vala +++ b/src/client/application/geary-controller.vala @@ -231,7 +231,6 @@ public class GearyController : Geary.BaseObject { main_window.conversation_viewer.email_row_added.connect(on_email_row_added); main_window.conversation_viewer.email_row_removed.connect(on_email_row_removed); main_window.conversation_viewer.mark_emails.connect(on_conversation_viewer_mark_emails); - main_window.conversation_viewer.save_attachments.connect(on_save_attachments); main_window.conversation_viewer.save_buffer_to_file.connect(on_save_buffer_to_file); new_messages_monitor = new NewMessagesMonitor(should_notify_new_messages); main_window.folder_list.set_new_messages_monitor(new_messages_monitor); @@ -307,7 +306,6 @@ public class GearyController : Geary.BaseObject { main_window.conversation_viewer.email_row_added.disconnect(on_email_row_added); main_window.conversation_viewer.email_row_removed.disconnect(on_email_row_removed); main_window.conversation_viewer.mark_emails.disconnect(on_conversation_viewer_mark_emails); - main_window.conversation_viewer.save_attachments.disconnect(on_save_attachments); main_window.conversation_viewer.save_buffer_to_file.disconnect(on_save_buffer_to_file); // hide window while shutting down, as this can take a few seconds under certain conditions main_window.hide(); @@ -1901,28 +1899,50 @@ public class GearyController : Geary.BaseObject { } } - private void on_attachment_activated(Geary.Attachment attachment) { + private void on_attachments_activated( + Gee.Collection attachments) { if (GearyApplication.instance.config.ask_open_attachment) { QuestionDialog ask_to_open = new QuestionDialog.with_checkbox(main_window, - _("Are you sure you want to open \"%s\"?").printf(attachment.file.get_basename()), + _("Are you sure you want to open these attachments?"), _("Attachments may cause damage to your system if opened. Only open files from trusted sources."), Stock._OPEN_BUTTON, Stock._CANCEL, _("Don't _ask me again"), false); - if (ask_to_open.run() != Gtk.ResponseType.OK) + if (ask_to_open.run() != Gtk.ResponseType.OK) { return; - + } // only save checkbox state if OK was selected GearyApplication.instance.config.ask_open_attachment = !ask_to_open.is_checked; } - // Open the attachment if we know what to do with it. - if (!open_uri(attachment.file.get_uri())) { - // Failing that, trigger a save dialog. - Gee.List attachment_list = new Gee.ArrayList(); - attachment_list.add(attachment); - on_save_attachments(attachment_list); + foreach (ConversationEmail.AttachmentInfo info in attachments) { + if (info.app == null) { + string content_type = info.attachment.content_type.get_mime_type(); + Gtk.AppChooserDialog app_chooser = + new Gtk.AppChooserDialog.for_content_type( + this.main_window, + Gtk.DialogFlags.MODAL | Gtk.DialogFlags.USE_HEADER_BAR, + content_type + ); + if (app_chooser.run() == Gtk.ResponseType.OK) { + info.app = app_chooser.get_app_info(); + } + app_chooser.hide(); + } + if (info.app != null) { + List files = new List(); + files.append(info.attachment.file); + try { + info.app.launch(files, null); + } catch (Error error) { + warning( + "Failed to launch %s: %s\n", + info.app.get_name(), + error.message + ); + } + } } } - + private bool do_overwrite_confirmation(File to_overwrite) { string primary = _("A file named \"%s\" already exists. Do you want to replace it?").printf( to_overwrite.get_basename()); @@ -1939,11 +1959,12 @@ public class GearyController : Geary.BaseObject { return do_overwrite_confirmation(chooser.get_file()) ? Gtk.FileChooserConfirmation.ACCEPT_FILENAME : Gtk.FileChooserConfirmation.SELECT_AGAIN; } - - private void on_save_attachments(Gee.List attachments) { + + private void on_save_attachments( + Gee.Collection attachments) { if (attachments.size == 0) return; - + Gtk.FileChooserAction action = (attachments.size == 1) ? Gtk.FileChooserAction.SAVE : Gtk.FileChooserAction.SELECT_FOLDER; @@ -1952,7 +1973,10 @@ public class GearyController : Geary.BaseObject { if (last_save_directory != null) dialog.set_current_folder(last_save_directory.get_path()); if (attachments.size == 1) { - dialog.set_current_name(attachments[0].file.get_basename()); + Gee.Iterator it = attachments.iterator(); + it.next(); + ConversationEmail.AttachmentInfo info = it.get(); + dialog.set_current_name(info.attachment.file.get_basename()); dialog.set_do_overwrite_confirmation(true); // use custom overwrite confirmation so it looks consistent whether one or many // attachments are being saved @@ -1977,9 +2001,9 @@ public class GearyController : Geary.BaseObject { debug("Saving attachments to %s", destination.get_path()); // Save each one, checking for overwrite only if multiple attachments are being written - foreach (Geary.Attachment attachment in attachments) { - File source_file = attachment.file; - File dest_file = (attachments.size == 1) ? destination : destination.get_child(attachment.file.get_basename()); + foreach (ConversationEmail.AttachmentInfo info in attachments) { + File source_file = info.attachment.file; + File dest_file = (attachments.size == 1) ? destination : destination.get_child(info.attachment.file.get_basename()); if (attachments.size > 1 && dest_file.query_exists() && !do_overwrite_confirmation(dest_file)) return; @@ -2604,7 +2628,8 @@ public class GearyController : Geary.BaseObject { message.reply_all_message.connect(on_reply_all_message); message.forward_message.connect(on_forward_message); message.link_activated.connect(on_link_activated); - message.attachment_activated.connect(on_attachment_activated); + message.attachments_activated.connect(on_attachments_activated); + message.save_attachments.connect(on_save_attachments); message.edit_draft.connect(on_edit_draft); message.view_source.connect(on_view_source); } @@ -2614,7 +2639,8 @@ public class GearyController : Geary.BaseObject { message.reply_all_message.disconnect(on_reply_all_message); message.forward_message.disconnect(on_forward_message); message.link_activated.disconnect(on_link_activated); - message.attachment_activated.disconnect(on_attachment_activated); + message.attachments_activated.disconnect(on_attachments_activated); + message.save_attachments.disconnect(on_save_attachments); message.edit_draft.disconnect(on_edit_draft); message.view_source.disconnect(on_view_source); } diff --git a/src/client/conversation-viewer/conversation-email.vala b/src/client/conversation-viewer/conversation-email.vala index 687dd832..ca3aee84 100644 --- a/src/client/conversation-viewer/conversation-email.vala +++ b/src/client/conversation-viewer/conversation-email.vala @@ -18,6 +18,24 @@ [GtkTemplate (ui = "/org/gnome/Geary/conversation-email.ui")] public class ConversationEmail : Gtk.Box { + + /** + * Information related to a specific attachment. + */ + public class AttachmentInfo : GLib.Object { + // Extends GObject since we put it in a ListStore + + public Geary.Attachment attachment { get; private set; } + public AppInfo? app { get; internal set; default = null; } + + + internal AttachmentInfo(Geary.Attachment attachment) { + this.attachment = attachment; + } + + } + + private const int ATTACHMENT_ICON_SIZE = 32; private const int ATTACHMENT_PREVIEW_SIZE = 64; @@ -25,9 +43,12 @@ public class ConversationEmail : Gtk.Box { private const string ACTION_MARK_READ = "mark_read"; private const string ACTION_MARK_UNREAD = "mark_unread"; private const string ACTION_MARK_UNREAD_DOWN = "mark_unread_down"; + private const string ACTION_OPEN_ATTACHMENTS = "open_attachments"; private const string ACTION_PRINT = "print"; private const string ACTION_REPLY_SENDER = "reply_sender"; private const string ACTION_REPLY_ALL = "reply_all"; + private const string ACTION_SAVE_ATTACHMENTS = "save_attachments"; + private const string ACTION_SAVE_ALL_ATTACHMENTS = "save_all_attachments"; private const string ACTION_STAR = "star"; private const string ACTION_UNSTAR = "unstar"; private const string ACTION_VIEW_SOURCE = "view_source"; @@ -52,6 +73,15 @@ public class ConversationEmail : Gtk.Box { // Attachment ids that have been displayed inline private Gee.HashSet inlined_content_ids = new Gee.HashSet(); + // A subset of the message's attachments that are displayed in the + // attachments view + Gee.List displayed_attachments = + new Gee.LinkedList(); + + // A subset of the message's attachments selected by the user + Gee.Set selected_attachments = + new Gee.HashSet(); + // Message-specific actions private SimpleActionGroup message_actions = new SimpleActionGroup(); @@ -82,9 +112,14 @@ public class ConversationEmail : Gtk.Box { [GtkChild] private Gtk.Box attachments_box; + [GtkChild] + private Gtk.IconView attachments_view; + [GtkChild] private Gtk.ListStore attachments_model; + private Gtk.Menu attachments_menu; + // Fired when the user clicks "reply" in the message menu. public signal void reply_to_message(Geary.Email message); @@ -104,11 +139,15 @@ public class ConversationEmail : Gtk.Box { Geary.Email email, Geary.NamedFlag? to_add, Geary.NamedFlag? to_remove ); + // Fired on link activation in the web_view public signal void link_activated(string link); // Fired on attachment activation - public signal void attachment_activated(Geary.Attachment attachment); + public signal void attachments_activated(Gee.Collection attachments); + + // Fired when the save attachments action is activated + public signal void save_attachments(Gee.Collection attachments); // Fired the edit draft button is clicked. public signal void edit_draft(Geary.Email email); @@ -138,12 +177,21 @@ public class ConversationEmail : Gtk.Box { add_action(ACTION_MARK_UNREAD_DOWN).activate.connect(() => { mark_email_from(this.email, Geary.EmailFlags.UNREAD, null); }); + add_action(ACTION_OPEN_ATTACHMENTS).activate.connect(() => { + attachments_activated(selected_attachments); + }); add_action(ACTION_REPLY_ALL).activate.connect(() => { reply_all_message(this.email); }); add_action(ACTION_REPLY_SENDER).activate.connect(() => { reply_to_message(this.email); }); + add_action(ACTION_SAVE_ATTACHMENTS).activate.connect(() => { + save_attachments(selected_attachments); + }); + add_action(ACTION_SAVE_ALL_ATTACHMENTS).activate.connect(() => { + save_attachments(displayed_attachments); + }); add_action(ACTION_STAR).activate.connect(() => { mark_email(this.email, Geary.EmailFlags.FLAGGED, null); }); @@ -184,6 +232,11 @@ public class ConversationEmail : Gtk.Box { email_menubutton.set_menu_model((MenuModel) builder.get_object("email_menu")); email_menubutton.set_sensitive(false); + attachments_menu = new Gtk.Menu.from_model( + (MenuModel) builder.get_object("attachments_menu") + ); + attachments_menu.attach_to_widget(this, null); + primary_message.infobar_box.pack_start(draft_infobar, false, false, 0); if (is_draft) { draft_infobar.show(); @@ -341,78 +394,80 @@ public class ConversationEmail : Gtk.Box { [GtkCallback] private void on_attachments_view_activated(Gtk.IconView view, Gtk.TreePath path) { - Gtk.TreeIter iter; - Value attachment_id; - - attachments_model.get_iter(out iter, path); - attachments_model.get_value(iter, 2, out attachment_id); - - Geary.Attachment? attachment = null; - try { - attachment = email.get_attachment(attachment_id.get_string()); - } catch (Error error) { - warning("Error getting attachment: %s", error.message); - } - - if (attachment != null) { - attachment_activated(attachment); - } + AttachmentInfo attachment_info = attachment_info_for_view_path(path); + attachments_activated( + Geary.iterate(attachment_info).to_array_list() + ); } - // private void save_attachment(Geary.Attachment attachment) { - // Gee.List attachments = new Gee.ArrayList(); - // attachments.add(attachment); - // get_viewer().save_attachments(attachments); - // } + [GtkCallback] + private void on_attachments_view_selection_changed() { + selected_attachments.clear(); + List selected = attachments_view.get_selected_items(); + selected.foreach((path) => { + selected_attachments.add(attachment_info_for_view_path(path)); + }); + } - // private void show_attachment_menu(Geary.Email email, Geary.Attachment attachment) { - // attachment_menu = build_attachment_menu(email, attachment); - // attachment_menu.show_all(); - // attachment_menu.popup(null, null, null, 0, Gtk.get_current_event_time()); - // } + [GtkCallback] + private bool on_attachments_view_button_press_event(Gdk.EventButton event) { + if (event.button != Gdk.BUTTON_SECONDARY) { + return false; + } - // private Gtk.Menu build_attachment_menu(Geary.Email email, Geary.Attachment attachment) { - // Gtk.Menu menu = new Gtk.Menu(); - // menu.selection_done.connect(on_attachment_menu_selection_done); + Gtk.TreePath path = attachments_view.get_path_at_pos( + (int) event.x, (int) event.y + ); + AttachmentInfo attachment = attachment_info_for_view_path(path); + if (!selected_attachments.contains(attachment)) { + attachments_view.unselect_all(); + attachments_view.select_path(path); + } + attachments_menu.popup(null, null, null, event.button, event.time); + return false; + } - // Gtk.MenuItem save_attachment_item = new Gtk.MenuItem.with_mnemonic(_("_Save As...")); - // save_attachment_item.activate.connect(() => save_attachment(attachment)); - // menu.append(save_attachment_item); - - // if (displayed_attachments(email) > 1) { - // Gtk.MenuItem save_all_item = new Gtk.MenuItem.with_mnemonic(_("Save All A_ttachments...")); - // save_all_item.activate.connect(() => save_attachments(email.attachments)); - // menu.append(save_all_item); - // } - - // return menu; - // } + private AttachmentInfo attachment_info_for_view_path(Gtk.TreePath path) { + Gtk.TreeIter iter; + attachments_model.get_iter(out iter, path); + Value info_value; + attachments_model.get_value(iter, 2, out info_value); + AttachmentInfo info = (AttachmentInfo) info_value.dup_object(); + info_value.unset(); + return info; + } private async void load_attachments(Cancellable load_cancelled) { - Gee.List displayed_attachments = - new Gee.LinkedList(); - - // Do we have any attachments to display? + // Do we have any attachments to be displayed? foreach (Geary.Attachment attachment in email.attachments) { if (!(attachment.content_id in inlined_content_ids) && attachment.content_disposition.disposition_type == Geary.Mime.DispositionType.ATTACHMENT) { - displayed_attachments.add(attachment); + displayed_attachments.add(new AttachmentInfo(attachment)); } } if (displayed_attachments.is_empty) { + set_action_enabled(ACTION_OPEN_ATTACHMENTS, false); + set_action_enabled(ACTION_SAVE_ATTACHMENTS, false); + set_action_enabled(ACTION_SAVE_ALL_ATTACHMENTS, false); return; } // Show attachments container. Would like to do this in the // ctor but we don't know at that point if any attachments - // will be displayed inline + // will be displayed inline. attachment_icon.set_visible(true); primary_message.body_box.pack_start(attachments_box, false, false, 0); // Add each displayed attachment to the icon view - foreach (Geary.Attachment attachment in displayed_attachments) { + foreach (AttachmentInfo attachment_info in displayed_attachments) { + Geary.Attachment attachment = attachment_info.attachment; + + attachment_info.app = AppInfo.get_default_for_type( + attachment.content_type.get_mime_type(), false + ); + Gdk.Pixbuf? icon = yield load_attachment_icon(attachment, load_cancelled); string file_name = null; @@ -441,7 +496,7 @@ public class ConversationEmail : Gtk.Box { iter, 0, icon, 1, Markup.printf_escaped("%s\n%s", file_name, file_size), - 2, attachment.id, + 2, attachment_info, -1 ); } diff --git a/src/client/conversation-viewer/conversation-viewer.vala b/src/client/conversation-viewer/conversation-viewer.vala index e0d930d1..0e9f596b 100644 --- a/src/client/conversation-viewer/conversation-viewer.vala +++ b/src/client/conversation-viewer/conversation-viewer.vala @@ -62,12 +62,6 @@ public class ConversationViewer : Gtk.Stack { public signal void mark_emails(Gee.Collection emails, Geary.EmailFlags? flags_to_add, Geary.EmailFlags? flags_to_remove); - // Fired when the user opens an attachment. - public signal void open_attachment(Geary.Attachment attachment); - - // Fired when the user wants to save one or more attachments. - public signal void save_attachments(Gee.List attachment); - // Fired when the user wants to save an image buffer to disk public signal void save_buffer_to_file(string? filename, Geary.Memory.Buffer buffer); diff --git a/ui/conversation-email.ui b/ui/conversation-email.ui index 51191489..d867aed7 100644 --- a/ui/conversation-email.ui +++ b/ui/conversation-email.ui @@ -112,8 +112,8 @@ - - + + @@ -140,7 +140,9 @@ horizontal attachments_model 6 + + diff --git a/ui/conversation-message-menu.ui b/ui/conversation-message-menu.ui index f2ae478f..fc187310 100644 --- a/ui/conversation-message-menu.ui +++ b/ui/conversation-message-menu.ui @@ -5,7 +5,7 @@
_Save Attachments - msg.selected + msg.save_all_attachments
@@ -47,4 +47,22 @@
+ +
+ + _Open + msg.open_attachments + +
+
+ + _Save + msg.save_attachments + + + _Save All + msg.save_all_attachments + +
+