[geary/mjog/account-command-stacks: 66/77] Move email action handling to ConversationListBox



commit c901ac61049f274b77842eaea61651423c4f7a4c
Author: Michael Gratton <mike vee net>
Date:   Wed Oct 30 14:30:26 2019 +1100

    Move email action handling to ConversationListBox
    
    This allows a single widget to get constructed to handle email actions,
    rather than every single ConversationEmail having to do so, and thus
    related signals can also be moved to and emitted from
    ConversationListBox, so that MainWindow only has to hook up to a single
    object's signals for a conversation, not every email in the
    conversation.

 src/client/components/main-window.vala             |  99 ++--
 .../conversation-viewer/conversation-email.vala    | 568 ++++++++++-----------
 .../conversation-viewer/conversation-list-box.vala | 309 ++++++++---
 src/client/util/util-gtk.vala                      |  77 +++
 ui/conversation-email-menus.ui                     |  16 +-
 ui/conversation-email.ui                           |   8 +-
 6 files changed, 645 insertions(+), 432 deletions(-)
---
diff --git a/src/client/components/main-window.vala b/src/client/components/main-window.vala
index 6f510205..5617f6a1 100644
--- a/src/client/components/main-window.vala
+++ b/src/client/components/main-window.vala
@@ -1390,27 +1390,13 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
     }
 
     private void on_conversation_view_added(ConversationListBox list) {
-        list.email_added.connect(on_conversation_viewer_email_added);
-        list.mark_emails.connect(on_mark_messages);
-    }
-
-    private void on_conversation_viewer_email_added(ConversationEmail view) {
-        view.forward_message.connect(on_forward_message);
-        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);
-
-        Geary.App.Conversation conversation = this.conversation_viewer.current_list.conversation;
-        bool in_selected_folder = (
-            conversation.is_in_base_folder(view.email.id) &&
-            conversation.base_folder == selected_folder
-        );
-        bool supports_trash = in_selected_folder && selected_folder_supports_trash();
-        bool supports_delete = in_selected_folder && selected_folder is Geary.FolderSupport.Remove;
-        view.trash_message.connect(on_trash_message);
-        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);
+        list.mark_email.connect(on_email_mark);
+        list.reply_to_all_email.connect(on_email_reply_to_all);
+        list.reply_to_sender_email.connect(on_email_reply_to_sender);
+        list.forward_email.connect(on_email_forward);
+        list.edit_email.connect(on_email_edit);
+        list.trash_email.connect(on_email_trash);
+        list.delete_email.connect(on_email_delete);
     }
 
     // Window-level action callbacks
@@ -1768,20 +1754,30 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         }
     }
 
-    // Individual message view action callbacks
+    // Individual conversation email view action callbacks
 
-    private void on_mark_messages(Geary.App.Conversation conversation,
-                                  Gee.Collection<Geary.EmailIdentifier> messages,
-                                  Geary.EmailFlags? to_add,
-                                  Geary.EmailFlags? to_remove) {
+    private void on_email_mark(ConversationListBox view,
+                               Gee.Collection<Geary.EmailIdentifier> messages,
+                               Geary.NamedFlag? to_add,
+                               Geary.NamedFlag? to_remove) {
         Geary.Account? target = this.selected_account;
         if (target != null) {
+            Geary.EmailFlags add_flags = null;
+            if (to_add != null) {
+                add_flags = new Geary.EmailFlags();
+                add_flags.add(to_add);
+            }
+            Geary.EmailFlags remove_flags = null;
+            if (to_remove != null) {
+                remove_flags = new Geary.EmailFlags();
+                remove_flags.add(to_remove);
+            }
             this.application.controller.mark_messages.begin(
                 target,
-                Geary.Collection.single(conversation),
+                Geary.Collection.single(view.conversation),
                 messages,
-                to_add,
-                to_remove,
+                add_flags,
+                remove_flags,
                 (obj, res) => {
                     try {
                         this.application.controller.mark_messages.end(res);
@@ -1793,58 +1789,49 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         }
     }
 
-    private void on_reply_to_message(ConversationEmail target_view) {
+    private void on_email_reply_to_sender(Geary.Email target, string? quote) {
         Geary.Account? account = this.selected_account;
         if (account != null) {
-            target_view.get_selection_for_quoting.begin((obj, res) => {
-                    string? quote = target_view.get_selection_for_quoting.end(res);
-                    this.application.controller.compose_with_context_email(
-                        account, REPLY, target_view.email, quote
-                    );
-                });
+            this.application.controller.compose_with_context_email(
+                account, REPLY, target, quote
+            );
         }
     }
 
-    private void on_reply_all_message(ConversationEmail target_view) {
+    private void on_email_reply_to_all(Geary.Email target, string? quote) {
         Geary.Account? account = this.selected_account;
         if (account != null) {
-            target_view.get_selection_for_quoting.begin((obj, res) => {
-                    string? quote = target_view.get_selection_for_quoting.end(res);
-                    this.application.controller.compose_with_context_email(
-                        account, REPLY_ALL, target_view.email, quote
-                    );
-                });
+            this.application.controller.compose_with_context_email(
+                account, REPLY_ALL, target, quote
+            );
         }
     }
 
-    private void on_forward_message(ConversationEmail target_view) {
+    private void on_email_forward(Geary.Email target, string? quote) {
         Geary.Account? account = this.selected_account;
         if (account != null) {
-            target_view.get_selection_for_quoting.begin((obj, res) => {
-                    string? quote = target_view.get_selection_for_quoting.end(res);
-                    this.application.controller.compose_with_context_email(
-                        account, FORWARD, target_view.email, quote
-                    );
-                });
+            this.application.controller.compose_with_context_email(
+                account, FORWARD, target, quote
+            );
         }
     }
 
-    private void on_edit_draft(ConversationEmail target_view) {
+    private void on_email_edit(Geary.Email target) {
         Geary.Account? account = this.selected_account;
         if (account != null) {
             this.application.controller.compose_with_context_email(
-                account, NEW_MESSAGE, target_view.email, null
+                account, NEW_MESSAGE, target, null
             );
         }
     }
 
-    private void on_trash_message(ConversationEmail target_view) {
+    private void on_email_trash(Geary.Email target) {
         Geary.Folder? source = this.selected_folder;
         if (source != null) {
             this.application.controller.move_messages_special.begin(
                 source,
                 TRASH,
-                Geary.Collection.single(target_view.email.id),
+                Geary.Collection.single(target.id),
                 (obj, res) => {
                     try {
                         this.application.controller.move_messages_special.end(res);
@@ -1856,13 +1843,13 @@ public class MainWindow : Gtk.ApplicationWindow, Geary.BaseInterface {
         }
     }
 
-    private void on_delete_message(ConversationEmail target_view) {
+    private void on_email_delete(Geary.Email target) {
         Geary.FolderSupport.Remove? source =
             this.selected_folder as Geary.FolderSupport.Remove;
         if (source != null && prompt_delete_messages(1)) {
             this.application.controller.delete_messages.begin(
                 source,
-                Geary.Collection.single(target_view.email.id),
+                Geary.Collection.single(target.id),
                 (obj, res) => {
                     try {
                         this.application.controller.delete_messages.end(res);
diff --git a/src/client/conversation-viewer/conversation-email.vala 
b/src/client/conversation-viewer/conversation-email.vala
index e77e6bcb..3e34b304 100644
--- a/src/client/conversation-viewer/conversation-email.vala
+++ b/src/client/conversation-viewer/conversation-email.vala
@@ -20,6 +20,10 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     // This isn't a Gtk.Grid since when added to a Gtk.ListBoxRow the
     // hover style isn't applied to it.
 
+    private const string MANUAL_READ_CLASS = "geary-manual-read";
+    private const string SENT_CLASS = "geary-sent";
+    private const string STARRED_CLASS = "geary-starred";
+    private const string UNREAD_CLASS = "geary-unread";
 
     /** Fields that must be available for constructing the view. */
     internal const Geary.Email.Field REQUIRED_FOR_CONSTRUCT = (
@@ -127,24 +131,20 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     }
 
 
-    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_PRINT = "print";
-    private const string ACTION_REPLY_SENDER = "reply_sender";
-    private const string ACTION_REPLY_ALL = "reply_all";
-    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";
+    private static GLib.MenuModel email_menu_template;
+    private static GLib.MenuModel email_menu_trash_section;
+    private static GLib.MenuModel email_menu_delete_section;
+
+
+    static construct {
+        Gtk.Builder builder = new Gtk.Builder.from_resource(
+            "/org/gnome/Geary/conversation-email-menus.ui"
+        );
+        email_menu_template = (GLib.MenuModel) builder.get_object("email_menu");
+        email_menu_trash_section  = (GLib.MenuModel) builder.get_object("email_menu_trash");
+        email_menu_delete_section = (GLib.MenuModel) builder.get_object("email_menu_delete");
+    }
 
-    private const string MANUAL_READ_CLASS = "geary-manual-read";
-    private const string SENT_CLASS = "geary-sent";
-    private const string STARRED_CLASS = "geary-starred";
-    private const string UNREAD_CLASS = "geary-unread";
 
     /**
      * The specific email that is displayed by this view.
@@ -156,6 +156,22 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
      */
     public Geary.Email email { get; private set; }
 
+    /** Determines if this email currently flagged as unread. */
+    public bool is_unread {
+        get {
+            Geary.EmailFlags? flags = this.email.email_flags;
+            return (flags != null && flags.is_unread());
+        }
+    }
+
+    /** Determines if this email currently flagged as starred. */
+    public bool is_starred {
+        get {
+            Geary.EmailFlags? flags = this.email.email_flags;
+            return (flags != null && flags.is_flagged());
+        }
+    }
+
     /** Determines if the email is showing a preview or the full message. */
     public bool is_collapsed = true;
 
@@ -177,6 +193,10 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     /** The view displaying the email's primary message headers and body. */
     public ConversationMessage primary_message { get; private set; }
 
+    public Components.AttachmentPane? attachments_pane {
+        get; private set; default = null;
+    }
+
     /** Views for attached messages. */
     public Gee.List<ConversationMessage> attached_messages {
         owned get { return this._attached_messages.read_only_view; }
@@ -187,6 +207,8 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     /** Determines the message body loading state. */
     public LoadState message_body_state { get; private set; default = NOT_STARTED; }
 
+    public Geary.App.Conversation conversation;
+
     // Store from which to load message content, if needed
     private Geary.App.EmailStore email_store;
 
@@ -200,7 +222,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
 
     private Geary.TimeoutManager body_loading_timeout;
 
-
     /** Determines if all message's web views have finished loading. */
     private Geary.Nonblocking.Spinlock message_bodies_loaded_lock;
 
@@ -212,8 +233,9 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     private Gee.List<Geary.Attachment> displayed_attachments =
          new Gee.LinkedList<Geary.Attachment>();
 
-    // Message-specific actions
-    private SimpleActionGroup message_actions = new SimpleActionGroup();
+    // Tracks if Shift key handler has been installed on the main
+    // window, for updating email menu trash/delete actions.
+    private bool shift_handler_installed = false;
 
     [GtkChild]
     private Gtk.Grid actions;
@@ -239,45 +261,9 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     [GtkChild]
     private Gtk.Grid sub_messages;
 
-    private Components.AttachmentPane? attachments_pane = null;
-
-    private Menu email_menu;
-    private Menu email_menu_model;
-    private Menu email_menu_trash;
-    private Menu email_menu_delete;
-    private bool shift_key_down;
-
-
-    /** Fired when the user clicks "reply" in the message menu. */
-    public signal void reply_to_message();
-
-    /** Fired when the user clicks "reply all" in the message menu. */
-    public signal void reply_all_message();
-
-    /** Fired when the user clicks "forward" in the message menu. */
-    public signal void forward_message();
-
-    /** Fired when the user updates the email's flags. */
-    public signal void mark_email(
-        Geary.NamedFlag? to_add, Geary.NamedFlag? to_remove
-    );
-
-    /** Fired when the user updates flags for this email and all others down. */
-    public signal void mark_email_from_here(
-        Geary.NamedFlag? to_add, Geary.NamedFlag? to_remove
-    );
-
-    /** Fired when the user clicks "trash" in the message menu. */
-    public signal void trash_message();
-
-    /** Fired when the user clicks "delete" in the message menu. */
-    public signal void delete_message();
-
-    /** Fired the edit draft button is clicked. */
-    public signal void edit_draft();
 
     /** Fired when a internal link is activated */
-    public signal void internal_link_activated(int y);
+    internal signal void internal_link_activated(int y);
 
     /** Fired when the user selects text in a message. */
     internal signal void body_selection_changed(bool has_selection);
@@ -290,7 +276,8 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
      * the complete email, but does not attempt any possibly
      * long-running loading processes.
      */
-    public ConversationEmail(Geary.Email email,
+    public ConversationEmail(Geary.App.Conversation conversation,
+                             Geary.Email email,
                              Geary.App.EmailStore email_store,
                              Application.ContactStore contacts,
                              Configuration config,
@@ -298,6 +285,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
                              bool is_draft,
                              GLib.Cancellable load_cancellable) {
         base_ref();
+        this.conversation = conversation;
         this.email = email;
         this.is_draft = is_draft;
         this.email_store = email_store;
@@ -311,45 +299,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
             get_style_context().add_class(SENT_CLASS);
         }
 
-        add_action(ACTION_FORWARD).activate.connect(() => {
-                forward_message();
-            });
-        add_action(ACTION_PRINT).activate.connect(() => {
-                print.begin();
-            });
-        add_action(ACTION_MARK_READ).activate.connect(() => {
-                mark_email(null, Geary.EmailFlags.UNREAD);
-            });
-        add_action(ACTION_MARK_UNREAD).activate.connect(() => {
-                mark_email(Geary.EmailFlags.UNREAD, null);
-            });
-        add_action(ACTION_MARK_UNREAD_DOWN).activate.connect(() => {
-                mark_email_from_here(Geary.EmailFlags.UNREAD, null);
-            });
-        add_action(ACTION_TRASH_MESSAGE).activate.connect(() => {
-                trash_message();
-            });
-        add_action(ACTION_DELETE_MESSAGE).activate.connect(() => {
-                delete_message();
-            });
-        add_action(ACTION_REPLY_ALL).activate.connect(() => {
-                reply_all_message();
-            });
-        add_action(ACTION_REPLY_SENDER).activate.connect(() => {
-                reply_to_message();
-            });
-        add_action(ACTION_SAVE_ALL_ATTACHMENTS).activate.connect(() => {
-                this.attachments_pane.save_all();
-            });
-        add_action(ACTION_STAR).activate.connect(() => {
-                mark_email(Geary.EmailFlags.FLAGGED, null);
-            });
-        add_action(ACTION_UNSTAR).activate.connect(() => {
-                mark_email(null, Geary.EmailFlags.FLAGGED);
-            });
-        add_action(ACTION_VIEW_SOURCE).activate.connect(on_view_source);
-        insert_action_group("eml", message_actions);
-
         // Construct the view for the primary message, hook into it
 
         this.primary_message = new ConversationMessage.from_email(
@@ -361,30 +310,19 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         connect_message_view_signals(this.primary_message);
 
         this.primary_message.summary.add(this.actions);
-
-        // Wire up the rest of the UI
-
-        Gtk.Builder builder = new Gtk.Builder.from_resource(
-            "/org/gnome/Geary/conversation-email-menus.ui"
-        );
-        this.email_menu = new Menu();
-        this.email_menu_model = (Menu) builder.get_object("email_menu");
-        this.email_menu_trash = (Menu) builder.get_object("email_menu_trash");
-        this.email_menu_delete = (Menu) builder.get_object("email_menu_delete");
-        this.email_menubutton.set_menu_model(this.email_menu);
-        this.email_menubutton.set_sensitive(false);
-        this.email_menubutton.toggled.connect(this.on_email_menu);
-
         this.primary_message.infobars.add(this.draft_infobar);
         if (is_draft) {
             this.draft_infobar.show();
             this.draft_infobar.response.connect((infobar, response_id) => {
-                    if (response_id == 1) { edit_draft(); }
+                    if (response_id == 1) {
+                        activate_email_action(ConversationListBox.ACTION_EDIT);
+                    }
                 });
         }
-
         this.primary_message.infobars.add(this.not_saved_infobar);
 
+        // Wire up the rest of the UI
+
         email_store.account.incoming.notify["current-status"].connect(
             on_service_status_change
         );
@@ -483,23 +421,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         }
     }
 
-    /**
-     * Enables or disables actions that require folder support.
-     */
-    public void set_folder_actions_enabled(bool supports_trash, bool supports_delete) {
-        set_action_enabled(ACTION_TRASH_MESSAGE, supports_trash);
-        set_action_enabled(ACTION_DELETE_MESSAGE, supports_delete);
-    }
-
-    /**
-     * Substitutes the "Delete Message" button for the "Move Message to Trash"
-     * button if the Shift key is pressed.
-     */
-    public void shift_key_changed(bool pressed) {
-        this.shift_key_down = pressed;
-        this.on_email_menu();
-    }
-
     /**
      * Shows the complete message: headers, body and attachments.
      */
@@ -507,7 +428,16 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         this.is_collapsed = false;
         update_email_state();
         this.attachments_button.set_sensitive(true);
-        this.email_menubutton.set_sensitive(true);
+        // Needs at least some menu set otherwise it won't be enabled,
+        // also has the side effect of making it sensitive
+        this.email_menubutton.set_menu_model(new GLib.Menu());
+
+        // Set targets to enable the actions
+        GLib.Variant email_target = email.id.to_variant();
+        this.attachments_button.set_action_target_value(email_target);
+        this.star_button.set_action_target_value(email_target);
+        this.unstar_button.set_action_target_value(email_target);
+
         foreach (ConversationMessage message in this) {
             message.show_message_body(include_transitions);
         }
@@ -521,6 +451,12 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         update_email_state();
         attachments_button.set_sensitive(false);
         email_menubutton.set_sensitive(false);
+
+        // Clear targets to disable the actions
+        this.attachments_button.set_action_target_value(null);
+        this.star_button.set_action_target_value(null);
+        this.unstar_button.set_action_target_value(null);
+
         primary_message.hide_message_body();
         foreach (ConversationMessage attached in this._attached_messages) {
             attached.hide_message_body();
@@ -567,36 +503,120 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         return selection;
     }
 
-    /**
-     * Returns a new Iterable over all message views in this email view
-     */
-    internal Gee.Iterator<ConversationMessage> iterator() {
-        return new MessageViewIterator(this);
-    }
+    /** Displays the raw RFC 822 source for this email. */
+    public async void view_source() {
+        MainWindow? main = get_toplevel() as MainWindow;
+        if (main != null) {
+            Geary.Email email = this.email;
+            try {
+                yield Geary.Nonblocking.Concurrent.global.schedule_async(
+                    () => {
+                        string source = (
+                            email.header.buffer.to_string() +
+                            email.body.buffer.to_string()
+                        );
+                        string temporary_filename;
+                        int temporary_handle = GLib.FileUtils.open_tmp(
+                            "geary-message-XXXXXX.txt",
+                            out temporary_filename
+                        );
+                        GLib.FileUtils.set_contents(temporary_filename, source);
+                        GLib.FileUtils.close(temporary_handle);
 
-    private SimpleAction add_action(string name, bool enabled = true) {
-        SimpleAction action = new SimpleAction(name, null);
-        action.set_enabled(enabled);
-        message_actions.add_action(action);
-        return action;
-    }
+                        // ensure this file is only readable by the
+                        // user ... this needs to be done after the
+                        // file is closed
+                        GLib.FileUtils.chmod(
+                            temporary_filename,
+                            (int) (Posix.S_IRUSR | Posix.S_IWUSR)
+                        );
 
-    private bool get_action_enabled(string name) {
-        SimpleAction? action =
-            this.message_actions.lookup_action(name) as SimpleAction;
-        if (action != null) {
-            return action.get_enabled();
-        } else {
-            return false;
+                        string temporary_uri = GLib.Filename.to_uri(
+                            temporary_filename, null
+                        );
+                        main.application.show_uri.begin(temporary_uri);
+                    },
+                    null
+                );
+            } catch (GLib.Error error) {
+                main.application.controller.report_problem(
+                    new Geary.ProblemReport(error)
+                );
+            }
         }
     }
 
-    private void set_action_enabled(string name, bool enabled) {
-        SimpleAction? action =
-            this.message_actions.lookup_action(name) as SimpleAction;
-        if (action != null) {
-            action.set_enabled(enabled);
+    /** Print this view's email. */
+    public async void print() throws Error {
+        Json.Builder builder = new Json.Builder();
+        builder.begin_object();
+        if (this.email.from != null) {
+            builder.set_member_name(_("From:"));
+            builder.add_string_value(this.email.from.to_string());
+        }
+        if (this.email.to != null) {
+            // Translators: Human-readable version of the RFC 822 To header
+            builder.set_member_name(_("To:"));
+            builder.add_string_value(this.email.to.to_string());
+        }
+        if (this.email.cc != null) {
+            // Translators: Human-readable version of the RFC 822 CC header
+            builder.set_member_name(_("Cc:"));
+            builder.add_string_value(this.email.cc.to_string());
+        }
+        if (this.email.bcc != null) {
+            // Translators: Human-readable version of the RFC 822 BCC header
+            builder.set_member_name(_("Bcc:"));
+            builder.add_string_value(this.email.bcc.to_string());
+        }
+        if (this.email.date != null) {
+            // Translators: Human-readable version of the RFC 822 Date header
+            builder.set_member_name(_("Date:"));
+            builder.add_string_value(
+                Util.Date.pretty_print_verbose(
+                    this.email.date.value.to_local(),
+                    this.config.clock_format
+                )
+            );
+        }
+        if (this.email.subject != null) {
+            // Translators: Human-readable version of the RFC 822 Subject header
+            builder.set_member_name(_("Subject:"));
+            builder.add_string_value(this.email.subject.to_string());
+        }
+        builder.end_object();
+        Json.Generator generator = new Json.Generator();
+        generator.set_root(builder.get_root());
+        string js = "geary.addPrintHeaders(" + generator.to_data(null) + ");";
+        yield this.primary_message.web_view.run_javascript(js, null);
+
+        Gtk.Window? window = get_toplevel() as Gtk.Window;
+        WebKit.PrintOperation op = new WebKit.PrintOperation(
+            this.primary_message.web_view
+        );
+        Gtk.PrintSettings settings = new Gtk.PrintSettings();
+
+        if (this.email.subject != null) {
+            string file_name = Geary.String.reduce_whitespace(this.email.subject.value);
+            file_name = file_name.replace("/", "_");
+            if (file_name.char_count() > 128) {
+                file_name = Geary.String.safe_byte_substring(file_name, 128);
+            }
+
+            if (!Geary.String.is_empty(file_name)) {
+                settings.set(Gtk.PRINT_SETTINGS_OUTPUT_BASENAME, file_name);
+            }
         }
+
+        op.set_print_settings(settings);
+        op.run_dialog(window);
+    }
+
+    /**
+     * Returns a new Iterable over all message views in this email view
+     */
+    internal Gee.Iterator<ConversationMessage> iterator() {
+        return new MessageViewIterator(this);
     }
 
     private void connect_message_view_signals(ConversationMessage view) {
@@ -715,37 +735,103 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
     }
 
     private void update_email_state() {
-        Geary.EmailFlags? flags = this.email.email_flags;
         Gtk.StyleContext style = get_style_context();
 
-        bool is_unread = (flags != null && flags.is_unread());
-        set_action_enabled(ACTION_MARK_READ, is_unread);
-        set_action_enabled(ACTION_MARK_UNREAD, !is_unread);
-        set_action_enabled(ACTION_MARK_UNREAD_DOWN, !is_unread);
-        if (is_unread) {
+        if (this.is_unread) {
             style.add_class(UNREAD_CLASS);
         } else {
             style.remove_class(UNREAD_CLASS);
         }
 
-        bool is_flagged = (flags != null && flags.is_flagged());
-        set_action_enabled(ACTION_STAR, !this.is_collapsed && !is_flagged);
-        set_action_enabled(ACTION_UNSTAR, !this.is_collapsed && is_flagged);
-        if (is_flagged) {
+        if (this.is_starred) {
             style.add_class(STARRED_CLASS);
-            star_button.hide();
-            unstar_button.show();
+            this.star_button.hide();
+            this.unstar_button.show();
         } else {
             style.remove_class(STARRED_CLASS);
-            star_button.show();
-            unstar_button.hide();
+            this.star_button.show();
+            this.unstar_button.hide();
         }
 
-        if (flags != null && flags.is_outbox_sent()) {
+        if (this.email.email_flags != null &&
+            this.email.email_flags.is_outbox_sent()) {
             this.not_saved_infobar.show();
         }
+
+        update_email_menu();
+    }
+
+    private void update_email_menu() {
+        if (this.email_menubutton.active) {
+            bool in_base_folder = this.conversation.is_in_base_folder(
+                this.email.id
+            );
+            bool supports_trash = (
+                in_base_folder &&
+                Application.Controller.does_folder_support_trash(
+                    this.conversation.base_folder
+                )
+            );
+            bool supports_delete = (
+                in_base_folder &&
+                this.conversation.base_folder is Geary.FolderSupport.Remove
+            );
+            bool is_shift_down = false;
+            MainWindow? main = get_toplevel() as MainWindow;
+            if (main != null) {
+                is_shift_down = main.is_shift_down;
+
+                if (!this.shift_handler_installed) {
+                    this.shift_handler_installed = true;
+                    main.notify["is-shift-down"].connect(on_shift_changed);
+                }
+            }
+
+            string[] blacklist = {};
+            if (this.is_unread) {
+                blacklist += (
+                    ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." +
+                    ConversationListBox.ACTION_MARK_UNREAD
+                );
+                blacklist += (
+                    ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." +
+                    ConversationListBox.ACTION_MARK_UNREAD_DOWN
+                );
+            } else {
+                blacklist += (
+                    ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." +
+                    ConversationListBox.ACTION_MARK_READ
+                );
+            }
+
+            bool show_trash = !is_shift_down && supports_trash;
+            bool show_delete = !show_trash && supports_delete;
+            GLib.Variant email_target = email.id.to_variant();
+            GLib.Menu new_model = Util.Gtk.construct_menu(
+                email_menu_template,
+                (menu, submenu, action, item) => {
+                    bool accept = true;
+                    if (submenu == email_menu_trash_section && !show_trash) {
+                        accept = false;
+                    }
+                    if (submenu == email_menu_delete_section && !show_delete) {
+                        accept = false;
+                    }
+                    if (action != null && !(action in blacklist)) {
+                        item.set_action_and_target_value(
+                            action, email_target
+                        );
+                    }
+                    return accept;
+                }
+            );
+
+            this.email_menubutton.popover.bind_model(new_model, null);
+            this.email_menubutton.popover.grab_focus();
+        }
     }
 
+
     private void update_displayed_attachments() {
         bool has_attachments = !this.displayed_attachments.is_empty;
         this.attachments_button.set_visible(has_attachments);
@@ -787,130 +873,22 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         return (this.email_store.account.incoming.current_status == CONNECTED);
     }
 
-    /**
-     * Updates the email menu if it is open.
-     */
-    private void on_email_menu() {
-        if (this.email_menubutton.active) {
-            this.email_menu.remove_all();
-
-            bool supports_trash = get_action_enabled(ACTION_TRASH_MESSAGE);
-            bool supports_delete = get_action_enabled(ACTION_DELETE_MESSAGE);
-            bool show_trash_button = !this.shift_key_down && (supports_trash || !supports_delete);
-            Util.Gtk.menu_foreach(this.email_menu_model, (label, name, target, section) => {
-                if ((section != this.email_menu_trash || show_trash_button) &&
-                    (section != this.email_menu_delete || !show_trash_button)) {
-                    this.email_menu.append_item(new MenuItem.section(label, section));
-                }
-            });
+    private void activate_email_action(string name) {
+        GLib.ActionGroup? email_actions = get_action_group(
+            ConversationListBox.EMAIL_ACTION_GROUP_NAME
+        );
+        if (email_actions != null) {
+            email_actions.activate_action(name, this.email.id.to_variant());
         }
     }
 
-    private async void view_source() {
-        MainWindow? main = get_toplevel() as MainWindow;
-        if (main != null) {
-            Geary.Email email = this.email;
-            try {
-                yield Geary.Nonblocking.Concurrent.global.schedule_async(
-                    () => {
-                        string source = (
-                            email.header.buffer.to_string() +
-                            email.body.buffer.to_string()
-                        );
-                        string temporary_filename;
-                        int temporary_handle = GLib.FileUtils.open_tmp(
-                            "geary-message-XXXXXX.txt",
-                            out temporary_filename
-                        );
-                        GLib.FileUtils.set_contents(temporary_filename, source);
-                        GLib.FileUtils.close(temporary_handle);
-
-                        // ensure this file is only readable by the
-                        // user ... this needs to be done after the
-                        // file is closed
-                        GLib.FileUtils.chmod(
-                            temporary_filename,
-                            (int) (Posix.S_IRUSR | Posix.S_IWUSR)
-                        );
-
-                        string temporary_uri = GLib.Filename.to_uri(
-                            temporary_filename, null
-                        );
-                        main.application.show_uri.begin(temporary_uri);
-                    },
-                    null
-                );
-            } catch (GLib.Error error) {
-                main.application.controller.report_problem(
-                    new Geary.ProblemReport(error)
-                );
-            }
-        }
+    [GtkCallback]
+    private void on_email_menu() {
+        update_email_menu();
     }
 
-    private async void print() throws Error {
-        Json.Builder builder = new Json.Builder();
-        builder.begin_object();
-        if (this.email.from != null) {
-            builder.set_member_name(_("From:"));
-            builder.add_string_value(this.email.from.to_string());
-        }
-        if (this.email.to != null) {
-            // Translators: Human-readable version of the RFC 822 To header
-            builder.set_member_name(_("To:"));
-            builder.add_string_value(this.email.to.to_string());
-        }
-        if (this.email.cc != null) {
-            // Translators: Human-readable version of the RFC 822 CC header
-            builder.set_member_name(_("Cc:"));
-            builder.add_string_value(this.email.cc.to_string());
-        }
-        if (this.email.bcc != null) {
-            // Translators: Human-readable version of the RFC 822 BCC header
-            builder.set_member_name(_("Bcc:"));
-            builder.add_string_value(this.email.bcc.to_string());
-        }
-        if (this.email.date != null) {
-            // Translators: Human-readable version of the RFC 822 Date header
-            builder.set_member_name(_("Date:"));
-            builder.add_string_value(
-                Util.Date.pretty_print_verbose(
-                    this.email.date.value.to_local(),
-                    this.config.clock_format
-                )
-            );
-        }
-        if (this.email.subject != null) {
-            // Translators: Human-readable version of the RFC 822 Subject header
-            builder.set_member_name(_("Subject:"));
-            builder.add_string_value(this.email.subject.to_string());
-        }
-        builder.end_object();
-        Json.Generator generator = new Json.Generator();
-        generator.set_root(builder.get_root());
-        string js = "geary.addPrintHeaders(" + generator.to_data(null) + ");";
-        yield this.primary_message.web_view.run_javascript(js, null);
-
-        Gtk.Window? window = get_toplevel() as Gtk.Window;
-        WebKit.PrintOperation op = new WebKit.PrintOperation(
-            this.primary_message.web_view
-        );
-        Gtk.PrintSettings settings = new Gtk.PrintSettings();
-
-        if (this.email.subject != null) {
-            string file_name = Geary.String.reduce_whitespace(this.email.subject.value);
-            file_name = file_name.replace("/", "_");
-            if (file_name.char_count() > 128) {
-                file_name = Geary.String.safe_byte_substring(file_name, 128);
-            }
-
-            if (!Geary.String.is_empty(file_name)) {
-                settings.set(Gtk.PRINT_SETTINGS_OUTPUT_BASENAME, file_name);
-            }
-        }
-
-        op.set_print_settings(settings);
-        op.run_dialog(window);
+    private void on_shift_changed() {
+        update_email_menu();
     }
 
     private void on_body_loading_timeout() {
@@ -921,12 +899,8 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         this.body_loading_timeout.reset();
     }
 
-    private void on_flag_remote_images(ConversationMessage view) {
-        if (!email.email_flags.contains(Geary.EmailFlags.LOAD_REMOTE_IMAGES)) {
-            // Don't pass a cancellable in to make sure the flag is
-            // always saved
-            mark_email(Geary.EmailFlags.LOAD_REMOTE_IMAGES, null);
-        }
+    private void on_flag_remote_images() {
+        activate_email_action(ConversationListBox.ACTION_MARK_LOAD_REMOTE);
     }
 
     private void on_save_image(string uri,
@@ -1000,10 +974,6 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
         }
     }
 
-    private void on_view_source() {
-        this.view_source.begin();
-    }
-
     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-list-box.vala 
b/src/client/conversation-viewer/conversation-list-box.vala
index a2a0f62b..52ca7abf 100644
--- a/src/client/conversation-viewer/conversation-list-box.vala
+++ b/src/client/conversation-viewer/conversation-list-box.vala
@@ -30,6 +30,24 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         Geary.Email.Field.ORIGINATORS
     );
 
+    internal const string EMAIL_ACTION_GROUP_NAME = "eml";
+
+    internal const string ACTION_DELETE = "delete";
+    internal const string ACTION_EDIT = "edit";
+    internal const string ACTION_FORWARD = "forward";
+    internal const string ACTION_MARK_LOAD_REMOTE = "mark-load-remote";
+    internal const string ACTION_MARK_READ = "mark-read";
+    internal const string ACTION_MARK_STARRED = "mark-starred";
+    internal const string ACTION_MARK_UNREAD = "mark-unread";
+    internal const string ACTION_MARK_UNREAD_DOWN = "mark-unread-down";
+    internal const string ACTION_MARK_UNSTARRED = "mark-unstarred";
+    internal const string ACTION_PRINT = "print";
+    internal const string ACTION_REPLY_ALL = "reply-all";
+    internal const string ACTION_REPLY_SENDER = "reply-sender";
+    internal const string ACTION_SAVE_ALL_ATTACHMENTS = "save-all-attachments";
+    internal const string ACTION_TRASH = "trash";
+    internal const string ACTION_VIEW_SOURCE = "view-source";
+
     // Offset from the top of the list box which emails views will
     // scrolled to, so the user can see there are additional messages
     // above it. XXX This is currently approx 0.5 times the height of
@@ -47,6 +65,27 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
     // mark it as read
     private const int MARK_READ_PADDING = 50;
 
+    private const string ACTION_TARGET_TYPE = (
+        Geary.EmailIdentifier.BASE_VARIANT_TYPE
+    );
+    private const ActionEntry[] email_action_entries = {
+        { ACTION_DELETE, on_email_delete, ACTION_TARGET_TYPE },
+        { ACTION_EDIT, on_email_edit, ACTION_TARGET_TYPE },
+        { ACTION_FORWARD, on_email_forward, ACTION_TARGET_TYPE },
+        { ACTION_MARK_LOAD_REMOTE, on_email_load_remote, ACTION_TARGET_TYPE },
+        { ACTION_MARK_READ, on_email_mark_read, ACTION_TARGET_TYPE },
+        { ACTION_MARK_STARRED, on_email_mark_starred, ACTION_TARGET_TYPE },
+        { ACTION_MARK_UNREAD, on_email_mark_unread, ACTION_TARGET_TYPE },
+        { ACTION_MARK_UNREAD_DOWN, on_email_mark_unread_down, ACTION_TARGET_TYPE },
+        { ACTION_MARK_UNSTARRED, on_email_mark_unstarred, ACTION_TARGET_TYPE },
+        { ACTION_PRINT, on_email_print, ACTION_TARGET_TYPE },
+        { ACTION_REPLY_ALL, on_email_reply_all, ACTION_TARGET_TYPE },
+        { ACTION_REPLY_SENDER, on_email_reply_sender, ACTION_TARGET_TYPE },
+        { ACTION_SAVE_ALL_ATTACHMENTS, on_email_save_all_attachments, ACTION_TARGET_TYPE },
+        { ACTION_TRASH, on_email_trash, ACTION_TARGET_TYPE },
+        { ACTION_VIEW_SOURCE, on_email_view_source, ACTION_TARGET_TYPE },
+    };
+
 
     /** Manages find/search term matching in a conversation. */
     public class SearchManager : Geary.BaseObject {
@@ -492,6 +531,8 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
 
     private Geary.TimeoutManager mark_read_timer;
 
+    private GLib.SimpleActionGroup email_actions = new GLib.SimpleActionGroup();
+
 
     /** Keyboard action to scroll the conversation. */
     [Signal (action=true)]
@@ -536,19 +577,28 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         this.mark_read_timer.start();
     }
 
-    /** Fired when an email view is added to the conversation list. */
-    public signal void email_added(ConversationEmail email);
+    /** Fired when the user clicks "reply" in the message menu. */
+    public signal void reply_to_sender_email(Geary.Email email, string? quote);
 
-    /** Fired when an email view is removed from the conversation list. */
-    public signal void email_removed(ConversationEmail email);
+    /** Fired when the user clicks "reply all" in the message menu. */
+    public signal void reply_to_all_email(Geary.Email email, string? quote);
 
-    /** Fired when the user updates the flags for a set of emails. */
-    public signal void mark_emails(
-        Geary.App.Conversation conversation,
-        Gee.Collection<Geary.EmailIdentifier> emails,
-        Geary.EmailFlags? flags_to_add,
-        Geary.EmailFlags? flags_to_remove
-    );
+    /** Fired when the user clicks "forward" in the message menu. */
+    public signal void forward_email(Geary.Email email, string? quote);
+
+    /** Emitted when email message flags are to be updated. */
+    public signal void mark_email(Gee.Collection<Geary.EmailIdentifier> email,
+                                  Geary.NamedFlag? to_add,
+                                  Geary.NamedFlag? to_remove);
+
+    /** Fired when the user clicks "trash" in the message menu. */
+    public signal void trash_email(Geary.Email email);
+
+    /** Fired when the user clicks "delete" in the message menu. */
+    public signal void delete_email(Geary.Email email);
+
+    /** Fired the edit draft button is clicked. */
+    public signal void edit_email(Geary.Email email);
 
 
     /**
@@ -579,6 +629,9 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         set_adjustment(adjustment);
         set_sort_func(ConversationListBox.on_sort);
 
+        this.email_actions.add_action_entries(email_action_entries, this);
+        insert_action_group(EMAIL_ACTION_GROUP_NAME, this.email_actions);
+
         this.row_activated.connect(on_row_activated);
 
         this.conversation.appended.connect(on_conversation_appended);
@@ -923,6 +976,7 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         }
 
         ConversationEmail view = new ConversationEmail(
+            conversation,
             email,
             this.email_store,
             this.contacts,
@@ -931,8 +985,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
             is_draft(email),
             this.cancellable
         );
-        view.mark_email.connect(on_mark_email);
-        view.mark_email_from_here.connect(on_mark_email_from_here);
         view.internal_link_activated.connect(on_internal_link_activated);
         view.body_selection_changed.connect((email, has_selection) => {
                 this.body_selected_view = has_selection ? email : null;
@@ -957,7 +1009,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         } else {
             insert(row, 0);
         }
-        email_added(view);
 
         return row;
     }
@@ -967,7 +1018,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         EmailRow? row = null;
         if (this.email_rows.unset(email.id, out row)) {
             remove(row);
-            email_removed(row.view);
         }
     }
 
@@ -1048,9 +1098,7 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         });
 
         if (email_ids.size > 0) {
-            Geary.EmailFlags flags = new Geary.EmailFlags();
-            flags.add(Geary.EmailFlags.UNREAD);
-            mark_emails(this.conversation, email_ids, null, flags);
+            mark_email(email_ids, null, Geary.EmailFlags.UNREAD);
         }
     }
 
@@ -1098,6 +1146,19 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         );
     }
 
+    private ConversationEmail action_target_to_view(GLib.Variant target) {
+        Geary.EmailIdentifier? id = null;
+        try {
+            id = this.conversation.base_folder.account.to_email_identifier(target);
+        } catch (Geary.EngineError err) {
+            debug("Failed to get email id for action target: %s", err.message);
+        }
+        debug("XXX have id? %s", (id != null).to_string());
+        EmailRow? row = (id != null) ? this.email_rows[id] : null;
+        debug("XXX have row? %s", (row != null).to_string());
+        return (row != null) ? row.view : null;
+    }
+
     private void on_conversation_appended(Geary.App.Conversation conversation,
                                           Geary.Email email) {
         on_conversation_appended_async.begin(conversation, email);
@@ -1135,44 +1196,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         row.view.update_flags(email);
     }
 
-    private void on_mark_email(ConversationEmail view,
-                               Geary.NamedFlag? to_add,
-                               Geary.NamedFlag? to_remove) {
-        Gee.Collection<Geary.EmailIdentifier> ids =
-            new Gee.LinkedList<Geary.EmailIdentifier>();
-        ids.add(view.email.id);
-        mark_emails(
-            this.conversation,
-            ids,
-            flag_to_flags(to_add),
-            flag_to_flags(to_remove)
-        );
-    }
-
-    private void on_mark_email_from_here(ConversationEmail view,
-                                         Geary.NamedFlag? to_add,
-                                         Geary.NamedFlag? to_remove) {
-        Geary.Email email = view.email;
-        Gee.Collection<Geary.EmailIdentifier> ids =
-            new Gee.LinkedList<Geary.EmailIdentifier>();
-        ids.add(email.id);
-        this.foreach((row) => {
-                if (row.get_visible()) {
-                    Geary.Email other = ((EmailRow) row).view.email;
-                    if (Geary.Email.compare_sent_date_ascending(
-                            email, other) < 0) {
-                        ids.add(other.id);
-                    }
-                }
-            });
-        mark_emails(
-            this.conversation,
-            ids,
-            flag_to_flags(to_add),
-            flag_to_flags(to_remove)
-        );
-    }
-
     private void on_message_body_state_notify(GLib.Object obj,
                                               GLib.ParamSpec param) {
         ConversationEmail? view = obj as ConversationEmail;
@@ -1181,15 +1204,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         }
     }
 
-    private Geary.EmailFlags? flag_to_flags(Geary.NamedFlag? flag) {
-        Geary.EmailFlags flags = null;
-        if (flag != null) {
-            flags = new Geary.EmailFlags();
-            flags.add(flag);
-        }
-        return flags;
-    }
-
     private void on_row_activated(Gtk.ListBoxRow widget) {
         EmailRow? row = widget as EmailRow;
         if (row != null) {
@@ -1212,4 +1226,169 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         scroll_to_anchor(row, y);
     }
 
+    // Email action callbacks
+
+    private void on_email_reply_sender(GLib.SimpleAction action,
+                                       GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.get_selection_for_quoting.begin((obj, res) => {
+                    string? quote = view.get_selection_for_quoting.end(res);
+                    reply_to_sender_email(view.email, quote);
+                });
+        }
+    }
+
+    private void on_email_reply_all(GLib.SimpleAction action,
+                                    GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.get_selection_for_quoting.begin((obj, res) => {
+                    string? quote = view.get_selection_for_quoting.end(res);
+                    reply_to_all_email(view.email, quote);
+                });
+        }
+    }
+
+    private void on_email_forward(GLib.SimpleAction action,
+                                  GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.get_selection_for_quoting.begin((obj, res) => {
+                    string? quote = view.get_selection_for_quoting.end(res);
+                    forward_email(view.email, quote);
+                });
+        }
+    }
+
+    private void on_email_mark_read(GLib.SimpleAction action,
+                                    GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                null,
+                Geary.EmailFlags.UNREAD
+            );
+        }
+    }
+
+    private void on_email_mark_unread(GLib.SimpleAction action,
+                                      GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                Geary.EmailFlags.UNREAD,
+                null
+            );
+        }
+    }
+
+    private void on_email_mark_unread_down(GLib.SimpleAction action,
+                                           GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            Geary.Email email = view.email;
+            var ids = new Gee.LinkedList<Geary.EmailIdentifier>();
+            ids.add(email.id);
+            this.foreach((row) => {
+                    if (row.get_visible()) {
+                        Geary.Email other = ((EmailRow) row).view.email;
+                        if (Geary.Email.compare_sent_date_ascending(
+                                email, other) < 0) {
+                            ids.add(other.id);
+                        }
+                    }
+                });
+            mark_email(ids, Geary.EmailFlags.UNREAD, null);
+        }
+    }
+
+    private void on_email_mark_starred(GLib.SimpleAction action,
+                                       GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                Geary.EmailFlags.FLAGGED,
+                null
+            );
+        }
+    }
+
+    private void on_email_mark_unstarred(GLib.SimpleAction action,
+                                         GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                null,
+                Geary.EmailFlags.FLAGGED
+            );
+        }
+    }
+
+    private void on_email_load_remote(GLib.SimpleAction action,
+                                      GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                Geary.EmailFlags.LOAD_REMOTE_IMAGES,
+                null
+            );
+        }
+    }
+
+    private void on_email_edit(GLib.SimpleAction action,
+                               GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            edit_email(view.email);
+        }
+    }
+
+    private void on_email_trash(GLib.SimpleAction action,
+                                GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            trash_email(view.email);
+        }
+    }
+
+    private void on_email_delete(GLib.SimpleAction action,
+                                 GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            delete_email(view.email);
+        }
+    }
+
+    private void on_email_save_all_attachments(GLib.SimpleAction action,
+                                               GLib.Variant? param) {
+        debug("XXX save all: %s", param.print(true));
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null && view.attachments_pane != null) {
+            debug("XXX really save all");
+            view.attachments_pane.save_all();
+        }
+    }
+
+    private void on_email_print(GLib.SimpleAction action,
+                                GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.print.begin();
+        }
+    }
+
+    private void on_email_view_source(GLib.SimpleAction action,
+                                      GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.view_source.begin();
+        }
+    }
+
 }
diff --git a/src/client/util/util-gtk.vala b/src/client/util/util-gtk.vala
index e25c319c..798ad0ed 100644
--- a/src/client/util/util-gtk.vala
+++ b/src/client/util/util-gtk.vala
@@ -84,6 +84,83 @@ namespace Util.Gtk {
         return widget.get_allocated_height() - margin.top - margin.bottom;
     }
 
+    /**
+     * Constructs a frozen GMenu from an existing model using a visitor.
+     *
+     * The visitor is applied to the given template model to each of
+     * its items, or recursively for any section or submenu. If the
+     * visitor returns false when passed an item, section or submenu
+     * then it will be skipped, otherwise it will be added to a new
+     * menu.
+     *
+     * The constructed menu will be returned frozen.
+     *
+     * @see MenuVisitor
+     */
+    public GLib.Menu construct_menu(GLib.MenuModel template,
+                                    MenuVisitor visitor) {
+        GLib.Menu model = new GLib.Menu();
+        for (int i = 0; i < template.get_n_items(); i++) {
+            GLib.MenuItem item = new GLib.MenuItem.from_model(template, i);
+            string? action = null;
+            GLib.Variant? action_value = item.get_attribute_value(
+                GLib.Menu.ATTRIBUTE_ACTION, GLib.VariantType.STRING
+            );
+            if (action_value != null) {
+                action = (string) action_value;
+            }
+            GLib.Menu? section = (GLib.Menu) item.get_link(
+                GLib.Menu.LINK_SECTION
+            );
+            GLib.Menu? submenu = (GLib.Menu) item.get_link(
+                GLib.Menu.LINK_SUBMENU
+            );
+
+            bool append = false;
+            if (section != null) {
+                if (visitor(template, section, action, item)) {
+                    append = true;
+                    section = construct_menu(section, visitor);
+                    item.set_section(section);
+                }
+            } else if (submenu != null) {
+                if (visitor(template, submenu, action, item)) {
+                    append = true;
+                    submenu = construct_menu(submenu, visitor);
+                    item.set_submenu(submenu);
+                }
+            } else {
+                append = visitor(template, null, action, item);
+            }
+
+            if (append) {
+                model.append_item(item);
+            }
+        }
+        model.freeze();
+        return model;
+    }
+
+    /**
+     * Visitor for {@link construct_menu}.
+     *
+     * Implementations should return true to accept the given child
+     * menu or menu item, causing it to be included in the new model,
+     * or false to reject it and cause it to be skipped.
+     *
+     * @param existing_menu - current menu or submenu being visited
+     * @param existing_child_menu - if not null, a child menu that is
+     * about to be descended into
+     * @param existing_action - existing fully qualified action name
+     * of the curent item, if any
+     * @param new_item - copy of the menu item being visited, which if
+     * accepted will be added to the new model
+     */
+    public delegate bool MenuVisitor(GLib.MenuModel existing_menu,
+                                     GLib.MenuModel? existing_child_menu,
+                                     string? existing_action,
+                                     GLib.MenuItem? new_item);
+
     /** Copies a GLib menu, setting targets for the given actions. */
     public GLib.Menu copy_menu_with_targets(GLib.Menu template,
                                             string group,
diff --git a/ui/conversation-email-menus.ui b/ui/conversation-email-menus.ui
index ecf3176d..5681cec0 100644
--- a/ui/conversation-email-menus.ui
+++ b/ui/conversation-email-menus.ui
@@ -7,13 +7,13 @@
       <item>
         <!-- Translators: Menu item to reply to a specific message. -->
         <attribute name="label" translatable="yes">_Reply</attribute>
-        <attribute name="action">eml.reply_sender</attribute>
+        <attribute name="action">eml.reply-sender</attribute>
         <attribute name="verb-icon">mail-reply-sender-symbolic</attribute>
       </item>
       <item>
         <!-- Translators: Menu item to reply to a specific message. -->
         <attribute name="label" translatable="yes">Reply to _All</attribute>
-        <attribute name="action">eml.reply_all</attribute>
+        <attribute name="action">eml.reply-all</attribute>
         <attribute name="verb-icon">mail-reply-all-symbolic</attribute>
       </item>
       <item>
@@ -28,19 +28,19 @@
         <!-- Translators: Menu item to mark a specific message as
              read. -->
         <attribute name="label" translatable="yes">_Mark Read</attribute>
-        <attribute name="action">eml.mark_read</attribute>
+        <attribute name="action">eml.mark-read</attribute>
       </item>
         <!-- Translators: Menu item to mark a specific message as
              unread. -->
       <item>
         <attribute name="label" translatable="yes">_Mark Unread</attribute>
-        <attribute name="action">eml.mark_unread</attribute>
+        <attribute name="action">eml.mark-unread</attribute>
       </item>
       <item>
         <!-- Translators: Menu item to mark all messages in a
              conversation from this one as unread. -->
         <attribute name="label" translatable="yes">Mark Unread From _Here</attribute>
-        <attribute name="action">eml.mark_unread_down</attribute>
+        <attribute name="action">eml.mark-unread-down</attribute>
       </item>
     </section>
     <section id="email_menu_trash">
@@ -48,14 +48,14 @@
         <!-- Translators: Menu item to move a single, specific message
              to the trash folder -->
         <attribute name="label" translatable="yes">Move message to _Trash</attribute>
-        <attribute name="action">eml.trash_msg</attribute>
+        <attribute name="action">eml.trash</attribute>
       </item>
     </section>
     <section id="email_menu_delete">
       <item>
         <!-- Translators: Menu item to delete a single, specific message -->
         <attribute name="label" translatable="yes">_Delete messageā€¦</attribute>
-        <attribute name="action">eml.delete_msg</attribute>
+        <attribute name="action">eml.delete</attribute>
       </item>
     </section>
     <section>
@@ -67,7 +67,7 @@
       <item>
         <!-- Translators: Menu item to view the source for a message -->
         <attribute name="label" translatable="yes">_View Source</attribute>
-        <attribute name="action">eml.view_source</attribute>
+        <attribute name="action">eml.view-source</attribute>
       </item>
     </section>
   </menu>
diff --git a/ui/conversation-email.ui b/ui/conversation-email.ui
index 156f1b0f..f3955dd0 100644
--- a/ui/conversation-email.ui
+++ b/ui/conversation-email.ui
@@ -26,7 +26,7 @@
         <property name="receives_default">True</property>
         <property name="tooltip_text" translatable="yes">Save all attachments</property>
         <property name="valign">start</property>
-        <property name="action_name">eml.save_all_attachments</property>
+        <property name="action_name">eml.save-all-attachments</property>
         <property name="relief">none</property>
         <child>
           <object class="GtkImage">
@@ -49,7 +49,7 @@
         <property name="receives_default">True</property>
         <property name="tooltip_text" translatable="yes" comments="Note: The application will never show 
this button at the same time as unstar_button, one will always be hidden.">Mark this message as 
starred</property>
         <property name="valign">start</property>
-        <property name="action_name">eml.star</property>
+        <property name="action_name">eml.mark-starred</property>
         <property name="relief">none</property>
         <child>
           <object class="GtkImage">
@@ -71,7 +71,7 @@
         <property name="receives_default">True</property>
         <property name="tooltip_text" translatable="yes" comments="Note: The application will never show 
this button at the same time as star_button, one will always be hidden.">Mark this message as not 
starred</property>
         <property name="valign">start</property>
-        <property name="action_name">eml.unstar</property>
+        <property name="action_name">eml.mark-unstarred</property>
         <property name="relief">none</property>
         <child>
           <object class="GtkImage">
@@ -92,9 +92,9 @@
         <property name="sensitive">False</property>
         <property name="can_focus">True</property>
         <property name="receives_default">True</property>
-        <property name="tooltip_text" translatable="yes">Display the message menu</property>
         <property name="valign">start</property>
         <property name="relief">none</property>
+        <signal name="toggled" handler="on_email_menu" swapped="no"/>
         <child>
           <object class="GtkImage">
             <property name="visible">True</property>


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