diff --git a/po/POTFILES.in b/po/POTFILES.in index bd269db8..993c0673 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -14,6 +14,7 @@ src/client/accounts/accounts-editor-row.vala src/client/accounts/accounts-editor-servers-pane.vala src/client/accounts/accounts-manager.vala src/client/accounts/accounts-signature-web-view.vala +src/client/application/application-attachment-manager.vala src/client/application/application-avatar-store.vala src/client/application/application-certificate-manager.vala src/client/application/application-command.vala @@ -28,6 +29,7 @@ src/client/application/goa-mediator.vala src/client/application/main.vala src/client/application/secret-mediator.vala src/client/components/client-web-view.vala +src/client/components/components-attachment-pane.vala src/client/components/components-in-app-notification.vala src/client/components/components-inspector.vala src/client/components/components-placeholder-pane.vala @@ -412,6 +414,9 @@ ui/composer-headerbar.ui ui/composer-link-popover.ui ui/composer-menus.ui ui/composer-widget.ui +ui/components-attachment-pane.ui +ui/components-attachment-pane-menus.ui +ui/components-attachment-view.ui ui/components-in-app-notification.ui ui/components-inspector-error-view.ui ui/components-inspector-log-view.ui @@ -419,7 +424,6 @@ ui/components-inspector.ui ui/components-placeholder-pane.ui ui/conversation-contact-popover.ui ui/conversation-email.ui -ui/conversation-email-attachment-view.ui ui/conversation-email-menus.ui ui/conversation-message-menus.ui ui/conversation-message.ui diff --git a/src/client/application/application-attachment-manager.vala b/src/client/application/application-attachment-manager.vala new file mode 100644 index 00000000..805ecb6f --- /dev/null +++ b/src/client/application/application-attachment-manager.vala @@ -0,0 +1,284 @@ +/* + * Copyright 2016 Software Freedom Conservancy Inc. + * Copyright 2019 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. + */ + +/* + * Manages downloading and saving email attachment parts. + */ +public class Application.AttachmentManager : GLib.Object { + + + public static string untitled_file_name; + + + static construct { + // Translators: File name used in save chooser when saving + // attachments that do not otherwise have a name. + AttachmentManager.untitled_file_name = _("Untitled"); + } + + + private weak MainWindow parent; + + + public AttachmentManager(MainWindow parent) { + this.parent = parent; + } + + /** + * Saves multiple attachments to disk, prompting for destination. + * + * Prompt for both a location and for confirmation before + * overwriting existing files. Files are written with their + * existing names. Returns true if written to disk, else false. + */ + public async bool save_attachments(Gee.Collection attachments, + GLib.Cancellable? cancellable) { + if (attachments.size == 1) { + return yield save_attachment( + Geary.Collection.get_first(attachments), null, cancellable + ); + } else { + return yield save_all(attachments, cancellable); + } + } + + /** + * Saves single attachment to disk, prompting for name and destination. + * + * Prompt for both a name and location and for confirmation before + * overwriting existing files. Returns true if written to disk, + * else false. + */ + public async bool save_attachment(Geary.Attachment attachment, + string? alt_name, + GLib.Cancellable? cancellable) { + string alt_display_name = Geary.String.is_empty_or_whitespace(alt_name) + ? AttachmentManager.untitled_file_name : alt_name; + string display_name = yield attachment.get_safe_file_name( + alt_display_name + ); + + Geary.Memory.Buffer? content = yield open_buffer( + attachment, cancellable + ); + + bool succeeded = false; + if (content != null) { + succeeded = yield this.save_buffer( + display_name, content, cancellable + ); + } + return succeeded; + } + + /** + * Saves a buffer to disk as if it was an attachment. + * + * Prompt for both a name and location and for confirmation before + * overwriting existing files. Returns true if written to disk, + * else false. + */ + public async bool save_buffer(string display_name, + Geary.Memory.Buffer buffer, + GLib.Cancellable? cancellable) { + Gtk.FileChooserNative dialog = new_save_chooser(SAVE); + dialog.set_current_name(display_name); + + string? destination_uri = null; + if (dialog.run() == Gtk.ResponseType.ACCEPT) { + destination_uri = dialog.get_uri(); + } + dialog.destroy(); + + bool succeeded = false; + if (!Geary.String.is_empty_or_whitespace(destination_uri)) { + succeeded = yield check_and_write( + buffer, GLib.File.new_for_uri(destination_uri), cancellable + ); + } + return succeeded; + } + + private async bool save_all(Gee.Collection attachments, + GLib.Cancellable? cancellable) { + var dialog = new_save_chooser(SELECT_FOLDER); + string? destination_uri = null; + if (dialog.run() == Gtk.ResponseType.ACCEPT) { + destination_uri = dialog.get_uri(); + } + dialog.destroy(); + + bool succeeded = false; + if (!Geary.String.is_empty_or_whitespace(destination_uri)) { + var destination_dir = GLib.File.new_for_uri(destination_uri); + foreach (Geary.Attachment attachment in attachments) { + GLib.File? destination = null; + try { + destination = destination_dir.get_child_for_display_name( + yield attachment.get_safe_file_name( + AttachmentManager.untitled_file_name + ) + ); + } catch (GLib.IOError.CANCELLED err) { + // Everything is going to fail from now on, so get + // out of here + succeeded = false; + break; + } catch (GLib.Error err) { + warning( + "Error determining file system name for \"%s\": %s", + attachment.file.get_uri(), err.message + ); + handle_error(err); + } + var content = yield open_buffer(attachment, cancellable); + if (content != null && + destination != null) { + succeeded &= yield check_and_write( + content, destination, cancellable + ); + } else { + succeeded = false; + } + } + } + return succeeded; + } + + private async Geary.Memory.Buffer open_buffer(Geary.Attachment attachment, + GLib.Cancellable? cancellable) { + Geary.Memory.FileBuffer? content = null; + try { + yield Geary.Nonblocking.Concurrent.global.schedule_async( + () => { + content = new Geary.Memory.FileBuffer(attachment.file, true); + }, + cancellable + ); + } catch (GLib.Error err) { + warning( + "Error opening attachment file \"%s\": %s", + attachment.file.get_uri(), err.message + ); + handle_error(err); + } + return content; + } + + private async bool check_and_write(Geary.Memory.Buffer content, + GLib.File destination, + GLib.Cancellable? cancellable) { + bool succeeded = false; + try { + if (yield check_overwrite(destination, cancellable)) { + yield write_buffer_to_file(content, destination, cancellable); + succeeded = true; + } + } catch (GLib.Error err) { + warning( + "Error saving attachment \"%s\": %s", + destination.get_uri(), err.message + ); + handle_error(err); + } + return succeeded; + } + + private async bool check_overwrite(GLib.File to_overwrite, + GLib.Cancellable? cancellable) + throws GLib.Error { + string target_name = ""; + string parent_name = ""; + try { + GLib.FileInfo file_info = yield to_overwrite.query_info_async( + GLib.FileAttribute.STANDARD_DISPLAY_NAME, + GLib.FileQueryInfoFlags.NONE, + GLib.Priority.DEFAULT, + cancellable + ); + target_name = file_info.get_display_name(); + GLib.FileInfo parent_info = yield to_overwrite.get_parent() + .query_info_async( + GLib.FileAttribute.STANDARD_DISPLAY_NAME, + GLib.FileQueryInfoFlags.NONE, + GLib.Priority.DEFAULT, + cancellable + ); + parent_name = parent_info.get_display_name(); + } catch (GLib.IOError.NOT_FOUND err) { + // All good + return true; + } + + /// Translators: Dialog primary label when prompting to + /// overwrite a file. The string substitution is the file'sx + /// name. + string primary = _( + "A file named “%s” already exists. Do you want to replace it?" + ).printf(target_name); + + /// Translators: Dialog secondary label when prompting to + /// overwrite a file. The string substitution is the parent + /// folder's name. + string secondary = _( + "The file already exists in “%s”. Replacing it will overwrite its contents." + ).printf(parent_name); + + ConfirmationDialog dialog = new ConfirmationDialog( + this.parent, + primary, + secondary, + _("_Replace"), + "destructive-action" + ); + return (dialog.run() == Gtk.ResponseType.OK); + } + + private async void write_buffer_to_file(Geary.Memory.Buffer buffer, + GLib.File destination, + GLib.Cancellable? cancellable) + throws GLib.Error { + try { + GLib.FileOutputStream outs = destination.replace( + null, false, REPLACE_DESTINATION, cancellable + ); + yield outs.splice_async( + buffer.get_input_stream(), + CLOSE_SOURCE | CLOSE_TARGET, + GLib.Priority.DEFAULT, + cancellable + ); + } catch (GLib.IOError.CANCELLED err) { + try { + yield destination.delete_async(GLib.Priority.HIGH, null); + } catch (GLib.Error err) { + // Oh well + } + throw err; + } + } + + private inline Gtk.FileChooserNative new_save_chooser(Gtk.FileChooserAction action) { + Gtk.FileChooserNative dialog = new Gtk.FileChooserNative( + null, + this.parent, + action, + Stock._SAVE, + Stock._CANCEL + ); + dialog.set_local_only(false); + return dialog; + } + + private inline void handle_error(GLib.Error error) { + this.parent.application.controller.report_problem( + new Geary.ProblemReport(error) + ); + } + +} diff --git a/src/client/application/application-controller.vala b/src/client/application/application-controller.vala index b41fcb56..bcfda6a8 100644 --- a/src/client/application/application-controller.vala +++ b/src/client/application/application-controller.vala @@ -20,15 +20,6 @@ public class Application.Controller : Geary.BaseObject { private const int SELECT_FOLDER_TIMEOUT_USEC = 100 * 1000; private const uint MAX_AUTH_ATTEMPTS = 3; - private static string untitled_file_name; - - - static construct { - // Translators: File name used in save chooser when saving - // attachments that do not otherwise have a name. - Controller.untitled_file_name = _("Untitled"); - } - /** * Collects objects and state related to a single open account. @@ -1650,228 +1641,6 @@ public class Application.Controller : Geary.BaseObject { } } - public async void save_attachment_to_file(Geary.Account account, - Geary.Attachment attachment, - string? alt_text) { - AccountContext? context = this.accounts.get(account.information); - GLib.Cancellable cancellable = ( - context != null ? context.cancellable : null - ); - - string alt_display_name = Geary.String.is_empty_or_whitespace(alt_text) - ? Application.Controller.untitled_file_name : alt_text; - string display_name = yield attachment.get_safe_file_name( - alt_display_name - ); - - Geary.Memory.FileBuffer? content = null; - try { - content = new Geary.Memory.FileBuffer(attachment.file, true); - } catch (GLib.Error err) { - warning( - "Error opening attachment file \"%s\": %s", - attachment.file.get_uri(), err.message - ); - report_problem(new Geary.ProblemReport(err)); - } - - yield this.prompt_save_buffer(display_name, content, cancellable); - } - - public async void - save_attachments_to_file(Geary.Account account, - Gee.Collection attachments) { - AccountContext? context = this.accounts.get(account.information); - GLib.Cancellable cancellable = ( - context != null ? context.cancellable : null - ); - - Gtk.FileChooserNative dialog = new_save_chooser(Gtk.FileChooserAction.SELECT_FOLDER); - - bool accepted = (dialog.run() == Gtk.ResponseType.ACCEPT); - string? filename = dialog.get_filename(); - dialog.destroy(); - if (!accepted || Geary.String.is_empty(filename)) - return; - - File dest_dir = File.new_for_path(filename); - foreach (Geary.Attachment attachment in attachments) { - Geary.Memory.FileBuffer? content = null; - GLib.File? dest = null; - try { - content = new Geary.Memory.FileBuffer(attachment.file, true); - dest = dest_dir.get_child_for_display_name( - yield attachment.get_safe_file_name( - Application.Controller.untitled_file_name - ) - ); - } catch (GLib.Error err) { - warning( - "Error opening attachment files \"%s\": %s", - attachment.file.get_uri(), err.message - ); - report_problem(new Geary.ProblemReport(err)); - } - - if (content != null && - dest != null && - yield check_overwrite(dest, cancellable)) { - yield write_buffer_to_file(content, dest, cancellable); - } - } - } - - public async void save_image_extended(Geary.Account account, - ConversationEmail view, - string url, - string? alt_text, - Geary.Memory.Buffer resource_buf) { - AccountContext? context = this.accounts.get(account.information); - GLib.Cancellable cancellable = ( - context != null ? context.cancellable : null - ); - - // This is going to be either an inline image, or a remote - // image, so either treat it as an attachment to assume we'll - // have a valid filename in the URL - bool handled = false; - if (url.has_prefix(ClientWebView.CID_URL_PREFIX)) { - string cid = url.substring(ClientWebView.CID_URL_PREFIX.length); - Geary.Attachment? attachment = null; - try { - attachment = view.email.get_attachment_by_content_id(cid); - } catch (Error err) { - debug("Could not get attachment \"%s\": %s", cid, err.message); - } - if (attachment != null) { - yield this.save_attachment_to_file( - account, attachment, alt_text - ); - handled = true; - } - } - - if (!handled) { - GLib.File source = GLib.File.new_for_uri(url); - // Querying the URL-based file for the display name - // results in it being looked up, so just get the basename - // from it directly. GIO seems to decode any %-encoded - // chars anyway. - string? display_name = source.get_basename(); - if (Geary.String.is_empty_or_whitespace(display_name)) { - display_name = Controller.untitled_file_name; - } - - yield this.prompt_save_buffer( - display_name, resource_buf, cancellable - ); - } - } - - private async void prompt_save_buffer(string display_name, - Geary.Memory.Buffer buffer, - GLib.Cancellable? cancellable) { - Gtk.FileChooserNative dialog = new_save_chooser( - Gtk.FileChooserAction.SAVE - ); - dialog.set_current_name(display_name); - - string? accepted_path = null; - if (dialog.run() == Gtk.ResponseType.ACCEPT) { - accepted_path = dialog.get_filename(); - } - dialog.destroy(); - - if (!Geary.String.is_empty_or_whitespace(accepted_path)) { - GLib.File dest_file = File.new_for_path(accepted_path); - if (yield check_overwrite(dest_file, cancellable)) { - yield write_buffer_to_file(buffer, dest_file, cancellable); - } - } - } - - private async bool check_overwrite(GLib.File to_overwrite, - GLib.Cancellable? cancellable) { - bool overwrite = true; - try { - GLib.FileInfo file_info = yield to_overwrite.query_info_async( - GLib.FileAttribute.STANDARD_DISPLAY_NAME, - GLib.FileQueryInfoFlags.NONE, - GLib.Priority.DEFAULT, - cancellable - ); - GLib.FileInfo parent_info = yield to_overwrite.get_parent() - .query_info_async( - GLib.FileAttribute.STANDARD_DISPLAY_NAME, - GLib.FileQueryInfoFlags.NONE, - GLib.Priority.DEFAULT, - cancellable - ); - - // Translators: Dialog primary label when prompting to - // overwrite a file. The string substitution is the file'sx - // name. - string primary = _( - "A file named “%s” already exists. Do you want to replace it?" - ).printf(file_info.get_display_name()); - - // Translators: Dialog secondary label when prompting to - // overwrite a file. The string substitution is the parent - // folder's name. - string secondary = _( - "The file already exists in “%s”. Replacing it will overwrite its contents." - ).printf(parent_info.get_display_name()); - - ConfirmationDialog dialog = new ConfirmationDialog( - main_window, primary, secondary, _("_Replace"), "destructive-action" - ); - overwrite = (dialog.run() == Gtk.ResponseType.OK); - } catch (GLib.Error err) { - // Oh well - } - return overwrite; - } - - private async void write_buffer_to_file(Geary.Memory.Buffer buffer, - File dest, - GLib.Cancellable? cancellable) { - try { - FileOutputStream outs = dest.replace( - null, false, FileCreateFlags.REPLACE_DESTINATION, cancellable - ); - yield outs.splice_async( - buffer.get_input_stream(), - OutputStreamSpliceFlags.CLOSE_SOURCE | OutputStreamSpliceFlags.CLOSE_TARGET, - Priority.DEFAULT, - cancellable - ); - } catch (GLib.IOError.CANCELLED err) { - try { - yield dest.delete_async(GLib.Priority.HIGH, null); - } catch (GLib.Error err) { - // Oh well - } - } catch (GLib.Error err) { - warning( - "Error writing buffer \"%s\": %s", - dest.get_uri(), err.message - ); - report_problem(new Geary.ProblemReport(err)); - } - } - - private inline Gtk.FileChooserNative new_save_chooser(Gtk.FileChooserAction action) { - Gtk.FileChooserNative dialog = new Gtk.FileChooserNative( - null, - this.main_window, - action, - Stock._SAVE, - Stock._CANCEL - ); - dialog.set_local_only(false); - return dialog; - } - internal bool close_composition_windows(bool main_window_only = false) { Gee.List composers_to_destroy = new Gee.ArrayList(); bool quit_cancelled = false; diff --git a/src/client/components/components-attachment-pane.vala b/src/client/components/components-attachment-pane.vala new file mode 100644 index 00000000..3157114d --- /dev/null +++ b/src/client/components/components-attachment-pane.vala @@ -0,0 +1,524 @@ +/* + * Copyright 2019 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. + */ + +/** + * Displays the attachment parts for an email. + * + * This can be used in an editable or non-editable context, the UI + * shown will differ slightly based on which is selected. + */ +[GtkTemplate (ui = "/org/gnome/Geary/components-attachment-pane.ui")] +public class Components.AttachmentPane : Gtk.Grid { + + + private const string GROUP_NAME = "cap"; + private const string ACTION_OPEN = "open"; + private const string ACTION_OPEN_SELECTED = "open-selected"; + private const string ACTION_REMOVE = "remove"; + private const string ACTION_REMOVE_SELECTED = "remove-selected"; + private const string ACTION_SAVE = "save"; + private const string ACTION_SAVE_ALL = "save-all"; + private const string ACTION_SAVE_SELECTED = "save-selected"; + private const string ACTION_SELECT_ALL = "select-all"; + + private const ActionEntry[] action_entries = { + { ACTION_OPEN, on_open, "s" }, + { ACTION_OPEN_SELECTED, on_open_selected }, + { ACTION_REMOVE, on_remove, "s" }, + { ACTION_REMOVE_SELECTED, on_remove_selected }, + { ACTION_SAVE, on_save, "s" }, + { ACTION_SAVE_ALL, on_save_all }, + { ACTION_SAVE_SELECTED, on_save_selected }, + { ACTION_SELECT_ALL, on_select_all }, + }; + + + // This exists purely to be able to set key bindings on it. + private class FlowBox : Gtk.FlowBox { + + /** Keyboard action to open the currently selected attachments. */ + [Signal (action=true)] + public signal void open_attachments(); + + /** Keyboard action to save the currently selected attachments. */ + [Signal (action=true)] + public signal void save_attachments(); + + /** Keyboard action to remove the currently selected attachments. */ + [Signal (action=true)] + public signal void remove_attachments(); + + } + + // Displays an attachment's icon and details + [GtkTemplate (ui = "/org/gnome/Geary/components-attachment-view.ui")] + private class View : Gtk.Grid { + + + private const int ATTACHMENT_ICON_SIZE = 32; + private const int ATTACHMENT_PREVIEW_SIZE = 64; + + public Geary.Attachment attachment { get; private set; } + + [GtkChild] + private Gtk.Image icon; + + [GtkChild] + private Gtk.Label filename; + + [GtkChild] + private Gtk.Label description; + + private string gio_content_type; + + + public View(Geary.Attachment attachment) { + this.attachment = attachment; + string mime_content_type = attachment.content_type.get_mime_type(); + this.gio_content_type = ContentType.from_mime_type( + mime_content_type + ); + + string? file_name = attachment.content_filename; + string file_desc = GLib.ContentType.get_description(gio_content_type); + if (GLib.ContentType.is_unknown(gio_content_type)) { + // Translators: This is the file type displayed for + // attachments with unknown file types. + file_desc = _("Unknown"); + } + string file_size = Files.get_filesize_as_string( + attachment.filesize + ); + + if (Geary.String.is_empty(file_name)) { + // XXX Check for unknown types here and try to guess + // using attachment data. + file_name = file_desc; + file_desc = file_size; + } else { + // Translators: The first argument will be a + // description of the document type, the second will + // be a human-friendly size string. For example: + // Document (100.9MB) + file_desc = _("%s (%s)".printf(file_desc, file_size)); + } + this.filename.set_text(file_name); + this.description.set_text(file_desc); + } + + internal async void load_icon(GLib.Cancellable load_cancelled) { + if (load_cancelled.is_cancelled()) { + return; + } + + Gdk.Pixbuf? pixbuf = null; + + // XXX We need to hook up to GtkWidget::style-set and + // reload the icon when the theme changes. + + int window_scale = get_scale_factor(); + try { + // If the file is an image, use it. Otherwise get the + // icon for this mime_type. + if (this.attachment.content_type.has_media_type("image")) { + // Get a thumbnail for the image. + // TODO Generate and save the thumbnail when + // extracting the attachments rather than when showing + // them in the viewer. + int preview_size = ATTACHMENT_PREVIEW_SIZE * window_scale; + GLib.InputStream stream = yield this.attachment.file.read_async( + Priority.DEFAULT, + load_cancelled + ); + pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async( + stream, preview_size, preview_size, true, load_cancelled + ); + pixbuf = pixbuf.apply_embedded_orientation(); + } else { + // Load the icon for this mime type + GLib.Icon icon = GLib.ContentType.get_icon( + this.gio_content_type + ); + Gtk.IconTheme theme = Gtk.IconTheme.get_default(); + Gtk.IconLookupFlags flags = Gtk.IconLookupFlags.DIR_LTR; + if (get_direction() == Gtk.TextDirection.RTL) { + flags = Gtk.IconLookupFlags.DIR_RTL; + } + Gtk.IconInfo? icon_info = theme.lookup_by_gicon_for_scale( + icon, ATTACHMENT_ICON_SIZE, window_scale, flags + ); + if (icon_info != null) { + pixbuf = yield icon_info.load_icon_async(load_cancelled); + } + } + } catch (GLib.Error error) { + debug("Failed to load icon for attachment '%s': %s", + this.attachment.file.get_path(), + error.message); + } + + if (pixbuf != null) { + Cairo.Surface surface = Gdk.cairo_surface_create_from_pixbuf( + pixbuf, window_scale, get_window() + ); + this.icon.set_from_surface(surface); + } + } + + } + + + static construct { + // Set up custom keybindings + unowned Gtk.BindingSet bindings = Gtk.BindingSet.by_class( + (ObjectClass) typeof(FlowBox).class_ref() + ); + + Gtk.BindingEntry.add_signal( + bindings, Gdk.Key.O, Gdk.ModifierType.CONTROL_MASK, "open-attachments", 0 + ); + + Gtk.BindingEntry.add_signal( + bindings, Gdk.Key.S, Gdk.ModifierType.CONTROL_MASK, "save-attachments", 0 + ); + + Gtk.BindingEntry.add_signal( + bindings, Gdk.Key.BackSpace, 0, "remove-attachments", 0 + ); + Gtk.BindingEntry.add_signal( + bindings, Gdk.Key.Delete, 0, "remove-attachments", 0 + ); + Gtk.BindingEntry.add_signal( + bindings, Gdk.Key.KP_Delete, 0, "remove-attachments", 0 + ); + } + + + /** Determines if this pane's contents can be modified. */ + public bool edit_mode { get; private set; } + + private Gee.List attachments = + new Gee.LinkedList(); + + private Application.AttachmentManager manager; + + private GLib.SimpleActionGroup actions = new GLib.SimpleActionGroup(); + + [GtkChild] + private Gtk.Grid attachments_container; + + [GtkChild] + private Gtk.Button save_button; + + [GtkChild] + private Gtk.Button remove_button; + + private FlowBox attachments_view; + + + public AttachmentPane(bool edit_mode, + Application.AttachmentManager manager) { + this.edit_mode = edit_mode; + if (edit_mode) { + save_button.hide(); + } else { + remove_button.hide(); + } + + this.manager = manager; + + this.attachments_view = new FlowBox(); + this.attachments_view.open_attachments.connect(on_open_selected); + this.attachments_view.remove_attachments.connect(on_remove_selected); + this.attachments_view.save_attachments.connect(on_save_selected); + this.attachments_view.child_activated.connect(on_child_activated); + this.attachments_view.selected_children_changed.connect(on_selected_changed); + this.attachments_view.button_press_event.connect(on_attachment_button_press); + this.attachments_view.popup_menu.connect(on_attachment_popup_menu); + this.attachments_view.activate_on_single_click = false; + this.attachments_view.max_children_per_line = 3; + this.attachments_view.column_spacing = 6; + this.attachments_view.row_spacing = 6; + this.attachments_view.selection_mode = Gtk.SelectionMode.MULTIPLE; + this.attachments_view.hexpand = true; + this.attachments_view.show(); + this.attachments_container.add(this.attachments_view); + + this.actions.add_action_entries(action_entries, this); + insert_action_group(GROUP_NAME, this.actions); + } + + public void add_attachment(Geary.Attachment attachment, + GLib.Cancellable? cancellable) { + View view = new View(attachment); + this.attachments_view.add(view); + this.attachments.add(attachment); + view.load_icon.begin(cancellable); + + update_actions(); + } + + public void open_attachment(Geary.Attachment attachment) { + open_attachments(Geary.Collection.single(attachment)); + } + + public void save_attachment(Geary.Attachment attachment) { + this.manager.save_attachment.begin( + attachment, + null, + null // No cancellable for the moment, need UI for it + ); + } + + public void remove_attachment(Geary.Attachment attachment) { + this.attachments.remove(attachment); + this.attachments_view.foreach(child => { + Gtk.FlowBoxChild flow_child = (Gtk.FlowBoxChild) child; + if (((View) flow_child.get_child()).attachment == attachment) { + this.attachments_view.remove(child); + } + }); + } + + public bool save_all() { + bool ret = false; + if (!this.attachments.is_empty) { + var all = new Gee.ArrayList(); + all.add_all(this.attachments); + this.manager.save_attachments.begin( + all, + null // No cancellable for the moment, need UI for it + ); + } + return ret; + } + + private Geary.Attachment? get_attachment(GLib.Variant param) { + Geary.Attachment? ret = null; + string path = (string) param; + foreach (var attachment in this.attachments) { + if (attachment.file.get_path() == path) { + ret = attachment; + break; + } + } + return ret; + } + + private Gee.Collection get_selected_attachments() { + var selected = new Gee.LinkedList(); + this.attachments_view.selected_foreach((box, child) => { + selected.add( + ((View) child.get_child()).attachment + ); + }); + return selected; + } + + private bool open_selected() { + bool ret = false; + var selected = get_selected_attachments(); + if (!selected.is_empty) { + open_attachments(selected); + ret = true; + } + return ret; + } + + private bool save_selected() { + bool ret = false; + var selected = get_selected_attachments(); + if (!this.edit_mode && !selected.is_empty) { + this.manager.save_attachments.begin( + selected, + null // No cancellable for the moment, need UI for it + ); + ret = true; + } + return ret; + } + + private bool remove_selected() { + bool ret = false; + GLib.List children = + this.attachments_view.get_selected_children(); + if (this.edit_mode && children.length() > 0) { + children.foreach(child => { + this.attachments_view.remove(child); + this.attachments.remove( + ((View) child.get_child()).attachment + ); + }); + ret = true; + } + return ret; + } + + private void update_actions() { + uint len = this.attachments_view.get_selected_children().length(); + bool not_empty = len > 0; + + set_action_enabled(ACTION_OPEN_SELECTED, not_empty); + set_action_enabled(ACTION_REMOVE_SELECTED, not_empty && this.edit_mode); + set_action_enabled(ACTION_SAVE_SELECTED, not_empty && !this.edit_mode); + set_action_enabled(ACTION_SELECT_ALL, len < this.attachments.size); + } + + private void open_attachments(Gee.Collection attachments) { + MainWindow? main = this.get_toplevel() as MainWindow; + if (main != null) { + GearyApplication app = main.application; + bool confirmed = true; + if (app.config.ask_open_attachment) { + QuestionDialog ask_to_open = new QuestionDialog.with_checkbox( + main, + _("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) { + app.config.ask_open_attachment = !ask_to_open.is_checked; + } else { + confirmed = false; + } + } + + if (confirmed) { + foreach (var attachment in attachments) { + app.show_uri.begin(attachment.file.get_uri()); + } + } + } + } + + private void set_action_enabled(string name, bool enabled) { + SimpleAction? action = this.actions.lookup_action(name) as SimpleAction; + if (action != null) { + action.set_enabled(enabled); + } + } + + private void show_popup(View view, Gdk.EventButton? event) { + Gtk.Builder builder = new Gtk.Builder.from_resource( + "/org/gnome/Geary/components-attachment-pane-menus.ui" + ); + var targets = new Gee.HashMap(); + GLib.Variant target = view.attachment.file.get_path(); + targets[ACTION_OPEN] = target; + targets[ACTION_REMOVE] = target; + targets[ACTION_SAVE] = target; + GLib.Menu model = Util.Gtk.copy_menu_with_targets( + (GLib.Menu) builder.get_object("attachments_menu"), + GROUP_NAME, + targets + ); + Gtk.Menu menu = new Gtk.Menu.from_model(model); + menu.attach_to_widget(view, null); + if (event != null) { + menu.popup_at_pointer(event); + } else { + menu.popup_at_widget(view, CENTER, SOUTH, null); + } + } + + private void beep() { + Gtk.Widget? toplevel = get_toplevel(); + if (toplevel == null) { + Gdk.Window? window = toplevel.get_window(); + if (window != null) { + window.beep(); + } + } + } + + private void on_open(GLib.SimpleAction action, GLib.Variant? param) { + var target = get_attachment(param); + if (target != null) { + open_attachment(target); + } + } + + private void on_open_selected() { + if (!open_selected()) { + beep(); + } + } + + private void on_save(GLib.SimpleAction action, GLib.Variant? param) { + var target = get_attachment(param); + if (target != null) { + save_attachment(target); + } + } + + private void on_save_all() { + debug("save all!"); + if (!save_all()) { + beep(); + } + } + + private void on_save_selected() { + if (!save_selected()) { + beep(); + } + } + + private void on_remove(GLib.SimpleAction action, GLib.Variant? param) { + var target = get_attachment(param); + if (target != null) { + remove_attachment(target); + } + } + + private void on_remove_selected() { + if (!remove_selected()) { + beep(); + } + } + + private void on_select_all() { + this.attachments_view.select_all(); + } + + private void on_child_activated() { + open_selected(); + } + + private void on_selected_changed() { + update_actions(); + } + + private bool on_attachment_popup_menu(Gtk.Widget widget) { + bool ret = Gdk.EVENT_PROPAGATE; + Gtk.Window parent = get_toplevel() as Gtk.Window; + if (parent != null) { + Gtk.FlowBoxChild? focus = parent.get_focus() as Gtk.FlowBoxChild; + if (focus != null && focus.parent == this.attachments_view) { + show_popup((View) focus.get_child(), null); + ret = Gdk.EVENT_STOP; + } + } + return ret; + } + + private bool on_attachment_button_press(Gtk.Widget widget, + Gdk.EventButton event) { + bool ret = Gdk.EVENT_PROPAGATE; + if (event.triggers_context_menu()) { + Gtk.FlowBoxChild? child = this.attachments_view.get_child_at_pos( + (int) event.x, + (int) event.y + ); + if (child != null) { + show_popup((View) child.get_child(), event); + ret = Gdk.EVENT_STOP; + } + } + return ret; + } +} diff --git a/src/client/components/main-window.vala b/src/client/components/main-window.vala index 5e248814..f49ad80e 100644 --- a/src/client/components/main-window.vala +++ b/src/client/components/main-window.vala @@ -171,6 +171,9 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface { get; private set; default = null; } + /** The attachment manager for this window. */ + public Application.AttachmentManager attachments { get; private set; } + /** Determines if a composer is currently open in this window. */ public bool has_composer { get { @@ -268,6 +271,8 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface { update_command_actions(); update_conversation_actions(NONE); + this.attachments = new Application.AttachmentManager(this); + this.application.engine.account_available.connect(on_account_available); this.application.engine.account_unavailable.connect(on_account_unavailable); @@ -1398,9 +1403,6 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface { view.reply_all_message.connect(on_reply_all_message); view.reply_to_message.connect(on_reply_to_message); view.edit_draft.connect(on_edit_draft); - - view.attachments_activated.connect(on_attachments_activated); - view.save_attachments.connect(on_save_attachments); view.view_source.connect(on_view_source); Geary.App.Conversation conversation = this.conversation_viewer.current_list.conversation; @@ -1414,12 +1416,6 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface { view.delete_message.connect(on_delete_message); view.set_folder_actions_enabled(supports_trash, supports_delete); this.on_shift_key.connect(view.shift_key_changed); - - foreach (ConversationMessage msg_view in view) { - msg_view.save_image.connect((url, alt_text, buf) => { - on_save_image_extended(view, url, alt_text, buf); - }); - } } // Window-level action callbacks @@ -1854,42 +1850,6 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface { } } - private void on_attachments_activated(Gee.Collection attachments) { - if (this.application.config.ask_open_attachment) { - QuestionDialog ask_to_open = new QuestionDialog.with_checkbox( - this, - _("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) { - return; - } - // only save checkbox state if OK was selected - this.application.config.ask_open_attachment = !ask_to_open.is_checked; - } - - foreach (Geary.Attachment attachment in attachments) { - this.application.show_uri.begin(attachment.file.get_uri()); - } - } - - private void on_save_attachments(Gee.Collection attachments) { - if (this.selected_account != null) { - if (attachments.size == 1) { - this.application.controller.save_attachment_to_file.begin( - this.selected_account, - attachments.to_array()[0], - null - ); - } else { - this.application.controller.save_attachments_to_file.begin( - this.selected_account, - attachments - ); - } - } - } - private void on_view_source(ConversationEmail email_view) { string source = (email_view.email.header.buffer.to_string() + email_view.email.body.buffer.to_string()); @@ -1916,17 +1876,6 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface { } } - private void on_save_image_extended(ConversationEmail view, - string url, - string? alt_text, - Geary.Memory.Buffer resource_buf) { - if (this.selected_account != null) { - this.application.controller.save_image_extended.begin( - this.selected_account, view, url, alt_text, resource_buf - ); - } - } - private void on_trash_message(ConversationEmail target_view) { Geary.Folder? source = this.selected_folder; if (source != null) { diff --git a/src/client/conversation-viewer/conversation-email.vala b/src/client/conversation-viewer/conversation-email.vala index 87a84b28..a349bebf 100644 --- a/src/client/conversation-viewer/conversation-email.vala +++ b/src/client/conversation-viewer/conversation-email.vala @@ -127,131 +127,16 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { } - // Displays an attachment's icon and details - [GtkTemplate (ui = "/org/gnome/Geary/conversation-email-attachment-view.ui")] - private class AttachmentView : Gtk.Grid { - - public Geary.Attachment attachment { get; private set; } - - [GtkChild] - private Gtk.Image icon; - - [GtkChild] - private Gtk.Label filename; - - [GtkChild] - private Gtk.Label description; - - private string gio_content_type; - - public AttachmentView(Geary.Attachment attachment) { - this.attachment = attachment; - string mime_content_type = attachment.content_type.get_mime_type(); - this.gio_content_type = ContentType.from_mime_type( - mime_content_type - ); - - string? file_name = attachment.content_filename; - string file_desc = ContentType.get_description(gio_content_type); - if (ContentType.is_unknown(gio_content_type)) { - // Translators: This is the file type displayed for - // attachments with unknown file types. - file_desc = _("Unknown"); - } - string file_size = Files.get_filesize_as_string(attachment.filesize); - - if (Geary.String.is_empty(file_name)) { - // XXX Check for unknown types here and try to guess - // using attachment data. - file_name = file_desc; - file_desc = file_size; - } else { - // Translators: The first argument will be a - // description of the document type, the second will - // be a human-friendly size string. For example: - // Document (100.9MB) - file_desc = _("%s (%s)".printf(file_desc, file_size)); - } - this.filename.set_text(file_name); - this.description.set_text(file_desc); - } - - internal async void load_icon(Cancellable load_cancelled) { - if (load_cancelled.is_cancelled()) { - return; - } - - Gdk.Pixbuf? pixbuf = null; - - // XXX We need to hook up to GtkWidget::style-set and - // reload the icon when the theme changes. - - int window_scale = get_scale_factor(); - try { - // If the file is an image, use it. Otherwise get the - // icon for this mime_type. - if (this.attachment.content_type.has_media_type("image")) { - // Get a thumbnail for the image. - // TODO Generate and save the thumbnail when - // extracting the attachments rather than when showing - // them in the viewer. - int preview_size = ATTACHMENT_PREVIEW_SIZE * window_scale; - InputStream stream = yield this.attachment.file.read_async( - Priority.DEFAULT, - load_cancelled - ); - pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async( - stream, preview_size, preview_size, true, load_cancelled - ); - pixbuf = pixbuf.apply_embedded_orientation(); - } else { - // Load the icon for this mime type - Icon icon = ContentType.get_icon(this.gio_content_type); - Gtk.IconTheme theme = Gtk.IconTheme.get_default(); - Gtk.IconLookupFlags flags = Gtk.IconLookupFlags.DIR_LTR; - if (get_direction() == Gtk.TextDirection.RTL) { - flags = Gtk.IconLookupFlags.DIR_RTL; - } - Gtk.IconInfo? icon_info = theme.lookup_by_gicon_for_scale( - icon, ATTACHMENT_ICON_SIZE, window_scale, flags - ); - if (icon_info != null) { - pixbuf = yield icon_info.load_icon_async(load_cancelled); - } - } - } catch (Error error) { - debug("Failed to load icon for attachment '%s': %s", - this.attachment.file.get_path(), - error.message); - } - - if (pixbuf != null) { - Cairo.Surface surface = Gdk.cairo_surface_create_from_pixbuf( - pixbuf, window_scale, get_window() - ); - this.icon.set_from_surface(surface); - } - } - - } - - - private const int ATTACHMENT_ICON_SIZE = 32; - private const int ATTACHMENT_PREVIEW_SIZE = 64; - private const string ACTION_FORWARD = "forward"; 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_TRASH_MESSAGE = "trash_msg"; private const string ACTION_DELETE_MESSAGE = "delete_msg"; - 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_SELECT_ALL_ATTACHMENTS = "select_all_attachments"; private const string ACTION_STAR = "star"; private const string ACTION_UNSTAR = "unstar"; private const string ACTION_VIEW_SOURCE = "view_source"; @@ -354,16 +239,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { [GtkChild] private Gtk.Grid sub_messages; - [GtkChild] - private Gtk.Grid attachments; - - [GtkChild] - private Gtk.FlowBox attachments_view; - - [GtkChild] - private Gtk.Button select_all_attachments; - - private Gtk.Menu attachments_menu; + private Components.AttachmentPane? attachments_pane = null; private Menu email_menu; private Menu email_menu_model; @@ -400,16 +276,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { /** Fired when the user clicks "delete" in the message menu. */ public signal void delete_message(); - /** Fired when the user activates an attachment. */ - public signal void attachments_activated( - Gee.Collection attachments - ); - - /** Fired when the user saves an attachment. */ - public signal void save_attachments( - Gee.Collection attachments - ); - /** Fired the edit draft button is clicked. */ public signal void edit_draft(); @@ -472,23 +338,14 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { add_action(ACTION_DELETE_MESSAGE).activate.connect(() => { delete_message(); }); - add_action(ACTION_OPEN_ATTACHMENTS, false).activate.connect(() => { - attachments_activated(get_selected_attachments()); - }); add_action(ACTION_REPLY_ALL).activate.connect(() => { reply_all_message(); }); add_action(ACTION_REPLY_SENDER).activate.connect(() => { reply_to_message(); }); - add_action(ACTION_SAVE_ATTACHMENTS, false).activate.connect(() => { - save_attachments(get_selected_attachments()); - }); add_action(ACTION_SAVE_ALL_ATTACHMENTS).activate.connect(() => { - save_attachments(this.displayed_attachments); - }); - add_action(ACTION_SELECT_ALL_ATTACHMENTS, false).activate.connect(() => { - this.attachments_view.select_all(); + this.attachments_pane.save_all(); }); add_action(ACTION_STAR).activate.connect(() => { mark_email(Geary.EmailFlags.FLAGGED, null); @@ -526,11 +383,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { this.email_menubutton.set_sensitive(false); this.email_menubutton.toggled.connect(this.on_email_menu); - this.attachments_menu = new Gtk.Menu.from_model( - (MenuModel) builder.get_object("attachments_menu") - ); - this.attachments_menu.attach_to_widget(this, null); - this.primary_message.infobars.add(this.draft_infobar); if (is_draft) { this.draft_infobar.show(); @@ -760,6 +612,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { view.internal_link_activated.connect((y) => { internal_link_activated(y); }); + view.save_image.connect(on_save_image); view.web_view.internal_resource_loaded.connect(on_resource_loaded); view.web_view.content_loaded.connect(on_content_loaded); view.web_view.selection_changed.connect((has_selection) => { @@ -904,32 +757,22 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { private void update_displayed_attachments() { bool has_attachments = !this.displayed_attachments.is_empty; this.attachments_button.set_visible(has_attachments); - if (has_attachments) { - this.primary_message.body_container.add(this.attachments); + MainWindow? main = get_toplevel() as MainWindow; - if (this.displayed_attachments.size > 1) { - this.select_all_attachments.show(); - set_action_enabled(ACTION_SELECT_ALL_ATTACHMENTS, true); - } + if (has_attachments && main != null) { + this.attachments_pane = new Components.AttachmentPane( + false, main.attachments + ); + this.primary_message.body_container.add(this.attachments_pane); - foreach (Geary.Attachment attachment in this.displayed_attachments) { - AttachmentView view = new AttachmentView(attachment); - this.attachments_view.add(view); - view.load_icon.begin(this.load_cancellable); + foreach (var attachment in this.displayed_attachments) { + this.attachments_pane.add_attachment( + attachment, this.load_cancellable + ); } } } - internal Gee.Collection get_selected_attachments() { - Gee.LinkedList selected = - new Gee.LinkedList(); - foreach (Gtk.FlowBoxChild child in - this.attachments_view.get_selected_children()) { - selected.add(((AttachmentView) child.get_child()).attachment); - } - return selected; - } - private void handle_load_failure(GLib.Error err) { load_error(err); this.message_body_state = FAILED; @@ -1045,6 +888,44 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { } } + private void on_save_image(string uri, + string? alt_text, + Geary.Memory.Buffer? content) { + MainWindow? main = get_toplevel() as MainWindow; + if (main != null) { + if (uri.has_prefix(ClientWebView.CID_URL_PREFIX)) { + string cid = uri.substring(ClientWebView.CID_URL_PREFIX.length); + try { + Geary.Attachment attachment = this.email.get_attachment_by_content_id( + cid + ); + main.attachments.save_attachment.begin( + attachment, + alt_text, + null // XXX no cancellable yet, need UI for it + ); + } catch (GLib.Error err) { + debug("Could not get attachment \"%s\": %s", cid, err.message); + } + } else if (content != null) { + GLib.File source = GLib.File.new_for_uri(uri); + // Querying the URL-based file for the display name + // results in it being looked up, so just get the basename + // from it directly. GIO seems to decode any %-encoded + // chars anyway. + string? display_name = source.get_basename(); + if (Geary.String.is_empty_or_whitespace(display_name)) { + display_name = Application.AttachmentManager.untitled_file_name; + } + main.attachments.save_buffer.begin( + display_name, + content, + null // XXX no cancellable yet, need UI for it + ); + } + } + } + private void on_resource_loaded(string id) { Gee.Iterator displayed = this.displayed_attachments.iterator(); @@ -1078,26 +959,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { } } - [GtkCallback] - private void on_attachments_child_activated(Gtk.FlowBox view, - Gtk.FlowBoxChild child) { - attachments_activated( - Geary.iterate( - ((AttachmentView) child.get_child()).attachment - ).to_array_list() - ); - } - - [GtkCallback] - private void on_attachments_selected_changed(Gtk.FlowBox view) { - uint len = view.get_selected_children().length(); - bool not_empty = len > 0; - set_action_enabled(ACTION_OPEN_ATTACHMENTS, not_empty); - set_action_enabled(ACTION_SAVE_ATTACHMENTS, not_empty); - set_action_enabled(ACTION_SELECT_ALL_ATTACHMENTS, - len < this.displayed_attachments.size); - } - private void on_service_status_change() { if (this.message_body_state == FAILED && !this.load_cancellable.is_cancelled() && diff --git a/src/client/conversation-viewer/conversation-message.vala b/src/client/conversation-viewer/conversation-message.vala index fb0cf1d0..1ccd7a7f 100644 --- a/src/client/conversation-viewer/conversation-message.vala +++ b/src/client/conversation-viewer/conversation-message.vala @@ -342,7 +342,9 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface { public signal void flag_remote_images(); /** Fired when the user saves an inline displayed image. */ - public signal void save_image(string? uri, string? alt_text, Geary.Memory.Buffer buffer); + public signal void save_image( + string uri, string? alt_text, Geary.Memory.Buffer? buffer + ); /** @@ -1232,27 +1234,35 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface { } private void on_save_image(Variant? param) { - string cid_url = param.get_child_value(0).get_string(); - + string uri = (string) param.get_child_value(0); string? alt_text = null; Variant? alt_maybe = param.get_child_value(1).get_maybe(); if (alt_maybe != null) { - alt_text = alt_maybe.get_string(); + alt_text = (string) alt_maybe; + } + + if (uri.has_prefix(ClientWebView.CID_URL_PREFIX)) { + // We can get the data directly from the attachment, so + // don't bother getting it from the web view + save_image(uri, alt_text, null); + } else { + WebKit.WebResource response = this.resources.get(uri); + response.get_data.begin(null, (obj, res) => { + try { + uint8[] data = response.get_data.end(res); + save_image( + uri, + alt_text, + new Geary.Memory.ByteBuffer(data, data.length) + ); + } catch (GLib.Error err) { + debug( + "Failed to get image data from web view: %s", + err.message + ); + } + }); } - WebKit.WebResource response = this.resources.get(cid_url); - response.get_data.begin(null, (obj, res) => { - try { - uint8[] data = response.get_data.end(res); - save_image(response.get_uri(), - alt_text, - new Geary.Memory.ByteBuffer(data, data.length)); - } catch (Error err) { - debug( - "Failed to get image data from web view: %s", - err.message - ); - } - }); } private void on_link_activated(GLib.Variant? param) { diff --git a/src/client/meson.build b/src/client/meson.build index e306071c..cf64427b 100644 --- a/src/client/meson.build +++ b/src/client/meson.build @@ -1,5 +1,6 @@ # Geary client geary_client_vala_sources = files( + 'application/application-attachment-manager.vala', 'application/application-avatar-store.vala', 'application/application-certificate-manager.vala', 'application/application-command.vala', @@ -25,6 +26,7 @@ geary_client_vala_sources = files( 'accounts/accounts-manager.vala', 'components/client-web-view.vala', + 'components/components-attachment-pane.vala', 'components/components-inspector.vala', 'components/components-in-app-notification.vala', 'components/components-inspector-error-view.vala', diff --git a/src/client/util/util-gtk.vala b/src/client/util/util-gtk.vala index bcc6aa33..37674761 100644 --- a/src/client/util/util-gtk.vala +++ b/src/client/util/util-gtk.vala @@ -1,7 +1,9 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. +/* + * Copyright 2016 Software Freedom Conservancy Inc. + * Copyright 2019 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. + * (version 2.1 or later). See the COPYING file in this distribution. */ namespace GtkUtil { @@ -80,3 +82,48 @@ public inline int get_border_box_height(Gtk.Widget widget) { } } + +namespace Util.Gtk { + + /** Copies a GLib menu, setting targets for the given actions. */ + public GLib.Menu copy_menu_with_targets(GLib.Menu template, + string group, + Gee.Map targets) { + string group_prefix = group + "."; + GLib.Menu copy = new GLib.Menu(); + for (int i = 0; i < template.get_n_items(); i++) { + GLib.MenuItem item = new GLib.MenuItem.from_model(template, i); + GLib.Menu? section = (GLib.Menu) item.get_link( + GLib.Menu.LINK_SECTION + ); + GLib.Menu? submenu = (GLib.Menu) item.get_link( + GLib.Menu.LINK_SUBMENU + ); + + if (section != null) { + item.set_section( + copy_menu_with_targets(section, group, targets) + ); + } else if (submenu != null) { + item.set_submenu( + copy_menu_with_targets(submenu, group, targets) + ); + } else { + string? action = (string) item.get_attribute_value( + GLib.Menu.ATTRIBUTE_ACTION, GLib.VariantType.STRING + ); + if (action != null && action.has_prefix(group_prefix)) { + GLib.Variant? target = targets.get( + action.substring(group_prefix.length) + ); + if (target != null) { + item.set_action_and_target_value(action, target); + } + } + } + copy.append_item(item); + } + return copy; + } + +} diff --git a/ui/components-attachment-pane-menus.ui b/ui/components-attachment-pane-menus.ui new file mode 100644 index 00000000..33f3a9e0 --- /dev/null +++ b/ui/components-attachment-pane-menus.ui @@ -0,0 +1,22 @@ + + + + +
+ + _Open + cap.open + + + _Save + cap.save + +
+
+ + Save _All + cap.save-all + +
+
+
diff --git a/ui/components-attachment-pane.ui b/ui/components-attachment-pane.ui new file mode 100644 index 00000000..206ac3ce --- /dev/null +++ b/ui/components-attachment-pane.ui @@ -0,0 +1,151 @@ + + + + + + diff --git a/ui/conversation-email-attachment-view.ui b/ui/components-attachment-view.ui similarity index 94% rename from ui/conversation-email-attachment-view.ui rename to ui/components-attachment-view.ui index 16b5f308..85257235 100644 --- a/ui/conversation-email-attachment-view.ui +++ b/ui/components-attachment-view.ui @@ -1,8 +1,8 @@ - + -