diff --git a/po/POTFILES.in b/po/POTFILES.in index 166ebb68..84222679 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -69,10 +69,10 @@ src/client/composer/composer-widget.vala src/client/composer/composer-window.vala src/client/composer/contact-entry-completion.vala src/client/composer/spell-check-popover.vala -src/client/conversation-list/conversation-list-cell-renderer.vala -src/client/conversation-list/conversation-list-store.vala +src/client/conversation-list/conversation-list-model.vala +src/client/conversation-list/conversation-list-row.vala src/client/conversation-list/conversation-list-view.vala -src/client/conversation-list/formatted-conversation-data.vala +src/client/conversation-list/conversation-list-participant.vala src/client/conversation-viewer/conversation-email.vala src/client/conversation-viewer/conversation-list-box.vala src/client/conversation-viewer/conversation-message.vala @@ -466,6 +466,8 @@ ui/components-placeholder-pane.ui ui/conversation-contact-popover.ui ui/conversation-email.ui ui/conversation-email-menus.ui +ui/conversation-list-row.ui +ui/conversation-list-view.ui ui/conversation-message-link-popover.ui ui/conversation-message-menus.ui ui/conversation-message.ui diff --git a/src/client/application/application-client.vala b/src/client/application/application-client.vala index 04b73f8d..51d0f632 100644 --- a/src/client/application/application-client.vala +++ b/src/client/application/application-client.vala @@ -399,17 +399,6 @@ public class Application.Client : Gtk.Application { add_edit_accelerators(Action.Edit.REDO, { "Z" }); add_edit_accelerators(Action.Edit.UNDO, { "Z" }); - // Set up custom keybindings - unowned Gtk.BindingSet bindings = Gtk.BindingSet.by_class( - (ObjectClass) typeof(Gtk.ListBoxRow).class_ref() - ); - Gtk.BindingEntry.add_signal( - bindings, Gdk.Key.Right, MOD1_MASK, "activate", 0 - ); - Gtk.BindingEntry.add_signal( - bindings, Gdk.Key.Forward, 0, "activate", 0 - ); - // Load Geary GTK CSS var provider = new Gtk.CssProvider(); Gtk.StyleContext.add_provider_for_screen( @@ -1203,7 +1192,7 @@ public class Application.Client : Gtk.Application { MainWindow? current = this.last_active_main_window; if (current != null) { folder = current.selected_folder; - conversations = current.conversation_list_view.copy_selected(); + conversations = current.conversation_list_view.selected; } this.new_window.begin(folder, conversations); } diff --git a/src/client/application/application-main-window.vala b/src/client/application/application-main-window.vala index 394c9e5a..d9f521c8 100644 --- a/src/client/application/application-main-window.vala +++ b/src/client/application/application-main-window.vala @@ -46,7 +46,7 @@ public class Application.MainWindow : { 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}, + { ACTION_NAVIGATION_BACK, go_to_previous_pane}, // Message actions { ACTION_REPLY_CONVERSATION, on_reply_conversation }, @@ -237,6 +237,16 @@ public class Application.MainWindow : "navigate", 1, typeof(Gtk.ScrollType), Gtk.ScrollType.STEP_DOWN ); + Gtk.BindingEntry.add_signal( + bindings, + Gdk.Key.Escape, 0, + "escape", 0 + ); + Gtk.BindingEntry.add_signal( + bindings, + Gdk.Key.a, CONTROL_MASK, + "select_all", 0 + ); } @@ -355,7 +365,7 @@ public class Application.MainWindow : // Widget descendants public FolderList.Tree folder_list { get; private set; default = new FolderList.Tree(); } public SearchBar search_bar { get; private set; } - public ConversationListView conversation_list_view { get; private set; } + public ConversationList.View conversation_list_view { get; private set; } public ConversationViewer conversation_viewer { get; private set; } public Components.InfoBarStack conversation_list_info_bars { @@ -402,7 +412,6 @@ public class Application.MainWindow : [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; @@ -517,22 +526,34 @@ public class Application.MainWindow : activate_action(get_window_action(ACTION_FIND_IN_CONVERSATION)); } + /** Keybinding signal for escaping current view. */ + [Signal (action=true)] + public virtual signal void escape() { + navigate_previous_pane(); + } + + /** Keybinding signal for selecting all elements in current view. */ + [Signal (action=true)] + public virtual signal void select_all() { + this.conversation_list_view.select_all(); + } + /** 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(); + go_to_previous_pane(); } else { - focus_next_pane(); + go_to_next_pane(); } break; case Gtk.ScrollType.PAGE_RIGHT: if (get_direction() != RTL) { - focus_next_pane(); + go_to_next_pane(); } else { - focus_previous_pane(); + go_to_previous_pane(); } break; case Gtk.ScrollType.STEP_UP: @@ -659,7 +680,9 @@ public class Application.MainWindow : cert_retry.clicked.connect(on_cert_problem_retry); this.cert_problem_infobar.get_action_area().add(cert_retry); - this.conversation_list_view.grab_focus(); + this.map.connect(() => { + this.folder_list.grab_focus(); + }); foreach (var actions in this.folder_conversation_actions) { actions.mark_message_button_toggled.connect(on_show_mark_menu); @@ -760,6 +783,8 @@ public class Application.MainWindow : this.folder_open.cancel(); var cancellable = this.folder_open = new GLib.Cancellable(); + this.conversation_list_headerbar.selection_open = false; + // Dispose of all existing objects for the currently // selected model. @@ -776,11 +801,7 @@ public class Application.MainWindow : 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_view.set_monitor(null); } this.conversation_list_info_bars.remove_all(); @@ -829,22 +850,17 @@ public class Application.MainWindow : // Include fields for the conversation viewer as well so // conversations can be displayed without having to go // back to the db - ConversationListStore.REQUIRED_FIELDS | + ConversationList.View.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); + this.conversation_list_view.set_monitor(this.conversations); // disable copy/move to the new folder foreach (var menu in this.folder_popovers) { @@ -930,7 +946,6 @@ public class Application.MainWindow : Gee.Collection.empty(), is_interactive ); - } else { } } } @@ -1335,15 +1350,16 @@ public class Application.MainWindow : 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 = new ConversationList.View(this.application.config); 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); + this.conversation_list_view.visible_conversations.notify.connect(on_visible_conversations_changed); + + this.conversation_list_box.pack_start( + this.conversation_list_view, true, true, 0 + ); // Conversation viewer this.conversation_viewer = new ConversationViewer( @@ -1361,11 +1377,25 @@ public class Application.MainWindow : this.search_bar, "search-mode-enabled", SYNC_CREATE | BIDIRECTIONAL ); + this.conversation_list_headerbar.bind_property( + "selection-open", + this.conversation_list_view, "selection-mode-enabled", + SYNC_CREATE | BIDIRECTIONAL + ); this.conversation_headerbar.bind_property( "find-open", this.conversation_viewer.conversation_find_bar, "search-mode-enabled", SYNC_CREATE | BIDIRECTIONAL ); + this.conversation_list_headerbar.notify["selection-open"].connect( + () => { + if (this.conversation_list_view.selection_mode_enabled) + this.conversation_list_actions_revealer.reveal_child = ( + this.outer_leaflet.folded); + else + this.conversation_list_actions_revealer.reveal_child = false; + } + ); this.conversation_headerbar.notify["shown-actions"].connect( () => { this.conversation_viewer_actions_revealer.reveal_child = ( @@ -1383,6 +1413,8 @@ public class Application.MainWindow : this.status_bar.add(this.spinner); this.status_bar.show_all(); + this.conversation_list_actions.set_mark_inverted(); + this.folder_conversation_actions = { this.conversation_headerbar.full_actions, this.conversation_list_actions @@ -1552,11 +1584,7 @@ public class Application.MainWindow : 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(); - } + this.conversation_list_view.refresh_times(); } } @@ -1750,15 +1778,49 @@ public class Application.MainWindow : } } - private void load_more() { - if (this.is_conversation_list_shown && - this.conversations != null) { - this.conversations.min_window_count += MIN_CONVERSATION_COUNT; + private void on_conversations_selected(Gee.Set selected) { + bool folded = this.outer_leaflet.folded; + // Else selection handled by activated + if (selected.size > 1 || !folded) { + select_conversations.begin(selected, Gee.Collection.empty(), true); + } + if (this.conversation_list_view.selection_mode_enabled) { + if (selected.size > 0) { + this.conversation_list_actions_revealer.reveal_child = folded; + } else { + this.conversation_list_actions_revealer.reveal_child = false; + } } } - private void on_conversations_selected(Gee.Set selected) { - this.select_conversations.begin(selected, Gee.Collection.empty(), true); + private void on_conversation_activated(Geary.App.Conversation activated, uint button) { + if (button == 1) { + bool folded = this.outer_leaflet.folded; + go_to_next_pane(true); + if (folded) { + Gee.Collection selected = + new Gee.ArrayList(); + selected.add(activated); + select_conversations.begin(selected, Gee.Collection.empty(), true); + } + } 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.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_conversation_count_changed() { @@ -1778,7 +1840,7 @@ public class Application.MainWindow : // 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_list_view.selected.size == 0) { this.conversation_viewer.show_none_selected(); update_conversation_actions(NONE); } @@ -1865,20 +1927,6 @@ public class Application.MainWindow : sensitive && (this.selected_folder is Geary.FolderSupport.Remove) ); - switch (count) { - case NONE: - this.conversation_list_actions_revealer.reveal_child = false; - break; - case SINGLE: - this.conversation_list_actions_revealer.reveal_child = ( - this.outer_leaflet.folded - ); - break; - case MULTIPLE: - this.conversation_list_actions_revealer.reveal_child = true; - break; - } - this.update_context_dependent_actions.begin(sensitive); } @@ -1907,7 +1955,7 @@ public class Application.MainWindow : Gee.Collection ids = new Gee.LinkedList(); foreach (Geary.App.Conversation convo in - this.conversation_list_view.get_selected()) { + this.conversation_list_view.selected) { ids.add_all(convo.get_email_ids()); } try { @@ -1956,65 +2004,83 @@ public class Application.MainWindow : } } - 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); + private void focus_widget(Gtk.Widget? widget) { + if (widget != null) { + widget.focus(TAB_FORWARD); } else { error_bell(); } } - private void focus_previous_pane() { + private void navigate_next_pane() { var focus = get_focus(); + 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.selected.size == 1 && + this.selected_folder.properties.email_total > 0) { + this.outer_leaflet.navigate(Hdy.NavigationDirection.FORWARD); + focus = this.conversation_viewer.visible_child; + } + } + } + focus_widget(focus); + } + private void focus_next_pane() { + var focus = get_focus(); + 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; + } + } + focus_widget(focus); + } + + private void go_to_next_pane(bool only_if_folded=false) { 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; + navigate_next_pane(); + } else if (!only_if_folded) { + focus_next_pane(); + } + } + + private void navigate_previous_pane() { + var focus = get_focus(); + 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 { - this.outer_leaflet.navigate(Hdy.NavigationDirection.BACK); - focus = this.conversation_list_view; + if (focus == this.conversation_list_view || + focus.is_ancestor(this.conversation_list_view)) + focus = this.folder_list; + else + focus = this.conversation_list_view; } - } else if (focus != null) { + } else { + this.outer_leaflet.navigate(Hdy.NavigationDirection.BACK); + focus = this.conversation_list_view; + } + focus_widget(focus); + } + + private void focus_previous_pane() { + var focus = get_focus(); + if (focus != null) { if (focus == this.folder_list || focus.is_ancestor(this.folder_list)) { focus = this.conversation_viewer.visible_child; @@ -2026,13 +2092,15 @@ public class Application.MainWindow : focus = this.conversation_list_view; } } + focus_widget(focus); + } - if (focus != null) { - focus.focus(TAB_FORWARD); + private void go_to_previous_pane() { + if (this.outer_leaflet.folded) { + navigate_previous_pane(); } else { - error_bell(); + focus_previous_pane(); } - } private SimpleAction get_window_action(string name) { @@ -2055,7 +2123,7 @@ public class Application.MainWindow : // 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 + this.conversation_list_view.get_vscrollbar() as Gtk.Scrollbar ); if (is_visible() && (scrollbar == null || !scrollbar.get_visible()) && @@ -2063,7 +2131,7 @@ public class Application.MainWindow : monitor.can_load_more) { debug("Not enough messages, loading more for folder %s", this.selected_folder.to_string()); - load_more(); + this.conversation_list_view.load_more(MIN_CONVERSATION_COUNT); } } @@ -2076,10 +2144,6 @@ public class Application.MainWindow : ); } - private void on_load_more() { - load_more(); - } - [GtkCallback] private void on_map() { this.update_ui_timeout.start(); @@ -2116,7 +2180,7 @@ public class Application.MainWindow : [GtkCallback] private void on_outer_leaflet_changed() { - int selected = this.conversation_list_view.get_selected().size; + int selected = this.conversation_list_view.selected.size; update_conversation_actions( ConversationCount.for_size(selected) ); @@ -2140,6 +2204,13 @@ public class Application.MainWindow : } else { this.conversation_list_headerbar.show_close_button = false; this.conversation_headerbar.back_button.visible = false; + if (selected > 0) { + select_conversations.begin( + this.conversation_list_view.selected, + Gee.Collection.empty(), + false + ); + } } } @@ -2321,7 +2392,7 @@ public class Application.MainWindow : if (this.selected_folder != null) { this.controller.clear_new_messages( this.selected_folder, - this.conversation_list_view.get_visible_conversations() + this.conversation_list_view.visible_conversations ); } } @@ -2355,39 +2426,15 @@ public class Application.MainWindow : } } - private void on_visible_conversations_changed(Gee.Set visible) { + private void on_visible_conversations_changed() { if (this.selected_folder != null) { - this.controller.clear_new_messages(this.selected_folder, visible); + this.controller.clear_new_messages(this.selected_folder, this.conversation_list_view.visible_conversations); } } 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 - ); - } + if (folder != null) { + go_to_next_pane(); } } @@ -2470,7 +2517,7 @@ public class Application.MainWindow : bool starred_selected = false; bool unstarred_selected = false; foreach (Geary.App.Conversation conversation in - this.conversation_list_view.get_selected()) { + this.conversation_list_view.selected) { if (conversation.is_unread()) unread_selected = true; @@ -2529,7 +2576,7 @@ public class Application.MainWindow : if (location != null) { this.controller.mark_conversations.begin( location, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, Geary.EmailFlags.UNREAD, false, (obj, res) => { @@ -2541,6 +2588,7 @@ public class Application.MainWindow : } ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_mark_as_unread() { @@ -2548,7 +2596,7 @@ public class Application.MainWindow : if (location != null) { this.controller.mark_conversations.begin( location, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, Geary.EmailFlags.UNREAD, true, (obj, res) => { @@ -2560,6 +2608,7 @@ public class Application.MainWindow : } ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_mark_as_starred() { @@ -2567,7 +2616,7 @@ public class Application.MainWindow : if (location != null) { this.controller.mark_conversations.begin( location, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, Geary.EmailFlags.FLAGGED, true, (obj, res) => { @@ -2579,6 +2628,7 @@ public class Application.MainWindow : } ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_mark_as_unstarred() { @@ -2586,7 +2636,7 @@ public class Application.MainWindow : if (location != null) { this.controller.mark_conversations.begin( location, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, Geary.EmailFlags.FLAGGED, false, (obj, res) => { @@ -2598,6 +2648,7 @@ public class Application.MainWindow : } ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_mark_as_junk_toggle() { @@ -2610,7 +2661,7 @@ public class Application.MainWindow : this.controller.move_conversations_special.begin( source, destination, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, (obj, res) => { try { this.controller.move_conversations_special.end(res); @@ -2620,6 +2671,7 @@ public class Application.MainWindow : } ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_move_conversation(Geary.Folder destination) { @@ -2629,7 +2681,7 @@ public class Application.MainWindow : this.controller.move_conversations.begin( source, destination, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, (obj, res) => { try { this.controller.move_conversations.end(res); @@ -2640,6 +2692,7 @@ public class Application.MainWindow : ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_copy_conversation(Geary.Folder destination) { @@ -2649,7 +2702,7 @@ public class Application.MainWindow : this.controller.copy_conversations.begin( source, destination, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, (obj, res) => { try { this.controller.copy_conversations.end(res); @@ -2660,6 +2713,7 @@ public class Application.MainWindow : ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_archive_conversation() { @@ -2668,7 +2722,7 @@ public class Application.MainWindow : this.controller.move_conversations_special.begin( source, ARCHIVE, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, (obj, res) => { try { this.controller.move_conversations_special.end(res); @@ -2678,6 +2732,7 @@ public class Application.MainWindow : } ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_trash_conversation() { @@ -2686,7 +2741,7 @@ public class Application.MainWindow : this.controller.move_conversations_special.begin( source, TRASH, - this.conversation_list_view.copy_selected(), + this.conversation_list_view.selected, (obj, res) => { try { this.controller.move_conversations_special.end(res); @@ -2696,13 +2751,14 @@ public class Application.MainWindow : } ); } + // No need to disable selection mode, handled by model change } 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(); + this.conversation_list_view.selected; if (target != null && this.prompt_delete_conversations(conversations.size)) { this.controller.delete_conversations.begin( target, @@ -2716,6 +2772,7 @@ public class Application.MainWindow : } ); } + // No need to disable selection mode, handled by model change } private void on_email_loaded(ConversationListBox view, @@ -2757,6 +2814,7 @@ public class Application.MainWindow : } ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_email_reply_to_sender(Geary.Email target, string? quote) { @@ -2765,6 +2823,7 @@ public class Application.MainWindow : this.selected_account, REPLY_SENDER, target, quote ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_email_reply_to_all(Geary.Email target, string? quote) { @@ -2773,6 +2832,7 @@ public class Application.MainWindow : this.selected_account, REPLY_ALL, target, quote ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_email_forward(Geary.Email target, string? quote) { @@ -2781,6 +2841,7 @@ public class Application.MainWindow : this.selected_account, FORWARD, target, quote ); } + this.conversation_list_view.selection_mode_enabled = false; } private void on_email_trash(ConversationListBox view, Geary.Email target) { diff --git a/src/client/components/components-conversation-actions.vala b/src/client/components/components-conversation-actions.vala index 7adb6afc..0483b9f3 100644 --- a/src/client/components/components-conversation-actions.vala +++ b/src/client/components/components-conversation-actions.vala @@ -98,6 +98,13 @@ public class Components.ConversationActions : Gtk.Box { this.copy_message_button.clicked(); } + public void set_mark_inverted() { + var image = new Gtk.Image.from_icon_name( + "pan-up-symbolic", Gtk.IconSize.BUTTON + ); + this.mark_message_button.set_image(image); + } + public void update_trash_button(bool show_trash) { this.show_trash_button = show_trash; update_conversation_buttons(); diff --git a/src/client/components/components-headerbar-conversation-list.vala b/src/client/components/components-headerbar-conversation-list.vala index 319b2a4e..78f24d35 100644 --- a/src/client/components/components-headerbar-conversation-list.vala +++ b/src/client/components/components-headerbar-conversation-list.vala @@ -19,8 +19,10 @@ public class Components.ConversationListHeaderBar : Hdy.HeaderBar { public string account { get; set; } public string folder { get; set; } public bool search_open { get; set; default = false; } + public bool selection_open { get; set; default = false; } [GtkChild] private unowned Gtk.ToggleButton search_button; + [GtkChild] private unowned Gtk.ToggleButton selection_button; [GtkChild] public unowned Gtk.Button back_button; @@ -33,5 +35,10 @@ public class Components.ConversationListHeaderBar : Hdy.HeaderBar { this.search_button, "active", SYNC_CREATE | BIDIRECTIONAL ); + this.bind_property( + "selection-open", + this.selection_button, "active", + SYNC_CREATE | BIDIRECTIONAL + ); } } diff --git a/src/client/components/count-badge.vala b/src/client/components/count-badge.vala index a0a1963d..b5a833b0 100644 --- a/src/client/components/count-badge.vala +++ b/src/client/components/count-badge.vala @@ -9,6 +9,7 @@ */ public class CountBadge : Geary.BaseObject { public const string UNREAD_BG_COLOR = "#888888"; + public const int SPACING = 6; private const int FONT_SIZE_MESSAGE_COUNT = 8; @@ -63,7 +64,7 @@ public class CountBadge : Geary.BaseObject { Pango.Rectangle? logical_rect; layout_num.get_pixel_extents(out ink_rect, out logical_rect); if (ctx != null) { - double bg_width = logical_rect.width + FormattedConversationData.SPACING; + double bg_width = logical_rect.width + SPACING; double bg_height = logical_rect.height; double radius = bg_height / 2.0; double degrees = Math.PI / 180.0; @@ -87,7 +88,7 @@ public class CountBadge : Geary.BaseObject { Pango.cairo_show_layout(ctx, layout_num); } - width = logical_rect.width + FormattedConversationData.SPACING; + width = logical_rect.width + SPACING; height = logical_rect.height; } } diff --git a/src/client/conversation-list/conversation-list-cell-renderer.vala b/src/client/conversation-list/conversation-list-cell-renderer.vala deleted file mode 100644 index ea009128..00000000 --- a/src/client/conversation-list/conversation-list-cell-renderer.vala +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - -public class ConversationListCellRenderer : Gtk.CellRenderer { - private static FormattedConversationData? example_data = null; - private static bool hover_selected = false; - - // Mail message data. - public FormattedConversationData data { get; set; } - - public ConversationListCellRenderer() { - } - - ~ConversationListCellRenderer() { - example_data = null; - } - - public override void get_preferred_height(Gtk.Widget widget, - out int minimum_size, - out int natural_size) { - if (example_data == null) - style_changed(widget); - - minimum_size = natural_size = example_data.get_height(); - } - - public override void get_preferred_width(Gtk.Widget widget, - out int minimum_size, - out int natural_size) { - // Set width to 1 (rather than 0) to work around certain - // themes that cause the conversation list to be shown as - // "squished": - // https://bugzilla.gnome.org/show_bug.cgi?id=713954 - minimum_size = natural_size = 1; - } - - public override void render(Cairo.Context ctx, Gtk.Widget widget, Gdk.Rectangle background_area, - Gdk.Rectangle cell_area, Gtk.CellRendererState flags) { - if (data != null) - data.render(ctx, widget, background_area, cell_area, flags, hover_selected); - } - - // Recalculates size when the style changed. - // Note: this must be called by the parent TreeView. - public static void style_changed(Gtk.Widget widget) { - var window = widget.get_toplevel() as Application.MainWindow; - if (window != null && example_data == null) { - example_data = new FormattedConversationData.create_example( - window.application.config - ); - } - - example_data.calculate_sizes(widget); - } - - // Shows hover effect on all selected cells. - public static void set_hover_selected(bool hover) { - hover_selected = hover; - } - - // This is implemented because it's required; ignore it and look at get_preferred_height() instead. - public override void get_size(Gtk.Widget widget, Gdk.Rectangle? cell_area, out int x_offset, - out int y_offset, out int width, out int height) { - // Set values to avoid compiler warning. - x_offset = 0; - y_offset = 0; - width = 0; - height = 0; - } -} diff --git a/src/client/conversation-list/conversation-list-model.vala b/src/client/conversation-list/conversation-list-model.vala new file mode 100644 index 00000000..9a5ea386 --- /dev/null +++ b/src/client/conversation-list/conversation-list-model.vala @@ -0,0 +1,217 @@ +/* + * Copyright © 2022 John Renner + * Copyright © 2022 Cédric Bellegarde + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +// The whole goal of this class to wrap the ConversationMonitor with a view that presents a sorted list +public class ConversationList.Model : Object, ListModel { + internal GLib.GenericArray items = new GLib.GenericArray(); + internal Geary.App.ConversationMonitor monitor { get; set; } + + private bool scanning = false; + + internal Model(Geary.App.ConversationMonitor monitor) { + this.monitor = monitor; + + monitor.conversations_added.connect(on_conversations_added); + monitor.conversation_appended.connect(on_conversation_updated); + monitor.conversation_trimmed.connect(on_conversation_updated); + monitor.conversations_removed.connect(on_conversations_removed); + monitor.scan_started.connect(on_scan_started); + monitor.scan_completed.connect(on_scan_completed); + } + + ~Model() { + this.monitor.conversations_added.disconnect(on_conversations_added); + this.monitor.conversation_appended.disconnect(on_conversation_updated); + this.monitor.conversation_trimmed.disconnect(on_conversation_updated); + this.monitor.conversations_removed.disconnect(on_conversations_removed); + this.monitor.scan_started.disconnect(on_scan_started); + this.monitor.scan_completed.disconnect(on_scan_completed); + } + + public signal void conversations_added(bool start); + public signal void conversations_removed(bool start); + public signal void conversations_loaded(); + public signal void conversation_updated(Geary.App.Conversation convo); + + private static int compare(Object a, Object b) { + return Util.Email.compare_conversation_descending(a as Geary.App.Conversation, b as Geary.App.Conversation); + } + + // ------------------------ + // Scanning and load_more + // ------------------------ + + private void on_scan_started(Geary.App.ConversationMonitor source) { + this.scanning = true; + } + + private void on_scan_completed(Geary.App.ConversationMonitor source) { + this.scanning = false; + GLib.Timeout.add(100, () => { + if (!this.scanning) { + conversations_loaded(); + } + return false; + }); + } + + public bool load_more(int amount) { + if (this.scanning) { + return false; + } + + this.monitor.min_window_count += amount; + return true; + } + + + // ------------------------ + // Model + // ------------------------ + + public Object? get_item(uint position) { + return this.items.get(position); + } + + public Type get_item_type() { + return typeof(Geary.App.Conversation); + } + + public uint get_n_items() { + return this.items.length; + } + + private bool insert_conversation(Geary.App.Conversation convo) { + // The conversation may be bogus, if so don't do anything + Geary.Email? last_email = convo.get_latest_recv_email(Geary.App.Conversation.Location.ANYWHERE); + + if (last_email == null) { + debug("Cannot add conversation: last email is null"); + return false; + } + + this.items.add(convo); + + return true; + } + + private GenericArray conversations_indexes(Gee.Collection conversations) { + GenericArray indexes = new GenericArray(); + uint index; + + foreach (Geary.App.Conversation convo in conversations) { + if (this.items.find(convo, out index)) { + indexes.add(index); + } + } + + return indexes; + } + + private void update_added(GenericArray indexes) { + indexes.sort((a, b) => { + return (int) (a > b) - (int) (a < b); + }); + + while (indexes.length > 0) { + uint? last_index = null; + uint count = 0; + foreach (unowned uint index in indexes) { + if (last_index != null && index > last_index + 1) { + break; + } + last_index = (int) index; + count++; + } + this.items_changed(indexes[0], 0, count); + indexes.remove_range(0, count); + } + } + + private void update_removed(GenericArray indexes) { + indexes.sort((a, b) => { + return (int) (a < b) - (int) (a > b); + }); + + while (indexes.length > 0) { + uint? last_index = null; + uint count = 0; + foreach (unowned uint index in indexes) { + if (last_index != null && index < last_index - 1) { + break; + } + last_index = index; + count++; + } + this.items_changed(last_index, count, 0); + indexes.remove_range(0, count); + } + } + + private void on_conversations_added(Gee.Collection conversations) { + debug("Adding %d conversations.", conversations.size); + + conversations_added(true); + + var added = 0; + foreach (Geary.App.Conversation convo in conversations) { + if (insert_conversation(convo)) { + added++; + } + } + this.items.sort(compare); + + GenericArray indexes = conversations_indexes(conversations); + update_added(indexes); + + conversations_added(false); + + debug("Added %d/%d conversations.", added, conversations.size); + } + + private void on_conversations_removed(Gee.Collection conversations) { + GenericArray indexes = conversations_indexes(conversations); + + debug("Removing %d conversations.", conversations.size); + + conversations_removed(true); + + var removed = 0; + foreach (Geary.App.Conversation convo in conversations) { + this.items.remove(convo); + removed++; + } + + update_removed(indexes); + + conversations_removed(false); + + debug("Removed %ld/%d conversations.", removed, conversations.size); + } + + private void on_conversation_updated(Geary.App.ConversationMonitor sender, Geary.App.Conversation convo, Gee.Collection emails) { + conversation_updated(convo); + + uint initial_index; + if (!this.items.find(convo, out initial_index)) { + return; + } + + this.items.sort(compare); + + uint final_index; + if (!this.items.find(convo, out final_index) || initial_index == final_index) { + return; + } + + uint count = initial_index > final_index ? + initial_index + 1 - final_index : + final_index + 1 - initial_index; + this.items_changed(uint.min(initial_index, final_index), count, count); + } +} diff --git a/src/client/conversation-list/conversation-list-participant.vala b/src/client/conversation-list/conversation-list-participant.vala new file mode 100644 index 00000000..1eade306 --- /dev/null +++ b/src/client/conversation-list/conversation-list-participant.vala @@ -0,0 +1,68 @@ +/* + * Copyright © 2022 John Renner + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +internal class ConversationList.Participant : Geary.BaseObject, Gee.Hashable { + private const string ME = "Me"; + public Geary.RFC822.MailboxAddress address; + + public Participant(Geary.RFC822.MailboxAddress address) { + this.address = address; + } + + public string get_full_markup(Gee.List account_mailboxes) { + return get_as_markup((address in account_mailboxes) ? ME : address.to_short_display()); + } + + public string get_short_markup(Gee.List account_mailboxes) { + if (address in account_mailboxes) + return get_as_markup(ME); + + if (address.is_spoofed()) { + return get_full_markup(account_mailboxes); + } + + string short_address = Markup.escape_text(address.to_short_display()); + + if (", " in short_address) { + // assume address is in Last, First format + string[] tokens = short_address.split(", ", 2); + short_address = tokens[1].strip(); + if (Geary.String.is_empty(short_address)) + return get_full_markup(account_mailboxes); + } + + // use first name as delimited by a space + string[] tokens = short_address.split(" ", 2); + if (tokens.length < 1) + return get_full_markup(account_mailboxes); + + string first_name = tokens[0].strip(); + if (Geary.String.is_empty_or_whitespace(first_name)) + return get_full_markup(account_mailboxes); + + return get_as_markup(first_name); + } + + private string get_as_markup(string participant) { + string markup = Geary.HTML.escape_markup(participant); + + if (this.address.is_spoofed()) { + markup = "%s".printf(markup); + } + + return markup; + } + + public bool equal_to(Participant other) { + return address.equal_to(other.address) + && address.name == other.address.name; + } + + public uint hash() { + return address.hash(); + } +} diff --git a/src/client/conversation-list/conversation-list-row.vala b/src/client/conversation-list/conversation-list-row.vala new file mode 100644 index 00000000..f972fc3e --- /dev/null +++ b/src/client/conversation-list/conversation-list-row.vala @@ -0,0 +1,212 @@ +/* + * Copyright © 2022 John Renner + * Copyright © 2022 Cédric Bellegarde + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + + +/** + * A conversation list row displaying an email summary + */ +[GtkTemplate (ui = "/org/gnome/Geary/conversation-list-row.ui")] +internal class ConversationList.Row : Gtk.ListBoxRow { + + private Gee.List? user_accounts { + owned get { + return conversation.base_folder.account.information.sender_mailboxes; + } + } + + [GtkChild] unowned Gtk.Label preview; + [GtkChild] unowned Gtk.Box preview_row; + [GtkChild] unowned Gtk.Label subject; + [GtkChild] unowned Gtk.Label participants; + [GtkChild] unowned Gtk.Label date; + [GtkChild] unowned Gtk.Label count_badge; + + [GtkChild] unowned Gtk.Image read_icon; + [GtkChild] unowned Gtk.Image flagged_icon; + + [GtkChild] unowned Gtk.Stack stack; + [GtkChild] unowned Gtk.CheckButton selected_button; + + internal Geary.App.Conversation conversation; + private Application.Configuration config; + private DateTime? recv_time; + + internal signal void toggle_flag(ConversationList.Row row, + Geary.NamedFlag flag); + internal signal void toggle_selection(ConversationList.Row row, + bool active); + + internal Row(Application.Configuration config, + Geary.App.Conversation conversation, + bool selection_mode_enabled) { + this.config = config; + this.conversation = conversation; + + conversation.email_flags_changed.connect(update_flags); + + config.bind(Application.Configuration.DISPLAY_PREVIEW_KEY, + this.preview_row, "visible"); + + if (selection_mode_enabled) { + set_selection_enabled(true); + } + + update(); + } + + internal void update() { + Geary.Email? last_email = conversation.get_latest_recv_email( + Geary.App.Conversation.Location.ANYWHERE + ); + + if (last_email != null) { + var text = Util.Email.strip_subject_prefixes(last_email); + this.subject.set_text(text); + this.preview.set_text(last_email.get_preview_as_string()); + this.recv_time = last_email.properties.date_received.to_local(); + refresh_time(); + } + + this.participants.set_markup(get_participants()); + + var count = conversation.get_count(); + if (count > 1) { + this.count_badge.set_text(conversation.get_count().to_string()); + } else { + this.count_badge.hide(); + } + + update_flags(null); + + } + + internal void set_selection_enabled(bool enabled) { + if (enabled) { + this.selected_button.show(); + set_button_active(this.is_selected()); + this.state_flags_changed.connect(update_button); + this.selected_button.toggled.connect(update_state_flags); + this.stack.set_visible_child_name("selection-button"); + } else { + this.stack.set_visible_child_name("buttons"); + this.state_flags_changed.disconnect(update_button); + this.selected_button.toggled.disconnect(update_state_flags); + set_button_active(false); + this.selected_button.hide(); + } + } + + internal void refresh_time() { + if (this.recv_time != null) { + // conversation list store sorts by date-received, so display that + // instead of the sent time + this.date.set_text(Util.Date.pretty_print( + this.recv_time, + this.config.clock_format + )); + } + } + + private void set_button_active(bool active) { + this.selected_button.set_active(active); + if (active) { + this.get_style_context().add_class("selected"); + this.set_state_flags(Gtk.StateFlags.SELECTED, false); + } else { + this.get_style_context().remove_class("selected"); + this.unset_state_flags(Gtk.StateFlags.SELECTED); + } + } + private void update_button() { + bool is_selected = (Gtk.StateFlags.SELECTED in this.get_state_flags()); + + this.selected_button.toggled.disconnect(update_state_flags); + set_button_active(is_selected); + this.selected_button.toggled.connect(update_state_flags); + + } + + private void update_state_flags() { + this.state_flags_changed.disconnect(update_button); + toggle_selection(this, this.selected_button.get_active()); + this.state_flags_changed.connect(update_button); + } + + private void update_flags(Geary.Email? email) { + if (conversation.is_unread()) { + get_style_context().add_class("unread"); + read_icon.set_from_icon_name("mail-unread-symbolic", Gtk.IconSize.BUTTON); + } else { + get_style_context().remove_class("unread"); + read_icon.set_from_icon_name("mail-read-symbolic", Gtk.IconSize.BUTTON); + } + + if (conversation.is_flagged()) { + get_style_context().add_class("starred"); + flagged_icon.set_from_icon_name("starred-symbolic", Gtk.IconSize.BUTTON); + } else { + get_style_context().remove_class("starred"); + flagged_icon.set_from_icon_name("non-starred-symbolic", Gtk.IconSize.BUTTON); + } + } + + [GtkCallback] private void on_unread_button_clicked() { + toggle_flag(this, Geary.EmailFlags.UNREAD); + } + + [GtkCallback] private void on_flagged_button_clicked() { + toggle_flag(this, Geary.EmailFlags.FLAGGED); + } + + private string get_participants() { + var participants = new Gee.ArrayList(); + Gee.List emails = conversation.get_emails( + Geary.App.Conversation.Ordering.RECV_DATE_ASCENDING); + + foreach (Geary.Email message in emails) { + Geary.RFC822.MailboxAddresses? addresses = + conversation.base_folder.used_as.is_outgoing() + ? new Geary.RFC822.MailboxAddresses.single(Util.Email.get_primary_originator(message)) + : message.from; + + if (addresses == null) { + continue; + } + + foreach (Geary.RFC822.MailboxAddress address in addresses) { + Participant participant_display = new Participant(address); + int existing_index = participants.index_of(participant_display); + if (existing_index < 0) { + participants.add(participant_display); + continue; + } + } + } + + if (participants.size == 0) { + return ""; + } + + if(participants.size == 1) { + return participants[0].get_full_markup(this.user_accounts); + } + + StringBuilder builder = new StringBuilder(); + bool first = true; + foreach (Participant participant in participants) { + if (!first) { + builder.append(", "); + } + + builder.append(participant.get_short_markup(this.user_accounts)); + first = false; + } + + return builder.str; + } +} diff --git a/src/client/conversation-list/conversation-list-store.vala b/src/client/conversation-list/conversation-list-store.vala deleted file mode 100644 index 3854253c..00000000 --- a/src/client/conversation-list/conversation-list-store.vala +++ /dev/null @@ -1,494 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - -/** - * A Gtk.ListStore of sorted {@link Geary.App.Conversation}s. - * - * Conversations are sorted by {@link Geary.EmailProperties.date_received} (IMAP's INTERNALDATE) - * rather than the Date: header, as that ensures newly received email sort to the top where the - * user expects to see them. The ConversationViewer sorts by the Date: header, as that presents - * better to the user. - */ - -public class ConversationListStore : Gtk.ListStore { - - public const Geary.Email.Field REQUIRED_FIELDS = ( - Geary.Email.Field.ENVELOPE | - Geary.Email.Field.FLAGS | - Geary.Email.Field.PROPERTIES - ); - - // XXX Remove REQUIRED_FOR_BODY when PREVIEW has been fixed. See Bug 714317. - public const Geary.Email.Field WITH_PREVIEW_FIELDS = ( - REQUIRED_FIELDS | - Geary.Email.Field.PREVIEW | - Geary.Email.REQUIRED_FOR_MESSAGE - ); - - public enum Column { - CONVERSATION_DATA, - CONVERSATION_OBJECT, - ROW_WRAPPER; - - public static Type[] get_types() { - return { - typeof (FormattedConversationData), // CONVERSATION_DATA - typeof (Geary.App.Conversation), // CONVERSATION_OBJECT - typeof (RowWrapper) // ROW_WRAPPER - }; - } - - public string to_string() { - switch (this) { - case CONVERSATION_DATA: - return "data"; - - case CONVERSATION_OBJECT: - return "envelope"; - - case ROW_WRAPPER: - return "wrapper"; - - default: - assert_not_reached(); - } - } - } - - private class RowWrapper : Geary.BaseObject { - public Geary.App.Conversation conversation; - public Gtk.TreeRowReference row; - - public RowWrapper(Gtk.TreeModel model, Geary.App.Conversation conversation, Gtk.TreePath path) { - this.conversation = conversation; - this.row = new Gtk.TreeRowReference(model, path); - } - - public Gtk.TreePath get_path() { - return row.get_path(); - } - - public bool get_iter(out Gtk.TreeIter iter) { - return row.get_model().get_iter(out iter, get_path()); - } - } - - - private static int sort_by_date(Gtk.TreeModel model, - Gtk.TreeIter aiter, - Gtk.TreeIter biter) { - Geary.App.Conversation a, b; - model.get(aiter, Column.CONVERSATION_OBJECT, out a); - model.get(biter, Column.CONVERSATION_OBJECT, out b); - return Util.Email.compare_conversation_ascending(a, b); - } - - - public Geary.App.ConversationMonitor conversations { get; set; } - public Geary.ProgressMonitor preview_monitor { get; private set; default = - new Geary.SimpleProgressMonitor(Geary.ProgressType.ACTIVITY); } - - private Application.Configuration config; - - private Gee.HashMap row_map = new Gee.HashMap< - Geary.App.Conversation, RowWrapper>(); - private Geary.App.EmailStore? email_store = null; - private Cancellable cancellable = new Cancellable(); - private bool loading_local_only = true; - private Geary.Nonblocking.Mutex refresh_mutex = new Geary.Nonblocking.Mutex(); - - public signal void conversations_added(bool start); - public signal void conversations_removed(bool start); - - public ConversationListStore(Geary.App.ConversationMonitor conversations, - Application.Configuration config) { - set_column_types(Column.get_types()); - set_default_sort_func(ConversationListStore.sort_by_date); - set_sort_column_id(Gtk.SortColumn.DEFAULT, Gtk.SortType.DESCENDING); - - this.conversations = conversations; - this.email_store = new Geary.App.EmailStore( - conversations.base_folder.account - ); - this.config = config; - this.config.settings.changed[ - Application.Configuration.DISPLAY_PREVIEW_KEY - ].connect(on_display_preview_changed); - - conversations.scan_completed.connect(on_scan_completed); - conversations.conversations_added.connect(on_conversations_added); - conversations.conversations_removed.connect(on_conversations_removed); - conversations.conversation_appended.connect(on_conversation_appended); - conversations.conversation_trimmed.connect(on_conversation_trimmed); - conversations.email_flags_changed.connect(on_email_flags_changed); - - // add all existing conversations - on_conversations_added(conversations.read_only_view); - } - - public void destroy() { - this.cancellable.cancel(); - this.email_store = null; - clear(); - - // Release circular refs. - this.row_map.clear(); - } - - public void update_display() { - this.foreach(update_date_string); - } - - public Geary.App.Conversation? get_conversation_at_path(Gtk.TreePath path) { - Gtk.TreeIter iter; - if (!get_iter(out iter, path)) - return null; - - return get_conversation_at_iter(iter); - } - - private async void refresh_previews_async(Geary.App.ConversationMonitor conversation_monitor) { - // Use a mutex because it's possible for the conversation monitor to fire multiple - // "scan-started" signals as messages come in fast and furious, but only want to process - // previews one at a time, otherwise it's possible to issue multiple requests for the - // same set - int token; - try { - token = yield refresh_mutex.claim_async(this.cancellable); - } catch (Error err) { - debug("Unable to claim refresh mutex: %s", err.message); - - return; - } - - preview_monitor.notify_start(); - - yield do_refresh_previews_async(conversation_monitor); - - preview_monitor.notify_finish(); - - try { - refresh_mutex.release(ref token); - } catch (Error err) { - debug("Unable to release refresh mutex: %s", err.message); - } - } - - // should only be called by refresh_previews_async() - private async void do_refresh_previews_async(Geary.App.ConversationMonitor conversation_monitor) { - if (conversation_monitor == null || !this.config.display_preview) - return; - - Gee.Set needing_previews = get_emails_needing_previews(); - - Gee.ArrayList emails = new Gee.ArrayList(); - if (needing_previews.size > 0) - emails.add_all(yield do_get_previews_async(needing_previews)); - if (emails.size < 1) - return; - - foreach (Geary.Email email in emails) { - Geary.App.Conversation? conversation = conversation_monitor.get_by_email_identifier(email.id); - // The conversation can be null if e.g. a search is - // changing quickly and the original has evaporated - // already. - if (conversation != null) { - set_preview_for_conversation(conversation, email); - } - } - } - - private async Gee.Collection do_get_previews_async( - Gee.Collection emails_needing_previews) { - Geary.Folder.ListFlags flags = (loading_local_only) ? Geary.Folder.ListFlags.LOCAL_ONLY - : Geary.Folder.ListFlags.NONE; - Gee.Collection? emails = null; - try { - emails = yield email_store.list_email_by_sparse_id_async(emails_needing_previews, - ConversationListStore.WITH_PREVIEW_FIELDS, flags, cancellable); - } catch (GLib.IOError.CANCELLED err) { - // All good - } catch (Geary.EngineError.NOT_FOUND err) { - // All good also, as that's entirely possible when waiting - // for the remote to open - } catch (GLib.Error err) { - warning("Unable to fetch preview: %s", err.message); - } - - return emails ?? new Gee.ArrayList(); - } - - private Gee.Set get_emails_needing_previews() { - Gee.Set needing = new Gee.HashSet(); - - // sort the conversations so the previews are fetched from the newest to the oldest, matching - // the user experience - var sorted_conversations = Geary.traverse( - this.conversations.read_only_view - ).to_sorted_list( - Util.Email.compare_conversation_descending - ); - foreach (Geary.App.Conversation conversation in sorted_conversations) { - // find oldest unread message for the preview - Geary.Email? need_preview = null; - foreach (Geary.Email email in conversation.get_emails(Geary.App.Conversation.Ordering.RECV_DATE_ASCENDING)) { - if (email.email_flags.is_unread()) { - need_preview = email; - - break; - } - } - - // if all are read, use newest in-folder message, then newest out-of-folder if not - // present - if (need_preview == null) { - need_preview = conversation.get_latest_recv_email(Geary.App.Conversation.Location.IN_FOLDER_OUT_OF_FOLDER); - if (need_preview == null) - continue; - } - - Geary.Email? current_preview = get_preview_for_conversation(conversation); - - // if all preview fields present and it's the same email, don't need to refresh - if (current_preview != null - && need_preview.id.equal_to(current_preview.id) - && current_preview.fields.is_all_set(ConversationListStore.WITH_PREVIEW_FIELDS)) { - continue; - } - - needing.add(need_preview.id); - } - - return needing; - } - - private Geary.Email? get_preview_for_conversation(Geary.App.Conversation conversation) { - Gtk.TreeIter iter; - if (!get_iter_for_conversation(conversation, out iter)) { - debug("Unable to find preview for conversation"); - - return null; - } - - FormattedConversationData? message_data = get_message_data_at_iter(iter); - return message_data == null ? null : message_data.preview; - } - - private void set_preview_for_conversation(Geary.App.Conversation conversation, Geary.Email preview) { - Gtk.TreeIter iter; - if (get_iter_for_conversation(conversation, out iter)) - set_row(iter, conversation, preview); - else - debug("Unable to find preview for conversation"); - } - - private void set_row(Gtk.TreeIter iter, Geary.App.Conversation conversation, Geary.Email preview) { - FormattedConversationData conversation_data = new FormattedConversationData( - this.config, - conversation, - preview, - this.conversations.base_folder.account.information.sender_mailboxes - ); - - Gtk.TreePath? path = get_path(iter); - assert(path != null); - RowWrapper wrapper = new RowWrapper(this, conversation, path); - - set(iter, - Column.CONVERSATION_DATA, conversation_data, - Column.CONVERSATION_OBJECT, conversation, - Column.ROW_WRAPPER, wrapper - ); - - row_map.set(conversation, wrapper); - } - - private void refresh_conversation(Geary.App.Conversation conversation) { - Gtk.TreeIter iter; - if (!get_iter_for_conversation(conversation, out iter)) { - // Unknown conversation, attempt to append it. - add_conversation(conversation); - return; - } - - Geary.Email? last_email = conversation.get_latest_recv_email(Geary.App.Conversation.Location.ANYWHERE); - if (last_email == null) { - debug("Cannot refresh conversation: last email is null"); - -#if VALA_0_36 - remove(ref iter); -#else - remove(iter); -#endif - return; - } - - set_row(iter, conversation, last_email); - - Gtk.TreePath? path = get_path(iter); - if (path != null) - row_changed(path, iter); - else - debug("Cannot refresh conversation: no path for iterator"); - } - - private void refresh_flags(Geary.App.Conversation conversation) { - Gtk.TreeIter iter; - if (!get_iter_for_conversation(conversation, out iter)) { - // Unknown conversation, attempt to append it. - add_conversation(conversation); - return; - } - - FormattedConversationData? existing_message_data = get_message_data_at_iter(iter); - if (existing_message_data == null) - return; - - existing_message_data.is_unread = conversation.is_unread(); - existing_message_data.is_flagged = conversation.is_flagged(); - - Gtk.TreePath? path = get_path(iter); - if (path != null) - row_changed(path, iter); - } - - public Gtk.TreePath? get_path_for_conversation(Geary.App.Conversation conversation) { - RowWrapper? wrapper = row_map.get(conversation); - - return (wrapper != null) ? wrapper.get_path() : null; - } - - private bool get_iter_for_conversation(Geary.App.Conversation conversation, out Gtk.TreeIter iter) { - RowWrapper? wrapper = row_map.get(conversation); - if (wrapper != null) - return wrapper.get_iter(out iter); - - // use get_iter_first() because boxing Gtk.TreeIter with a nullable is problematic with - // current bindings - get_iter_first(out iter); - - return false; - } - - private bool has_conversation(Geary.App.Conversation conversation) { - return row_map.has_key(conversation); - } - - private Geary.App.Conversation? get_conversation_at_iter(Gtk.TreeIter iter) { - Geary.App.Conversation? conversation; - get(iter, Column.CONVERSATION_OBJECT, out conversation); - - return conversation; - } - - private FormattedConversationData? get_message_data_at_iter(Gtk.TreeIter iter) { - FormattedConversationData? message_data; - get(iter, Column.CONVERSATION_DATA, out message_data); - - return message_data; - } - - private void remove_conversation(Geary.App.Conversation conversation) { - Gtk.TreeIter iter; - if (get_iter_for_conversation(conversation, out iter)) -#if VALA_0_36 - remove(ref iter); -#else - remove(iter); -#endif - - row_map.unset(conversation); - } - - private bool add_conversation(Geary.App.Conversation conversation) { - Geary.Email? last_email = conversation.get_latest_recv_email(Geary.App.Conversation.Location.ANYWHERE); - if (last_email == null) { - debug("Cannot add conversation: last email is null"); - - return false; - } - - if (has_conversation(conversation)) { - debug("Conversation already present; not adding"); - - return false; - } - - Gtk.TreeIter iter; - append(out iter); - set_row(iter, conversation, last_email); - - return true; - } - - private void on_scan_completed(Geary.App.ConversationMonitor sender) { - refresh_previews_async.begin(sender); - loading_local_only = false; - } - - private void on_conversations_added(Gee.Collection conversations) { - // this handler is used to initialize the display, so it's possible for an empty list to - // be passed in (the ConversationMonitor signal should never do this) - if (conversations.size == 0) - return; - - conversations_added(true); - - debug("Adding %d conversations.", conversations.size); - int added = 0; - foreach (Geary.App.Conversation conversation in conversations) { - if (add_conversation(conversation)) - added++; - } - debug("Added %d/%d conversations.", added, conversations.size); - - conversations_added(false); - } - - private void on_conversations_removed(Gee.Collection conversations) { - conversations_removed(true); - foreach (Geary.App.Conversation removed in conversations) - remove_conversation(removed); - conversations_removed(false); - } - - private void on_conversation_appended(Geary.App.Conversation conversation) { - if (has_conversation(conversation)) { - refresh_conversation(conversation); - } else { - add_conversation(conversation); - } - } - - private void on_conversation_trimmed(Geary.App.Conversation conversation) { - refresh_conversation(conversation); - } - - private void on_display_preview_changed() { - refresh_previews_async.begin(this.conversations); - } - - private void on_email_flags_changed(Geary.App.Conversation conversation) { - refresh_flags(conversation); - - // refresh previews because the oldest unread message is displayed as the preview, and if - // that's changed, need to change the preview - // TODO: need support code to load preview for single conversation, not scan all - refresh_previews_async.begin(this.conversations); - } - - private bool update_date_string(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) { - FormattedConversationData? message_data; - model.get(iter, Column.CONVERSATION_DATA, out message_data); - - if (message_data != null && message_data.update_date_string()) - row_changed(path, iter); - - // Continue iterating, don't stop - return false; - } - -} diff --git a/src/client/conversation-list/conversation-list-view.vala b/src/client/conversation-list/conversation-list-view.vala index 37a2a640..0489ad3f 100644 --- a/src/client/conversation-list/conversation-list-view.vala +++ b/src/client/conversation-list/conversation-list-view.vala @@ -1,666 +1,699 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. +/* + * Copyright © 2022 John Renner + * Copyright © 2022 Cédric Bellegarde * * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. + * (version 2.1 or later). See the COPYING file in this distribution. */ -public class ConversationListView : Gtk.TreeView, Geary.BaseInterface { - const int LOAD_MORE_HEIGHT = 100; +/** + * Represents in folder conversations list. + * + */ +[GtkTemplate (ui = "/org/gnome/Geary/conversation-list-view.ui")] +public class ConversationList.View : Gtk.ScrolledWindow, Geary.BaseInterface { + /** + * The fields that must be available on any ConversationMonitor + * passed to ConversationList.View + */ + public const Geary.Email.Field REQUIRED_FIELDS = ( + Geary.Email.Field.ENVELOPE | + Geary.Email.Field.FLAGS | + Geary.Email.Field.PROPERTIES + ); + [CCode(notify = false)] + public bool selection_mode_enabled { + get { + return this.list.get_selection_mode() == Gtk.SelectionMode.MULTIPLE; + } + set { + Gtk.SelectionMode mode = value ? Gtk.SelectionMode.MULTIPLE : Gtk.SelectionMode.SINGLE; + if (this.list.get_selection_mode() != mode) { + this.list.set_selection_mode(mode); + notify_property("selection-mode-enabled"); + } + } + } + + public Gee.Set selected { + get; set; default = new Gee.HashSet(); + } private Application.Configuration config; - private bool enable_load_more = true; + private Gtk.GestureMultiPress press_gesture; + private Gtk.GestureLongPress long_press_gesture; + private Gtk.EventControllerKey key_event_controller; + private Gdk.ModifierType last_modifier_type; - private bool reset_adjustment = false; - private Gee.Set? current_visible_conversations = null; - private Geary.Scheduler.Scheduled? scheduled_update_visible_conversations = null; - private Gee.Set selected = new Gee.HashSet(); - private Geary.IdleManager selection_update; - private Gtk.GestureMultiPress gesture; + [GtkChild] private unowned Gtk.ListBox list; - // Determines if the next folder scan should avoid selecting a - // conversation when autoselect is enabled - private bool should_inhibit_autoselect = false; - - - public signal void conversations_selected(Gee.Set selected); - - // Signal for when a conversation has been double-clicked, or selected and enter is pressed. - public signal void conversation_activated(Geary.App.Conversation activated, bool single = false); - - public virtual signal void load_more() { - enable_load_more = false; - } - - public signal void mark_conversations(Gee.Collection conversations, - Geary.NamedFlag flag); - - public signal void visible_conversations_changed(Gee.Set visible); - - - public ConversationListView(Application.Configuration config) { - base_ref(); - set_show_expanders(false); - set_headers_visible(false); - set_grid_lines(Gtk.TreeViewGridLines.HORIZONTAL); + /* + * Use to restore selected row when exiting selection/edition + */ + private Gtk.ListBoxRow? to_restore_row = null; + public View(Application.Configuration config) { this.config = config; - append_column(create_column(ConversationListStore.Column.CONVERSATION_DATA, - new ConversationListCellRenderer(), ConversationListStore.Column.CONVERSATION_DATA.to_string(), - 0)); + this.notify["selection-mode-enabled"].connect(on_selection_mode_changed); - Gtk.TreeSelection selection = get_selection(); - selection.set_mode(Gtk.SelectionMode.MULTIPLE); - style_updated.connect(on_style_changed); + this.list.selected_rows_changed.connect(on_selected_rows_changed); + this.list.row_activated.connect(on_row_activated); - notify["vadjustment"].connect(on_vadjustment_changed); + this.list.set_header_func(header_func); - key_press_event.connect(on_key_press); - button_press_event.connect(on_button_press); - gesture = new Gtk.GestureMultiPress(this); - gesture.pressed.connect(on_gesture_pressed); + this.vadjustment.value_changed.connect(maybe_load_more); + this.vadjustment.value_changed.connect(update_visible_conversations); - // Set up drag and drop. - Gtk.drag_source_set(this, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST, + this.press_gesture = new Gtk.GestureMultiPress(this.list); + this.press_gesture.set_button(0); + this.press_gesture.released.connect(on_press_gesture_released); + + this.long_press_gesture = new Gtk.GestureLongPress(this.list); + this.long_press_gesture.propagation_phase = CAPTURE; + this.long_press_gesture.pressed.connect((n_press, x, y) => { + Row? row = (Row) this.list.get_row_at_y((int) y); + if (row != null) { + this.list.unselect_all(); + this.selection_mode_enabled = true; + } + }); + + this.key_event_controller = new Gtk.EventControllerKey(this.list); + this.key_event_controller.key_pressed.connect(on_key_event_controller_key_pressed); + + Gtk.drag_source_set(this.list, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST, Gdk.DragAction.COPY | Gdk.DragAction.MOVE); - - this.config.settings.changed[ - Application.Configuration.DISPLAY_PREVIEW_KEY - ].connect(on_display_preview_changed); - - // Watch for mouse events. - motion_notify_event.connect(on_motion_notify_event); - leave_notify_event.connect(on_leave_notify_event); - - // GtkTreeView binds Ctrl+N to "move cursor to next". Not so interested in that, so we'll - // remove it. - unowned Gtk.BindingSet? binding_set = Gtk.BindingSet.find("GtkTreeView"); - assert(binding_set != null); - Gtk.BindingEntry.remove(binding_set, Gdk.Key.N, Gdk.ModifierType.CONTROL_MASK); - - this.selection_update = new Geary.IdleManager(do_selection_changed); - this.selection_update.priority = Geary.IdleManager.Priority.LOW; - - this.visible = true; + this.list.drag_begin.connect(on_drag_begin); + this.list.drag_end.connect(on_drag_end); } - ~ConversationListView() { - base_unref(); + static construct { + set_css_name("conversation-list"); } - public override void destroy() { - this.selection_update.reset(); - base.destroy(); - } - - public new ConversationListStore? get_model() { - return base.get_model() as ConversationListStore; - } - - public new void set_model(ConversationListStore? new_store) { - ConversationListStore? old_store = get_model(); - if (old_store != null) { - old_store.conversations.scan_started.disconnect(on_scan_started); - old_store.conversations.scan_completed.disconnect(on_scan_completed); - - old_store.conversations_added.disconnect(on_conversations_added); - old_store.conversations_removed.disconnect(on_conversations_removed); - old_store.row_inserted.disconnect(on_rows_changed); - old_store.rows_reordered.disconnect(on_rows_changed); - old_store.row_changed.disconnect(on_rows_changed); - old_store.row_deleted.disconnect(on_rows_changed); - old_store.destroy(); - } - - if (new_store != null) { - new_store.conversations.scan_started.connect(on_scan_started); - new_store.conversations.scan_completed.connect(on_scan_completed); - - new_store.row_inserted.connect(on_rows_changed); - new_store.rows_reordered.connect(on_rows_changed); - new_store.row_changed.connect(on_rows_changed); - new_store.row_deleted.connect(on_rows_changed); - new_store.conversations_removed.connect(on_conversations_removed); - new_store.conversations_added.connect(on_conversations_added); - } - - // Disconnect the selection handler since we don't want to - // fire selection signals while changing the model. - Gtk.TreeSelection selection = get_selection(); - selection.changed.disconnect(on_selection_changed); - base.set_model(new_store); - this.selected.clear(); - selection.changed.connect(on_selection_changed); - } - - /** Returns a read-only iteration of the current selection. */ - public Gee.Set get_selected() { - return this.selected.read_only_view; - } - - /** Returns a copy of the current selection. */ - public Gee.Set copy_selected() { - var copy = new Gee.HashSet(); - copy.add_all(this.selected); - return copy; - } - - public void inhibit_next_autoselect() { - this.should_inhibit_autoselect = true; - } - - public void scroll(Gtk.ScrollType where) { - Gtk.TreeSelection selection = get_selection(); - weak Gtk.TreeModel model; - GLib.List selected = selection.get_selected_rows(out model); - Gtk.TreePath? target_path = null; - Gtk.TreeIter? target_iter = null; - if (selected.length() > 0) { - switch (where) { - case STEP_UP: - target_path = selected.first().data; - model.get_iter(out target_iter, target_path); - if (model.iter_previous(ref target_iter)) { - target_path = model.get_path(target_iter); - } else { - this.get_window().beep(); - } - break; - - case STEP_DOWN: - target_path = selected.last().data; - model.get_iter(out target_iter, target_path); - if (model.iter_next(ref target_iter)) { - target_path = model.get_path(target_iter); - } else { - this.get_window().beep(); - } - break; - - default: - // no-op - break; - } - - set_cursor(target_path, null, false); + // ------- + // UI + // ------- + private void header_func(Gtk.ListBoxRow row, Gtk.ListBoxRow? before) { + if (before != null) { + var sep = new Gtk.Separator(Gtk.Orientation.HORIZONTAL); + sep.show(); + row.set_header(sep); } } - private void check_load_more() { - ConversationListStore? model = get_model(); - Geary.App.ConversationMonitor? conversations = (model != null) - ? model.conversations - : null; - if (conversations != null) { - // Check if we're at the very bottom of the list. If we - // are, it's time to issue a load_more signal. - Gtk.Adjustment adjustment = ((Gtk.Scrollable) this).get_vadjustment(); - double upper = adjustment.get_upper(); - double threshold = upper - adjustment.page_size - LOAD_MORE_HEIGHT; - if (this.is_visible() && - conversations.can_load_more && - adjustment.get_value() >= threshold) { - load_more(); - } + /** + * Updates the display of the received time on each list row. + * + * Because the received time is displayed as relative to the current time, + * it must be periodically updated. ConversationList.View does not do this + * automatically but instead it must be externally scheduled + */ + public void refresh_times() { + this.list.foreach((child) => { + var row = (Row) child; + row.refresh_time(); + }); + } - schedule_visible_conversations_changed(); + // ------------------- + // Model Management + // ------------------- + + /** + * The currently bound model + */ + private Model? model; + + /** + * Set the conversation monitor which the listview is displaying + */ + public void set_monitor(Geary.App.ConversationMonitor? monitor) { + if (this.model != null) { + this.model.conversations_loaded.disconnect(on_conversations_loaded); + this.model.conversations_removed.disconnect(on_conversations_removed); + this.model.conversation_updated.disconnect(on_conversation_updated); + } + if (monitor == null) { + this.model = null; + this.list.bind_model(null, row_factory); + } else { + this.model = new Model(monitor); + this.list.bind_model(this.model, row_factory); + this.model.conversations_loaded.connect(on_conversations_loaded); + this.model.conversations_removed.connect(on_conversations_removed); + this.model.conversation_updated.connect(on_conversation_updated); } } - private void on_scan_started() { - this.enable_load_more = false; - } - - private void on_scan_completed() { - this.enable_load_more = true; - check_load_more(); - - // Select the first conversation, if autoselect is enabled, - // nothing has been selected yet and we're not showing a - // composer. - if (this.config.autoselect && - !this.should_inhibit_autoselect && - get_selection().count_selected_rows() == 0) { - var parent = get_toplevel() as Application.MainWindow; - if (parent != null && !parent.has_composer) { - set_cursor(new Gtk.TreePath.from_indices(0, -1), null, false); - } - } - - this.should_inhibit_autoselect = false; - } - - private void on_conversations_added(bool start) { - Gtk.Adjustment? adjustment = get_adjustment(); - if (start) { - // If we were at the top, we want to stay there after - // conversations are added. - this.reset_adjustment = adjustment != null && adjustment.get_value() == 0; - } else if (this.reset_adjustment && adjustment != null) { - // Pump the loop to make sure the new conversations are - // taking up space in the window. Without this, setting - // the adjustment here is a no-op because as far as it's - // concerned, it's already at the top. - while (Gtk.events_pending()) - Gtk.main_iteration(); - - adjustment.set_value(0); - } - this.reset_adjustment = false; - } - - private void on_conversations_removed(bool start) { - if (!this.config.autoselect) { - Gtk.SelectionMode mode = start - // Stop GtkTreeView from automatically selecting the - // next row after the removed rows - ? Gtk.SelectionMode.NONE - // Allow the user to make selections again - : Gtk.SelectionMode.MULTIPLE; - get_selection().set_mode(mode); + /** + * Attempt to load more conversations from the current monitor + */ + public void load_more(int request) { + if (model != null) { + model.load_more(request); } } - private Gtk.Adjustment? get_adjustment() { - Gtk.ScrolledWindow? parent = get_parent() as Gtk.ScrolledWindow; - if (parent == null) { - debug("Parent was not scrolled window"); - return null; - } + public void scroll(Gtk.ScrollType scroll_type) { + Gtk.ListBoxRow row = this.list.get_selected_row(); - return parent.get_vadjustment(); - } - - private void on_gesture_pressed(int n_press, double x, double y) { - if (gesture.get_current_button() != Gdk.BUTTON_PRIMARY) + if (row == null) { return; + } - Gtk.TreePath? path; - get_path_at_pos((int) x, (int) y, out path, null, null, null); + int index = row.get_index(); + if (scroll_type == Gtk.ScrollType.STEP_UP) { + row = this.list.get_row_at_index(index + 1); + } else { + row = this.list.get_row_at_index(index - 1); + } - // If the user clicked in an empty area, do nothing. - if (path == null) - return; - - Geary.App.Conversation? c = get_model().get_conversation_at_path(path); - if (c == null) - return; - - Gdk.Event event = gesture.get_last_event(gesture.get_current_sequence()); - Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask(); - - Gdk.ModifierType state_mask; - event.get_state(out state_mask); - - if ((state_mask & modifiers) == 0 && n_press == 1) { - conversation_activated(c, true); - } else if ((state_mask & modifiers) == Gdk.ModifierType.SHIFT_MASK && n_press == 2) { - conversation_activated(c); + if (row != null) { + this.list.select_row(row); } } - private bool on_key_press(Gdk.EventKey event) { - if (this.selected.size != 1) - return false; - - Geary.App.Conversation? c = this.selected.to_array()[0]; - if (c == null) - return false; - - Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask(); - - if (event.keyval == Gdk.Key.Return || - event.keyval == Gdk.Key.ISO_Enter || - event.keyval == Gdk.Key.KP_Enter || - event.keyval == Gdk.Key.space || - event.keyval == Gdk.Key.KP_Space) - conversation_activated(c, !((event.state & modifiers) == Gdk.ModifierType.SHIFT_MASK)); - return false; + private Gtk.Widget row_factory(Object convo_obj) { + var convo = (Geary.App.Conversation) convo_obj; + var row = new Row(config, convo, this.selection_mode_enabled); + row.toggle_flag.connect(on_toggle_flags); + row.toggle_selection.connect(on_toggle_selection); + return row; } - private bool on_button_press(Gdk.EventButton event) { - // Get the coordinates on the cell as well as the clicked path. - int cell_x; - int cell_y; - Gtk.TreePath? path; - get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y); - // If the user clicked in an empty area, do nothing. - if (path == null) - return false; + // -------------------- + // Right-click Popup + // -------------------- + private void context_menu(Row row, Gdk.Rectangle? rect=null) { + if (!row.is_selected()) { + this.list.unselect_all(); + this.list.select_row(row); + } - // Handle clicks to toggle read and starred status. - if ((event.state & Gdk.ModifierType.SHIFT_MASK) == 0 && - (event.state & Gdk.ModifierType.CONTROL_MASK) == 0 && - event.type == Gdk.EventType.BUTTON_PRESS) { + var popup_menu = construct_popover(row, this.list.get_selected_rows().length()); + if (rect != null) { + popup_menu.set_pointing_to(rect); + } + popup_menu.popup(); + } - // Click positions depend on whether the preview is enabled. - bool read_clicked = false; - bool star_clicked = false; - if (this.config.display_preview) { - read_clicked = cell_x < 25 && cell_y >= 14 && cell_y <= 30; - star_clicked = cell_x < 25 && cell_y >= 40 && cell_y <= 62; + private Gtk.Popover construct_popover(Row row, uint selection_size) { + GLib.Menu context_menu_model = new GLib.Menu(); + var main = get_toplevel() as Application.MainWindow; + + if (main != null) { + if (!main.is_shift_down) { + context_menu_model.append( + /// Translators: Context menu item + ngettext( + "Move conversation to _Trash", + "Move conversations to _Trash", + selection_size + ), + Action.Window.prefix( + Application.MainWindow.ACTION_TRASH_CONVERSATION + ) + ); } else { - read_clicked = cell_x < 25 && cell_y >= 8 && cell_y <= 22; - star_clicked = cell_x < 25 && cell_y >= 28 && cell_y <= 43; + context_menu_model.append( + /// Translators: Context menu item + ngettext( + "_Delete conversation", + "_Delete conversations", + selection_size + ), + Action.Window.prefix( + Application.MainWindow.ACTION_DELETE_CONVERSATION + ) + ); } + } - // Get the current conversation. If it's selected, we'll apply the mark operation to - // all selected conversations; otherwise, it just applies to this one. - Geary.App.Conversation conversation = get_model().get_conversation_at_path(path); - Gee.Collection to_mark = ( - this.selected.contains(conversation) - ? copy_selected() - : Geary.Collection.single(conversation) + if (row.conversation.is_unread()) { + context_menu_model.append( + _("Mark as _Read"), + Action.Window.prefix( + Application.MainWindow.ACTION_MARK_AS_READ + ) ); - - if (read_clicked) { - mark_conversations(to_mark, Geary.EmailFlags.UNREAD); - return true; - } else if (star_clicked) { - mark_conversations(to_mark, Geary.EmailFlags.FLAGGED); - return true; - } } - // Check if changing the selection will require any composers - // to be closed, but only on the first click of a - // double/triple click, so that double-clicking a draft - // doesn't attempt to load it then close it straight away. - if (event.type == Gdk.EventType.BUTTON_PRESS && - !get_selection().path_is_selected(path)) { - var parent = get_toplevel() as Application.MainWindow; - if (parent != null && !parent.close_composer(false)) { - return true; - } + if (row.conversation.has_any_read_message()) { + context_menu_model.append( + _("Mark as _Unread"), + Action.Window.prefix( + Application.MainWindow.ACTION_MARK_AS_UNREAD + ) + ); } - if (event.button == 3 && event.type == Gdk.EventType.BUTTON_PRESS) { - Geary.App.Conversation conversation = get_model().get_conversation_at_path(path); + if (row.conversation.is_flagged()) { + context_menu_model.append( + _("U_nstar"), + Action.Window.prefix( + Application.MainWindow.ACTION_MARK_AS_UNSTARRED + ) + ); + } else { + context_menu_model.append( + _("_Star"), + Action.Window.prefix( + Application.MainWindow.ACTION_MARK_AS_STARRED + ) + ); + } - GLib.Menu context_menu_model = new GLib.Menu(); - var main = get_toplevel() as Application.MainWindow; - if (main != null) { - if (!main.is_shift_down) { - context_menu_model.append( - /// Translators: Context menu item - ngettext( - "Move conversation to _Trash", - "Move conversations to _Trash", - this.selected.size - ), - Action.Window.prefix( - Application.MainWindow.ACTION_TRASH_CONVERSATION - ) - ); - } else { - context_menu_model.append( - /// Translators: Context menu item - ngettext( - "_Delete conversation", - "_Delete conversations", - this.selected.size - ), - Action.Window.prefix( - Application.MainWindow.ACTION_DELETE_CONVERSATION - ) - ); - } - } - - if (conversation.is_unread()) + if ((row.conversation.base_folder.used_as != ARCHIVE) && + (row.conversation.base_folder.used_as != ALL_MAIL)) { context_menu_model.append( - _("Mark as _Read"), - Action.Window.prefix( - Application.MainWindow.ACTION_MARK_AS_READ - ) - ); - - if (conversation.has_any_read_message()) - context_menu_model.append( - _("Mark as _Unread"), - Action.Window.prefix( - Application.MainWindow.ACTION_MARK_AS_UNREAD - ) - ); - - if (conversation.is_flagged()) { - context_menu_model.append( - _("U_nstar"), - Action.Window.prefix( - Application.MainWindow.ACTION_MARK_AS_UNSTARRED - ) - ); - } else { - context_menu_model.append( - _("_Star"), - Action.Window.prefix( - Application.MainWindow.ACTION_MARK_AS_STARRED - ) - ); - } - if ((conversation.base_folder.used_as != ARCHIVE) && (conversation.base_folder.used_as != ALL_MAIL)) { - context_menu_model.append( - _("Archive conversation"), + ngettext( + "_Archive conversation", + "_Archive conversations", + selection_size + ), Action.Window.prefix( Application.MainWindow.ACTION_ARCHIVE_CONVERSATION ) ); + } + + Menu actions_section = new Menu(); + actions_section.append( + _("_Reply"), + Action.Window.prefix( + Application.MainWindow.ACTION_REPLY_CONVERSATION + ) + ); + actions_section.append( + _("R_eply All"), + Action.Window.prefix( + Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION + ) + ); + actions_section.append( + _("_Forward"), + Action.Window.prefix( + Application.MainWindow.ACTION_FORWARD_CONVERSATION + ) + ); + context_menu_model.append_section(null, actions_section); + + // Use a popover rather than a regular context menu since + // the latter grabs the event queue, so the MainWindow + // will not receive events if the user releases Shift, + // making the trash/delete header bar state wrong. + Gtk.Popover context_menu = new Gtk.Popover.from_model( + row, context_menu_model + ); + + return context_menu; + } + + // ------------------- + // Selection + // ------------------- + + /** + * Emitted when one or more conversations are selected + */ + public signal void conversations_selected(Gee.Set selected); + + /** + * Emitted when one conversation is activated + */ + public signal void conversation_activated(Geary.App.Conversation activated, + uint button); + + /** + * Gets the conversations represented by the current selection in the ListBox + */ + public Gee.Set get_selected_conversations() { + var selected = new Gee.HashSet(); + + foreach (var row in this.list.get_selected_rows()) { + selected.add(((Row) row).conversation); + } + return selected; + } + + /** + * Selects the rows for a given collection of conversations + * + * If a conversation is not present in the ListBox, it is ignored. + */ + public void select_conversations(Gee.Collection selection) { + this.list.foreach((child) => { + var row = (Row) child; + Geary.App.Conversation conversation = row.conversation; + if (selection.contains(conversation)) { + this.list.select_row(row); } + }); + } - Menu actions_section = new Menu(); - actions_section.append( - _("_Reply"), - Action.Window.prefix( - Application.MainWindow.ACTION_REPLY_CONVERSATION - ) - ); - actions_section.append( - _("R_eply All"), - Action.Window.prefix( - Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION - ) - ); - actions_section.append( - _("_Forward"), - Action.Window.prefix( - Application.MainWindow.ACTION_FORWARD_CONVERSATION - ) - ); - context_menu_model.append_section(null, actions_section); + /** + * Selects all conversations + */ + public void select_all() { + this.selection_mode_enabled = true; + this.list.select_all(); + } - // Use a popover rather than a regular context menu since - // the latter grabs the event queue, so the MainWindow - // will not receive events if the user releases Shift, - // making the trash/delete header bar state wrong. - Gtk.Popover context_menu = new Gtk.Popover.from_model( - this, context_menu_model - ); - Gdk.Rectangle dest = Gdk.Rectangle(); - dest.x = (int) event.x; - dest.y = (int) event.y; - context_menu.set_pointing_to(dest); - context_menu.popup(); + /** + * Unselects all conversations + */ + public void unselect_all() { + this.list.unselect_all(); + } - // When the conversation under the mouse is selected, stop event propagation - return get_selection().path_is_selected(path); + private bool selection_changed(Gee.Set selection) { + bool changed = this.selected.size != selection.size; + if (changed) { + return true; } - - return false; - } - - private void on_style_changed() { - // Recalculate dimensions of child cells. - ConversationListCellRenderer.style_changed(this); - - schedule_visible_conversations_changed(); - } - - private void on_value_changed() { - if (this.enable_load_more) { - check_load_more(); - } - } - - private static Gtk.TreeViewColumn create_column(ConversationListStore.Column column, - Gtk.CellRenderer renderer, string attr, int width = 0) { - Gtk.TreeViewColumn view_column = new Gtk.TreeViewColumn.with_attributes(column.to_string(), - renderer, attr, column); - view_column.set_resizable(true); - - if (width != 0) { - view_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED); - view_column.set_fixed_width(width); - } - - return view_column; - } - - private List get_all_selected_paths() { - Gtk.TreeModel model; - return get_selection().get_selected_rows(out model); - } - - private void on_selection_changed() { - // Schedule processing selection changes at low idle for - // two reasons: (a) if a lot of changes come in - // back-to-back, this allows for all that activity to - // settle before updating state and firing signals (which - // results in a lot of I/O), and (b) it means the - // ConversationMonitor's signals may be processed in any - // order by this class and the ConversationListView and - // not result in a lot of screen flashing and (again) - // unnecessary I/O as both classes update selection state. - this.selection_update.schedule(); - } - - // Gtk.TreeSelection can fire its "changed" signal even when - // nothing's changed, so look for that to avoid subscribers from - // doing the same things (in particular, I/O) multiple times - private void do_selection_changed() { - Gee.HashSet new_selection = - new Gee.HashSet(); - List paths = get_all_selected_paths(); - if (paths.length() != 0) { - // Conversations are selected, so collect them and - // signal if different - foreach (Gtk.TreePath path in paths) { - Geary.App.Conversation? conversation = - get_model().get_conversation_at_path(path); - if (conversation != null) - new_selection.add(conversation); + foreach (Geary.App.Conversation conversation in selection) { + if (!this.selected.contains(conversation)) { + changed = true; } } - - // only notify if different than what was previously reported - if (this.selected.size != new_selection.size || - !this.selected.contains_all(new_selection)) { - this.selected = new_selection; - conversations_selected(this.selected.read_only_view); - } - } - - public Gee.Set get_visible_conversations() { - Gee.HashSet visible_conversations = new Gee.HashSet(); - - Gtk.TreePath start_path; - Gtk.TreePath end_path; - if (!get_visible_range(out start_path, out end_path)) - return visible_conversations; - - while (start_path.compare(end_path) <= 0) { - Geary.App.Conversation? conversation = get_model().get_conversation_at_path(start_path); - if (conversation != null) - visible_conversations.add(conversation); - - start_path.next(); - } - - return visible_conversations; - } - - // Always returns false, so it can be used as a one-time SourceFunc - private bool update_visible_conversations() { - bool changed = false; - Gee.Set visible_conversations = get_visible_conversations(); - if (this.current_visible_conversations == null || - this.current_visible_conversations.size != visible_conversations.size || - !this.current_visible_conversations.contains_all(visible_conversations)) { - this.current_visible_conversations = visible_conversations; - visible_conversations_changed( - this.current_visible_conversations.read_only_view - ); - changed = true; - } return changed; } - private void schedule_visible_conversations_changed() { - scheduled_update_visible_conversations = Geary.Scheduler.on_idle(update_visible_conversations); - } - - public void select_conversations(Gee.Collection new_selection) { - if (this.selected.size != new_selection.size || - !this.selected.contains_all(new_selection)) { - var selection = get_selection(); - selection.unselect_all(); - var model = get_model(); - if (model != null) { - foreach (var conversation in new_selection) { - var path = model.get_path_for_conversation(conversation); - if (path != null) { - selection.select_path(path); - } - } - } + private void restore_row() { + if (this.to_restore_row != null) { + // Workaround GTK issue, activating/selecting row while model is + // updated scrolls to top + GLib.Timeout.add(100, () => { + this.to_restore_row.activate(); + this.to_restore_row = null; + return false; + }); } } - private void on_rows_changed() { - schedule_visible_conversations_changed(); + // ----------------- + // Button Actions + // ---------------- + + /** + * Emitted when the user expresses intent to update the flags on a set of conversations + */ + public signal void mark_conversations(Gee.Collection conversations, + Geary.NamedFlag flag); + + + private void on_toggle_flags(ConversationList.Row row, Geary.NamedFlag flag) { + if (row.is_selected()) { + mark_conversations(this.selected, flag); + } else { + mark_conversations(Geary.Collection.single(row.conversation), flag); + } } - private void on_display_preview_changed() { - style_updated(); - model.foreach(refresh_path); - - schedule_visible_conversations_changed(); + private void on_toggle_selection(ConversationList.Row row, bool active) { + if (active) { + this.list.select_row(row); + } else { + this.list.unselect_row(row); + } } - private bool refresh_path(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) { - model.row_changed(path, iter); + // ---------------- + // Visibility + // --------------- + + /** + * If the number of pixels between the bottom of the viewport and the bottom of + * of the listbox is less than LOAD_MORE_THRESHOLD, request more from the + * monitor. + */ + private double LOAD_MORE_THRESHOLD = 100; + private int LOAD_MORE_COUNT = 50; + + /** + * Called on scroll to possibly load more conversations from the model + */ + private void maybe_load_more(Gtk.Adjustment adjustment) { + double upper = adjustment.get_upper(); + double threshold = upper - adjustment.page_size - LOAD_MORE_THRESHOLD; + + if (this.is_visible() && adjustment.get_value() >= threshold) { + this.load_more(LOAD_MORE_COUNT); + } + } + + /** + * Time in milliseconds to delay updating the set of visible conversations. + * If another update is triggered during this delay, it will be discarded + * and the delay begins again. + */ + private int VISIBILITY_UPDATE_DELAY_MS = 1000; + + /** + * The set of all conversations currently displayed in the viewport + */ + public Gee.Set visible_conversations {get; private set; default = new Gee.HashSet(); } + private Geary.Scheduler.Scheduled? scheduled_visible_update; + + /** + * Called on scroll to update the set of visible conversations + */ + private void update_visible_conversations() { + if(scheduled_visible_update != null) { + scheduled_visible_update.cancel(); + } + + scheduled_visible_update = Geary.Scheduler.after_msec(VISIBILITY_UPDATE_DELAY_MS, () => { + var visible = new Gee.HashSet(); + Gtk.ListBoxRow? first = this.list.get_row_at_y((int) this.vadjustment.value); + + if (first == null) { + this.visible_conversations = visible; + return Source.REMOVE; + } + + uint start_index = ((uint) first.get_index()); + uint end_index = uint.min( + // Assume that all messages are the same height + start_index + (uint) (this.vadjustment.page_size / first.get_allocated_height()), + this.model.get_n_items() + ); + + for (uint i = start_index; i < end_index; i++) { + visible_conversations.add( + this.model.get_item(i) as Geary.App.Conversation + ); + } + + this.visible_conversations = visible; + return Source.REMOVE; + }, GLib.Priority.DEFAULT_IDLE); + } + + // ------------ + // Model + // ------------ + private bool should_inhibit_autoactivate = false; + + /** + * Informs the listbox to suppress autoactivate behavior on the next update + */ + public void inhibit_next_autoselect() { + should_inhibit_autoactivate = true; + } + + /** + * Find a selectable conversation near current selection + */ + private Gtk.ListBoxRow? get_next_conversation(bool asc=true) { + int index = asc ? 0 : int.MAX; + + foreach (Gtk.ListBoxRow row in this.list.get_selected_rows().copy()) { + if ((asc && row.get_index() > index) || + (!asc && row.get_index() < index)) { + index = row.get_index(); + } + } + if (asc) { + index += 1; + } else { + index -= 1; + } + Gtk.ListBoxRow? row = this.list.get_row_at_index(index); + return row != null || !asc ? row : get_next_conversation(false); + } + + private void on_conversations_loaded() { + if (this.config.autoselect && + !this.should_inhibit_autoactivate && + this.list.get_selected_rows().length() == 0) { + + Gtk.ListBoxRow first_row = this.list.get_row_at_index(0); + if (first_row != null) { + this.list.select_row(first_row); + } + } + + this.should_inhibit_autoactivate = false; + } + + /* + * Select next conversation + */ + private void on_conversations_removed(bool start) { + // Before model update, just find a conversation + if (start) { + this.to_restore_row = get_next_conversation(); + // If in selection mode, leaving will do the job + } else if (this.selection_mode_enabled) { + this.selection_mode_enabled = false; + // Set next conversation + } else { + restore_row(); + } + } + + /* + * Update conversation row + */ + private void on_conversation_updated(Geary.App.Conversation convo) { + this.list.foreach((child) => { + var row = (Row) child; + if (convo == row.conversation) { + row.update(); + } + }); + } + + // ---------- + // Gestures + // ---------- + + private void on_press_gesture_released(int n_press, double x, double y) { + var row = (Row) this.list.get_row_at_y((int) y); + + if (row == null) + return; + + var button = this.press_gesture.get_current_button(); + if (button == 1) { + Gdk.EventSequence sequence = this.press_gesture.get_current_sequence(); + Gdk.Event event = this.press_gesture.get_last_event(sequence); + event.get_state(out this.last_modifier_type); + if (!this.selection_mode_enabled) { + if ((this.last_modifier_type & Gdk.ModifierType.SHIFT_MASK) == + Gdk.ModifierType.SHIFT_MASK || + (this.last_modifier_type & Gdk.ModifierType.CONTROL_MASK) == + Gdk.ModifierType.CONTROL_MASK) { + this.selection_mode_enabled = true; + } else { + conversation_activated(((Row) row).conversation, 1); + } + } + } else if (button == 2) { + conversation_activated(row.conversation, 2); + } else if (button == 3) { + var rect = Gdk.Rectangle(); + row.translate_coordinates(this.list, 0, 0, out rect.x, out rect.y); + rect.x = (int) x; + rect.y = (int) y - rect.y; + rect.width = rect.height = 0; + context_menu(row, rect); + } + } + + private bool on_key_event_controller_key_pressed(uint keyval, uint keycode, Gdk.ModifierType modifier_type) { + switch (keyval) { + case Gdk.Key.Up: + case Gdk.Key.Down: + if ((modifier_type & Gdk.ModifierType.SHIFT_MASK) == + Gdk.ModifierType.SHIFT_MASK) { + this.selection_mode_enabled = true; + } + break; + case Gdk.Key.Escape: + if (this.selection_mode_enabled) { + this.selection_mode_enabled = false; + return true; + } + break; + } return false; } - // Enable/disable hover effect on all selected cells. - private void set_hover_selected(bool hover) { - ConversationListCellRenderer.set_hover_selected(hover); - queue_draw(); - } - private bool on_motion_notify_event(Gdk.EventMotion event) { - if (get_selection().count_selected_rows() > 0) { - Gtk.TreePath? path = null; - int cell_x, cell_y; - get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y); + /** + * Widgets used as drag icons have to be explicitly destroyed after the drag + * so we track the widget as a private member + */ + private Row? drag_widget = null; - set_hover_selected(path != null && get_selection().path_is_selected(path)); + private void on_drag_begin(Gdk.DragContext ctx) { + int screen_x, screen_y; + Gdk.ModifierType _modifier; + + this.get_window().get_device_position(ctx.get_device(), out screen_x, out screen_y, out _modifier); + + // If the user has a selection but drags starting from an unselected + // row, we need to set the selection to that row + Row? row = this.list.get_row_at_y(screen_y + (int) this.vadjustment.value) as Row?; + if (row != null && !row.is_selected()) { + this.list.unselect_all(); + this.list.select_row(row); } - return Gdk.EVENT_PROPAGATE; + + this.drag_widget = new Row(this.config, row.conversation, false); + this.drag_widget.width_request = row.get_allocated_width(); + this.drag_widget.get_style_context().add_class("drag-n-drop"); + this.drag_widget.visible = true; + + int hot_x, hot_y; + this.translate_coordinates(row, screen_x, screen_y, out hot_x, out hot_y); + Gtk.drag_set_icon_widget(ctx, this.drag_widget, hot_x, hot_y); } - private bool on_leave_notify_event() { - if (get_selection().count_selected_rows() > 0) { - set_hover_selected(false); + private void on_drag_end(Gdk.DragContext ctx) { + if (this.drag_widget != null) { + this.drag_widget.destroy(); + this.drag_widget = null; } - return Gdk.EVENT_PROPAGATE; - } - private void on_vadjustment_changed() { - this.vadjustment.value_changed.connect(on_value_changed); + private void on_selected_rows_changed() { + var selected = get_selected_conversations(); + + if (!selection_changed(selected)) { + return; + } + + this.selected = selected; + if (this.selected.size > 0 || this.to_restore_row == null) { + conversations_selected(this.selected); + } } + private void on_row_activated() { + var row = this.list.get_selected_row(); + if (row != null) { + conversation_activated(((Row) row).conversation, 1); + } + } + + private void on_selection_mode_changed() { + this.list.foreach((child) => { + var row = (Row) child; + row.set_selection_enabled(this.selection_mode_enabled); + }); + + if (this.selection_mode_enabled) { + this.to_restore_row = this.list.get_selected_row(); + } else { + restore_row(); + } + } } diff --git a/src/client/conversation-list/formatted-conversation-data.vala b/src/client/conversation-list/formatted-conversation-data.vala deleted file mode 100644 index afbb1f69..00000000 --- a/src/client/conversation-list/formatted-conversation-data.vala +++ /dev/null @@ -1,476 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - -// Stores formatted data for a message. -public class FormattedConversationData : Geary.BaseObject { - struct Participants { - string? markup; - - // markup may look different depending on whether widget is selected - bool was_widget_selected; - } - - public const int SPACING = 6; - - private const string ME = _("Me"); - private const string STYLE_EXAMPLE = "Gg"; // Use both upper and lower case to get max height. - private const int TEXT_LEFT = SPACING * 2 + IconFactory.UNREAD_ICON_SIZE; - private const double DIM_TEXT_AMOUNT = 0.05; - private const double DIM_PREVIEW_TEXT_AMOUNT = 0.25; - - - private class ParticipantDisplay : Geary.BaseObject, Gee.Hashable { - public Geary.RFC822.MailboxAddress address; - public bool is_unread; - - public ParticipantDisplay(Geary.RFC822.MailboxAddress address, bool is_unread) { - this.address = address; - this.is_unread = is_unread; - } - - public string get_full_markup(Gee.List account_mailboxes) { - return get_as_markup((address in account_mailboxes) ? ME : address.to_short_display()); - } - - public string get_short_markup(Gee.List account_mailboxes) { - if (address in account_mailboxes) - return get_as_markup(ME); - - if (address.is_spoofed()) { - return get_full_markup(account_mailboxes); - } - - string short_address = Markup.escape_text(address.to_short_display()); - - if (", " in short_address) { - // assume address is in Last, First format - string[] tokens = short_address.split(", ", 2); - short_address = tokens[1].strip(); - if (Geary.String.is_empty(short_address)) - return get_full_markup(account_mailboxes); - } - - // use first name as delimited by a space - string[] tokens = short_address.split(" ", 2); - if (tokens.length < 1) - return get_full_markup(account_mailboxes); - - string first_name = tokens[0].strip(); - if (Geary.String.is_empty_or_whitespace(first_name)) - return get_full_markup(account_mailboxes); - - return get_as_markup(first_name); - } - - private string get_as_markup(string participant) { - string markup = Geary.HTML.escape_markup(participant); - - if (is_unread) { - markup = "%s".printf(markup); - } - - if (this.address.is_spoofed()) { - markup = "%s".printf(markup); - } - - return markup; - } - - public bool equal_to(ParticipantDisplay other) { - return address.equal_to(other.address) - && address.name == other.address.name; - } - - public uint hash() { - return address.hash(); - } - } - - private static int cell_height = -1; - private static int preview_height = -1; - - public bool is_unread { get; set; } - public bool is_flagged { get; set; } - public string date { get; private set; } - public string? body { get; private set; default = null; } // optional - public int num_emails { get; set; } - public Geary.Email? preview { get; private set; default = null; } - - private Application.Configuration config; - - private Gtk.Settings? gtk; - private Pango.FontDescription font; - - private Geary.App.Conversation? conversation = null; - private Gee.List? account_owner_emails = null; - private bool use_to = true; - private CountBadge count_badge = new CountBadge(2); - private string subject_html_escaped; - private Participants participants = Participants(){markup = null}; - - // Creates a formatted message data from an e-mail. - public FormattedConversationData(Application.Configuration config, - Geary.App.Conversation conversation, - Geary.Email preview, - Gee.List account_owner_emails) { - this.config = config; - this.gtk = Gtk.Settings.get_default(); - this.conversation = conversation; - this.account_owner_emails = account_owner_emails; - this.use_to = conversation.base_folder.used_as.is_outgoing(); - - this.gtk.notify["gtk-font-name"].connect(this.update_font); - update_font(); - - // Load preview-related data. - update_date_string(); - this.subject_html_escaped - = Geary.HTML.escape_markup(Util.Email.strip_subject_prefixes(preview)); - this.body = Geary.String.reduce_whitespace(preview.get_preview_as_string()); - this.preview = preview; - - // Load conversation-related data. - this.is_unread = conversation.is_unread(); - this.is_flagged = conversation.is_flagged(); - this.num_emails = conversation.get_count(); - - // todo: instead of clearing the cache update it - this.conversation.appended.connect(clear_participants_cache); - this.conversation.trimmed.connect(clear_participants_cache); - this.conversation.email_flags_changed.connect(clear_participants_cache); - } - - // Creates an example message (used internally for styling calculations.) - public FormattedConversationData.create_example(Application.Configuration config) { - this.config = config; - this.is_unread = false; - this.is_flagged = false; - this.date = STYLE_EXAMPLE; - this.subject_html_escaped = STYLE_EXAMPLE; - this.body = STYLE_EXAMPLE + "\n" + STYLE_EXAMPLE; - this.num_emails = 1; - - this.font = Pango.FontDescription.from_string( - this.config.gnome_interface.get_string("font-name") - ); - } - - private void clear_participants_cache(Geary.Email email) { - participants.markup = null; - } - - public bool update_date_string() { - // get latest email *in folder* for the conversation's date, fall back on out-of-folder - Geary.Email? latest = conversation.get_latest_recv_email(Geary.App.Conversation.Location.IN_FOLDER_OUT_OF_FOLDER); - if (latest == null || latest.properties == null) - return false; - - // conversation list store sorts by date-received, so display that instead of sender's - // Date: - string new_date = Util.Date.pretty_print( - latest.properties.date_received.to_local(), - this.config.clock_format - ); - if (new_date == date) - return false; - - date = new_date; - - return true; - } - - private uint8 gdk_to_rgb(double gdk) { - return (uint8) (gdk.clamp(0.0, 1.0) * 255.0); - } - - private Gdk.RGBA dim_rgba(Gdk.RGBA rgba, double amount) { - amount = amount.clamp(0.0, 1.0); - - // can't use ternary in struct initializer due to this bug: - // https://bugzilla.gnome.org/show_bug.cgi?id=684742 - double dim_red = (rgba.red >= 0.5) ? -amount : amount; - double dim_green = (rgba.green >= 0.5) ? -amount : amount; - double dim_blue = (rgba.blue >= 0.5) ? -amount : amount; - - return Gdk.RGBA() { - red = (rgba.red + dim_red).clamp(0.0, 1.0), - green = (rgba.green + dim_green).clamp(0.0, 1.0), - blue = (rgba.blue + dim_blue).clamp(0.0, 1.0), - alpha = rgba.alpha - }; - } - - private string rgba_to_markup(Gdk.RGBA rgba) { - return "#%02x%02x%02x".printf( - gdk_to_rgb(rgba.red), gdk_to_rgb(rgba.green), gdk_to_rgb(rgba.blue)); - } - - private Gdk.RGBA get_foreground_rgba(Gtk.Widget widget, bool selected) { - // Do the https://bugzilla.gnome.org/show_bug.cgi?id=763796 dance - Gtk.StyleContext context = widget.get_style_context(); - context.save(); - context.set_state( - selected ? Gtk.StateFlags.SELECTED : Gtk.StateFlags.NORMAL - ); - Gdk.RGBA colour = context.get_color(context.get_state()); - context.restore(); - return colour; - } - - private string get_participants_markup(Gtk.Widget widget, bool selected) { - if (participants.markup != null && participants.was_widget_selected == selected) - return participants.markup; - - if (conversation == null || account_owner_emails == null || account_owner_emails.size == 0) - return ""; - - // Build chronological list of unique AuthorDisplay records, setting to - // unread if any message by that author is unread - Gee.ArrayList list = new Gee.ArrayList(); - foreach (Geary.Email message in conversation.get_emails(Geary.App.Conversation.Ordering.RECV_DATE_ASCENDING)) { - // only display if something to display - Geary.RFC822.MailboxAddresses? addresses = use_to - ? new Geary.RFC822.MailboxAddresses.single(Util.Email.get_primary_originator(message)) - : message.from; - if (addresses == null || addresses.size < 1) - continue; - - foreach (Geary.RFC822.MailboxAddress address in addresses) { - ParticipantDisplay participant_display = new ParticipantDisplay(address, - message.email_flags.is_unread()); - - int existing_index = list.index_of(participant_display); - if (existing_index < 0) { - list.add(participant_display); - - continue; - } - - // if present and this message is unread but the prior were read, - // this author is now unread - if (message.email_flags.is_unread()) - list[existing_index].is_unread = true; - } - } - - if (list.size == 1) { - // if only one participant, use full name - participants.markup = "%s" - .printf(rgba_to_markup(get_foreground_rgba(widget, selected)), - list[0].get_full_markup(account_owner_emails)); - } else { - StringBuilder builder = new StringBuilder("".printf( - rgba_to_markup(get_foreground_rgba(widget, selected)))); - bool first = true; - foreach (ParticipantDisplay participant in list) { - if (!first) - builder.append(", "); - - builder.append(participant.get_short_markup(account_owner_emails)); - first = false; - } - builder.append(""); - participants.markup = builder.str; - } - participants.was_widget_selected = selected; - return participants.markup; - } - - public void render(Cairo.Context ctx, Gtk.Widget widget, Gdk.Rectangle background_area, - Gdk.Rectangle cell_area, Gtk.CellRendererState flags, bool hover_select) { - render_internal(widget, cell_area, ctx, flags, false, hover_select); - } - - // Call this on style changes. - public void calculate_sizes(Gtk.Widget widget) { - render_internal(widget, null, null, 0, true, false); - } - - // Must call calculate_sizes() first. - public int get_height() { - assert(cell_height != -1); // ensures calculate_sizes() was called. - return cell_height; - } - - // Can be used for rendering or calculating height. - private void render_internal(Gtk.Widget widget, Gdk.Rectangle? cell_area, - Cairo.Context? ctx, Gtk.CellRendererState flags, bool recalc_dims, - bool hover_select) { - bool display_preview = this.config.display_preview; - int y = SPACING + (cell_area != null ? cell_area.y : 0); - - bool selected = (flags & Gtk.CellRendererState.SELECTED) != 0; - bool hover = (flags & Gtk.CellRendererState.PRELIT) != 0 || (selected && hover_select); - - // Date field. - Pango.Rectangle ink_rect = render_date(widget, cell_area, ctx, y, selected); - - // From field. - ink_rect = render_from(widget, cell_area, ctx, y, selected, ink_rect); - y += ink_rect.height + ink_rect.y + SPACING; - - // If we are displaying a preview then the message counter goes on the same line as the - // preview, otherwise it is with the subject. - int preview_height = 0; - - // Setup counter badge. - count_badge.count = num_emails; - int counter_width = count_badge.get_width(widget) + SPACING; - int counter_x = cell_area != null ? cell_area.width - cell_area.x - counter_width + - (SPACING / 2) : 0; - - if (display_preview) { - // Subject field. - render_subject(widget, cell_area, ctx, y, selected); - y += ink_rect.height + ink_rect.y + (SPACING / 2); - - // Number of e-mails field. - count_badge.render(widget, ctx, counter_x, y + (SPACING / 2), selected); - - // Body preview. - ink_rect = render_preview(widget, cell_area, ctx, y, selected, counter_width); - preview_height = ink_rect.height + ink_rect.y + (int) (SPACING * 1.2); - } else { - // Number of e-mails field. - count_badge.render(widget, ctx, counter_x, y, selected); - - // Subject field. - render_subject(widget, cell_area, ctx, y, selected, counter_width); - y += ink_rect.height + ink_rect.y + (int) (SPACING * 1.2); - } - - if (recalc_dims) { - FormattedConversationData.preview_height = preview_height; - FormattedConversationData.cell_height = y + preview_height; - } else { - int unread_y = display_preview ? cell_area.y + SPACING * 2 : cell_area.y + - SPACING; - - // Unread indicator. - if (is_unread || hover) { - Gdk.Pixbuf read_icon = IconFactory.instance.load_symbolic( - is_unread ? "mail-unread-symbolic" : "mail-read-symbolic", - IconFactory.UNREAD_ICON_SIZE, widget.get_style_context()); - Gdk.cairo_set_source_pixbuf(ctx, read_icon, cell_area.x + SPACING, unread_y); - ctx.paint(); - } - - // Starred indicator. - if (is_flagged || hover) { - int star_y = cell_area.y + (cell_area.height / 2) + (display_preview ? SPACING : 0); - Gdk.Pixbuf starred_icon = IconFactory.instance.load_symbolic( - is_flagged ? "starred-symbolic" : "non-starred-symbolic", - IconFactory.STAR_ICON_SIZE, widget.get_style_context()); - Gdk.cairo_set_source_pixbuf(ctx, starred_icon, cell_area.x + SPACING, star_y); - ctx.paint(); - } - } - } - - private Pango.Rectangle render_date(Gtk.Widget widget, Gdk.Rectangle? cell_area, - Cairo.Context? ctx, int y, bool selected) { - string date_markup = "%s".printf( - rgba_to_markup(dim_rgba(get_foreground_rgba(widget, selected), DIM_TEXT_AMOUNT)), - Geary.HTML.escape_markup(date)); - - Pango.Rectangle? ink_rect; - Pango.Rectangle? logical_rect; - Pango.Layout layout_date = widget.create_pango_layout(null); - layout_date.set_font_description(this.font); - layout_date.set_markup(date_markup, -1); - layout_date.set_alignment(Pango.Alignment.RIGHT); - layout_date.get_pixel_extents(out ink_rect, out logical_rect); - if (ctx != null && cell_area != null) { - ctx.move_to(cell_area.width - cell_area.x - ink_rect.width - ink_rect.x - SPACING, y); - Pango.cairo_show_layout(ctx, layout_date); - } - return ink_rect; - } - - private Pango.Rectangle render_from(Gtk.Widget widget, Gdk.Rectangle? cell_area, - Cairo.Context? ctx, int y, bool selected, Pango.Rectangle ink_rect) { - string from_markup = (conversation != null) ? get_participants_markup(widget, selected) : STYLE_EXAMPLE; - - Pango.FontDescription font = this.font; - if (is_unread) { - font = font.copy(); - font.set_weight(Pango.Weight.BOLD); - } - Pango.Layout layout_from = widget.create_pango_layout(null); - layout_from.set_font_description(font); - layout_from.set_markup(from_markup, -1); - layout_from.set_ellipsize(Pango.EllipsizeMode.END); - if (ctx != null && cell_area != null) { - layout_from.set_width((cell_area.width - ink_rect.width - ink_rect.x - (SPACING * 3) - - TEXT_LEFT) - * Pango.SCALE); - ctx.move_to(cell_area.x + TEXT_LEFT, y); - Pango.cairo_show_layout(ctx, layout_from); - } - return ink_rect; - } - - private void render_subject(Gtk.Widget widget, Gdk.Rectangle? cell_area, Cairo.Context? ctx, - int y, bool selected, int counter_width = 0) { - string subject_markup = "%s".printf( - rgba_to_markup(dim_rgba(get_foreground_rgba(widget, selected), DIM_TEXT_AMOUNT)), - subject_html_escaped); - - Pango.FontDescription font = this.font; - if (is_unread) { - font = font.copy(); - font.set_weight(Pango.Weight.BOLD); - } - Pango.Layout layout_subject = widget.create_pango_layout(null); - layout_subject.set_font_description(font); - layout_subject.set_markup(subject_markup, -1); - if (cell_area != null) - layout_subject.set_width((cell_area.width - TEXT_LEFT - counter_width) * Pango.SCALE); - layout_subject.set_ellipsize(Pango.EllipsizeMode.END); - if (ctx != null && cell_area != null) { - ctx.move_to(cell_area.x + TEXT_LEFT, y); - Pango.cairo_show_layout(ctx, layout_subject); - } - } - - private Pango.Rectangle render_preview(Gtk.Widget widget, Gdk.Rectangle? cell_area, - Cairo.Context? ctx, int y, bool selected, int counter_width = 0) { - double dim = selected ? DIM_TEXT_AMOUNT : DIM_PREVIEW_TEXT_AMOUNT; - string preview_markup = "%s".printf( - rgba_to_markup(dim_rgba(get_foreground_rgba(widget, selected), dim)), - Geary.String.is_empty(body) ? "" : Geary.HTML.escape_markup(body)); - - Pango.Layout layout_preview = widget.create_pango_layout(null); - layout_preview.set_font_description(this.font); - layout_preview.set_markup(preview_markup, -1); - layout_preview.set_wrap(Pango.WrapMode.WORD); - layout_preview.set_ellipsize(Pango.EllipsizeMode.END); - if (ctx != null && cell_area != null) { - layout_preview.set_width((cell_area.width - TEXT_LEFT - counter_width - SPACING) * Pango.SCALE); - layout_preview.set_height(preview_height * Pango.SCALE); - - ctx.move_to(cell_area.x + TEXT_LEFT, y); - Pango.cairo_show_layout(ctx, layout_preview); - } else { - layout_preview.set_width(int.MAX); - layout_preview.set_height(int.MAX); - } - - Pango.Rectangle? ink_rect; - Pango.Rectangle? logical_rect; - layout_preview.get_pixel_extents(out ink_rect, out logical_rect); - return ink_rect; - } - - private void update_font() { - var name = "Cantarell 11"; - if (this.gtk != null) { - name = this.gtk.gtk_font_name; - } - this.font = Pango.FontDescription.from_string(name); - } - -} diff --git a/src/client/conversation-viewer/conversation-viewer.vala b/src/client/conversation-viewer/conversation-viewer.vala index 983d6539..58606610 100644 --- a/src/client/conversation-viewer/conversation-viewer.vala +++ b/src/client/conversation-viewer/conversation-viewer.vala @@ -156,9 +156,9 @@ public class ConversationViewer : Gtk.Stack, Geary.BaseInterface { // XXX move the ConversationListView management code into // MainWindow or somewhere more appropriate - ConversationListView conversation_list = main_window.conversation_list_view; - this.selection_while_composing = conversation_list.copy_selected(); - conversation_list.get_selection().unselect_all(); + ConversationList.View conversation_list = main_window.conversation_list_view; + this.selection_while_composing = conversation_list.selected; + conversation_list.unselect_all(); box.vanished.connect(on_composer_closed); this.composer_page.add(box); diff --git a/src/client/meson.build b/src/client/meson.build index 0e9f437a..89008d0f 100644 --- a/src/client/meson.build +++ b/src/client/meson.build @@ -89,10 +89,10 @@ client_vala_sources = files( 'composer/contact-entry-completion.vala', 'composer/spell-check-popover.vala', - 'conversation-list/conversation-list-cell-renderer.vala', - 'conversation-list/conversation-list-store.vala', + 'conversation-list/conversation-list-model.vala', + 'conversation-list/conversation-list-participant.vala', + 'conversation-list/conversation-list-row.vala', 'conversation-list/conversation-list-view.vala', - 'conversation-list/formatted-conversation-data.vala', 'conversation-viewer/conversation-contact-popover.vala', 'conversation-viewer/conversation-email.vala', diff --git a/src/client/sidebar/sidebar-count-cell-renderer.vala b/src/client/sidebar/sidebar-count-cell-renderer.vala index 615cb864..c6bd1bfb 100644 --- a/src/client/sidebar/sidebar-count-cell-renderer.vala +++ b/src/client/sidebar/sidebar-count-cell-renderer.vala @@ -23,7 +23,7 @@ public class SidebarCountCellRenderer : Gtk.CellRenderer { public override void get_preferred_width(Gtk.Widget widget, out int minimum_size, out int natural_size) { unread_count.count = counter; - minimum_size = unread_count.get_width(widget) + FormattedConversationData.SPACING; + minimum_size = unread_count.get_width(widget) + CountBadge.SPACING; natural_size = minimum_size; } diff --git a/ui/application-main-window.ui b/ui/application-main-window.ui index 70ca4f7c..94247a5e 100644 --- a/ui/application-main-window.ui +++ b/ui/application-main-window.ui @@ -45,20 +45,13 @@ - - True - True - 0 - none - - - True - never - - - + + True + True + never + True @@ -94,28 +87,6 @@ 0 - - - True - 0 - none - - - 250 - True - - - - - - True - True - end - 1 - - True diff --git a/ui/components-conversation-actions.ui b/ui/components-conversation-actions.ui index f823dfcf..744a464c 100644 --- a/ui/components-conversation-actions.ui +++ b/ui/components-conversation-actions.ui @@ -81,25 +81,6 @@ True - - - True - False - False - True - - - True - checkbox-checked-symbolic - - - - - False - False - 0 - - True @@ -116,7 +97,7 @@ False False - 1 + 0 @@ -132,6 +113,28 @@ + + False + False + 1 + + + + + True + False + False + True + + + True + pan-down-symbolic + + + + False False diff --git a/ui/components-headerbar-conversation-list.ui b/ui/components-headerbar-conversation-list.ui index ce2257c6..789fbc3d 100644 --- a/ui/components-headerbar-conversation-list.ui +++ b/ui/components-headerbar-conversation-list.ui @@ -61,7 +61,24 @@ end - 3 + + + + + True + False + False + Selection conversations + True + + + True + selection-mode-symbolic + + + + + end diff --git a/ui/conversation-list-row.ui b/ui/conversation-list-row.ui new file mode 100644 index 00000000..7ae0d51e --- /dev/null +++ b/ui/conversation-list-row.ui @@ -0,0 +1,203 @@ + + + + + + True + + + True + + + diff --git a/ui/conversation-list-view.ui b/ui/conversation-list-view.ui new file mode 100644 index 00000000..59a7ba50 --- /dev/null +++ b/ui/conversation-list-view.ui @@ -0,0 +1,25 @@ + + + + + + diff --git a/ui/geary.css b/ui/geary.css index bb1d369a..f6a17661 100644 --- a/ui/geary.css +++ b/ui/geary.css @@ -8,13 +8,7 @@ /* MainWindow */ -.geary-folder-frame > border { - border-left-width: 0; - border-top-width: 0; - border-right-width: 0; -} - -.geary-folder-frame { +.geary-folder { min-width: 300px; } @@ -22,10 +16,7 @@ geary-conversation-list revealer { margin: 6px; } -.geary-conversation-frame > border { - border-left-width: 0; - border-top-width: 0; - border-right-width: 0; +geary-conversation-list { min-width: 360px; } @@ -33,10 +24,6 @@ geary-conversation-viewer { min-width: 360px; } -.geary-sidebar-pane-separator.vertical .conversation-frame > border { - border-bottom-width: 0; -} - .geary-overlay { background-color: @theme_base_color; padding: 2px 6px; @@ -57,7 +44,7 @@ geary-conversation-viewer { } infobar flowboxchild { - padding: 0px; + padding: 0px; } revealer components-conversation-actions { @@ -66,6 +53,89 @@ revealer components-conversation-actions { padding: 6px; } + +/* Conversation List */ +row.conversation-list { + padding-top: 0.5em; + padding-bottom: 0.5em; + padding-right: 0.5em; +} + +row.conversation-list.drag-n-drop { + background: @theme_base_color; + opacity: 0.7; + box-shadow: none; +} + +row.conversation-list label { + margin-bottom: .4em; +} + +row.conversation-list .tertiary { + opacity: 0.7; + font-size: 0.8em; +} + +row.conversation-list .subject { + font-size: 0.9em; +} + +row.conversation-list .date { + margin-left: 1em; +} + +/* Unread styling */ +row.conversation-list.unread .preview { + opacity: 1; +} + +row.conversation-list.unread .subject { + font-weight: bold; +} + +row.conversation-list.unread .participants { + font-weight: bold; +} + +row.conversation-list.unread .unread-button { + opacity: 1; +} + +/* Hover buttons */ +row.conversation-list .conversation-ephemeral-button { + opacity: 0; + margin: 2px; + border-radius: 50%; + border: none; +} + +row.conversation-list:hover .conversation-ephemeral-button { + opacity: 1; +} + +row.conversation-list.starred .flagged-button { + opacity: 1; +} + +row.conversation-list:selected .conversation-ephemeral-button { + opacity: 1; +} + +row.conversation-list .count-badge { + background: #888888; + color: white; + min-width: 1.5em; + border-radius: 1em; + font-size: .8em; + font-weight: bold; +} + +row.conversation-list check { + border-radius: 50%; + padding: 2px; + margin: 6px; +} + /* FolderPopover */ .geary-folder-popover-list { @@ -440,11 +510,11 @@ popover.geary-editor > grid > button.geary-setting-remove { } dialog.geary-remove-confirm .dialog-vbox { - margin: 12px; + margin: 12px; } dialog.geary-remove-confirm .dialog-action-box { - margin: 6px; + margin: 6px; } /* FolderList.Tree */ @@ -489,3 +559,10 @@ dialog.geary-upgrade grid { dialog.geary-upgrade label { margin-top: 12px; } + +/* Misc */ + +.thin-button { + padding-left: 4px; + padding-right: 4px; +} diff --git a/ui/org.gnome.Geary.gresource.xml b/ui/org.gnome.Geary.gresource.xml index a486d1fa..3e314b92 100644 --- a/ui/org.gnome.Geary.gresource.xml +++ b/ui/org.gnome.Geary.gresource.xml @@ -33,6 +33,8 @@ composer-web-view.css composer-web-view.js conversation-contact-popover.ui + conversation-list-row.ui + conversation-list-view.ui conversation-email.ui conversation-email-menus.ui conversation-message.ui