diff --git a/src/client/conversation-viewer/conversation-list-box.vala b/src/client/conversation-viewer/conversation-list-box.vala index 1972f4e5..519e51a4 100644 --- a/src/client/conversation-viewer/conversation-list-box.vala +++ b/src/client/conversation-viewer/conversation-list-box.vala @@ -47,8 +47,7 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { protected const string EXPANDED_CLASS = "geary-expanded"; - private const string FIRST_CLASS = "geary-first"; - private const string LAST_CLASS = "geary-last"; + // The email being displayed by this row, if any public Geary.Email? email { get; private set; default = null; } @@ -64,24 +63,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { } private bool _is_expanded = false; - // Designate this row as the first visible row in the - // conversation listbox, or not. See Bug 764710 and - // ::update_first_last_row() below. - internal bool is_first { - set { - set_style_context_class(FIRST_CLASS, value); - } - } - - // Designate this row as the last visible row in the - // conversation listbox, or not. See Bug 764710 and - // ::update_first_last_row() below. - internal bool is_last { - set { - set_style_context_class(LAST_CLASS, value); - } - } - // We can only scroll to a specific row once it has been // allocated space. This signal allows the viewer to hook up @@ -202,6 +183,28 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { } + // Displays a loading widget in the list box + private class LoadingRow : ConversationRow { + + + protected const string LOADING_CLASS = "geary-loading"; + + + public LoadingRow() { + base(null); + get_style_context().add_class(LOADING_CLASS); + + Gtk.Spinner spinner = new Gtk.Spinner(); + spinner.height_request = 16; + spinner.width_request = 16; + spinner.show(); + spinner.start(); + add(spinner); + } + + } + + // Displays a single embedded composer in the list box private class ComposerRow : ConversationRow { @@ -290,9 +293,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { // App config private Configuration config; - // Was this conversation loaded from the drafts folder? - private bool is_draft_folder; - // Cancellable for this conversation's data loading. private Cancellable cancellable = new Cancellable(); @@ -306,10 +306,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { // The id of the draft referred to by the current composer. private Geary.EmailIdentifier? draft_id = null; - // First and last visible row in the list, if any - private ConversationRow? first_row = null; - private ConversationRow? last_row = null; - // Cached search terms to apply to new messages private Gee.Set? search_terms = null; @@ -387,11 +383,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { this.avatar_store = avatar_store; this.config = config; - this.is_draft_folder = ( - conversation.base_folder.special_folder_type == - Geary.SpecialFolderType.DRAFTS - ); - get_style_context().add_class("background"); get_style_context().add_class("conversation-listbox"); @@ -442,116 +433,46 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { // Work out what the first interesting email is, and load it // before all of the email before and after that so we can - // load them in an optimal order - EmailRow? first_interesting = null; + // load them in an optimal order. Gee.LinkedList uninteresting = new Gee.LinkedList(); + Geary.Email? first_interesting = null; + Gee.LinkedList post_interesting = + new Gee.LinkedList(); foreach (Geary.Email email in all_email) { - if (this.cancellable.is_cancelled()) { - throw new GLib.IOError.CANCELLED("Conversation load cancelled"); - } - if (first_interesting == null) { if (email.is_unread().is_certain() || email.is_flagged().is_certain()) { - // Found and an interesting email! So load it! - EmailRow row = add_email(email); - first_interesting = row; - - // Update first/last row after adding the first to - // avoid the UI flashing as the border - // appears/disappears - update_first_last_row(); - - yield row.expand(this.email_store, this.avatar_store); + first_interesting = email; } else { // Inserted reversed so most recent uninteresting - // rows are added first + // rows are added first. uninteresting.insert(0, email); } } else { - // Already found and loaded an interesting email, so - // load the rest now normally. - EmailRow row = add_email(email); - if (email.is_unread().is_certain() || - email.is_flagged().is_certain() || - row.view.is_draft) { - yield row.expand(this.email_store, this.avatar_store); - } + post_interesting.add(email); } } if (first_interesting == null) { - // No interesting messages found so none have been - // expanded yet, show the last one at least. - EmailRow row = add_email( - uninteresting.remove_at(0) - ); - // Update first/last row after adding the first to - // avoid the UI flashing as the border - // appears/disappears - update_first_last_row(); + // No interesting messages found so use the last one. + first_interesting = uninteresting.remove_at(0); + } + EmailRow interesting_row = add_email(first_interesting); - yield row.expand(this.email_store, this.avatar_store); - first_interesting = row; + // If we have at least one uninteresting and one + // post-interesting to load afterwards, show a spinner above + // the interesting row to act as a placeholder. + if (!uninteresting.is_empty && !post_interesting.is_empty) { + insert(new LoadingRow(), 0); } - // Finally, load all of the uninteresting messages - if (!uninteresting.is_empty) { - bool added = false; - Gtk.Adjustment listbox_adj = get_adjustment(); - foreach (Geary.Email email in uninteresting) { - // Give GTK a moment to process newly added rows, so - // when updating the adjustment below the values are - // valid. Priority must be low otherwise other async - // tasks (like cancelling loading if another - // conversation is selected) won't get a look in until - // this is done. - GLib.Idle.add( - this.load_conversation.callback, GLib.Priority.LOW - ); - yield; - - // Check for cancellation after resuming in case the - // load was cancelled in the mean time. - if (this.cancellable.is_cancelled()) { - throw new GLib.IOError.CANCELLED( - "Conversation load cancelled" - ); - } - - EmailRow row = add_email(email, false); - // Drafts aren't interesting? - if (row.view.is_draft) { - yield row.expand(this.email_store, this.avatar_store); - } - - if (!added) { - added = true; - update_first_last_row(); - } - - // Since uninteresting rows are inserted above the - // first expanded, adjust the scrollbar as they are - // inserted so as to keep the list scrolled to the - // same place. - row.enable_should_scroll(); - row.should_scroll.connect(() => { - Gtk.Allocation row_alloc; - row.get_allocation(out row_alloc); - listbox_adj.value += row_alloc.height; - }); - } - } - - set_sort_func(on_sort); - - if (query != null) { - // XXX this sucks for large conversations because it can take - // a long time for the load to complete and hence for - // matches to show up. - highlight_matching_email(query); - } + // Load the interesting row completely up front, and load the + // remaining in the background so we can return fast. + yield interesting_row.expand(this.email_store, this.avatar_store); + this.finish_loading.begin( + query, uninteresting, post_interesting + ); } /** Cancels loading the current conversation, if still in progress */ @@ -619,7 +540,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { row.enable_should_scroll(); row.should_scroll.connect(() => { scroll_to(row); }); add(row); - update_first_last_row(); embed.composer.draft_id_changed.connect((id) => { this.draft_id = id; }); embed.vanished.connect(() => { @@ -752,6 +672,81 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { }); } + private async void finish_loading(Geary.SearchQuery? query, + Gee.LinkedList to_insert, + Gee.LinkedList to_append) + throws GLib.Error { + // Add emails to append first because if the first interesting + // message was short, these will show up in the UI under it, + // filling the empty space. + foreach (Geary.Email email in to_append) { + EmailRow row = add_email(email); + if (is_interesting(email)) { + yield row.expand(this.email_store, this.avatar_store); + } + yield throttle_loading(); + } + + // Since first rows may have extra margin, remove that from + // the height of rows when adjusting scrolling. + Gtk.ListBoxRow initial_row = get_row_at_index(0); + int loading_height = 0; + if (initial_row is LoadingRow) { + loading_height = GtkUtil.get_border_box_height(initial_row); + remove(initial_row); + } + + // None of these will be interesting, so just add them all, + // but keep the scrollbar adjusted so that the first + // interesting message remains visible. + Gtk.Adjustment listbox_adj = get_adjustment(); + foreach (Geary.Email email in to_insert) { + EmailRow row = add_email(email, false); + // Since uninteresting rows are inserted above the + // first expanded, adjust the scrollbar as they are + // inserted so as to keep the list scrolled to the + // same place. + row.enable_should_scroll(); + row.should_scroll.connect(() => { + listbox_adj.value += GtkUtil.get_border_box_height(row); + }); + + // Only adjust for the loading row going away once + loading_height = 0; + + yield throttle_loading(); + } + + set_sort_func(on_sort); + + if (query != null) { + // XXX this sucks for large conversations because it can take + // a long time for the load to complete and hence for + // matches to show up. + yield highlight_matching_email(query); + } + } + + private inline async void throttle_loading() throws GLib.IOError { + // Give GTK a moment to process newly added rows, so when + // updating the adjustment below the values are + // valid. Priority must be low otherwise other async tasks + // (like cancelling loading if another conversation is + // selected) won't get a look in until this is done. + GLib.Idle.add( + this.throttle_loading.callback, GLib.Priority.LOW + ); + yield; + + // Check for cancellation after resuming in case the load was + // cancelled in the mean time. + if (this.cancellable.is_cancelled()) { + throw new GLib.IOError.CANCELLED( + "Conversation load cancelled" + ); + } + } + // Loads full version of an email, adds it to the listbox private async void load_full_email(Geary.EmailIdentifier id) throws Error { @@ -768,8 +763,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { if (!this.cancellable.is_cancelled()) { EmailRow row = add_email(full_email); - update_first_last_row(); - row.view.load_avatar.begin(this.avatar_store); yield row.expand(this.email_store, this.avatar_store); } @@ -777,12 +770,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { // Constructs a row and view for an email, adds it to the listbox private EmailRow add_email(Geary.Email email, bool append_row = true) { - // Should be able to edit draft emails from any - // conversation. This test should be more like "is in drafts - // folder" - bool is_in_folder = this.conversation.is_in_base_folder(email.id); - bool is_draft = (this.is_draft_folder && is_in_folder); - bool is_sent = false; Geary.Account account = this.conversation.base_folder.account; if (email.from != null) { @@ -799,7 +786,7 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { account.get_contact_store(), this.config, is_sent, - is_draft, + is_draft(email), this.cancellable ); view.mark_email.connect(on_mark_email); @@ -919,38 +906,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { } } - // Due to Bug 764710, we can only use the CSS :last-child selector - // for GTK themes after 3.20.3, so for now manually maintain a - // class on the last box so we can emulate it - private void update_first_last_row() { - ConversationRow? first = null; - ConversationRow? last = null; - this.foreach((child) => { - if (first == null) { - first = (ConversationRow) child; - } - last = (ConversationRow) child; - }); - - if (this.first_row != first) { - if (this.first_row != null) { - this.first_row.is_first = false; - } - - this.first_row = first; - this.first_row.is_first = true; - } - - if (this.last_row != last) { - if (this.last_row != null) { - this.last_row.is_last = false; - } - - this.last_row = last; - this.last_row.is_last = true; - } - } - private void apply_search_terms(EmailRow row) { if (row.view.message_bodies_loaded) { this.apply_search_terms_impl.begin(row); @@ -998,6 +953,30 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { ); } + /** Determines if an email should be expanded by default. */ + private inline bool is_interesting(Geary.Email email) { + return ( + email.is_unread().is_certain() || + email.is_flagged().is_certain() || + is_draft(email) + ); + } + + /** Determines if an email should be considered to be a draft. */ + private inline bool is_draft(Geary.Email email) { + // XXX should be able to edit draft emails from any + // conversation. This test should be more like "is in drafts + // folder" + Geary.SpecialFolderType type = + this.conversation.base_folder.special_folder_type; + bool is_in_folder = this.conversation.is_in_base_folder(email.id); + + return ( + is_in_folder && type == Geary.SpecialFolderType.DRAFTS // || + //email.flags.is_draft() + ); + } + private void on_conversation_appended(Geary.App.Conversation conversation, Geary.Email email) { on_conversation_appended_async.begin(conversation, email); @@ -1080,7 +1059,7 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface { // be appended last. Finally, don't let rows with active // composers be collapsed. if (row.is_expanded) { - if (row != this.last_row) { + if (get_row_at_index(row.get_index() + 1) != null) { row.collapse(); } } else { diff --git a/src/client/util/util-gtk.vala b/src/client/util/util-gtk.vala index f07dff79..bcc6aa33 100644 --- a/src/client/util/util-gtk.vala +++ b/src/client/util/util-gtk.vala @@ -65,4 +65,18 @@ void menu_foreach(Menu menu, MenuForeachFunc foreach_func) { */ delegate void MenuForeachFunc(string? label, string? action_name, Variant? target, Menu? section); +/** + * Returns the CSS border box height for a widget. + * + * This adjusts the GTK widget's allocated height to exclude extra + * space added by the CSS margin property, if any. + */ +public inline int get_border_box_height(Gtk.Widget widget) { + Gtk.StyleContext style = widget.get_style_context(); + Gtk.StateFlags flags = style.get_state(); + Gtk.Border margin = style.get_margin(flags); + + return widget.get_allocated_height() - margin.top - margin.bottom; +} + } diff --git a/ui/client-web-view.js b/ui/client-web-view.js index 76d4456e..aee2438c 100644 --- a/ui/client-web-view.js +++ b/ui/client-web-view.js @@ -52,8 +52,11 @@ PageState.prototype = { // Queues an update after the DOM has been initially loaded // and had any changes made to it by derived classes. document.addEventListener("DOMContentLoaded", function(e) { + // Always fire a prefered height update first so that it + // will be vaguegly correct when notifying of the HTML + // load completing. + state.updatePreferredHeight(); state.loaded(); - queuePreferredHeightUpdate(); }); // Queues an update when the complete document is loaded. diff --git a/ui/geary.css b/ui/geary.css index e41caa43..665a8947 100644 --- a/ui/geary.css +++ b/ui/geary.css @@ -77,7 +77,7 @@ row.geary-folder-popover-list-row > label { /* ConversationListBox */ .conversation-listbox { - padding: 12px; + padding: 0 12px; } .conversation-listbox > row { margin: 0; @@ -85,7 +85,6 @@ row.geary-folder-popover-list-row > label { border-bottom-width: 0; padding: 0; box-shadow: 0 4px 8px 1px rgba(0,0,0,0.4); - transition: margin 0.1s; } .conversation-listbox > row > box { background: @theme_base_color; @@ -101,9 +100,16 @@ row.geary-folder-popover-list-row > label { .conversation-listbox *.geary-matched *.geary-match { color: @theme_selected_fg_color; background: @theme_selected_bg_color; +;} +.conversation-listbox > row.geary-loading { + border-top-width: 0px; + padding: 6px; } -.conversation-listbox > row.geary-last { - margin-bottom: 0; +.conversation-listbox > row:first-child:not(.geary-loading) { + margin-top: 12px; +} +.conversation-listbox > row:last-child { + margin-bottom: 12px; } /* ConversationEmail */