/* * Copyright © 2016 Software Freedom Conservancy Inc. * Copyright © 2016, 2019-2021 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. */ [GtkTemplate (ui = "/org/gnome/Geary/application-main-window.ui")] public class Application.MainWindow : Hdy.ApplicationWindow, Geary.BaseInterface { // Named actions. public const string ACTION_ARCHIVE_CONVERSATION = "archive-conversation"; public const string ACTION_CONVERSATION_DOWN = "down-conversation"; public const string ACTION_CONVERSATION_UP = "up-conversation"; public const string ACTION_DELETE_CONVERSATION = "delete-conversation"; public const string ACTION_FIND_IN_CONVERSATION = "find-in-conversation"; public const string ACTION_FORWARD_CONVERSATION = "forward-conversation"; public const string ACTION_MARK_AS_READ = "mark-conversation-read"; public const string ACTION_MARK_AS_STARRED = "mark-conversation-starred"; public const string ACTION_MARK_AS_UNREAD = "mark-conversation-unread"; public const string ACTION_MARK_AS_UNSTARRED = "mark-conversation-unstarred"; public const string ACTION_REPLY_ALL_CONVERSATION = "reply-all-conversation"; public const string ACTION_REPLY_CONVERSATION = "reply-conversation"; public const string ACTION_SEARCH = "search"; public const string ACTION_SELECT_INBOX = "select-inbox"; public const string ACTION_SHOW_COPY_MENU = "show-copy-menu"; public const string ACTION_SHOW_MARK_MENU = "show-mark-menu"; public const string ACTION_SHOW_MOVE_MENU = "show-move-menu"; public const string ACTION_TOGGLE_JUNK = "toggle-conversation-junk"; public const string ACTION_TRASH_CONVERSATION = "trash-conversation"; public const string ACTION_ZOOM = "zoom"; public const string ACTION_NAVIGATION_BACK = "navigation-back"; private const ActionEntry[] EDIT_ACTIONS = { { Action.Edit.UNDO, on_undo }, { Action.Edit.REDO, on_redo }, }; private const ActionEntry[] WINDOW_ACTIONS = { { Action.Window.CLOSE, on_close }, { Action.Window.SHOW_HELP_OVERLAY, on_show_help_overlay }, { Action.Window.SHOW_MENU, on_show_window_menu }, { ACTION_FIND_IN_CONVERSATION, on_find_in_conversation_action }, { ACTION_SEARCH, on_search_activated }, { ACTION_SELECT_INBOX, on_select_inbox, "i" }, { ACTION_NAVIGATION_BACK, focus_previous_pane}, // Message actions { ACTION_REPLY_CONVERSATION, on_reply_conversation }, { ACTION_REPLY_ALL_CONVERSATION, on_reply_all_conversation }, { ACTION_FORWARD_CONVERSATION, on_forward_conversation }, { ACTION_ARCHIVE_CONVERSATION, on_archive_conversation }, { ACTION_TRASH_CONVERSATION, on_trash_conversation }, { ACTION_DELETE_CONVERSATION, on_delete_conversation }, { ACTION_SHOW_COPY_MENU, on_show_copy_menu }, { ACTION_SHOW_MOVE_MENU, on_show_move_menu }, { ACTION_CONVERSATION_UP, on_conversation_up }, { ACTION_CONVERSATION_DOWN, on_conversation_down }, // Message marking actions { ACTION_SHOW_MARK_MENU, on_show_mark_menu }, { ACTION_MARK_AS_READ, on_mark_as_read }, { ACTION_MARK_AS_UNREAD, on_mark_as_unread }, { ACTION_MARK_AS_STARRED, on_mark_as_starred }, { ACTION_MARK_AS_UNSTARRED, on_mark_as_unstarred }, { ACTION_TOGGLE_JUNK, on_mark_as_junk_toggle }, // Message viewer { ACTION_ZOOM, on_zoom, "s" }, }; // Handy leaflet children names private const string INNER_LEAFLET = "inner_leaflet"; private const string FOLDER_LIST = "folder_list"; private const string CONVERSATION_LIST = "conversation_list"; private const string CONVERSATION_VIEWER = "conversation_viewer"; private const int STATUS_BAR_HEIGHT = 18; private const int UPDATE_UI_INTERVAL = 60; private const int MIN_CONVERSATION_COUNT = 50; static construct { // Set up default keybindings unowned Gtk.BindingSet bindings = Gtk.BindingSet.by_class( (ObjectClass) typeof(MainWindow).class_ref() ); // // Replying & forwarding Gtk.BindingEntry.add_signal( bindings, Gdk.Key.R, CONTROL_MASK, "reply-conversation-sender", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.R, CONTROL_MASK | SHIFT_MASK, "reply-conversation-all", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.L, CONTROL_MASK, "forward-conversation", 0 ); // Marking actions // // Unread is the primary action, so it doesn't get the // modifier Gtk.BindingEntry.add_signal( bindings, Gdk.Key.U, CONTROL_MASK, "mark-conversations-read", 1, typeof(bool), true ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.U, CONTROL_MASK | SHIFT_MASK, "mark-conversations-read", 1, typeof(bool), false ); // Ephy uses Ctrl+D for bookmarking Gtk.BindingEntry.add_signal( bindings, Gdk.Key.D, CONTROL_MASK, "mark-conversations-starred", 1, typeof(bool), true ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.D, CONTROL_MASK | SHIFT_MASK, "mark-conversations-starred", 1, typeof(bool), false ); // // Moving & labelling Gtk.BindingEntry.add_signal( bindings, Gdk.Key.B, CONTROL_MASK, "show-copy-menu", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.M, CONTROL_MASK, "show-move-menu", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.K, CONTROL_MASK, "archive-conversations", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.J, CONTROL_MASK, "junk-conversations", 0 ); // Many ways to trash Gtk.BindingEntry.add_signal( bindings, Gdk.Key.BackSpace, 0, "trash-conversations", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.Delete, 0, "trash-conversations", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.KP_Delete, 0, "trash-conversations", 0 ); // Many ways to delete Gtk.BindingEntry.add_signal( bindings, Gdk.Key.BackSpace, SHIFT_MASK, "delete-conversations", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.Delete, SHIFT_MASK, "delete-conversations", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.KP_Delete, SHIFT_MASK, "delete-conversations", 0 ); // // Find & search Gtk.BindingEntry.add_signal( bindings, Gdk.Key.F, CONTROL_MASK, "find", 0 ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.S, CONTROL_MASK, "search", 0 ); // // Navigation Gtk.BindingEntry.add_signal( bindings, Gdk.Key.Left, MOD1_MASK, "navigate", 1, typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_LEFT ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.Back, 0, "navigate", 1, typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_LEFT ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.Right, MOD1_MASK, "navigate", 1, typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_RIGHT ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.Forward, 0, "navigate", 1, typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_RIGHT ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.comma, CONTROL_MASK, "navigate", 1, typeof(Gtk.ScrollType), Gtk.ScrollType.STEP_UP ); Gtk.BindingEntry.add_signal( bindings, Gdk.Key.period, CONTROL_MASK, "navigate", 1, typeof(Gtk.ScrollType), Gtk.ScrollType.STEP_DOWN ); } public static void add_accelerators(Client owner) { for (int i = 1; i <= 9; i++) { owner.add_window_accelerators( ACTION_SELECT_INBOX+("(%d)".printf(i - 1)), { "%d".printf(i) } ); } // Zoom owner.add_window_accelerators( ACTION_ZOOM+("('in')"), { "equal", "plus" } ); owner.add_window_accelerators( ACTION_ZOOM+("('out')"), { "minus" } ); owner.add_window_accelerators( ACTION_ZOOM+("('normal')"), { "0" } ); } private enum ConversationCount { NONE, SINGLE, MULTIPLE; } /** Returns the window's associated client application instance. */ public new Client application { get { return (Client) base.get_application(); } set { base.set_application(value); } } /** Currently selected account, null if none selected */ public Geary.Account? selected_account { get; private set; default = null; } /** Currently selected folder, null if none selected */ public Geary.Folder? selected_folder { get; private set; default = null; } /** Conversations for the current folder, null if none selected */ public Geary.App.ConversationMonitor? conversations { get; private set; default = null; } /** Specifies if the conversation list is currently displayed. */ public bool is_folder_list_shown { get { return ( (!this.outer_leaflet.folded || this.outer_leaflet.visible_child_name == INNER_LEAFLET) && (!this.inner_leaflet.folded || this.inner_leaflet.visible_child_name == FOLDER_LIST) ); } } /** Specifies if the conversation list is currently displayed. */ public bool is_conversation_list_shown { get { return ( (!this.outer_leaflet.folded || this.outer_leaflet.visible_child_name == INNER_LEAFLET) && (!this.inner_leaflet.folded || this.inner_leaflet.visible_child_name == CONVERSATION_LIST) ); } } /** Specifies if the conversation viewer is currently displayed. */ public bool is_conversation_viewer_shown { get { return ( (!this.outer_leaflet.folded || this.outer_leaflet.visible_child_name == CONVERSATION_VIEWER) && !this.has_composer ); } } /** The attachment manager for this window. */ public AttachmentManager attachments { get; private set; } /** Determines if conversations in the selected folder can be trashed. */ public bool selected_folder_supports_trash { get { return Controller.does_folder_support_trash(this.selected_folder); } } /** Determines if a composer is currently open in this window. */ public bool has_composer { get { return (this.conversation_viewer.current_composer != null); } } /** Specifies if the Shift key is currently being held. */ public bool is_shift_down { get; private set; default = false; } // Used to save/load the window state between sessions. public int window_width { get; set; } public int window_height { get; set; } public bool window_maximized { get; set; } // Widget descendants public FolderList.Tree folder_list { get; private set; default = new FolderList.Tree(); } public Components.MainToolbar main_toolbar { get; private set; } public SearchBar search_bar { get; private set; } public ConversationListView conversation_list_view { get; private set; } public ConversationViewer conversation_viewer { get; private set; } public Components.InfoBarStack conversation_list_info_bars { get; private set; default = new Components.InfoBarStack(PRIORITY_QUEUE); } public StatusBar status_bar { get; private set; default = new StatusBar(); } private Controller controller; private MonitoredSpinner spinner = new MonitoredSpinner(); private Gee.Set accounts = new Gee.HashSet(); private GLib.SimpleActionGroup edit_actions = new GLib.SimpleActionGroup(); // Determines if the conversation viewer should auto-mark messages // on next load private bool previous_selection_was_interactive = false; // Caches the last non-search folder so it can be re-selected on // the search folder closing private Geary.Folder? previous_non_search_folder = null; private Geary.AggregateProgressMonitor progress_monitor = new Geary.AggregateProgressMonitor(); private GLib.Cancellable action_update_cancellable = new GLib.Cancellable(); private GLib.Cancellable folder_open = new GLib.Cancellable(); private Geary.TimeoutManager update_ui_timeout; private int64 update_ui_last = 0; [GtkChild] private unowned Gtk.Box main_layout; // Folds the inner leaftlet and conversation viewer [GtkChild] private unowned Hdy.Leaflet outer_leaflet; // Folds the folder list and the conversation list [GtkChild] private unowned Hdy.Leaflet inner_leaflet; [GtkChild] private unowned Gtk.Box folder_box; [GtkChild] private unowned Gtk.ScrolledWindow folder_list_scrolled; [GtkChild] private unowned Gtk.Box conversation_list_box; [GtkChild] private unowned Gtk.ScrolledWindow conversation_list_scrolled; [GtkChild] private unowned Gtk.Revealer conversation_list_actions_revealer; [GtkChild] private unowned Components.ConversationActions conversation_list_actions; [GtkChild] private unowned Gtk.Box conversation_viewer_box; [GtkChild] private unowned Gtk.Revealer conversation_viewer_actions_revealer; [GtkChild] private unowned Components.ConversationActions conversation_viewer_actions; [GtkChild] private unowned Gtk.SizeGroup folder_size_group; [GtkChild] private unowned Gtk.SizeGroup folder_separator_size_group; [GtkChild] private unowned Gtk.SizeGroup conversations_size_group; [GtkChild] private unowned Gtk.SizeGroup conversations_separator_size_group; [GtkChild] private unowned Gtk.SizeGroup conversation_size_group; [GtkChild] private unowned Hdy.SwipeGroup conversations_swipe_group; [GtkChild] private unowned Hdy.SwipeGroup conversation_swipe_group; [GtkChild] private unowned Gtk.Overlay overlay; private Components.InfoBarStack info_bars = new Components.InfoBarStack(SINGLE); private Components.InfoBar offline_infobar; private Components.InfoBar cert_problem_infobar; private Components.InfoBar auth_problem_infobar; private Components.ProblemReportInfoBar? service_problem_infobar = null; /** Fired when the user requests an account status be retried. */ public signal void retry_service_problem(Geary.ClientService.Status problem); /** Keybinding signal for replying to sender for the current conversation. */ [Signal (action=true)] public virtual signal void reply_conversation_sender() { activate_action(get_window_action(ACTION_REPLY_CONVERSATION)); } /** Keybinding signal for replying to all for the current conversation. */ [Signal (action=true)] public virtual signal void reply_conversation_all() { activate_action(get_window_action(ACTION_REPLY_ALL_CONVERSATION)); } /** Keybinding signal for forwarding the current conversation. */ [Signal (action=true)] public virtual signal void forward_conversation() { activate_action(get_window_action(ACTION_FORWARD_CONVERSATION)); } /** Keybinding signal for marking the current selection read. */ [Signal (action=true)] public virtual signal void mark_conversations_read(bool prefer_read) { activate_action( prefer_read ? get_window_action(ACTION_MARK_AS_READ) : get_window_action(ACTION_MARK_AS_UNREAD) ); } /** Keybinding signal for marking the current selection starred. */ [Signal (action=true)] public virtual signal void mark_conversations_starred(bool prefer_starred) { activate_action( prefer_starred ? get_window_action(ACTION_MARK_AS_STARRED) : get_window_action(ACTION_MARK_AS_UNSTARRED) ); } /** Keybinding signal for showing the copy/label menu. */ [Signal (action=true)] public virtual signal void show_copy_menu() { activate_action(get_window_action(ACTION_SHOW_COPY_MENU)); } /** Keybinding signal for showing the move menu. */ [Signal (action=true)] public virtual signal void show_move_menu() { activate_action(get_window_action(ACTION_SHOW_MOVE_MENU)); } /** Keybinding signal for archiving the current selection. */ [Signal (action=true)] public virtual signal void archive_conversations() { activate_action(get_window_action(ACTION_ARCHIVE_CONVERSATION)); } /** Keybinding signal for junking the current selection. */ [Signal (action=true)] public virtual signal void junk_conversations() { activate_action(get_window_action(ACTION_TOGGLE_JUNK)); } /** Keybinding signal for trashing the current selection. */ [Signal (action=true)] public virtual signal void trash_conversations() { // XXX the Shift+BackSpace combo above doesn't seem to work // for delete, so double-check here. activate_action( !this.is_shift_down ? get_window_action(ACTION_TRASH_CONVERSATION) : get_window_action(ACTION_DELETE_CONVERSATION) ); } /** Keybinding signal for deleting the current selection. */ [Signal (action=true)] public virtual signal void delete_conversations() { activate_action(get_window_action(ACTION_DELETE_CONVERSATION)); } /** Keybinding signal for activating conversation search. */ [Signal (action=true)] public virtual signal void search() { activate_action(get_window_action(ACTION_SEARCH)); } /** Keybinding signal for activating in-conversation find. */ [Signal (action=true)] public virtual signal void find() { activate_action(get_window_action(ACTION_FIND_IN_CONVERSATION)); } /** Keybinding signal for shifting the keyboard focus. */ [Signal (action=true)] public virtual signal void navigate(Gtk.ScrollType type) { switch (type) { case Gtk.ScrollType.PAGE_LEFT: if (get_direction() != RTL) { focus_previous_pane(); } else { focus_next_pane(); } break; case Gtk.ScrollType.PAGE_RIGHT: if (get_direction() != RTL) { focus_next_pane(); } else { focus_previous_pane(); } break; case Gtk.ScrollType.STEP_UP: activate_action(get_window_action(ACTION_CONVERSATION_UP)); break; case Gtk.ScrollType.STEP_DOWN: activate_action(get_window_action(ACTION_CONVERSATION_DOWN)); break; default: this.get_window().beep(); break; } } internal MainWindow(Client application) { Object( application: application, show_menubar: false ); base_ref(); // Keep a ref on the current controller so that even if the // application has been shut down, any async tasks that are // running (and presumably being cancelled) can still complete // without a warning. this.controller = application.controller; // Create a new group for the window so attachment file // choosers (save attachments & composer attach files) do not // block other main windows or composers. var group = new Gtk.WindowGroup(); group.add_window(this); load_config(application.config); restore_saved_window_state(); if (_PROFILE != Client.PROFILE_RELEASE) { this.get_style_context().add_class("devel"); } this.info_bars.shadow_type = IN; this.conversation_list_info_bars.shadow_type = IN; // Edit actions this.edit_actions.add_action_entries(EDIT_ACTIONS, this); insert_action_group(Action.Edit.GROUP_NAME, this.edit_actions); // Window actions add_action_entries(MainWindow.WINDOW_ACTIONS, this); this.focus_in_event.connect((w, e) => { application.controller.window_focus_in(); return false; }); this.focus_out_event.connect((w, e) => { application.controller.window_focus_out(); return false; }); setup_layout(application.config); this.folder_box.pack_start(status_bar, false, false); update_command_actions(); update_conversation_actions(NONE); this.attachments = new AttachmentManager(this); this.update_ui_timeout = new Geary.TimeoutManager.seconds( UPDATE_UI_INTERVAL, on_update_ui_timeout ); this.update_ui_timeout.repetition = FOREVER; // Add future and existing accounts to the main window this.controller.account_available.connect( on_account_available ); this.controller.account_unavailable.connect( on_account_unavailable ); foreach (AccountContext context in this.controller.get_account_contexts()) { add_account(context); } this.offline_infobar = new Components.InfoBar( // Translators: An info bar status label _("Working offline"), // Translators: An info bar description label _("You will not be able to send or receive email until re-connected.") ); this.offline_infobar.show_close_button = true; this.offline_infobar.response.connect(on_offline_infobar_response); this.auth_problem_infobar = new Components.InfoBar( // Translators: An info bar status label _("Login problem"), // Translators: An info bar description label _("An account has reported an incorrect login or password.") ); // Translators: An info bar button label var auth_retry = new Gtk.Button.with_label(_("Login")); auth_retry.show(); // Translators: An info bar button tool-tip auth_retry.tooltip_text = _( "Retry login, you will be prompted for your password" ); auth_retry.clicked.connect(on_auth_problem_retry); this.auth_problem_infobar.get_action_area().add(auth_retry); this.cert_problem_infobar = new Components.InfoBar( // Translators: An info bar status label _("Security problem"), // Translators: An info bar description label _("An account has reported an untrusted server.") ); // Translators: An info bar button label var cert_retry = new Gtk.Button.with_label(_("Check")); cert_retry.show(); // Translators: An info bar button tool-tip cert_retry.tooltip_text = _( "Check the security details for the connection" ); cert_retry.clicked.connect(on_cert_problem_retry); this.cert_problem_infobar.get_action_area().add(cert_retry); this.conversation_list_view.grab_focus(); } ~MainWindow() { base_unref(); } /** {@inheritDoc} */ public override void destroy() { if (this.application != null) { this.controller.account_available.disconnect( on_account_available ); this.controller.account_unavailable.disconnect( on_account_unavailable ); } this.update_ui_timeout.reset(); base.destroy(); } /** Updates the window's title and headerbar titles. */ public void update_title() { AccountContext? account = get_selected_account_context(); FolderContext? folder = ( account != null && this.selected_folder != null ? account.get_folder(this.selected_folder) : null ); string title = _("Geary"); string? account_name = null; string? folder_name = null; if (account != null && folder != null) { account_name = account.account.information.display_name; folder_name = folder.display_name; /// Translators: Main window title, first string /// substitution being the currently selected folder name, /// the second being the selected account name. title = _("%s — %s").printf(folder_name, account_name); } this.title = title; this.main_toolbar.account = account_name ?? ""; this.main_toolbar.folder = folder_name?? ""; } /** Updates the window's account status info bars. */ public void update_account_status(Geary.Account.Status status, bool has_auth_error, bool has_cert_error, Geary.Account? problem_source) { // Only ever show one info bar at a time. Offline is primary // since nothing else can happen when offline. Service // problems are secondary since auth and cert problems can't // be resolved when the service isn't talking to the // server. Cert problems are tertiary since you can't auth if // you can't connect. if (!status.is_online()) { this.info_bars.add(this.offline_infobar); } else if (status.has_service_problem()) { Geary.ClientService? service = ( problem_source.incoming.last_error != null ? problem_source.incoming : problem_source.outgoing ); this.service_problem_infobar = new Components.ProblemReportInfoBar( new Geary.ServiceProblemReport( problem_source.information, service.configuration, service.last_error.thrown ) ); this.service_problem_infobar.retry.connect(on_service_problem_retry); this.info_bars.add(this.service_problem_infobar); } else if (has_cert_error) { this.info_bars.add(this.cert_problem_infobar); } else if (has_auth_error) { this.info_bars.add(this.auth_problem_infobar); } else { this.info_bars.remove_all(); } } /** * Selects and open the given folder. * * If is_interactive is true, the selection is treated as being * caused directly by human request (e.g. clicking on a folder in * the folder list), as opposed to some side effect. */ public async void select_folder(Geary.Folder? to_select, bool is_interactive, bool inhibit_autoselect = false) { if (this.selected_folder != to_select) { // Cancel any existing folder loading this.folder_open.cancel(); var cancellable = this.folder_open = new GLib.Cancellable(); // Dispose of all existing objects for the currently // selected model. if (this.selected_folder != null) { this.conversation_actions.copy_folder_menu.enable_disable_folder( this.selected_folder, true ); this.conversation_actions.move_folder_menu.enable_disable_folder( this.selected_folder, true ); this.progress_monitor.remove(this.selected_folder.opening_monitor); this.selected_folder.properties.notify.disconnect(update_headerbar); this.selected_folder = null; } if (this.conversations != null) { this.progress_monitor.remove(this.conversations.progress_monitor); close_conversation_monitor(this.conversations); this.conversations = null; } var conversations_model = this.conversation_list_view.get_model(); if (conversations_model != null) { this.progress_monitor.remove(conversations_model.preview_monitor); this.conversation_list_view.set_model(null); } this.conversation_list_info_bars.remove_all(); // With everything disposed of, update existing window // state select_account(to_select != null ? to_select.account : null); this.selected_folder = to_select; // Ensure that the folder is selected in the UI if // this was called by something other than the // selection changed callback. That will check to // ensure that we're not setting it again. if (to_select != null) { // Prefer the inboxes branch if it is a thing, but // only for non-interactive calls if (is_interactive || (to_select.used_as != INBOX || !this.folder_list.select_inbox(to_select.account))) { this.folder_list.select_folder(to_select); } } else { this.folder_list.deselect_folder(); } update_title(); update_conversation_actions(NONE); update_trash_action(); this.conversation_viewer.show_loading(); this.previous_selection_was_interactive = is_interactive; debug("Folder selected: %s", (to_select != null) ? to_select.to_string() : "(null)"); // Finally, hook up the new folder if any and start // loading conversations. if (to_select != null) { this.progress_monitor.add(to_select.opening_monitor); to_select.properties.notify.connect(update_headerbar); this.conversations = new Geary.App.ConversationMonitor( to_select, // Include fields for the conversation viewer as well so // conversations can be displayed without having to go // back to the db ConversationListStore.REQUIRED_FIELDS | ConversationListBox.REQUIRED_FIELDS | ConversationEmail.REQUIRED_FOR_CONSTRUCT, MIN_CONVERSATION_COUNT ); this.progress_monitor.add(this.conversations.progress_monitor); conversations_model = new ConversationListStore( this.conversations, this.application.config ); this.progress_monitor.add(conversations_model.preview_monitor); if (inhibit_autoselect) { this.conversation_list_view.inhibit_next_autoselect(); } this.conversation_list_view.set_model(conversations_model); // disable copy/move to the new folder this.conversation_actions.copy_folder_menu.enable_disable_folder( to_select, false ); this.conversation_actions.move_folder_menu.enable_disable_folder( to_select, false ); yield open_conversation_monitor(this.conversations, cancellable); yield this.controller.process_pending_composers(); } } update_headerbar(); } /** Selects the given account, folder and conversations. */ public async void show_conversations(Geary.Folder location, Gee.Collection to_show, bool is_interactive) { bool inhibit_autoselect = (location != this.selected_folder); yield select_folder(location, is_interactive, inhibit_autoselect); // The folder may have changed again by the type the async // call returns, so only continue if still current if (this.selected_folder == location) { // Since conversation ids don't persist between // conversation monitor instances, need to load // conversations based on their messages. var latest_email = new Gee.HashSet(); foreach (var stale in to_show) { Geary.Email? first = stale.get_latest_recv_email(IN_FOLDER); if (first != null) { latest_email.add(first.id); } } var loaded = yield load_conversations_for_email( location, latest_email ); if (!loaded.is_empty) { yield select_conversations( loaded, Gee.Collection.empty(), is_interactive ); } } } /** Selects the given account, folder and email. */ public async void show_email(Geary.Folder location, Gee.Collection to_show, bool is_interactive) { bool inhibit_autoselect = (location != this.selected_folder); yield select_folder(location, is_interactive, inhibit_autoselect); // The folder may have changed again by the type the async // call returns, so only continue if still current if (this.selected_folder == location) { var loaded = yield load_conversations_for_email(location, to_show); if (loaded.size == 1) { // A single conversation was loaded, so ensure we // scroll to the email in the conversation. Geary.App.Conversation? target = Geary.Collection.first(loaded); ConversationListBox? current_list = this.conversation_viewer.current_list; if (current_list != null && current_list.conversation == target) { // The target conversation is already loaded, just // scroll to the messages. // // XXX this is actually racy, since the view may // still be in the middle of loading the messages // obtained from the conversation monitor when // this call is made. current_list.scroll_to_messages(to_show); } else { // The target conversation is not loaded, select // it and scroll to the messages. yield select_conversations(loaded, to_show, is_interactive); } } else if (!loaded.is_empty) { // Multiple conversations found, just select those yield select_conversations( loaded, Gee.Collection.empty(), is_interactive ); } else { } } } /** Shows the appopriate window menu, if any. */ public void show_window_menu() { if (this.outer_leaflet.folded) { this.outer_leaflet.navigate(Hdy.NavigationDirection.BACK); } if (this.inner_leaflet.folded) { this.inner_leaflet.navigate(Hdy.NavigationDirection.BACK); } this.main_toolbar.show_main_menu(); } /** Displays and focuses the search bar for the window. */ public void show_search_bar(string? text = null) { this.search_bar.grab_focus(); if (text != null) { this.search_bar.entry.text = text; } } /** Displays an infobar in the window. */ public void show_info_bar(Components.InfoBar info_bar) { if (!this.info_bars.has_current) { this.info_bars.add(info_bar); } } /** * Displays a composer in the window if possible, else in a new window. */ internal void show_composer(Composer.Widget composer) { if (this.has_composer) { composer.detach(this.application); } else { // See if the currently displayed conversation contains // any of the composer's referred emails (preferring the // latest), and if so add it inline, otherwise add it full // paned. Geary.Email? latest_referred = null; if (this.conversation_viewer.current_list != null) { Gee.Collection? referrants = composer.get_referred_ids(); Geary.App.Conversation selected = this.conversation_viewer.current_list.conversation; latest_referred = selected.get_emails( RECV_DATE_DESCENDING ).first_match( (email) => email.id in referrants ); } if (latest_referred != null) { this.conversation_viewer.do_compose_embedded( composer, latest_referred ); } else { this.conversation_viewer.do_compose(composer); } // Show the correct leaflet this.outer_leaflet.set_visible_child_name(CONVERSATION_VIEWER); } } /** * Closes any open composers, after prompting the user if requested. * * Returns true if none were open or the user approved closing * them. */ internal bool close_composer(bool should_prompt, bool is_shutdown = false) { bool closed = true; Composer.Widget? composer = this.conversation_viewer.current_composer; if (composer != null && composer.conditional_close(should_prompt, is_shutdown) == CANCELLED) { closed = false; } return closed; } internal void start_search(string query_text, bool is_interactive) { var context = get_selected_account_context(); if (context != null) { // If the current folder is not the search folder, save it // so it can be re-selected later when search is closed if (this.previous_non_search_folder == null && this.selected_folder != null && this.selected_folder.used_as != SEARCH) { this.previous_non_search_folder = this.selected_folder; } try { var expr_factory = new Util.Email.SearchExpressionFactory( this.application.config.get_search_strategy(), context.account.information ); var query = context.account.new_search_query( expr_factory.parse_query(query_text), query_text ); this.folder_list.set_search( this.application.engine, context.search ); context.search.update_query(query); } catch (GLib.Error error) { handle_error(context.account.information, error); } } } internal void stop_search(bool is_interactive) { // If the search folder is current selected, deselect and // re-select any previously selected folder if (this.selected_folder == null || this.selected_folder.used_as == SEARCH) { var to_select = this.previous_non_search_folder; if (to_select == null) { var account = get_selected_account_context(); if (account != null) { to_select = account.inbox; } } if (to_select != null) { this.select_folder.begin( this.previous_non_search_folder, is_interactive ); } else { select_first_inbox(is_interactive); } } this.folder_list.remove_search(); foreach (var context in this.controller.get_account_contexts()) { context.search.clear_query(); } } internal bool select_first_inbox(bool is_interactive) { bool success = false; Geary.Folder? inbox = get_first_inbox(); if (inbox != null) { this.select_folder.begin(inbox, is_interactive); success = true; } return success; } private void add_account(AccountContext to_add) { if (!this.accounts.contains(to_add)) { this.folder_list.set_user_folders_root_name( to_add.account, _("Labels") ); this.progress_monitor.add(to_add.account.background_progress); Geary.Smtp.ClientService? smtp = ( to_add.account.outgoing as Geary.Smtp.ClientService ); if (smtp != null) { this.progress_monitor.add(smtp.sending_monitor); } to_add.folders_available.connect(on_folders_available); to_add.folders_unavailable.connect(on_folders_unavailable); to_add.commands.executed.connect(on_command_execute); to_add.commands.undone.connect(on_command_undo); to_add.commands.redone.connect(on_command_redo); // Sort the folders so FolderListTree adds them all // correctly var added = new Gee.TreeSet(); added.add_all(to_add.get_folders()); add_folders(added); this.accounts.add(to_add); } } /** * Removes the given account from the main window. * * If `to_select` is not null, the given folder will be selected, * otherwise no folder will be. */ private async void remove_account(AccountContext to_remove, Geary.Folder? to_select) { if (this.accounts.contains(to_remove)) { // Explicitly unset the selected folder if it belongs to the // account so we block until it's gone. This also clears the // previous search folder, so it won't try to re-load that // that when the account is gone. if (this.selected_folder != null && this.selected_folder.account == to_remove.account) { bool is_account_search_active = ( this.selected_folder.used_as == SEARCH ); yield select_folder(to_select, false); if (is_account_search_active) { this.search_bar.entry.text = ""; this.search_bar.search_mode_enabled = false; } } to_remove.commands.executed.disconnect(on_command_execute); to_remove.commands.undone.disconnect(on_command_undo); to_remove.commands.redone.disconnect(on_command_redo); to_remove.folders_available.disconnect(on_folders_available); to_remove.folders_available.disconnect(on_folders_unavailable); this.progress_monitor.remove(to_remove.account.background_progress); Geary.Smtp.ClientService? smtp = ( to_remove.account.outgoing as Geary.Smtp.ClientService ); if (smtp != null) { this.progress_monitor.remove(smtp.sending_monitor); } // Finally, remove the account and its folders remove_folders(to_remove.get_folders(), false); this.folder_list.remove_account(to_remove.account); this.accounts.remove(to_remove); } } /** Adds a folder to the window. */ private void add_folders(Gee.Collection to_add) { foreach (var context in to_add) { this.folder_list.add_folder(context); if (context.folder.account == this.selected_account) { this.conversation_actions.copy_folder_menu.add_folder(context.folder); this.conversation_actions.move_folder_menu.add_folder(context.folder); } context.folder.use_changed.connect(on_use_changed); } } /** Removes a folder from the window. */ private void remove_folders(Gee.Collection to_remove, bool update_selecton) { foreach (var context in to_remove) { Geary.Folder folder = context.folder; if (this.selected_folder == folder) { var account = get_selected_account_context(); if (account != null) { this.select_folder.begin(account.inbox, true); } } folder.use_changed.disconnect(on_use_changed); if (folder.account == this.selected_account) { this.conversation_actions.copy_folder_menu.remove_folder(folder); this.conversation_actions.move_folder_menu.remove_folder(folder); } this.folder_list.remove_folder(context); } } private AccountContext? get_selected_account_context() { AccountContext? context = null; if (this.selected_account != null) { context = this.controller.get_context_for_account( this.selected_account.information ); } return context; } private Geary.Folder? get_first_inbox() { Geary.Folder? inbox = null; try { Geary.Account? first = Geary.Collection.first( this.application.engine.get_accounts() ); if (first != null) { inbox = first.get_special_folder(INBOX); } } catch (GLib.Error error) { debug("Error getting inbox for first account"); } return inbox; } private void load_config(Configuration config) { // This code both loads AND saves the pane positions with live updating. This is more // resilient against crashes because the value in dconf changes *immediately*, and // stays saved in the event of a crash. config.bind(Configuration.WINDOW_WIDTH_KEY, this, "window-width"); config.bind(Configuration.WINDOW_HEIGHT_KEY, this, "window-height"); config.bind(Configuration.WINDOW_MAXIMIZE_KEY, this, "window-maximized"); } private void restore_saved_window_state() { Gdk.Display? display = Gdk.Display.get_default(); if (display != null) { Gdk.Monitor? monitor = display.get_primary_monitor(); if (monitor == null) { monitor = display.get_monitor_at_point(1, 1); } if (monitor != null && this.window_width <= monitor.geometry.width && this.window_height <= monitor.geometry.height) { set_default_size(this.window_width, this.window_height); } } this.window_position = Gtk.WindowPosition.CENTER; if (this.window_maximized) { maximize(); } } // Called on [un]maximize and possibly others. Save maximized state // for the next start. public override bool window_state_event(Gdk.EventWindowState event) { if ((event.new_window_state & Gdk.WindowState.WITHDRAWN) == 0) { bool maximized = ( (event.new_window_state & Gdk.WindowState.MAXIMIZED) != 0 ); if (this.window_maximized != maximized) { this.window_maximized = maximized; } } return base.window_state_event(event); } // Called on window resize. Save window size for the next start. public override void size_allocate(Gtk.Allocation allocation) { base.size_allocate(allocation); if (!this.window_maximized) { Gdk.Display? display = get_display(); Gdk.Window? window = get_window(); if (display != null && window != null) { Gdk.Monitor monitor = display.get_monitor_at_window(window); // Get the size via ::get_size instead of the // allocation so that the window isn't ever-expanding. int width = 0; int height = 0; get_size(out width, out height); // Only store if the values have changed and are // reasonable-looking. if (this.window_width != width && width > 0 && width <= monitor.geometry.width) { this.window_width = width; } if (this.window_height != height && height > 0 && height <= monitor.geometry.height) { this.window_height = height; } } } } public void add_notification(Components.InAppNotification notification) { this.overlay.add_overlay(notification); notification.show(); } private void setup_layout(Configuration config) { this.notify["has-toplevel-focus"].connect(on_has_toplevel_focus); // Search bar this.search_bar = new SearchBar(this.application.engine); this.search_bar.search_text_changed.connect(on_search); this.conversation_list_box.pack_start(this.search_bar, false, false, 0); // Folder list this.folder_list.folder_selected.connect(on_folder_selected); this.folder_list.move_conversation.connect(on_move_conversation); this.folder_list.copy_conversation.connect(on_copy_conversation); this.folder_list.folder_activated.connect(on_folder_activated); this.folder_list_scrolled.add(this.folder_list); // Conversation list this.conversation_list_box.pack_start( this.conversation_list_info_bars, false, false, 0 ); this.conversation_list_view = new ConversationListView( this.application.config ); this.conversation_list_view.load_more.connect(on_load_more); this.conversation_list_view.mark_conversations.connect(on_mark_conversations); this.conversation_list_view.conversations_selected.connect(on_conversations_selected); this.conversation_list_view.conversation_activated.connect(on_conversation_activated); this.conversation_list_view.visible_conversations_changed.connect(on_visible_conversations_changed); this.conversation_list_scrolled.add(conversation_list_view); // Conversation viewer this.conversation_viewer = new ConversationViewer( this.application.config ); this.conversation_viewer.conversation_added.connect( on_conversation_view_added ); this.conversation_viewer.hexpand = true; this.conversation_size_group.add_widget(this.conversation_viewer); this.conversation_viewer_box.add(this.conversation_viewer); // Main toolbar this.main_toolbar = new Components.MainToolbar(config); this.main_toolbar.add_to_size_groups(this.folder_size_group, this.folder_separator_size_group, this.conversations_size_group, this.conversations_separator_size_group, this.conversation_size_group); this.main_toolbar.add_to_swipe_groups(this.conversations_swipe_group, this.conversation_swipe_group); this.main_toolbar.bind_property( "search-open", this.search_bar, "search-mode-enabled", SYNC_CREATE | BIDIRECTIONAL ); this.main_toolbar.bind_property( "find-open", this.conversation_viewer.conversation_find_bar, "search-mode-enabled", SYNC_CREATE | BIDIRECTIONAL ); if (config.desktop_environment == UNITY) { this.main_toolbar.show_close_button = false; this.main_layout.pack_start(main_toolbar, false, true, 0); } else { this.main_layout.pack_start(main_toolbar, false, true, 0); } this.main_layout.pack_start(this.info_bars, false, true, 0); // Status bar this.status_bar.set_size_request(-1, STATUS_BAR_HEIGHT); this.status_bar.set_border_width(2); this.spinner.set_size_request(STATUS_BAR_HEIGHT - 2, -1); this.spinner.set_progress_monitor(progress_monitor); this.status_bar.add(this.spinner); this.status_bar.show_all(); } /** {@inheritDoc} */ public override bool key_press_event(Gdk.EventKey event) { check_shift_event(event); return base.key_press_event(event); } /** {@inheritDoc} */ public override bool key_release_event(Gdk.EventKey event) { check_shift_event(event); return base.key_release_event(event); } internal bool prompt_empty_folder(Geary.Folder.SpecialUse type) { var folder_name = Util.I18n.to_folder_type_display_name(type); ConfirmationDialog dialog = new ConfirmationDialog( this, _("Empty all email from your %s folder?").printf(folder_name), _("This removes the email from Geary and your email server.") + " " + _("This cannot be undone.") + "", _("Empty %s").printf(folder_name), "destructive-action" ); dialog.use_secondary_markup(true); dialog.set_focus_response(Gtk.ResponseType.CANCEL); return (dialog.run() == Gtk.ResponseType.OK); } /** Un-does the last executed application command, if any. */ private async void undo() { AccountContext? selected = get_selected_account_context(); if (selected != null) { selected.commands.undo.begin( selected.cancellable, (obj, res) => { try { selected.commands.undo.end(res); } catch (GLib.Error err) { handle_error(selected.account.information, err); } } ); } } /** Re-does the last undone application command, if any. */ private async void redo() { AccountContext? selected = get_selected_account_context(); if (selected != null) { selected.commands.redo.begin( selected.cancellable, (obj, res) => { try { selected.commands.redo.end(res); } catch (GLib.Error err) { handle_error(selected.account.information, err); } } ); } } private void update_command_actions() { AccountContext? selected = get_selected_account_context(); get_edit_action(Action.Edit.UNDO).set_enabled( selected != null && selected.commands.can_undo ); get_edit_action(Action.Edit.REDO).set_enabled( selected != null && selected.commands.can_redo ); } private bool prompt_delete_conversations(int count) { ConfirmationDialog dialog = new ConfirmationDialog( this, /// Translators: Primary text for a confirmation dialog ngettext( "Do you want to permanently delete this conversation?", "Do you want to permanently delete these conversations?", count ), null, _("Delete"), "destructive-action" ); return (dialog.run() == Gtk.ResponseType.OK); } private bool prompt_delete_messages(int count) { ConfirmationDialog dialog = new ConfirmationDialog( this, /// Translators: Primary text for a confirmation dialog ngettext( "Do you want to permanently delete this message?", "Do you want to permanently delete these messages?", count ), null, _("Delete"), "destructive-action" ); return (dialog.run() == Gtk.ResponseType.OK); } private async Gee.Collection load_conversations_for_email( Geary.Folder location, Gee.Collection to_load) { bool was_loaded = false; // Can't assume the conversation monitor is valid, so check // it first. if (this.conversations != null && this.conversations.base_folder == location) { try { yield this.conversations.load_email(to_load, this.folder_open); was_loaded = true; } catch (GLib.Error err) { debug("Error loading conversations to show them: %s", err.message); } } // Conversation monitor may have changed since resuming from // the last async statement, so check it's still valid again. var loaded = new Gee.HashSet(); if (was_loaded && this.conversations != null && this.conversations.base_folder == location) { foreach (var id in to_load) { Geary.App.Conversation? conversation = this.conversations.get_by_email_identifier(id); if (conversation != null) { loaded.add(conversation); } } } return loaded; } private inline void handle_error(Geary.AccountInformation? account, GLib.Error error) { Geary.ProblemReport? report = (account != null) ? new Geary.AccountProblemReport(account, error) : new Geary.ProblemReport(error); this.controller.report_problem(report); } private void update_ui() { // Only update if we haven't done so within the last while int64 now = GLib.get_monotonic_time() / (1000 * 1000); if (this.update_ui_last + UPDATE_UI_INTERVAL < now) { this.update_ui_last = now; if (this.conversation_viewer.current_list != null) { this.conversation_viewer.current_list.update_display(); } ConversationListStore? list_store = this.conversation_list_view.get_model() as ConversationListStore; if (list_store != null) { list_store.update_display(); } } } private void select_account(Geary.Account? account) { if (this.selected_account != account) { if (this.selected_account != null) { this.conversation_actions.copy_folder_menu.clear(); this.conversation_actions.move_folder_menu.clear(); } this.selected_account = account; this.search_bar.set_account(account); if (account != null) { foreach (Geary.Folder folder in account.list_folders()) { this.conversation_actions.copy_folder_menu.add_folder(folder); this.conversation_actions.move_folder_menu.add_folder(folder); } } update_command_actions(); } } private async void select_conversations(Gee.Collection to_select, Gee.Collection scroll_to, bool is_interactive) { bool start_mark_timer = ( this.previous_selection_was_interactive && is_interactive ); this.previous_selection_was_interactive = is_interactive; // Ensure that the conversations are selected in the UI if // this was called by something other than the selection // changed callback. That will check to ensure that we're not // setting it again. this.conversation_list_view.select_conversations(to_select); this.conversation_list_actions.selected_conversations = to_select.size; this.main_toolbar.full_actions.selected_conversations = to_select.size; this.main_toolbar.compact_actions.selected_conversations = to_select.size; if (this.selected_folder != null && !this.has_composer) { switch(to_select.size) { case 0: update_conversation_actions(NONE); this.conversation_viewer.show_none_selected(); break; case 1: update_conversation_actions(SINGLE); Geary.App.Conversation? convo = Geary.Collection.first(to_select); // It's possible for a conversation with zero email to // be selected, when it has just evaporated after its // last email was removed but the conversation monitor // hasn't signalled its removal yet. In this case, // just don't load it since it will soon disappear. AccountContext? context = get_selected_account_context(); if (context != null && convo.get_count() > 0) { try { yield this.conversation_viewer.load_conversation( convo, scroll_to, context.emails, context.contacts, start_mark_timer ); } catch (Geary.EngineError.NOT_FOUND err) { // The first interesting email from the // conversation wasn't found. If the // conversation has completely evaporated by // now then fine, otherwise throw the // error. This happens e.g. in the drafts // folder, there is a race between the // composer being discarded and the draft // itself disappearing if (convo.get_count() == 0) { debug("Ignoring not found error: %s", err.message); } else { handle_error( convo.base_folder.account.information, err ); } } catch (GLib.IOError.CANCELLED err) { // All good } catch (GLib.Error err) { handle_error(convo.base_folder.account.information, err); } } break; default: update_conversation_actions(MULTIPLE); this.conversation_viewer.show_multiple_selected(); break; } } } private async void open_conversation_monitor(Geary.App.ConversationMonitor to_open, GLib.Cancellable cancellable) { to_open.scan_completed.connect(on_scan_completed); to_open.scan_error.connect(on_scan_error); to_open.scan_completed.connect(on_conversation_count_changed); to_open.conversations_added.connect(on_conversation_count_changed); to_open.conversations_removed.connect(on_conversation_count_changed); to_open.start_monitoring.begin( NO_DELAY, cancellable, (obj, res) => { try { to_open.start_monitoring.end(res); } catch (GLib.Error err) { handle_error(to_open.base_folder.account.information, err); } } ); } private void close_conversation_monitor(Geary.App.ConversationMonitor to_close) { to_close.scan_completed.disconnect(on_scan_completed); to_close.scan_error.disconnect(on_scan_error); to_close.scan_completed.disconnect(on_conversation_count_changed); to_close.conversations_added.disconnect(on_conversation_count_changed); to_close.conversations_removed.disconnect(on_conversation_count_changed); to_close.stop_monitoring.begin( null, (obj, res) => { try { to_close.stop_monitoring.end(res); } catch (GLib.Error err) { warning( "Error closing conversation monitor %s: %s", to_close.base_folder.to_string(), err.message ); } } ); } private async void create_composer(Geary.Account send_context, Composer.Widget.ContextType type, Geary.Email context, string? quote) { var composer = yield this.controller.compose_with_context( this.controller.get_context_for_account(send_context.information), type, context, quote ?? "" ); this.controller.present_composer(composer); } private async void create_composer_from_viewer(Composer.Widget.ContextType type) { Geary.Account? account = this.selected_account; ConversationEmail? email_view = null; ConversationListBox? list_view = this.conversation_viewer.current_list; if (list_view != null) { email_view = list_view.get_reply_target(); } if (account != null && email_view != null) { string? quote = yield email_view.get_selection_for_quoting(); yield create_composer(account, type, email_view.email, quote); } } private void load_more() { if (this.conversations != null) { this.conversations.min_window_count += MIN_CONVERSATION_COUNT; } } private void on_conversations_selected(Gee.Set selected) { this.select_conversations.begin(selected, Gee.Collection.empty(), true); } private void on_conversation_count_changed() { // Only update the UI if we don't currently have a composer, // so we don't clobber it if (!this.has_composer) { if (this.conversations.size == 0) { // Let the user know if there's no available conversations if (this.selected_folder.used_as == SEARCH) { this.conversation_viewer.show_empty_search(); } else { this.conversation_viewer.show_empty_folder(); } update_conversation_actions(NONE); } else { // When not doing autoselect, we never get // conversations_selected firing from the convo list, // so we need to stop the loading spinner here. if (!this.application.config.autoselect && this.conversation_list_view.get_selection().count_selected_rows() == 0) { this.conversation_viewer.show_none_selected(); update_conversation_actions(NONE); } } } } private void update_headerbar() { update_title(); if (this.selected_folder != null) { // Current folder's name followed by its unread count, // i.e. "Inbox (42)" except for Drafts and Outbox, where // we show total count int count; switch (this.selected_folder.used_as) { case DRAFTS: case OUTBOX: count = this.selected_folder.properties.email_total; break; default: count = this.selected_folder.properties.email_unread; break; } if (count > 0) { this.main_toolbar.folder = _("%s (%d)").printf( this.main_toolbar.folder, count ); } } } private void update_conversation_actions(ConversationCount count) { bool sensitive = (count != NONE); bool multiple = (count == MULTIPLE); get_window_action(ACTION_FIND_IN_CONVERSATION).set_enabled( sensitive && !multiple ); bool reply_sensitive = ( sensitive && !multiple && this.selected_folder != null && this.selected_folder.used_as != DRAFTS ); get_window_action(ACTION_REPLY_CONVERSATION).set_enabled(reply_sensitive); get_window_action(ACTION_REPLY_ALL_CONVERSATION).set_enabled(reply_sensitive); get_window_action(ACTION_FORWARD_CONVERSATION).set_enabled(reply_sensitive); bool move_enabled = ( sensitive && (selected_folder is Geary.FolderSupport.Move) ); this.conversation_actions.move_message_button.set_sensitive(move_enabled); get_window_action(ACTION_SHOW_MOVE_MENU).set_enabled(move_enabled); bool copy_enabled = ( sensitive && (selected_folder is Geary.FolderSupport.Copy) ); this.conversation_actions.copy_message_button.set_sensitive(copy_enabled); get_window_action(ACTION_SHOW_COPY_MENU).set_enabled(move_enabled); get_window_action(ACTION_ARCHIVE_CONVERSATION).set_enabled( sensitive && (selected_folder is Geary.FolderSupport.Archive) ); get_window_action(ACTION_TRASH_CONVERSATION).set_enabled( sensitive && this.selected_folder_supports_trash ); get_window_action(ACTION_DELETE_CONVERSATION).set_enabled( sensitive && (selected_folder is Geary.FolderSupport.Remove) ); this.update_context_dependent_actions.begin(sensitive); switch (count) { case NONE: this.conversation_list_actions_revealer.reveal_child = false; break; case SINGLE: this.conversation_list_actions_revealer.reveal_child = false; break; case MULTIPLE: this.conversation_list_actions_revealer.reveal_child = true; break; } } private void update_trash_action() { var show_trash = ( !this.is_shift_down && this.selected_folder_supports_trash ); this.conversation_list_actions.update_trash_button(show_trash); this.main_toolbar.full_actions.update_trash_button(show_trash); this.main_toolbar.compact_actions.update_trash_button(show_trash); } private async void update_context_dependent_actions(bool sensitive) { // Cancel any existing update that is running this.action_update_cancellable.cancel(); GLib.Cancellable cancellable = new Cancellable(); this.action_update_cancellable = cancellable; Gee.MultiMap? selected_operations = null; if (this.selected_folder != null) { AccountContext? context = this.controller.get_context_for_account( this.selected_folder.account.information ); if (context != null) { Gee.Collection ids = new Gee.LinkedList(); foreach (Geary.App.Conversation convo in this.conversation_list_view.get_selected()) { ids.add_all(convo.get_email_ids()); } try { selected_operations = yield context.emails.get_supported_operations_async( ids, cancellable ); } catch (GLib.Error e) { debug("Error checking for what operations are supported in the selected conversations: %s", e.message); } } } if (!cancellable.is_cancelled()) { Gee.HashSet supported_operations = new Gee.HashSet(); if (selected_operations != null) { supported_operations.add_all(selected_operations.get_values()); } get_window_action(ACTION_SHOW_MARK_MENU).set_enabled( sensitive && (typeof(Geary.FolderSupport.Mark) in supported_operations) ); get_window_action(ACTION_SHOW_COPY_MENU).set_enabled( sensitive && (supported_operations.contains(typeof(Geary.FolderSupport.Copy))) ); get_window_action(ACTION_SHOW_MOVE_MENU).set_enabled( sensitive && (supported_operations.contains(typeof(Geary.FolderSupport.Move))) ); } } private void set_shift_key_down(bool down) { this.is_shift_down = down; update_trash_action(); } private inline void check_shift_event(Gdk.EventKey event) { // FIXME: it's possible the user will press two shift keys. We want // the shift key to report as released when they release ALL of them. // There doesn't seem to be an easy way to do this in Gdk. if (event.keyval == Gdk.Key.Shift_L || event.keyval == Gdk.Key.Shift_R) { Gtk.Widget? focus = get_focus(); if (focus == null || (!(focus is Gtk.Entry) && !(focus is Composer.WebView))) { set_shift_key_down(event.type == Gdk.EventType.KEY_PRESS); } } } private void focus_next_pane() { var focus = get_focus(); if (this.outer_leaflet.folded) { if (this.outer_leaflet.visible_child_name == INNER_LEAFLET) { if (this.inner_leaflet.folded && this.inner_leaflet.visible_child_name == FOLDER_LIST || focus == this.folder_list) { this.inner_leaflet.navigate(Hdy.NavigationDirection.FORWARD); focus = this.conversation_list_view; } else { if (this.conversation_list_view.get_selected().size == 1 && this.selected_folder.properties.email_total > 0) { this.outer_leaflet.navigate(Hdy.NavigationDirection.FORWARD); focus = this.conversation_viewer.visible_child; } } } } else if (focus != null) { if (focus == this.folder_list || focus.is_ancestor(this.folder_list)) { focus = this.conversation_list_view; } else if (focus == this.conversation_list_view || focus.is_ancestor(this.conversation_list_view)) { focus = this.conversation_viewer.visible_child; } else if (focus == this.conversation_viewer || focus.is_ancestor(this.conversation_viewer)) { focus = this.folder_list; } } if (focus != null) { focus.focus(TAB_FORWARD); } else { get_window().beep(); } } private void focus_previous_pane() { var focus = get_focus(); if (this.outer_leaflet.folded) { if (this.outer_leaflet.visible_child_name == INNER_LEAFLET) { if (this.inner_leaflet.folded) { if (this.inner_leaflet.visible_child_name == CONVERSATION_LIST) { this.inner_leaflet.navigate(Hdy.NavigationDirection.BACK); focus = this.folder_list; } } else { if (focus == this.conversation_list_view) focus = this.folder_list; else focus = this.conversation_list_view; } } else { this.outer_leaflet.navigate(Hdy.NavigationDirection.BACK); focus = this.conversation_list_view; } } else if (focus != null) { if (focus == this.folder_list || focus.is_ancestor(this.folder_list)) { focus = this.conversation_viewer.visible_child; } else if (focus == this.conversation_list_view || focus.is_ancestor(this.conversation_list_view)) { focus = this.folder_list; } else if (focus == this.conversation_viewer || focus.is_ancestor(this.conversation_viewer)) { focus = this.conversation_list_view; } } if (focus != null) { focus.focus(TAB_FORWARD); } else { get_window().beep(); } } private SimpleAction get_window_action(string name) { return (SimpleAction) lookup_action(name); } private SimpleAction get_edit_action(string name) { return (SimpleAction) this.edit_actions.lookup_action(name); } private void activate_action(GLib.Action? action) { if (action != null && action.get_enabled()) { action.activate(null); } else { get_window().beep(); } } private void on_scan_completed(Geary.App.ConversationMonitor monitor) { // Done scanning. Check if we have enough messages to fill // the conversation list; if not, trigger a load_more(); Gtk.Scrollbar? scrollbar = ( this.conversation_list_scrolled.get_vscrollbar() as Gtk.Scrollbar ); if (is_visible() && (scrollbar == null || !scrollbar.get_visible()) && monitor == this.conversations && monitor.can_load_more) { debug("Not enough messages, loading more for folder %s", this.selected_folder.to_string()); load_more(); } } private void on_scan_error(Geary.App.ConversationMonitor monitor, GLib.Error err) { Geary.AccountInformation account = monitor.base_folder.account.information; this.controller.report_problem( new Geary.ServiceProblemReport(account, account.incoming, err) ); } private void on_load_more() { load_more(); } [GtkCallback] private void on_map() { this.update_ui_timeout.start(); update_ui(); } [GtkCallback] private void on_unmap() { this.update_ui_timeout.reset(); } [GtkCallback] private bool on_focus_event() { this.set_shift_key_down(false); return false; } [GtkCallback] private bool on_delete_event() { if (close_composer(true, false)) { this.sensitive = false; this.select_folder.begin( null, false, true, (obj, res) => { this.select_folder.end(res); destroy(); } ); } return Gdk.EVENT_STOP; } [GtkCallback] private void on_outer_leaflet_changed() { if (this.has_composer && this.outer_leaflet.folded && (this.is_folder_list_shown || this.is_conversation_list_shown)) { close_composer(false, false); } } private void on_offline_infobar_response() { this.info_bars.remove(this.offline_infobar); } private void on_service_problem_retry() { this.info_bars.remove(this.service_problem_infobar); this.service_problem_infobar = null; retry_service_problem(Geary.ClientService.Status.CONNECTION_FAILED); } private void on_cert_problem_retry() { this.info_bars.remove(this.cert_problem_infobar); retry_service_problem(Geary.ClientService.Status.TLS_VALIDATION_FAILED); } private void on_auth_problem_retry() { this.info_bars.remove(this.auth_problem_infobar); retry_service_problem(Geary.ClientService.Status.AUTHENTICATION_FAILED); } private void on_update_ui_timeout() { update_ui(); } private void on_account_available(AccountContext account) { add_account(account); } private void on_account_unavailable(AccountContext account, bool is_shutdown) { // If we're not shutting down, select the inbox of the first // account so that we show something other than empty // conversation list/viewer. Geary.Folder? to_select = null; if (!is_shutdown) { to_select = get_first_inbox(); } this.remove_account.begin(account, to_select); } private void on_folders_available(Gee.Collection available) { add_folders(available); } private void on_folders_unavailable(Gee.Collection unavailable) { remove_folders(unavailable, true); } private void on_use_changed(Geary.Folder folder, Geary.Folder.SpecialUse old_type, Geary.Folder.SpecialUse new_type) { // Update the main window AccountContext? context = this.controller.get_context_for_account( folder.account.information ); if (context != null) { FolderContext? folder_context = context.get_folder(folder); if (folder_context != null) { this.folder_list.remove_folder(folder_context); this.folder_list.add_folder(folder_context); // Since removing the folder will also remove its children // from the folder list, we need to check for any and re-add // them. See issue #11. try { foreach (Geary.Folder child in folder.account.list_matching_folders(folder.path)) { FolderContext? child_context = context.get_folder(child); if (child_context != null) { this.folder_list.add_folder(child_context); } } } catch (Error err) { // Oh well } } } } private void on_command_execute(Command command) { if (!(command is TrivialCommand)) { // Only show an execute notification for non-trivial // commands on_command_redo(command); } else { // Still have to update the undo/redo actions for trivial // commands update_command_actions(); } } private void on_command_undo(Command command) { update_command_actions(); EmailCommand? email = command as EmailCommand; if (email != null) { if (email.conversations.size > 1) { this.show_conversations.begin( email.location, email.conversations, false ); } else { this.show_email.begin( email.location, email.email, false ); } } if (command.undone_label != null) { Components.InAppNotification ian = new Components.InAppNotification(command.undone_label); ian.set_button(_("Redo"), Action.Edit.prefix(Action.Edit.REDO)); add_notification(ian); } } private void on_command_redo(Command command) { update_command_actions(); if (command.executed_label != null) { uint notification_time = Components.InAppNotification.DEFAULT_DURATION; if (command.executed_notification_brief) { notification_time = application.config.brief_notification_duration; } Components.InAppNotification ian = new Components.InAppNotification( command.executed_label, notification_time ); ian.set_button(_("Undo"), Action.Edit.prefix(Action.Edit.UNDO)); add_notification(ian); } } private void on_conversation_view_added(ConversationListBox list) { list.email_loaded.connect(on_email_loaded); 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.trash_email.connect(on_email_trash); list.delete_email.connect(on_email_delete); } // Window-level action callbacks private void on_undo() { this.undo.begin(); } private void on_redo() { this.redo.begin(); } private void on_close() { close(); } // this signal does not necessarily indicate that the application // previously didn't have focus and now it does private void on_has_toplevel_focus() { if (this.selected_folder != null) { this.controller.clear_new_messages( this.selected_folder, this.conversation_list_view.get_visible_conversations() ); } } private void on_folder_selected(Geary.Folder? folder) { this.select_folder.begin(folder, true); } private void on_select_inbox(SimpleAction action, Variant? parameter) { if (parameter != null) { int account_number = parameter.get_int32(); try { Gee.Collection accounts = this.application.engine.get_accounts(); if (account_number < accounts.size) { Geary.Account account = accounts.to_array()[account_number]; Geary.Folder inbox = account.get_special_folder(INBOX); this.select_folder.begin(inbox, true); } } catch (GLib.Error error) { debug("Error getting accounts"); } } } private void on_search(string text) { if (Geary.String.is_empty_or_whitespace(text)) { stop_search(true); } else { this.start_search(text, true); } } private void on_visible_conversations_changed(Gee.Set visible) { if (this.selected_folder != null) { this.controller.clear_new_messages(this.selected_folder, visible); } } private void on_folder_activated(Geary.Folder? folder) { if (folder != null) focus_next_pane(); } private void on_conversation_activated(Geary.App.Conversation activated, bool single) { if (single) { if (this.outer_leaflet.folded) { focus_next_pane(); } } else if (this.selected_folder != null) { if (this.selected_folder.used_as != DRAFTS) { this.application.new_window.begin( this.selected_folder, this.conversation_list_view.copy_selected() ); } else { // TODO: Determine how to map between conversations // and drafts correctly. Geary.Email draft = activated.get_latest_recv_email(IN_FOLDER); this.create_composer.begin( this.selected_folder.account, EDIT, draft, null ); } } } private void on_find_in_conversation_action() { this.conversation_viewer.enable_find(); } private void on_search_activated() { show_search_bar(); } private void on_zoom(SimpleAction action, Variant? parameter) { ConversationListBox? view = this.conversation_viewer.current_list; if (view != null && parameter != null) { string zoom_action = parameter.get_string(); if (zoom_action == "in") view.zoom_in(); else if (zoom_action == "out") view.zoom_out(); else view.zoom_reset(); } } private void on_reply_conversation() { this.create_composer_from_viewer.begin(REPLY_SENDER); } private void on_reply_all_conversation() { this.create_composer_from_viewer.begin(REPLY_ALL); } private void on_forward_conversation() { this.create_composer_from_viewer.begin(FORWARD); } private void on_show_window_menu() { show_window_menu(); } private void on_show_help_overlay() { var overlay = get_help_overlay(); overlay.section_name = "conversation"; overlay.show(); } private void on_show_copy_menu() { if (this.is_conversation_list_shown && this.conversation_list_actions_revealer.child_revealed) { this.conversation_list_actions.show_copy_menu(); } else if (this.is_conversation_viewer_shown) { this.main_toolbar.shown_actions.show_copy_menu(); } else { this.error_bell(); } } private void on_show_move_menu() { if (this.is_conversation_list_shown && this.conversation_list_actions_revealer.child_revealed) { this.conversation_list_actions.show_move_menu(); } else if (this.is_conversation_viewer_shown) { this.main_toolbar.shown_actions.show_move_menu(); } else { this.error_bell(); } } private void on_conversation_up() { this.conversation_list_view.scroll(Gtk.ScrollType.STEP_UP); } private void on_conversation_down() { this.conversation_list_view.scroll(Gtk.ScrollType.STEP_DOWN); } private void on_show_mark_menu() { bool unread_selected = false; bool read_selected = false; bool starred_selected = false; bool unstarred_selected = false; foreach (Geary.App.Conversation conversation in this.conversation_list_view.get_selected()) { if (conversation.is_unread()) unread_selected = true; // Only check the messages that "Mark as Unread" would mark, so we // don't add the menu option and have it not do anything. // // Sort by Date: field to correspond with ConversationViewer ordering Geary.Email? latest = conversation.get_latest_sent_email( Geary.App.Conversation.Location.IN_FOLDER_OUT_OF_FOLDER); if (latest != null && latest.email_flags != null && !latest.email_flags.contains(Geary.EmailFlags.UNREAD)) read_selected = true; if (conversation.is_flagged()) { starred_selected = true; } else { unstarred_selected = true; } } get_window_action(ACTION_MARK_AS_READ).set_enabled(unread_selected); get_window_action(ACTION_MARK_AS_UNREAD).set_enabled(read_selected); get_window_action(ACTION_MARK_AS_STARRED).set_enabled(unstarred_selected); get_window_action(ACTION_MARK_AS_UNSTARRED).set_enabled(starred_selected); // If we're in Drafts/Outbox, we also shouldn't set a message as junk bool in_junk_folder = (selected_folder.used_as == JUNK); get_window_action(ACTION_TOGGLE_JUNK).set_enabled( !in_junk_folder && selected_folder.used_as != DRAFTS && selected_folder.used_as != OUTBOX ); } private void on_mark_conversations(Gee.Collection conversations, Geary.NamedFlag flag) { Geary.Folder? location = this.selected_folder; if (location != null) { this.controller.mark_conversations.begin( location, conversations, flag, true, (obj, res) => { try { this.controller.mark_conversations.end(res); } catch (GLib.Error err) { handle_error(location.account.information, err); } } ); } } private void on_mark_as_read() { Geary.Folder? location = this.selected_folder; if (location != null) { this.controller.mark_conversations.begin( location, this.conversation_list_view.copy_selected(), Geary.EmailFlags.UNREAD, false, (obj, res) => { try { this.controller.mark_conversations.end(res); } catch (GLib.Error err) { handle_error(location.account.information, err); } } ); } } private void on_mark_as_unread() { Geary.Folder? location = this.selected_folder; if (location != null) { this.controller.mark_conversations.begin( location, this.conversation_list_view.copy_selected(), Geary.EmailFlags.UNREAD, true, (obj, res) => { try { this.controller.mark_conversations.end(res); } catch (GLib.Error err) { handle_error(location.account.information, err); } } ); } } private void on_mark_as_starred() { Geary.Folder? location = this.selected_folder; if (location != null) { this.controller.mark_conversations.begin( location, this.conversation_list_view.copy_selected(), Geary.EmailFlags.FLAGGED, true, (obj, res) => { try { this.controller.mark_conversations.end(res); } catch (GLib.Error err) { handle_error(location.account.information, err); } } ); } } private void on_mark_as_unstarred() { Geary.Folder? location = this.selected_folder; if (location != null) { this.controller.mark_conversations.begin( location, this.conversation_list_view.copy_selected(), Geary.EmailFlags.FLAGGED, false, (obj, res) => { try { this.controller.mark_conversations.end(res); } catch (GLib.Error err) { handle_error(location.account.information, err); } } ); } } private void on_mark_as_junk_toggle() { Geary.Folder? source = this.selected_folder; if (source != null) { Geary.Folder.SpecialUse destination = (source.used_as != JUNK) ? Geary.Folder.SpecialUse.JUNK : Geary.Folder.SpecialUse.INBOX; this.controller.move_conversations_special.begin( source, destination, this.conversation_list_view.copy_selected(), (obj, res) => { try { this.controller.move_conversations_special.end(res); } catch (GLib.Error err) { handle_error(source.account.information, err); } } ); } } private void on_move_conversation(Geary.Folder destination) { Geary.FolderSupport.Move source = this.selected_folder as Geary.FolderSupport.Move; if (source != null) { this.controller.move_conversations.begin( source, destination, this.conversation_list_view.copy_selected(), (obj, res) => { try { this.controller.move_conversations.end(res); } catch (GLib.Error err) { handle_error(source.account.information, err); } } ); } } private void on_copy_conversation(Geary.Folder destination) { Geary.FolderSupport.Copy source = this.selected_folder as Geary.FolderSupport.Copy; if (source != null) { this.controller.copy_conversations.begin( source, destination, this.conversation_list_view.copy_selected(), (obj, res) => { try { this.controller.copy_conversations.end(res); } catch (GLib.Error err) { handle_error(source.account.information, err); } } ); } } private void on_archive_conversation() { Geary.Folder source = this.selected_folder; if (source != null) { this.controller.move_conversations_special.begin( source, ARCHIVE, this.conversation_list_view.copy_selected(), (obj, res) => { try { this.controller.move_conversations_special.end(res); } catch (GLib.Error err) { handle_error(source.account.information, err); } } ); } } private void on_trash_conversation() { Geary.Folder source = this.selected_folder; if (source != null) { this.controller.move_conversations_special.begin( source, TRASH, this.conversation_list_view.copy_selected(), (obj, res) => { try { this.controller.move_conversations_special.end(res); } catch (GLib.Error err) { handle_error(source.account.information, err); } } ); } } private void on_delete_conversation() { Geary.FolderSupport.Remove target = this.selected_folder as Geary.FolderSupport.Remove; Gee.Collection conversations = this.conversation_list_view.copy_selected(); if (target != null && this.prompt_delete_conversations(conversations.size)) { this.controller.delete_conversations.begin( target, conversations, (obj, res) => { try { this.controller.delete_conversations.end(res); } catch (GLib.Error err) { handle_error(target.account.information, err); } } ); } } private void on_email_loaded(ConversationListBox view, Geary.Email loaded) { this.controller.email_loaded( view.conversation.base_folder.account.information, loaded ); } private void on_email_mark(ConversationListBox view, Gee.Collection messages, Geary.NamedFlag? to_add, Geary.NamedFlag? to_remove) { Geary.Folder? location = this.selected_folder; if (location != 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.controller.mark_messages.begin( location, Geary.Collection.single(view.conversation), messages, add_flags, remove_flags, (obj, res) => { try { this.controller.mark_messages.end(res); } catch (GLib.Error err) { handle_error(location.account.information, err); } } ); } } private void on_email_reply_to_sender(Geary.Email target, string? quote) { if (this.selected_account != null) { this.create_composer.begin( this.selected_account, REPLY_SENDER, target, quote ); } } private void on_email_reply_to_all(Geary.Email target, string? quote) { if (this.selected_account != null) { this.create_composer.begin( this.selected_account, REPLY_ALL, target, quote ); } } private void on_email_forward(Geary.Email target, string? quote) { if (this.selected_account != null) { this.create_composer.begin( this.selected_account, FORWARD, target, quote ); } } private void on_email_trash(ConversationListBox view, Geary.Email target) { Geary.Folder? source = this.selected_folder; if (source != null) { this.controller.move_messages_special.begin( source, TRASH, Geary.Collection.single(view.conversation), Geary.Collection.single(target.id), (obj, res) => { try { this.controller.move_messages_special.end(res); } catch (GLib.Error err) { handle_error(source.account.information, err); } } ); } } private void on_email_delete(ConversationListBox view, Geary.Email target) { Geary.FolderSupport.Remove? source = this.selected_folder as Geary.FolderSupport.Remove; if (source != null && prompt_delete_messages(1)) { this.controller.delete_messages.begin( source, Geary.Collection.single(view.conversation), Geary.Collection.single(target.id), (obj, res) => { try { this.controller.delete_messages.end(res); } catch (GLib.Error err) { handle_error(source.account.information, err); } } ); } } }