[geary/wip/765516-gtk-widget-conversation-viewer: 101/101] Break out ListBox used to display conversations into standalone widget.



commit 685355a243ef358e805682d31a5dd2699c48c6ff
Author: Michael James Gratton <mike vee net>
Date:   Mon Jul 25 10:33:42 2016 +1000

    Break out ListBox used to display conversations into standalone widget.
    
    The conversation viewer's ListBox is sufficiently complex to warrant its
    own widget. Use empty placeholders for the list per the HIG, and
    correctly fix mamagement of empty folder vs no conversations selected
    this time.
    
    * src/client/application/geary-controller.vala (GearyController):
      Directly manage secondary parts of the conversation viewer, since the
      controller since it has a better and more timely idea of when a
      conversation change is due to folder loading status or from the user
      selecting conversations, and so the viwer doesn't need to hook back
      into the controller. Remove the now-unused conversations_selected
      signal and its callers.
    
    * src/client/conversation-viewer/conversation-listbox.vala: New widget
      for displaying the list of emails for a conversation. Moved relevant
      code from ConversationViewer here. Made adding emails async to get
      better UI responsiveness. Don't implement anything to handle
      conversation changes or emptying the list.
    
    * src/client/conversation-viewer/conversation-viewer.vala: Replace user
      messages - empty folder/search & no/multiple messages selected with new
      EmptyPlaceholder. Remove a lot of the state manage code needed when
      managing the email listbox. Add a new ConversationListBox for every new
      conversation and just throw away.
    
    * src/client/conversation-list/conversation-list-view.vala
      (ConversationListView): Clean up firing the conversations_selected
      signal - don't actually emit it when the model is clearing, and don't
      bother delaying the check either.
    
    * src/client/components/empty-placeholder.vala: New widget for displaying
      empty list and grid placeholders per the HIG.
    
    * src/client/conversation-viewer/conversation-email.vala
      (ConversationEmail): Make manually read a property, since it
      effectively is one.
    
    * src/CMakeLists.txt: Include new source files.
    
    * po/POTFILES.in: Include new source and UI files, and some missing ones.
    
    * ui/CMakeLists.txt: Include new UI files.
    
    * ui/conversation-viewer.ui: Replace user message and splash page with
      placeholders for the new empty placeholders(!).
    
    * ui/empty-placeholder.ui: UI def for new widget class.
    
    * ui/geary.css: Chase widget name/class changes, style new
      empty placeholder UI.

 po/POTFILES.in                                     |    5 +-
 src/CMakeLists.txt                                 |    2 +
 src/client/application/geary-controller.vala       |  199 +++--
 src/client/components/empty-placeholder.vala       |   38 +
 src/client/composer/composer-widget.vala           |   29 +-
 .../conversation-list/conversation-list-view.vala  |  105 +-
 .../conversation-viewer/conversation-email.vala    |   27 +-
 .../conversation-viewer/conversation-listbox.vala  |  736 ++++++++++++++
 .../conversation-viewer/conversation-viewer.vala   | 1031 +++-----------------
 ui/CMakeLists.txt                                  |    1 +
 ui/conversation-viewer.ui                          |   83 +-
 ui/empty-placeholder.ui                            |   56 ++
 ui/geary.css                                       |   22 +-
 13 files changed, 1259 insertions(+), 1075 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index faa01cd..99797ad 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -23,6 +23,7 @@ src/client/application/main.vala
 src/client/application/secret-mediator.vala
 src/client/components/conversation-find-bar.vala
 src/client/components/count-badge.vala
+src/client/components/empty-placeholder.vala
 src/client/components/folder-popover.vala
 src/client/components/icon-factory.vala
 src/client/components/main-toolbar.vala
@@ -51,8 +52,10 @@ src/client/conversation-list/conversation-list-cell-renderer.vala
 src/client/conversation-list/conversation-list-store.vala
 src/client/conversation-list/conversation-list-view.vala
 src/client/conversation-list/formatted-conversation-data.vala
-src/client/conversation-viewer/conversation-viewer.vala
+src/client/conversation-viewer/conversation-email.vala
+src/client/conversation-viewer/conversation-listbox.vala
 src/client/conversation-viewer/conversation-message.vala
+src/client/conversation-viewer/conversation-viewer.vala
 src/client/conversation-viewer/conversation-web-view.vala
 src/client/dialogs/alert-dialog.vala
 src/client/dialogs/attachment-dialog.vala
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a82f6aa..8844ace 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -332,6 +332,7 @@ client/accounts/login-dialog.vala
 
 client/components/conversation-find-bar.vala
 client/components/count-badge.vala
+client/components/empty-placeholder.vala
 client/components/folder-popover.vala
 client/components/icon-factory.vala
 client/components/main-toolbar.vala
@@ -363,6 +364,7 @@ client/conversation-list/conversation-list-view.vala
 client/conversation-list/formatted-conversation-data.vala
 
 client/conversation-viewer/conversation-email.vala
+client/conversation-viewer/conversation-listbox.vala
 client/conversation-viewer/conversation-message.vala
 client/conversation-viewer/conversation-viewer.vala
 client/conversation-viewer/conversation-web-view.vala
diff --git a/src/client/application/geary-controller.vala b/src/client/application/geary-controller.vala
index 92586a4..869f413 100644
--- a/src/client/application/geary-controller.vala
+++ b/src/client/application/geary-controller.vala
@@ -148,12 +148,6 @@ public class GearyController : Geary.BaseObject {
     public signal void folder_selected(Geary.Folder? folder);
     
     /**
-     * Fired when the currently selected conversation(s) has/have changed.
-     */
-    public signal void conversations_selected(Gee.Set<Geary.App.Conversation> conversations,
-        Geary.Folder current_folder);
-    
-    /**
      * Fired when the number of conversations changes.
      */
     public signal void conversation_count_changed(int count);
@@ -233,12 +227,15 @@ public class GearyController : Geary.BaseObject {
         main_window.main_toolbar.copy_folder_menu.folder_selected.connect(on_copy_conversation);
         main_window.main_toolbar.move_folder_menu.folder_selected.connect(on_move_conversation);
         main_window.search_bar.search_text_changed.connect(on_search_text_changed);
-        main_window.conversation_viewer.email_row_added.connect(on_email_row_added);
-        main_window.conversation_viewer.email_row_removed.connect(on_email_row_removed);
-        main_window.conversation_viewer.mark_emails.connect(on_conversation_viewer_mark_emails);
+        main_window.conversation_viewer.conversation_added.connect(
+            on_conversation_view_added
+        );
+        main_window.conversation_viewer.conversation_removed.connect(
+            on_conversation_view_removed
+        );
         new_messages_monitor = new NewMessagesMonitor(should_notify_new_messages);
         main_window.folder_list.set_new_messages_monitor(new_messages_monitor);
-        
+
         // New messages indicator (Ubuntuism)
         new_messages_indicator = NewMessagesIndicator.create(new_messages_monitor);
         new_messages_indicator.application_activated.connect(on_indicator_activated_application);
@@ -294,8 +291,8 @@ public class GearyController : Geary.BaseObject {
         Geary.Engine.instance.account_available.disconnect(on_account_available);
         Geary.Engine.instance.account_unavailable.disconnect(on_account_unavailable);
         Geary.Engine.instance.untrusted_host.disconnect(on_untrusted_host);
-        
-        // Connect to various UI signals.
+
+        // Disconnect from various UI signals.
         main_window.conversation_list_view.conversations_selected.disconnect(on_conversations_selected);
         main_window.conversation_list_view.conversation_activated.disconnect(on_conversation_activated);
         main_window.conversation_list_view.load_more.disconnect(on_load_more);
@@ -307,9 +304,12 @@ public class GearyController : Geary.BaseObject {
         main_window.main_toolbar.copy_folder_menu.folder_selected.disconnect(on_copy_conversation);
         main_window.main_toolbar.move_folder_menu.folder_selected.disconnect(on_move_conversation);
         main_window.search_bar.search_text_changed.disconnect(on_search_text_changed);
-        main_window.conversation_viewer.email_row_added.disconnect(on_email_row_added);
-        main_window.conversation_viewer.email_row_removed.disconnect(on_email_row_removed);
-        main_window.conversation_viewer.mark_emails.disconnect(on_conversation_viewer_mark_emails);
+        main_window.conversation_viewer.conversation_added.disconnect(
+            on_conversation_view_added
+        );
+        main_window.conversation_viewer.conversation_removed.disconnect(
+            on_conversation_view_removed
+        );
 
         // hide window while shutting down, as this can take a few seconds under certain conditions
         main_window.hide();
@@ -1322,14 +1322,15 @@ public class GearyController : Geary.BaseObject {
 
     private void on_folder_selected(Geary.Folder? folder) {
         debug("Folder %s selected", folder != null ? folder.to_string() : "(null)");
-        
+        this.main_window.conversation_viewer.show_loading();
+
         // If the folder is being unset, clear the message list and exit here.
         if (folder == null) {
             current_folder = null;
             main_window.conversation_list_store.clear();
             main_window.main_toolbar.folder = null;
             folder_selected(null);
-            
+
             return;
         }
         
@@ -1469,12 +1470,22 @@ public class GearyController : Geary.BaseObject {
             on_load_more();
         }
     }
-    
+
     private void on_conversation_count_changed() {
-        if (current_conversations != null)
-            conversation_count_changed(current_conversations.get_conversation_count());
+        if (this.current_conversations != null) {
+            int count = this.current_conversations.get_conversation_count();
+            if (count == 0) {
+                // Let the user know if there's no available conversations
+                if (this.current_folder is Geary.SearchFolder) {
+                    this.main_window.conversation_viewer.show_empty_search();
+                } else {
+                    this.main_window.conversation_viewer.show_empty_folder();
+                }
+            }
+            conversation_count_changed(count);
+        }
     }
-    
+
     private void on_libnotify_invoked(Geary.Folder? folder, Geary.Email? email) {
         new_messages_monitor.clear_all_new_messages();
         
@@ -1516,11 +1527,13 @@ public class GearyController : Geary.BaseObject {
             debug("Unable to select folder: %s", err.message);
         }
     }
-    
+
     private void on_conversations_selected(Gee.Set<Geary.App.Conversation> selected) {
         selected_conversations = selected;
-        if (current_folder != null) {
-            conversations_selected(selected_conversations, current_folder);
+        if (this.current_folder != null) {
+            this.main_window.conversation_viewer.load_conversations.begin(
+                selected, this.current_folder
+            );
         }
     }
 
@@ -1897,9 +1910,13 @@ public class GearyController : Geary.BaseObject {
         
         Gee.ArrayList<Geary.EmailIdentifier> ids = get_selected_email_ids(false);
         mark_email(ids, null, flags);
-        
-        foreach (Geary.EmailIdentifier id in ids)
-            main_window.conversation_viewer.mark_manual_read(id);
+
+        ConversationListBox? list =
+            main_window.conversation_viewer.current_list;
+        if (list != null) {
+            foreach (Geary.EmailIdentifier id in ids)
+                list.mark_manual_read(id);
+        }
     }
 
     private void on_mark_as_unread() {
@@ -1908,9 +1925,13 @@ public class GearyController : Geary.BaseObject {
         
         Gee.ArrayList<Geary.EmailIdentifier> ids = get_selected_email_ids(true);
         mark_email(ids, flags, null);
-        
-        foreach (Geary.EmailIdentifier id in ids)
-            main_window.conversation_viewer.mark_manual_read(id);
+
+        ConversationListBox? list =
+            main_window.conversation_viewer.current_list;
+        if (list != null) {
+            foreach (Geary.EmailIdentifier id in ids)
+                list.mark_manual_unread(id);
+        }
     }
 
     private void on_mark_as_starred() {
@@ -2228,15 +2249,19 @@ public class GearyController : Geary.BaseObject {
     // was triggered.  If null, this was triggered from the headerbar
     // or shortcut.
     private void create_reply_forward_widget(ComposerWidget.ComposeType compose_type,
-                                             owned ConversationEmail? view) {
-        if (view == null) {
-            view = main_window.conversation_viewer.get_reply_email_view();
+                                             owned ConversationEmail? email_view) {
+        if (email_view == null) {
+            ConversationListBox? list_view =
+                main_window.conversation_viewer.current_list;
+            if (list_view != null) {
+                email_view = list_view.reply_target;
+            }
         }
         string? quote = null;
-        if (view != null) {
-            quote = view.get_body_selection();
+        if (email_view != null) {
+            quote = email_view.get_body_selection();
         }
-        create_compose_widget(compose_type, view.email, quote);
+        create_compose_widget(compose_type, email_view.email, quote);
     }
 
     private void create_compose_widget(ComposerWidget.ComposeType compose_type,
@@ -2254,7 +2279,10 @@ public class GearyController : Geary.BaseObject {
         bool inline;
         if (!should_create_new_composer(compose_type, referred, quote, is_draft, out inline))
             return;
-        
+
+        ConversationListBox? conversation_view =
+            main_window.conversation_viewer.current_list;
+
         ComposerWidget widget;
         if (mailto != null) {
             widget = new ComposerWidget.from_mailto(current_account, mailto);
@@ -2273,7 +2301,9 @@ public class GearyController : Geary.BaseObject {
             widget = new ComposerWidget(current_account, compose_type, full, quote, is_draft);
             if (is_draft) {
                 yield widget.restore_draft_state_async(current_account);
-                main_window.conversation_viewer.blacklist_by_id(referred.id);
+                if (conversation_view != null) {
+                    conversation_view.blacklist_by_id(referred.id);
+                }
             }
         }
         widget.show_all();
@@ -2289,7 +2319,14 @@ public class GearyController : Geary.BaseObject {
                 widget.state == ComposerWidget.ComposerState.PANED) {
                 main_window.conversation_viewer.do_compose(widget);
             } else {
-                main_window.conversation_viewer.do_embedded_composer(widget, referred);
+                ComposerEmbed embed = new ComposerEmbed(
+                    referred,
+                    widget,
+                    main_window.conversation_viewer.conversation_page
+                );
+                if (conversation_view != null) {
+                    conversation_view.add_embedded_composer(embed);
+                }
             }
         } else {
             new ComposerWindow(widget);
@@ -2535,13 +2572,15 @@ public class GearyController : Geary.BaseObject {
         Cancellable? cancellable) throws Error {
         if (!can_switch_conversation_view())
             return;
-        
-        if (main_window.conversation_viewer.current_conversation != null
-            && main_window.conversation_viewer.current_conversation == last_deleted_conversation) {
+
+        ConversationListBox list_view =
+            main_window.conversation_viewer.current_list;
+        if (list_view != null &&
+            list_view.conversation == last_deleted_conversation) {
             debug("Not archiving/trashing/deleting; viewed conversation is last deleted conversation");
             return;
         }
-        
+
         last_deleted_conversation = selected_conversations.size > 0
             ? Geary.traverse<Geary.App.Conversation>(selected_conversations).first() : null;
         
@@ -2670,15 +2709,27 @@ public class GearyController : Geary.BaseObject {
     }
 
     private void on_zoom_in() {
-        this.main_window.conversation_viewer.zoom_in();
+        ConversationListBox? view =
+            main_window.conversation_viewer.current_list;
+        if (view != null) {
+            view.zoom_in();
+        }
     }
 
     private void on_zoom_out() {
-        this.main_window.conversation_viewer.zoom_out();
+        ConversationListBox? view =
+            main_window.conversation_viewer.current_list;
+        if (view != null) {
+            view.zoom_out();
+        }
     }
 
     private void on_zoom_normal() {
-        this.main_window.conversation_viewer.zoom_reset();
+        ConversationListBox? view =
+            main_window.conversation_viewer.current_list;
+        if (view != null) {
+            view.zoom_reset();
+        }
     }
 
     private void on_search() {
@@ -2693,28 +2744,40 @@ public class GearyController : Geary.BaseObject {
         Libnotify.play_sound("message-sent-email");
     }
 
-    private void on_email_row_added(ConversationEmail message) {
-        message.reply_to_message.connect(on_reply_to_message);
-        message.reply_all_message.connect(on_reply_all_message);
-        message.forward_message.connect(on_forward_message);
-        message.link_activated.connect(on_link_activated);
-        message.attachments_activated.connect(on_attachments_activated);
-        message.save_attachments.connect(on_save_attachments);
-        message.edit_draft.connect(on_edit_draft);
-        message.view_source.connect(on_view_source);
-        message.save_image.connect(on_save_buffer_to_file);
-    }
-
-    private void on_email_row_removed(ConversationEmail message) {
-        message.reply_to_message.disconnect(on_reply_to_message);
-        message.reply_all_message.disconnect(on_reply_all_message);
-        message.forward_message.disconnect(on_forward_message);
-        message.link_activated.disconnect(on_link_activated);
-        message.attachments_activated.disconnect(on_attachments_activated);
-        message.save_attachments.disconnect(on_save_attachments);
-        message.edit_draft.disconnect(on_edit_draft);
-        message.view_source.disconnect(on_view_source);
-        message.save_image.disconnect(on_save_buffer_to_file);
+    private void on_conversation_view_added(ConversationListBox list) {
+        list.email_added.connect(on_conversation_viewer_email_added);
+        list.email_removed.connect(on_conversation_viewer_email_removed);
+        list.mark_emails.connect(on_conversation_viewer_mark_emails);
+    }
+
+    private void on_conversation_view_removed(ConversationListBox list) {
+        list.email_added.disconnect(on_conversation_viewer_email_added);
+        list.email_removed.disconnect(on_conversation_viewer_email_removed);
+        list.mark_emails.disconnect(on_conversation_viewer_mark_emails);
+    }
+
+    private void on_conversation_viewer_email_added(ConversationEmail view) {
+        view.reply_to_message.connect(on_reply_to_message);
+        view.reply_all_message.connect(on_reply_all_message);
+        view.forward_message.connect(on_forward_message);
+        view.link_activated.connect(on_link_activated);
+        view.attachments_activated.connect(on_attachments_activated);
+        view.save_attachments.connect(on_save_attachments);
+        view.edit_draft.connect(on_edit_draft);
+        view.view_source.connect(on_view_source);
+        view.save_image.connect(on_save_buffer_to_file);
+    }
+
+    private void on_conversation_viewer_email_removed(ConversationEmail view) {
+        view.reply_to_message.disconnect(on_reply_to_message);
+        view.reply_all_message.disconnect(on_reply_all_message);
+        view.forward_message.disconnect(on_forward_message);
+        view.link_activated.disconnect(on_link_activated);
+        view.attachments_activated.disconnect(on_attachments_activated);
+        view.save_attachments.disconnect(on_save_attachments);
+        view.edit_draft.disconnect(on_edit_draft);
+        view.view_source.disconnect(on_view_source);
+        view.save_image.disconnect(on_save_buffer_to_file);
     }
 
     private void on_link_activated(string link) {
diff --git a/src/client/components/empty-placeholder.vala b/src/client/components/empty-placeholder.vala
new file mode 100644
index 0000000..8331c08
--- /dev/null
+++ b/src/client/components/empty-placeholder.vala
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2016 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * A placeholder image and message for empty views.
+ */
+[GtkTemplate (ui = "/org/gnome/Geary/empty-placeholder.ui")]
+public class EmptyPlaceholder : Gtk.Grid {
+
+    public string image_name {
+        owned get { return this.placeholder_image.icon_name; }
+        set { this.placeholder_image.icon_name = value; }
+    }
+
+    public string title {
+        get { return this.title_label.get_text(); }
+        set { this.title_label.set_text(value); }
+    }
+
+    public string subtitle {
+        get { return this.subtitle_label.get_text(); }
+        set { this.subtitle_label.set_text(value); }
+    }
+
+    [GtkChild]
+    private Gtk.Image placeholder_image;
+
+    [GtkChild]
+    private Gtk.Label title_label;
+
+    [GtkChild]
+    private Gtk.Label subtitle_label;
+
+}
diff --git a/src/client/composer/composer-widget.vala b/src/client/composer/composer-widget.vala
index 1740d01..8cf7a67 100644
--- a/src/client/composer/composer-widget.vala
+++ b/src/client/composer/composer-widget.vala
@@ -617,11 +617,12 @@ public class ComposerWidget : Gtk.EventBox {
         // Remind the conversation viewer of draft ids when it reloads
         ConversationViewer conversation_viewer =
             GearyApplication.instance.controller.main_window.conversation_viewer;
-        conversation_viewer.cleared.connect(() => {
-            if (draft_manager != null)
-                conversation_viewer.blacklist_by_id(draft_manager.current_draft_id);
+        conversation_viewer.conversation_added.connect((list_view) => {
+                if (draft_manager != null) {
+                    list_view.blacklist_by_id(draft_manager.current_draft_id);
+                }
         });
-        
+
         destroy.connect(() => { close_draft_manager_async.begin(null); });
     }
     
@@ -1345,12 +1346,15 @@ public class ComposerWidget : Gtk.EventBox {
                 assert_not_reached();
         }
     }
-    
+
     private void on_draft_id_changed() {
-        GearyApplication.instance.controller.main_window.conversation_viewer.blacklist_by_id(
-            draft_manager.current_draft_id);
+        ConversationListBox? list_view =
+            GearyApplication.instance.controller.main_window.conversation_viewer.current_list;
+        if (list_view != null) {
+            list_view.blacklist_by_id(draft_manager.current_draft_id);
+        }
     }
-    
+
     private void on_draft_manager_fatal(Error err) {
         draft_save_text = DRAFT_ERROR_TEXT;
     }
@@ -1488,10 +1492,11 @@ public class ComposerWidget : Gtk.EventBox {
         } catch (Error err) {
             // ignored
         }
-        if (draft_manager != null)
-            GearyApplication.instance.controller.main_window.conversation_viewer
-                .unblacklist_by_id(draft_manager.current_draft_id);
-        
+        ConversationListBox? list_view =
+            GearyApplication.instance.controller.main_window.conversation_viewer.current_list;
+        if (draft_manager != null && list_view != null) {
+            list_view.unblacklist_by_id(draft_manager.current_draft_id);
+        }
         container.close_container();
     }
     
diff --git a/src/client/conversation-list/conversation-list-view.vala 
b/src/client/conversation-list/conversation-list-view.vala
index e81e117..0ea62ef 100644
--- a/src/client/conversation-list/conversation-list-view.vala
+++ b/src/client/conversation-list/conversation-list-view.vala
@@ -311,69 +311,66 @@ public class ConversationListView : Gtk.TreeView {
     private Gtk.TreePath? get_selected_path() {
         return get_all_selected_paths().nth_data(0);
     }
-    
+
     private void on_selection_changed() {
-        if (selection_changed_id != 0)
-            Source.remove(selection_changed_id);
-        
-        // 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.
-        selection_changed_id = Idle.add(() => {
-            // no longer scheduled
-            selection_changed_id = 0;
-            
-            do_selection_changed();
-            
-            return false;
-        }, Priority.LOW);
+        if (this.selection_changed_id != 0)
+            Source.remove(this.selection_changed_id);
+
+        if (this.conversation_list_store.is_clearing) {
+            // The list store is clearing, so the folder has changed
+            // and we don't want to notify about the selection
+            // changing, so just clear it.
+            this.selected.clear();
+        } else {
+            // 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_changed_id = Idle.add(() => {
+                    // no longer scheduled
+                    this.selection_changed_id = 0;
+
+                    // Pass the is_clearing flag through here so the value is
+                    // accurate later on, when the idle callback actually
+                    // happens.
+                    do_selection_changed();
+
+                    return false;
+                }, Priority.LOW);
+        }
     }
-    
-    // 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
+
+    // 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() {
-        // if the ConversationListStore is clearing, then this is called repeatedly as the elements
-        // are removed, causing signals to fire and a flurry of I/O that is immediately cancelled
-        // this prevents that, merely firing the signal once to indicate all selections are
-        // dropped while clearing
-        if (conversation_list_store.is_clearing) {
-            if (selected.size > 0) {
-                selected.clear();
-                conversations_selected(selected.read_only_view);
-            }
-            
-            return;
-        }
-        
+        Gee.HashSet<Geary.App.Conversation> new_selection =
+        new Gee.HashSet<Geary.App.Conversation>();
         List<Gtk.TreePath> paths = get_all_selected_paths();
-        if (paths.length() == 0) {
-            // only notify if this is different than what was previously reported
-            if (selected.size != 0) {
-                selected.clear();
-                conversations_selected(selected.read_only_view);
+        if (paths.length() != 0) {
+            // Conversations are selected, so collect them and
+            // signal if different
+            foreach (Gtk.TreePath path in paths) {
+                Geary.App.Conversation? conversation =
+                this.conversation_list_store.get_conversation_at_path(path);
+                if (conversation != null)
+                    new_selection.add(conversation);
             }
-            
-            return;
         }
-        
-        // Conversations are selected, so collect them and signal if different
-        Gee.HashSet<Geary.App.Conversation> new_selected = new Gee.HashSet<Geary.App.Conversation>();
-        foreach (Gtk.TreePath path in paths) {
-            Geary.App.Conversation? conversation = conversation_list_store.get_conversation_at_path(path);
-            if (conversation != null)
-                new_selected.add(conversation);
-        }
-        
+
         // only notify if different than what was previously reported
-        if (!Geary.Collection.are_sets_equal<Geary.App.Conversation>(selected, new_selected)) {
-            selected = new_selected;
-            conversations_selected(selected.read_only_view);
+        if (!Geary.Collection.are_sets_equal<Geary.App.Conversation>(
+                this.selected, new_selection)) {
+            this.selected = new_selection;
+            conversations_selected(this.selected.read_only_view);
         }
     }
-    
+
     public Gee.Set<Geary.App.Conversation> get_visible_conversations() {
         Gee.HashSet<Geary.App.Conversation> visible_conversations = new 
Gee.HashSet<Geary.App.Conversation>();
         
diff --git a/src/client/conversation-viewer/conversation-email.vala 
b/src/client/conversation-viewer/conversation-email.vala
index d03e394..9d789cd 100644
--- a/src/client/conversation-viewer/conversation-email.vala
+++ b/src/client/conversation-viewer/conversation-email.vala
@@ -119,6 +119,7 @@ public class ConversationEmail : Gtk.Box {
     private const string ACTION_UNSTAR = "unstar";
     private const string ACTION_VIEW_SOURCE = "view_source";
 
+    private const string MANUAL_READ_CLASS = "geary-manual-read";
 
     /** The specific email that is displayed by this view. */
     public Geary.Email email { get; private set; }
@@ -126,6 +127,18 @@ public class ConversationEmail : Gtk.Box {
     /** Determines if the email is showing a preview or the full message. */
     public bool is_collapsed = true;
 
+    /** Determines if the email has been manually marked as being read. */
+    public bool is_manually_read {
+        get { return get_style_context().has_class(MANUAL_READ_CLASS); }
+        set {
+            if (value) {
+                get_style_context().add_class(MANUAL_READ_CLASS);
+            } else {
+                get_style_context().remove_class(MANUAL_READ_CLASS);
+            }
+        }
+    }
+
     /** The view displaying the email's primary message headers and body. */
     public ConversationMessage primary_message { get; private set; }
 
@@ -422,20 +435,6 @@ public class ConversationEmail : Gtk.Box {
     }
 
     /**
-     * Determines if the email is flagged as read on the client side only.
-     */
-    public bool is_manual_read() {
-        return get_style_context().has_class("geary_manual_read");
-    }
-
-    /**
-     * Displays the message as read, even if not reflected in its flags.
-     */
-    public void mark_manual_read() {
-        get_style_context().add_class("geary_manual_read");
-    }
-
-    /**
      * Returns user-selected body HTML from a message, if any.
      */
     public string? get_body_selection() {
diff --git a/src/client/conversation-viewer/conversation-listbox.vala 
b/src/client/conversation-viewer/conversation-listbox.vala
new file mode 100644
index 0000000..a8a5bd8
--- /dev/null
+++ b/src/client/conversation-viewer/conversation-listbox.vala
@@ -0,0 +1,736 @@
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2016 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * A widget for displaying conversations as a list of emails.
+ *
+ * The view displays the current selected {@link
+ * Geary.App.Conversation} from the conversation list. To do so, it
+ * listens to signals from both the list and the current conversation
+ * monitor, updating the email list as needed.
+ *
+ * Unlike ConversationListStore (which sorts by date received),
+ * ConversationListBox sorts by the {@link Geary.Email.date} field
+ * (the Date: header), as that's the date displayed to the user.
+ */
+public class ConversationListBox : Gtk.ListBox {
+
+    /** Fields that must be available for display as a conversation. */
+    public const Geary.Email.Field REQUIRED_FIELDS =
+        Geary.Email.Field.HEADER
+        | Geary.Email.Field.BODY
+        | Geary.Email.Field.ORIGINATORS
+        | Geary.Email.Field.RECEIVERS
+        | Geary.Email.Field.SUBJECT
+        | Geary.Email.Field.DATE
+        | Geary.Email.Field.FLAGS
+        | Geary.Email.Field.PREVIEW;
+
+    // Offset from the top of the list box which emails views will
+    // scrolled to, so the user can see there are additional messages
+    // above it. XXX This is currently approx 1.5 times the height of
+    // a collapsed ConversationEmail, it should probably calculated
+    // somehow so that differences user's font size are taken into
+    // account.
+    private const int EMAIL_TOP_OFFSET = 92;
+
+
+    // Custom class used to display ConversationEmail views in the
+    // conversation listbox.
+    private class EmailRow : Gtk.ListBoxRow {
+
+        private const string EXPANDED_CLASS = "geary-expanded";
+        private const string LAST_CLASS = "geary-last";
+
+        // Is the row showing the email's message body?
+        public bool is_expanded {
+            get { return get_style_context().has_class(EXPANDED_CLASS); }
+        }
+
+        // Designate this row as the last visible row in the
+        // conversation listbox, or not. See Bug 764710 and
+        // ::update_last_row() below.
+        public bool is_last {
+            get { return get_style_context().has_class(LAST_CLASS); }
+            set {
+                if (value) {
+                    get_style_context().add_class(LAST_CLASS);
+                } else {
+                    get_style_context().remove_class(LAST_CLASS);
+                }
+            }
+        }
+
+        // We can only scroll to a specific row once it has been
+        // allocated space. This signal allows the viewer to hook up
+        // to appropriate times to try to do that scroll.
+        public signal void should_scroll();
+
+        public ConversationEmail view {
+            get { return (ConversationEmail) get_child(); }
+        }
+
+        public EmailRow(ConversationEmail view) {
+            add(view);
+        }
+
+        public new void expand(bool include_transitions=true) {
+            get_style_context().add_class(EXPANDED_CLASS);
+            this.view.expand_email(include_transitions);
+        }
+
+        public void collapse() {
+            get_style_context().remove_class(EXPANDED_CLASS);
+            this.view.collapse_email();
+        }
+
+        public void enable_should_scroll() {
+            this.size_allocate.connect(on_size_allocate);
+        }
+
+        private void on_size_allocate() {
+            // We need to wait the web view to load first, so that the
+            // message has a non-trivial height, and then wait for it
+            // to be reallocated, so that it picks up the web_view's
+            // height.
+            ConversationWebView web_view = view.primary_message.web_view;
+            if (web_view.load_status == WebKit.LoadStatus.FINISHED) {
+                // Disable should_scroll after the message body has
+                // been loaded so we don't keep on scrolling later,
+                // like when the window has been resized.
+                this.size_allocate.disconnect(on_size_allocate);
+            }
+
+            should_scroll();
+        }
+
+    }
+
+    /**
+     * Returns the view for the email to be replied to, if any.
+     *
+     * If an email view has selected body text that view will be
+     * returned. Else the last message by sort order will be returned,
+     * if any.
+     */
+    public ConversationEmail? reply_target {
+        get {
+            unowned ConversationEmail? view = this.body_selected_view;
+            if (view == null && this.last_email_row != null) {
+                view = this.last_email_row.view;
+            }
+            return view;
+        }
+    }
+
+
+    /** Conversation being displayed. */
+    public Geary.App.Conversation conversation { get; private set; }
+
+    // Contacts for the account this conversation exists in
+    private Geary.ContactStore contact_store;
+
+    private Geary.App.EmailStore email_store;
+
+    // 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();
+
+    // Email view with selected text, if any
+    private ConversationEmail? body_selected_view = null;
+
+    // Maps displayed emails to their corresponding EmailRow.
+    private Gee.HashMap<Geary.EmailIdentifier, EmailRow> id_to_row = new
+        Gee.HashMap<Geary.EmailIdentifier, EmailRow>();
+
+    // Last visible row in the list, if any
+    private EmailRow? last_email_row = null;
+
+
+    /** Fired when an email view is added to the conversation list. */
+    public signal void email_added(ConversationEmail email);
+
+    /** Fired when an email view is removed from the conversation list. */
+    public signal void email_removed(ConversationEmail email);
+
+    /** Fired when the user updates the flags for a set of emails. */
+    public signal void mark_emails(Gee.Collection<Geary.EmailIdentifier> emails,
+        Geary.EmailFlags? flags_to_add, Geary.EmailFlags? flags_to_remove);
+
+    /**
+     * Constructs a new conversation list box instance.
+     */
+    public ConversationListBox(Geary.App.Conversation conversation,
+                               Geary.ContactStore contact_store,
+                               Geary.App.EmailStore? email_store,
+                               bool is_draft_folder,
+                               Gtk.Adjustment adjustment) {
+        this.conversation = conversation;
+        this.contact_store = contact_store;
+        this.email_store = email_store;
+        this.is_draft_folder = is_draft_folder;
+
+        get_style_context().add_class("background");
+        get_style_context().add_class("conversation-listbox");
+
+        set_adjustment(adjustment);
+        set_selection_mode(Gtk.SelectionMode.NONE);
+
+        this.key_press_event.connect(on_key_press);
+        this.realize.connect(() => {
+                adjustment.value_changed.connect(check_mark_read);
+            });
+        this.row_activated.connect(on_row_activated);
+        this.set_sort_func(on_sort);
+        this.size_allocate.connect(() => { check_mark_read(); });
+
+        this.conversation.appended.connect(on_conversation_appended);
+        this.conversation.trimmed.connect(on_conversation_trimmed);
+        this.conversation.email_flags_changed.connect(on_update_flags);
+    }
+
+    ~ConversationListBox() {
+        this.cancellable.cancel();
+        this.conversation.email_flags_changed.disconnect(on_update_flags);
+        this.conversation.trimmed.disconnect(on_conversation_trimmed);
+        this.conversation.appended.disconnect(on_conversation_appended);
+        get_adjustment().value_changed.disconnect(check_mark_read);
+    }
+
+    public async void load_conversation()
+        throws Error {
+        // Fetch full emails.
+        Gee.Collection<Geary.Email>? emails_to_add =
+            yield list_full_emails_async(
+                this.conversation.get_emails(
+                    Geary.App.Conversation.Ordering.SENT_DATE_ASCENDING
+                ),
+                this.cancellable
+            );
+
+        if (emails_to_add != null) {
+            foreach (Geary.Email email in emails_to_add) {
+                if (this.cancellable.is_cancelled()) {
+                    return;
+                }
+                yield add_email(
+                    email, conversation.is_in_current_folder(email.id)
+                );
+            }
+        }
+
+        if (this.cancellable.is_cancelled()) {
+            return;
+        }
+
+        // Work out what the first expanded row is. We can't do this
+        // in the foreach above since that is not adding messages in
+        // order.
+        EmailRow? first_expanded_row = null;
+        this.foreach((child) => {
+                if (first_expanded_row == null) {
+                    EmailRow row = (EmailRow) child;
+                    row.should_scroll.connect(scroll_to);
+                    if (row.is_expanded) {
+                        first_expanded_row = row;
+                    }
+                }
+            });
+
+        if (this.last_email_row != null) {
+            // The last email should always be expanded so the user
+            // isn't presented with a list of collapsed headers when a
+            // conversation has no unread messages.
+            this.last_email_row.expand(true);
+
+            if (first_expanded_row == null) {
+                first_expanded_row = this.last_email_row;
+            }
+
+            // The first expanded row (i.e. first unread or simply the
+            // last message) should always be scrolled to the top of
+            // the visible area.
+            first_expanded_row.enable_should_scroll();
+        }
+
+        debug("Conversation loading complete");
+    }
+
+    /**
+     * Cancel all loading activity for the conversation.
+     */
+    public void cancel_load() {
+        this.cancellable.cancel();
+    }
+
+    /**
+     * Adds an an embedded composer to the view.
+     */
+    public void add_embedded_composer(ComposerEmbed embed) {
+        EmailRow? row = this.id_to_row.get(embed.referred.id);
+        if (row != null) {
+            row.view.attach_composer(embed);
+            embed.loaded.connect((box) => {
+                    embed.grab_focus();
+                });
+            embed.vanished.connect((box) => {
+                    row.view.remove_composer(embed);
+                });
+        } else {
+            error("Could not find referred email for embedded composer: %s",
+                  embed.referred.id.to_string());
+        }
+    }
+
+    /**
+     * Finds any currently visible messages, marks them as being read.
+     */
+    public void check_mark_read() {
+        Gee.ArrayList<Geary.EmailIdentifier> email_ids =
+            new Gee.ArrayList<Geary.EmailIdentifier>();
+
+        Gtk.Adjustment adj = get_adjustment();
+        int top_bound = (int) adj.value;
+        int bottom_bound = top_bound + (int) adj.page_size;
+
+        email_view_iterator().foreach((email_view) => {
+            const int TEXT_PADDING = 50;
+            ConversationMessage conversation_message = email_view.primary_message;
+            // Don't bother with not-yet-loaded emails since the
+            // size of the body will be off, affecting the visibility
+            // of emails further down the conversation.
+            if (email_view.email.email_flags.is_unread() &&
+                conversation_message.is_loading_complete &&
+                !email_view.is_manually_read) {
+                 int body_top = 0;
+                 int body_left = 0;
+                 conversation_message.web_view.translate_coordinates(
+                     this,
+                     0, 0,
+                     out body_left, out body_top
+                 );
+                 int body_bottom = body_top +
+                     conversation_message.web_view_allocation.height;
+
+                 // Only mark the email as read if it's actually visible
+                 if (body_bottom > top_bound &&
+                     body_top + TEXT_PADDING < bottom_bound) {
+                     email_ids.add(email_view.email.id);
+
+                     // Since it can take some time for the new flags
+                     // to round-trip back to our signal handlers,
+                     // mark as manually read here
+                     email_view.is_manually_read = true;
+                 }
+             }
+            return true;
+        });
+
+        if (email_ids.size > 0) {
+            Geary.EmailFlags flags = new Geary.EmailFlags();
+            flags.add(Geary.EmailFlags.UNREAD);
+            mark_emails(email_ids, null, flags);
+        }
+    }
+
+    /**
+     * Displays an email as being read, regardless of its actual flags.
+     */
+    public void mark_manual_read(Geary.EmailIdentifier id) {
+        EmailRow? row = this.id_to_row.get(id);
+        if (row != null) {
+            row.view.is_manually_read = true;
+        }
+    }
+
+    /**
+     * Displays an email as being unread, regardless of its actual flags.
+     */
+    public void mark_manual_unread(Geary.EmailIdentifier id) {
+        EmailRow? row = this.id_to_row.get(id);
+        if (row != null) {
+            row.view.is_manually_read = false;
+        }
+    }
+
+    /**
+     * Hides a specific email in the conversation.
+     */
+    public void blacklist_by_id(Geary.EmailIdentifier? id) {
+        EmailRow? row = this.id_to_row.get(id);
+        if (row != null) {
+            row.hide();
+            update_last_row();
+        }
+    }
+
+    /**
+     * Re-displays a previously blacklisted email.
+     */
+    public void unblacklist_by_id(Geary.EmailIdentifier? id) {
+        EmailRow? row = this.id_to_row.get(id);
+        if (row != null) {
+            row.show();
+            update_last_row();
+        }
+    }
+
+    /**
+     * Loads search term matches for this list's emails.
+     */
+    public async void load_search_terms(Geary.SearchFolder search) {
+        Geary.SearchQuery? query = search.search_query;
+        if (query != null) {
+
+            // List all IDs of emails we're viewing.
+            Gee.Collection<Geary.EmailIdentifier> ids =
+                new Gee.ArrayList<Geary.EmailIdentifier>();
+            foreach (Gee.Map.Entry<Geary.EmailIdentifier, EmailRow> entry
+                        in this.id_to_row.entries) {
+                if (entry.value.get_visible()) {
+                    ids.add(entry.key);
+                }
+            }
+
+            Gee.Set<string>? search_matches = null;
+            try {
+                search_matches = yield search.get_search_matches_async(
+                    ids, cancellable
+                );
+            } catch (Error e) {
+                debug("Error highlighting search results: %s", e.message);
+                // Continue on here since if nothing else we have the
+                // fudging to fall back on immediately below.
+            }
+
+            if (search_matches == null)
+                search_matches = new Gee.HashSet<string>();
+
+            // This applies a fudge-factor set of matches when the database results
+            // aren't entirely satisfactory, such as when you search for an email
+            // address and the database tokenizes out the @ and ., etc.  It's not meant
+            // to be comprehensive, just a little extra highlighting applied to make
+            // the results look a little closer to what you typed.
+            foreach (string word in query.raw.split(" ")) {
+                if (word.has_suffix("\""))
+                    word = word.substring(0, word.length - 1);
+                if (word.has_prefix("\""))
+                    word = word.substring(1);
+
+                if (!Geary.String.is_empty_or_whitespace(word))
+                    search_matches.add(word);
+            }
+            if (!this.cancellable.is_cancelled()) {
+                highlight_search_terms(search_matches);
+            }
+        }
+    }
+
+    /**
+     * Applies search term highlighting to all email views.
+     */
+    public void highlight_search_terms(Gee.Set<string>? search_matches) {
+        // Webkit's highlighting is ... weird.  In order to actually
+        // see all the highlighting you're applying, it seems
+        // necessary to start with the shortest string and work up.
+        // If you don't, it seems that shorter strings will overwrite
+        // longer ones, and you're left with incomplete highlighting.
+        Gee.ArrayList<string> ordered_matches = new Gee.ArrayList<string>();
+        ordered_matches.add_all(search_matches);
+        ordered_matches.sort((a, b) => a.length - b.length);
+
+        message_view_iterator().foreach((msg_view) => {
+                msg_view.highlight_search_terms(search_matches);
+                return true;
+            });
+    }
+
+    /**
+     * Removes search term highlighting from all messages.
+     */
+    public void unmark_search_terms() {
+        message_view_iterator().foreach((msg_view) => {
+                msg_view.unmark_search_terms();
+                return true;
+            });
+    }
+
+    /**
+     * Increases the magnification level used for displaying messages.
+     */
+    public void zoom_in() {
+        message_view_iterator().foreach((msg_view) => {
+                msg_view.web_view.zoom_in();
+                return true;
+            });
+    }
+
+    /**
+     * Decreases the magnification level used for displaying messages.
+     */
+    public void zoom_out() {
+        message_view_iterator().foreach((msg_view) => {
+                msg_view.web_view.zoom_out();
+                return true;
+            });
+    }
+
+    /**
+     * Resets magnification level used for displaying messages to the default.
+     */
+    public void zoom_reset() {
+        message_view_iterator().foreach((msg_view) => {
+                msg_view.web_view.zoom_level = 1.0f;
+                return true;
+            });
+    }
+
+    private async void add_email(Geary.Email email, bool is_in_folder) {
+        if (this.id_to_row.contains(email.id)) {
+            return;
+        }
+
+        // Should be able to edit draft emails from any
+        // conversation. This test should be more like "is in drafts
+        // folder"
+        bool is_draft = (this.is_draft_folder && is_in_folder);
+
+        ConversationEmail view = new ConversationEmail(
+            email,
+            this.contact_store,
+            is_draft
+        );
+        view.mark_email.connect(on_mark_email);
+        view.mark_email_from_here.connect(on_mark_email_from_here);
+        view.body_selection_changed.connect((email, has_selection) => {
+                this.body_selected_view = has_selection ? email : null;
+            });
+
+        ConversationMessage conversation_message = view.primary_message;
+        conversation_message.body_box.button_release_event.connect_after((event) => {
+                // Consume all non-consumed clicks so the row is not
+                // inadvertently activated after clicking on the
+                // email body.
+                return true;
+            });
+
+        // Capture key events on the email's web views to allow
+        // scrolling on Space, etc. need to do this after loading so
+        // attached messages are present
+        view.message_view_iterator().foreach((msg_view) => {
+                msg_view.web_view.key_press_event.connect(on_key_press);
+                return true;
+            });
+
+        EmailRow row = new EmailRow(view);
+        row.show();
+        this.id_to_row.set(email.id, row);
+
+        add(row);
+        update_last_row();
+        email_added(view);
+
+        if (email.is_unread() == Geary.Trillian.TRUE ||
+            email.is_flagged() == Geary.Trillian.TRUE) {
+            row.expand(false);
+        }
+        yield view.start_loading(this.cancellable);
+    }
+
+    private void remove_email(Geary.Email email) {
+        EmailRow? row = null;
+        if (this.id_to_row.unset(email.id, out row)) {
+            remove(row);
+            email_removed(row.view);
+        }
+    }
+
+    private void scroll_to(EmailRow row) {
+        Gtk.Allocation? alloc = null;
+        row.get_allocation(out alloc);
+        int y = 0;
+        if (alloc.y > EMAIL_TOP_OFFSET) {
+            y = alloc.y - EMAIL_TOP_OFFSET;
+        }
+
+        // XXX This doesn't always quite work right, maybe since it's
+        // hard getting a reliable height out of WebKitGTK, or maybe
+        // because we stop calling this method when the email message
+        // body has finished loading, but attachments and sub-messages
+        // may still be loading. Or both?
+        get_adjustment().set_value(y);
+    }
+
+    // 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_last_row() {
+        EmailRow? last = null;
+        this.foreach((child) => {
+                if (child.get_visible()) {
+                    last = (EmailRow) child;
+                }
+            });
+
+        if (this.last_email_row != last) {
+            if (this.last_email_row != null) {
+                this.last_email_row.is_last = false;
+            }
+
+            this.last_email_row = last;
+            this.last_email_row.is_last = true;
+        }
+    }
+
+    // Given some emails, fetch the full versions with all required fields.
+    private async Gee.Collection<Geary.Email>? list_full_emails_async(
+        Gee.Collection<Geary.Email> emails, Cancellable? cancellable) throws Error {
+        Geary.Email.Field required_fields = ConversationListBox.REQUIRED_FIELDS |
+            Geary.ComposedEmail.REQUIRED_REPLY_FIELDS;
+
+        Gee.ArrayList<Geary.EmailIdentifier> ids = new Gee.ArrayList<Geary.EmailIdentifier>();
+        foreach (Geary.Email email in emails)
+            ids.add(email.id);
+
+        return yield this.email_store.list_email_by_sparse_id_async(ids, required_fields,
+            Geary.Folder.ListFlags.NONE, cancellable);
+    }
+
+    // Given an email, fetch the full version with all required fields.
+    private async Geary.Email fetch_full_email_async(Geary.Email email,
+        Cancellable? cancellable) throws Error {
+        Geary.Email.Field required_fields = ConversationListBox.REQUIRED_FIELDS |
+            Geary.ComposedEmail.REQUIRED_REPLY_FIELDS;
+
+        return yield this.email_store.fetch_email_async(email.id, required_fields,
+            Geary.Folder.ListFlags.NONE, cancellable);
+    }
+
+    /**
+     * Returns an new Iterable over all email views in the viewer
+     */
+    private Gee.Iterator<ConversationEmail> email_view_iterator() {
+        return this.id_to_row.values.map<ConversationEmail>((row) => {
+                return (ConversationEmail) row.get_child();
+            });
+    }
+
+    /**
+     * Returns a new Iterable over all message views in the viewer
+     */
+    private Gee.Iterator<ConversationMessage> message_view_iterator() {
+        return Gee.Iterator.concat<ConversationMessage>(
+            email_view_iterator().map<Gee.Iterator<ConversationMessage>>(
+                (email_view) => { return email_view.message_view_iterator(); }
+            )
+        );
+    }
+
+    private void on_conversation_appended(Geary.App.Conversation conversation, Geary.Email email) {
+        on_conversation_appended_async.begin(conversation, email, on_conversation_appended_complete);
+    }
+
+    private async void on_conversation_appended_async(Geary.App.Conversation conversation,
+        Geary.Email email) throws Error {
+        yield add_email(yield fetch_full_email_async(email, this.cancellable),
+            conversation.is_in_current_folder(email.id));
+    }
+
+    private void on_conversation_appended_complete(Object? source, AsyncResult result) {
+        try {
+            on_conversation_appended_async.end(result);
+        } catch (Error err) {
+            debug("Unable to append email to conversation: %s", err.message);
+        }
+    }
+
+    private void on_conversation_trimmed(Geary.Email email) {
+        remove_email(email);
+    }
+
+    private void on_update_flags(Geary.Email email) {
+        if (!this.id_to_row.has_key(email.id)) {
+            return;
+        }
+
+        EmailRow row = this.id_to_row.get(email.id);
+        row.view.update_flags(email);
+    }
+
+    private void on_mark_email(ConversationEmail view,
+                               Geary.NamedFlag? to_add,
+                               Geary.NamedFlag? to_remove) {
+        Gee.Collection<Geary.EmailIdentifier> ids =
+            new Gee.LinkedList<Geary.EmailIdentifier>();
+        ids.add(view.email.id);
+        mark_emails(ids, flag_to_flags(to_add), flag_to_flags(to_remove));
+    }
+
+    private void on_mark_email_from_here(ConversationEmail view,
+                                         Geary.NamedFlag? to_add,
+                                         Geary.NamedFlag? to_remove) {
+        Geary.Email email = view.email;
+        Gee.Collection<Geary.EmailIdentifier> ids =
+            new Gee.LinkedList<Geary.EmailIdentifier>();
+        ids.add(email.id);
+        this.foreach((row) => {
+                if (row.get_visible()) {
+                    Geary.Email other = ((EmailRow) row).view.email;
+                    if (Geary.Email.compare_sent_date_ascending(
+                            email, other) < 0) {
+                        ids.add(other.id);
+                    }
+                }
+            });
+        mark_emails(ids, flag_to_flags(to_add), flag_to_flags(to_remove));
+    }
+
+    private Geary.EmailFlags? flag_to_flags(Geary.NamedFlag? flag) {
+        Geary.EmailFlags flags = null;
+        if (flag != null) {
+            flags = new Geary.EmailFlags();
+            flags.add(flag);
+        }
+        return flags;
+    }
+
+    private bool on_key_press(Gtk.Widget widget, Gdk.EventKey event) {
+        // Override some key bindings to get something that works more
+        // like a browser page.
+        if (event.keyval == Gdk.Key.space) {
+            Gtk.ScrollType dir = Gtk.ScrollType.PAGE_DOWN;
+            if ((event.state & Gdk.ModifierType.SHIFT_MASK) ==
+                Gdk.ModifierType.SHIFT_MASK) {
+                dir = Gtk.ScrollType.PAGE_UP;
+            }
+            this.move_cursor(Gtk.MovementStep.PAGES, 1);
+            return true;
+        }
+        return false;
+    }
+
+    private void on_row_activated(Gtk.ListBoxRow widget) {
+        EmailRow row = (EmailRow) widget;
+        if (!row.is_last) {
+            if (row.is_expanded) {
+                row.collapse();
+            } else {
+                row.expand();
+            }
+        }
+    }
+
+    private int on_sort(Gtk.ListBoxRow row1, Gtk.ListBoxRow row2) {
+        ConversationEmail? msg1 = row1.get_child() as ConversationEmail;
+        ConversationEmail? msg2 = row2.get_child() as ConversationEmail;
+        return Geary.Email.compare_sent_date_ascending(msg1.email, msg2.email);
+    }
+
+}
diff --git a/src/client/conversation-viewer/conversation-viewer.vala 
b/src/client/conversation-viewer/conversation-viewer.vala
index 9476dab..8328810 100644
--- a/src/client/conversation-viewer/conversation-viewer.vala
+++ b/src/client/conversation-viewer/conversation-viewer.vala
@@ -7,51 +7,13 @@
  */
 
 /**
- * A widget for displaying conversations as a list of emails.
- *
- * The view displays the current selected {@link
- * Geary.App.Conversation} from the conversation list. To do so, it
- * listens to signals from both the list and the current conversation
- * monitor, updating the email list as needed.
- *
- * Unlike ConversationListStore (which sorts by date received),
- * ConversationViewer sorts by the {@link Geary.Email.date} field (the
- * Date: header), as that's the date displayed to the user.
- *
- * In addition to the email list, docked composers, a progress spinner
- * and user messages are also displayed, depending on its current
- * state.
+ * Displays the messages in a conversation and in-window composers.
  */
 [GtkTemplate (ui = "/org/gnome/Geary/conversation-viewer.ui")]
 public class ConversationViewer : Gtk.Stack {
 
-    /** Fields that must be available for display as a conversation. */
-    public const Geary.Email.Field REQUIRED_FIELDS =
-        Geary.Email.Field.HEADER
-        | Geary.Email.Field.BODY
-        | Geary.Email.Field.ORIGINATORS
-        | Geary.Email.Field.RECEIVERS
-        | Geary.Email.Field.SUBJECT
-        | Geary.Email.Field.DATE
-        | Geary.Email.Field.FLAGS
-        | Geary.Email.Field.PREVIEW;
-
     private const int SELECT_CONVERSATION_TIMEOUT_MSEC = 100;
 
-    // Offset from the top of the list box which emails views will
-    // scrolled to, so the user can see there are additional messages
-    // above it. XXX This is currently approx 1.5 times the height of
-    // a collapsed ConversationEmail, it should probably calculated
-    // somehow so that differences user's font size are taken into
-    // account.
-    private const int EMAIL_TOP_OFFSET = 92;
-
-    private enum ViewState {
-        // Main view state
-        CONVERSATION,
-        COMPOSE;
-    }
-
     private enum SearchState {
         // Search/find states.
         NONE,         // Not in search
@@ -71,159 +33,80 @@ public class ConversationViewer : Gtk.Stack {
         COUNT;
     }
 
-    // Custom class used to display ConversationEmail views in the
-    // conversation listbox.
-    private class EmailRow : Gtk.ListBoxRow {
-
-        private const string EXPANDED_CLASS = "geary-expanded";
-        private const string LAST_CLASS = "geary-last";
-
-        // Is the row showing the email's message body?
-        public bool is_expanded {
-            get { return get_style_context().has_class(EXPANDED_CLASS); }
-        }
-
-        // Designate this row as the last visible row in the
-        // conversation listbox, or not. See Bug 764710 and
-        // on_conversation_listbox_email_row_added
-        public bool is_last {
-            get { return get_style_context().has_class(LAST_CLASS); }
-            set {
-                if (value) {
-                    get_style_context().add_class(LAST_CLASS);
-                } else {
-                    get_style_context().remove_class(LAST_CLASS);
-                }
-            }
-        }
-
-        // We can only scroll to a specific row once it has been
-        // allocated space in the conversation listbox. This signal
-        // allows the viewer to hook up to appropriate times to try to
-        // do that scroll.
-        public signal void should_scroll();
-
-        public ConversationEmail view {
-            get { return (ConversationEmail) get_child(); }
-        }
-
-        public EmailRow(ConversationEmail view) {
-            add(view);
-        }
-
-        public new void expand(bool include_transitions=true) {
-            get_style_context().add_class(EXPANDED_CLASS);
-            this.view.expand_email(include_transitions);
-        }
-
-        public void collapse() {
-            get_style_context().remove_class(EXPANDED_CLASS);
-            this.view.collapse_email();
-        }
-
-        public void enable_should_scroll() {
-            this.size_allocate.connect(on_size_allocate);
-        }
-
-        private void on_size_allocate() {
-            // Disable should_scroll after the message body has been
-            // loaded so we don't keep on scrolling later, like when
-            // the window has been resized.
-            ConversationWebView web_view = view.primary_message.web_view;
-            if (web_view.is_height_valid &&
-                web_view.load_status == WebKit.LoadStatus.FINISHED) {
-                this.size_allocate.disconnect(on_size_allocate);
-            }
-            
-            should_scroll();
-        }
-
+    public ConversationListBox? current_list {
+        get; private set; default = null;
     }
 
-
-    /** Fired when an email view is added to the conversation list. */
-    public signal void email_row_added(ConversationEmail email);
-
-    /** Fired when an email view is removed from the conversation list. */
-    public signal void email_row_removed(ConversationEmail email);
-
-    /** Fired when the user updates the flags for a set of emails. */
-    public signal void mark_emails(Gee.Collection<Geary.EmailIdentifier> emails,
-        Geary.EmailFlags? flags_to_add, Geary.EmailFlags? flags_to_remove);
-
-    /** Fired when the email list has been cleared. */
-    public signal void cleared();
-
-    /** Current conversation being displayed, or null if none. */
-    public Geary.App.Conversation? current_conversation = null;
-
     // Stack pages
     [GtkChild]
-    private Gtk.Image splash_page;
-    [GtkChild]
     private Gtk.Spinner loading_page;
     [GtkChild]
-    private Gtk.ScrolledWindow conversation_page;
+    private Gtk.Box no_conversations_page;
     [GtkChild]
-    private Gtk.Box user_message_page;
+    internal Gtk.ScrolledWindow conversation_page;
     [GtkChild]
-    private Gtk.Box composer_page;
-
-    // Conversation emails list
+    private Gtk.Box multiple_conversations_page;
     [GtkChild]
-    private Gtk.ListBox conversation_listbox;
-    private EmailRow? last_email_row = null;
-
-    // Email view with selected text, if any
-    private ConversationEmail? body_selected_view = null;
-
-    // Label for displaying messages in the main pane.
+    private Gtk.Box empty_folder_page;
     [GtkChild]
-    private Gtk.Label user_message_label;
-
-    // Sorted set of emails being displayed
-    private Gee.TreeSet<Geary.Email> emails { get; private set; default =
-        new Gee.TreeSet<Geary.Email>(Geary.Email.compare_sent_date_ascending); }
+    private Gtk.Box empty_search_page;
+    [GtkChild]
+    private Gtk.Box composer_page;
 
-    // Maps displayed emails to their corresponding EmailRow.
-    private Gee.HashMap<Geary.EmailIdentifier, EmailRow> email_to_row = new
-        Gee.HashMap<Geary.EmailIdentifier, EmailRow>();
+    private ConversationFindBar conversation_find_bar;
 
     // State machine setup for search/find modes.
     private Geary.State.MachineDescriptor search_machine_desc = new Geary.State.MachineDescriptor(
         "ConversationViewer search", SearchState.NONE, SearchState.COUNT, SearchEvent.COUNT, null, null); 
-   
-    private ViewState state = ViewState.CONVERSATION;
-    private weak Geary.Folder? current_folder = null;
-    private weak Geary.SearchFolder? search_folder = null;
-    private Geary.App.EmailStore? email_store = null;
-    private ConversationFindBar conversation_find_bar;
-    private Cancellable cancellable_fetch = new Cancellable();
     private Geary.State.Machine fsm;
-    private uint select_conversation_timeout_id = 0;
-    private bool loading_conversations = false;
+
+    private uint conversation_timeout_id = 0;
+
+    /* Emitted when a new conversation list was added to this view. */
+    public signal void conversation_added(ConversationListBox list);
+
+    /* Emitted when a new conversation list was removed from this view. */
+    public signal void conversation_removed(ConversationListBox list);
 
     /**
      * Constructs a new conversation view instance.
      */
     public ConversationViewer() {
-        // Setup the conversation list box
-        conversation_listbox.add.connect(() => {
-                update_last_row();
-            });
-        conversation_listbox.realize.connect(() => {
-                conversation_page.get_vadjustment()
-                    .value_changed.connect(check_mark_read);
-            });
-        conversation_listbox.row_activated.connect(
-            on_conversation_listbox_row_activated
-            );
-        conversation_listbox.set_sort_func(
-            on_conversation_listbox_sort
-            );
-        conversation_listbox.size_allocate.connect(() => {
-                check_mark_read();
-            });
+        EmptyPlaceholder no_conversations = new EmptyPlaceholder();
+        no_conversations.title = _("No conversations selected");
+        no_conversations.subtitle = _(
+            "Selecting a conversation from the list will display it here"
+        );
+        this.no_conversations_page.pack_start(
+            no_conversations, true, true, 0
+        );
+
+        EmptyPlaceholder multi_conversations = new EmptyPlaceholder();
+        multi_conversations.title = _("Multiple conversations selected");
+        multi_conversations.subtitle = _(
+            "Choosing an action will apply to all selected conversations"
+        );
+        this.multiple_conversations_page.pack_start(
+            multi_conversations, true, true, 0
+        );
+
+        EmptyPlaceholder empty_folder = new EmptyPlaceholder();
+        empty_folder.title = _("No conversations found");
+        empty_folder.subtitle = _(
+            "This folder does not contain any conversations"
+        );
+        this.empty_folder_page.pack_start(
+            empty_folder, true, true, 0
+        );
+
+        EmptyPlaceholder empty_search = new EmptyPlaceholder();
+        empty_search.title = _("No conversations found");
+        empty_search.subtitle = _(
+            "Your search returned no results, try refining your search terms"
+        );
+        this.empty_search_page.pack_start(
+            empty_search, true, true, 0
+        );
 
         // Setup state machine for search/find states.
         Geary.State.Mapping[] mappings = {
@@ -245,90 +128,27 @@ public class ConversationViewer : Gtk.Stack {
         
         fsm = new Geary.State.Machine(search_machine_desc, mappings, null);
         fsm.set_logging(false);
-        
-        GearyApplication.instance.controller.conversations_selected.connect(on_conversations_selected);
-        GearyApplication.instance.controller.folder_selected.connect(on_folder_selected);
-        
GearyApplication.instance.controller.conversation_count_changed.connect(on_conversation_count_changed);
-        
+
         //conversation_find_bar = new ConversationFindBar(web_view);
         //conversation_find_bar.no_show_all = true;
         //conversation_find_bar.close.connect(() => { fsm.issue(SearchEvent.CLOSE_FIND_BAR); });
         //pack_start(conversation_find_bar, false);
-
-        do_conversation();
-    }
-
-    /**
-     * Returns the email view to be replied to, if any.
-     *
-     * If an email view has selected body text that view will be
-     * returned. Else the last message by sort order will be returned,
-     * if any.
-     */
-    public ConversationEmail? get_reply_email_view() {
-        ConversationEmail? view = this.body_selected_view;
-        if (view == null) {
-            if (this.last_email_row != null) {
-                view = this.last_email_row.view;
-            }
-        }
-        return view;
-    }
-
-    /**
-     * Displays an email as being read, regardless of its actual flags.
-     */
-    public void mark_manual_read(Geary.EmailIdentifier id) {
-        ConversationEmail? row = conversation_email_for_id(id);
-        if (row != null) {
-            row.mark_manual_read();
-        }
-    }
-
-    /**
-     * Hides a specific email in the conversation.
-     */
-    public void blacklist_by_id(Geary.EmailIdentifier? id) {
-        if (id == null) {
-            return;
-        }
-        email_to_row.get(id).hide();
-        update_last_row();
-    }
-
-    /**
-     * Re-displays a previously blacklisted email.
-     */
-    public void unblacklist_by_id(Geary.EmailIdentifier? id) {
-        if (id == null) {
-            return;
-        }
-        email_to_row.get(id).show();
-        update_last_row();
-    }
-
-    /**
-     * Puts the view into conversation mode, showing the email list.
-     */
-    public void do_conversation() {
-        state = ViewState.CONVERSATION;
-        set_visible_child(loading_page);
     }
 
     /**
      * Puts the view into composer mode, showing a full-height composer.
      */
     public void do_compose(ComposerWidget composer) {
-        state = ViewState.COMPOSE;
         ComposerBox box = new ComposerBox(composer);
 
         // XXX move the ConversationListView management code into
         // GearyController or somewhere more appropriate
-        ConversationListView conversation_list_view = ((MainWindow) 
GearyApplication.instance.controller.main_window).conversation_list_view;
+        ConversationListView conversation_list_view =
+            ((MainWindow) GearyApplication.instance.controller.main_window).conversation_list_view;
         Gee.Set<Geary.App.Conversation>? prev_selection = 
conversation_list_view.get_selected_conversations();
         conversation_list_view.get_selection().unselect_all();
         box.vanished.connect((box) => {
-                do_conversation();
+                set_visible_child(this.conversation_page);
                 if (prev_selection.is_empty) {
                     conversation_list_view.conversations_selected(prev_selection);
                 } else {
@@ -340,32 +160,6 @@ public class ConversationViewer : Gtk.Stack {
     }
 
     /**
-     * Puts the view into conversation mode, but with an embedded composer.
-     */
-    public void do_embedded_composer(ComposerWidget composer, Geary.Email referred) {
-        state = ViewState.CONVERSATION;
-
-        ComposerEmbed embed = new ComposerEmbed(
-            referred, composer, conversation_page
-        );
-        embed.get_style_context().add_class("geary-composer-embed");
-
-        ConversationEmail? email_view = conversation_email_for_id(referred.id);
-        if (email_view != null) {
-            email_view.attach_composer(embed);
-            embed.loaded.connect((box) => {
-                    embed.grab_focus();
-                });
-            embed.vanished.connect((box) => {
-                    email_view.remove_composer(embed);
-                });
-        } else {
-            error("Could not find referred email for embedded composer: %s",
-                  referred.id.to_string());
-        }
-    }
-
-    /**
      * Shows the in-conversation search UI.
      */
     public void show_find_bar() {
@@ -384,616 +178,132 @@ public class ConversationViewer : Gtk.Stack {
     }
 
     /**
-     * Increases the magnification level used for displaying messages.
+     * Shows the loading UI.
      */
-    public void zoom_in() {
-        message_view_iterator().foreach((msg_view) => {
-                msg_view.web_view.zoom_in();
-                return true;
-            });
+    public void show_loading() {
+        set_visible_child(this.loading_page);
     }
-
+ 
     /**
-     * Decreases the magnification level used for displaying messages.
+     * Shows the empty folder UI.
      */
-    public void zoom_out() {
-        message_view_iterator().foreach((msg_view) => {
-                msg_view.web_view.zoom_out();
-                return true;
-            });
+    public void show_empty_folder() {
+        set_visible_child(this.empty_folder_page);
     }
 
-    /**
-     * Resets magnification level used for displaying messages to the default.
+   /**
+     * Shows the empty search UI.
      */
-    public void zoom_reset() {
-        message_view_iterator().foreach((msg_view) => {
-                msg_view.web_view.zoom_level = 1.0f;
-                return true;
-            });
+    public void show_empty_search() {
+        set_visible_child(this.empty_search_page);
     }
 
     /**
-     * Sets the currently visible page of the stack.
+     * Shows one or more conversations in the viewer.
      */
-    private new void set_visible_child(Gtk.Widget widget) {
-        debug("Showing child: %s\n", widget.get_name());
-        base.set_visible_child(widget);
-    }
-
-    /**
-     * Returns an new Iterable over all email views in the viewer
-     */
-    private Gee.Iterator<ConversationEmail> email_view_iterator() {
-        return this.email_to_row.values.map<ConversationEmail>((row) => {
-                return (ConversationEmail) row.get_child();
-            });
-    }
-
-    /**
-     * Returns a new Iterable over all message views in the viewer
-     */
-    private Gee.Iterator<ConversationMessage> message_view_iterator() {
-        return Gee.Iterator.concat<ConversationMessage>(
-            email_view_iterator().map<Gee.Iterator<ConversationMessage>>(
-                (email_view) => { return email_view.message_view_iterator(); }
-            )
-        );
-    }
-
-    /**
-     * Finds any currently visible messages, marks them as being read.
-     */
-    private void check_mark_read() {
-        Gee.ArrayList<Geary.EmailIdentifier> email_ids =
-            new Gee.ArrayList<Geary.EmailIdentifier>();
-
-        Gtk.Adjustment adj = conversation_page.vadjustment;
-        int top_bound = (int) adj.value;
-        int bottom_bound = top_bound + (int) adj.page_size;
-
-        email_view_iterator().foreach((email_view) => {
-            const int TEXT_PADDING = 50;
-            ConversationMessage conversation_message = email_view.primary_message;
-            // Don't bother with not-yet-loaded emails since the
-            // size of the body will be off, affecting the visibility
-            // of emails further down the conversation.
-            if (email_view.email.email_flags.is_unread() &&
-                conversation_message.is_loading_complete &&
-                !email_view.is_manual_read()) {
-                 int body_top = 0;
-                 int body_left = 0;
-                 conversation_message.web_view.translate_coordinates(
-                     conversation_listbox,
-                     0, 0,
-                     out body_left, out body_top
-                 );
-                 int body_bottom = body_top +
-                     conversation_message.web_view_allocation.height;
-
-                 // Only mark the email as read if it's actually visible
-                 if (body_bottom > top_bound &&
-                     body_top + TEXT_PADDING < bottom_bound) {
-                     email_ids.add(email_view.email.id);
-
-                     // Since it can take some time for the new flags
-                     // to round-trip back to ConversationViewer's
-                     // signal handlers, mark as manually read here
-                     email_view.mark_manual_read();
-                 }
-             }
-            return true;
-        });
-
-        if (email_ids.size > 0) {
-            Geary.EmailFlags flags = new Geary.EmailFlags();
-            flags.add(Geary.EmailFlags.UNREAD);
-            mark_emails(email_ids, null, flags);
-        }
-    }
-
-    // Removes all displayed e-mails from the view.
-    private void clear() {
-        // Cancel any pending avatar loads here, rather than in
-        // ConversationMessage using a Cancellable callback since we
-        // don't have per-message control of it when using
-        // Soup.Session.queue_message.
-        GearyApplication.instance.controller.avatar_session.flush_queue();
-        foreach (Gtk.Widget child in conversation_listbox.get_children()) {
-            conversation_listbox.remove(child);
-        }
-        email_to_row.clear();
-        emails.clear();
-        current_conversation = null;
-        body_selected_view = null;
-        cleared();
-    }
-
-    private void scroll_to(EmailRow row) {
-        Gtk.Allocation? alloc = null;
-        row.get_allocation(out alloc);
-        int y = 0;
-        if (alloc.y > EMAIL_TOP_OFFSET) {
-            y = alloc.y - EMAIL_TOP_OFFSET;
-        }
-
-        // XXX This doesn't always quite work right, maybe since it's
-        // hard getting a reliable height out of WebKitGTK, or maybe
-        // because we stop calling this method when the email message
-        // body has finished loading, but attachments and sub-messages
-        // may still be loading. Or both?
-        this.conversation_page.get_vadjustment().clamp_page(
-            y, y + alloc.height
-        );
-    }
-
-    // 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 in the convo listbox so we can emulate
-    // it.
-    private void update_last_row() {
-        EmailRow? last = null;
-        this.conversation_listbox.foreach((child) => {
-                if (child.is_visible()) {
-                    last = (EmailRow) child;
-                }
-            });
-
-        if (this.last_email_row != last) {
-            if (this.last_email_row != null) {
-                this.last_email_row.is_last = false;
-            }
-
-            this.last_email_row = last;
-            this.last_email_row.is_last = true;
-        }
-    }
-
-    private void on_folder_selected(Geary.Folder? folder) {
-        loading_conversations = true;
-        current_folder = folder;
-
-        if (folder == null) {
-            email_store = null;
-            clear();
-        } else {
-            email_store = new Geary.App.EmailStore(current_folder.account);
-        }
-
-        fsm.issue(SearchEvent.RESET);
-        if (current_folder is Geary.SearchFolder) {
-            fsm.issue(SearchEvent.ENTER_SEARCH_FOLDER);
-            //web_view.allow_collapsing(false);
+    public async void load_conversations(Gee.Set<Geary.App.Conversation> conversations,
+                                         Geary.Folder location) {
+        debug("Conversations selected in %s: %u", location.to_string(), conversations.size);
+        if (conversations.size == 0) {
+            set_visible_child(this.no_conversations_page);
+            GearyApplication.instance.controller.enable_message_buttons(false);
+        } else if (conversations.size >1) {
+            set_visible_child(this.multiple_conversations_page);
+            GearyApplication.instance.controller.enable_multiple_message_buttons();
         } else {
-            //web_view.allow_collapsing(true);
-        }
-    }
-    
-    private void on_conversation_count_changed(int count) {
-        if (state == ViewState.CONVERSATION) {
-            if (count == 0) {
-                user_message_label.set_text((current_folder is Geary.SearchFolder)
-                                            ? _("No search results found.")
-                                            : _("No conversations in folder."));
-                set_visible_child(user_message_page);
+            // If the load is taking too long, display the spinner
+            if (this.conversation_timeout_id != 0) {
+                Source.remove(this.conversation_timeout_id);
             }
-        }
-    }
-    
-    private void on_conversations_selected(Gee.Set<Geary.App.Conversation> conversations,
-        Geary.Folder current_folder) {
-        cancel_load();
-
-        if (current_conversation != null) {
-            current_conversation.appended.disconnect(on_conversation_appended);
-            current_conversation.trimmed.disconnect(on_conversation_trimmed);
-            current_conversation.email_flags_changed.disconnect(on_update_flags);
-            current_conversation = null;
-        }
-
-        if (state == ViewState.CONVERSATION) {
-            // Disable message buttons until conversation loads.
-            GearyApplication.instance.controller.enable_message_buttons(false);
-
-            switch (conversations.size) {
-            case 0:
-                if (!loading_conversations) {
-                    set_visible_child(splash_page);
-                }
-                break;
-
-            case 1:
-                // Timer will take care of showing the loading page
-                break;
+            this.conversation_timeout_id =
+                Timeout.add(SELECT_CONVERSATION_TIMEOUT_MSEC, () => {
+                        if (this.conversation_timeout_id != 0) {
+                            debug("Loading timed out\n");
+                            show_loading();
+                        }
+                        return false;
+                    });
+
+            Geary.Account account = location.account;
+            ConversationListBox new_list = new ConversationListBox(
+                Geary.Collection.get_first(conversations),
+                account.get_contact_store(),
+                new Geary.App.EmailStore(account),
+                location.special_folder_type == Geary.SpecialFolderType.DRAFTS,
+                conversation_page.get_vadjustment()
+            );
 
-            default:
-                user_message_label.set_text(
-                    _("%u conversations selected").printf(conversations.size)
-                );
-                set_visible_child(user_message_page);
-                GearyApplication.instance.controller.enable_multiple_message_buttons();
-                break;
+            // Need to fire this signal early so the the controller
+            // can hook in to its signals to catch any emails added
+            // during loading.
+            this.conversation_added(new_list);
+
+            bool loaded = false;
+            try {
+                yield new_list.load_conversation();
+                loaded = true;
+                remove_current_list();
+                add_new_list(new_list);
+                this.conversation_timeout_id = 0;
+            } catch (Error err) {
+                debug("Unable to load conversation: %s", err.message);
             }
-        }
-
-        if (conversations.size == 1) {
-            loading_conversations = true;
-            clear();
-            
-            if (select_conversation_timeout_id != 0)
-                Source.remove(select_conversation_timeout_id);
-            
-            // If the load is taking too long, display a spinner.
-            select_conversation_timeout_id =
-            Timeout.add(SELECT_CONVERSATION_TIMEOUT_MSEC, () => {
-                    if (select_conversation_timeout_id != 0) {
-                        debug("Loading timed out\n");
-                        set_visible_child(loading_page);
-                    }
-                    return false;
-                });
-            
-            current_conversation = Geary.Collection.get_first(conversations);
-
-            select_conversation_async.begin(current_conversation, current_folder,
-                                            on_select_conversation_completed);
-            
-            current_conversation.appended.connect(on_conversation_appended);
-            current_conversation.trimmed.connect(on_conversation_trimmed);
-            current_conversation.email_flags_changed.connect(on_update_flags);
-            
+            set_visible_child(this.conversation_page);
             GearyApplication.instance.controller.enable_message_buttons(true);
-        }
-    }
-    
-    private async void select_conversation_async(Geary.App.Conversation conversation,
-        Geary.Folder current_folder) throws Error {
-        // Load this once, so if it's cancelled, we cancel the WHOLE load.
-        Cancellable cancellable = cancellable_fetch;
-
-        // Fetch full emails.
-        Gee.Collection<Geary.Email>? emails_to_add
-            = yield list_full_emails_async(conversation.get_emails(
-            Geary.App.Conversation.Ordering.SENT_DATE_ASCENDING), cancellable);
-
-        if (emails_to_add != null) {
-            foreach (Geary.Email email in emails_to_add)
-                add_email(email, conversation.is_in_current_folder(email.id));
-        }
-
-        // Work out what the first expanded row is. We can't do this
-        // in the foreach above since that is not adding messages in
-        // order.
-        EmailRow? first_expanded_row = null;
-        this.conversation_listbox.foreach((child) => {
-                if (first_expanded_row == null) {
-                    EmailRow row = (EmailRow) child;
-                    if (row.is_expanded) {
-                        first_expanded_row = row;
-                    }
-                }
-            });
-
-        if (this.last_email_row != null) {
-            // The last email should always be expanded so the user
-            // isn't presented with a list of collapsed headers when a
-            // conversation has no unread messages.
-            this.last_email_row.expand(false);
 
-            if (first_expanded_row == null) {
-                first_expanded_row = this.last_email_row;
+            if (loaded && location is Geary.SearchFolder) {
+                yield new_list.load_search_terms((Geary.SearchFolder) location);
             }
-
-            // The first expanded row (i.e. first unread or simply the
-            // last message) is scrolled to the top of the visible
-            // area. We need to wait the web view to load first, so
-            // that the message has a non-trivial height, and then
-            // wait for it to be reallocated, so that it picks up the
-            // web_view's height.
-            first_expanded_row.should_scroll.connect((row) => {
-                    scroll_to(row);
-                });
-            first_expanded_row.enable_should_scroll();
-        }
-
-        if (state == ViewState.CONVERSATION) {
-            set_visible_child(conversation_page);
         }
-
-        this.loading_conversations = false;
-        debug("Conversation loading complete");
-
-        // Only do search highlighting after all loading is complete
-        // since it's async, and hence things like the conversation
-        // changing could happen in the mean time
-        if (current_folder is Geary.SearchFolder) {
-            yield highlight_search_terms();
-        } else {
-            compress_emails();
-        }
-
     }
 
-    private void on_select_conversation_completed(Object? source, AsyncResult result) {
-        select_conversation_timeout_id = 0;
-        try {
-            select_conversation_async.end(result);
-            check_mark_read();
-        } catch (Error err) {
-            debug("Unable to select conversation: %s", err.message);
-        }
-    }
-
-    private int on_conversation_listbox_sort(Gtk.ListBoxRow row1, Gtk.ListBoxRow row2) {
-        ConversationEmail? msg1 = row1.get_child() as ConversationEmail;
-        ConversationEmail? msg2 = row2.get_child() as ConversationEmail;
-        return Geary.Email.compare_sent_date_ascending(msg1.email, msg2.email);
+    /**
+     * Sets the currently visible page of the stack.
+     */
+    private new void set_visible_child(Gtk.Widget widget) {
+        debug("Showing: %s\n", widget.get_name());
+        base.set_visible_child(widget);
     }
 
-    private void on_conversation_listbox_row_activated(Gtk.ListBoxRow widget) {
-        EmailRow row = (EmailRow) widget;
-        if (!row.is_last) {
-            if (row.is_expanded) {
-                row.collapse();
-            } else {
-                row.expand();
+    // Add a new conversation list to the UI
+    private void add_new_list(ConversationListBox list) {
+        list.show();
+        this.conversation_page.add(list);
+        this.current_list = list;
+    }
+
+    // Remove any existing conversation list, cancelling its loading
+    private void remove_current_list() {
+        Gtk.Viewport? viewport =
+            this.conversation_page.get_child() as Gtk.Viewport;
+        if (viewport != null) {
+            ConversationListBox? previous_list =
+                viewport.get_child() as ConversationListBox;
+            if (previous_list != null) {
+                // Cancel any pending avatar loads here, rather than in
+                // ConversationListBox, sinece we don't have per-message
+                // control of it when using Soup.Session.queue_message.
+                GearyApplication.instance.controller.avatar_session.flush_queue();
+                previous_list.cancel_load();
+                this.conversation_removed(previous_list);
             }
+            this.conversation_page.remove(viewport);
+            this.current_list = null;
         }
     }
 
-    private async void highlight_search_terms() {
-        Geary.SearchQuery? query = (this.search_folder != null)
-            ? search_folder.search_query
-            : null;
-        if (query == null)
-            return;
-
-        // List all IDs of emails we're viewing.
-        Gee.Collection<Geary.EmailIdentifier> ids = new Gee.ArrayList<Geary.EmailIdentifier>();
-        foreach (Geary.Email email in emails)
-            ids.add(email.id);
-
-        // Using the fetch cancellable here is appropriate since each
-        // time the search results change, the old fetch will be
-        // cancelled and we should also cancel the highlighting. Store
-        // it here for use later in the method.
-        Cancellable cancellable = this.cancellable_fetch;
-
-        Gee.Set<string>? search_matches = null;
-        try {
-            search_matches = yield search_folder.get_search_matches_async(
-                ids, cancellable);
-        } catch (Error e) {
-            debug("Error highlighting search results: %s", e.message);
-            // Continue on here since if nothing else we have the
-            // fudging to fall back on immediately below.
-        }
-
-        if (search_matches == null)
-            search_matches = new Gee.HashSet<string>();
-
-        // This applies a fudge-factor set of matches when the database results
-        // aren't entirely satisfactory, such as when you search for an email
-        // address and the database tokenizes out the @ and ., etc.  It's not meant
-        // to be comprehensive, just a little extra highlighting applied to make
-        // the results look a little closer to what you typed.
-        foreach (string word in query.raw.split(" ")) {
-            if (word.has_suffix("\""))
-                word = word.substring(0, word.length - 1);
-            if (word.has_prefix("\""))
-                word = word.substring(1);
-
-            if (!Geary.String.is_empty_or_whitespace(word))
-                search_matches.add(word);
-        }
-
-        // Webkit's highlighting is ... weird.  In order to actually
-        // see all the highlighting you're applying, it seems
-        // necessary to start with the shortest string and work up.
-        // If you don't, it seems that shorter strings will overwrite
-        // longer ones, and you're left with incomplete highlighting.
-        Gee.ArrayList<string> ordered_matches = new Gee.ArrayList<string>();
-        ordered_matches.add_all(search_matches);
-        ordered_matches.sort((a, b) => a.length - b.length);
-
-        if (!cancellable.is_cancelled()) {
-            message_view_iterator().foreach((msg_view) => {
-                    msg_view.highlight_search_terms(search_matches);
-                    return true;
-                });
-        }
-    }
-
-    // Given some emails, fetch the full versions with all required fields.
-    private async Gee.Collection<Geary.Email>? list_full_emails_async(
-        Gee.Collection<Geary.Email> emails, Cancellable? cancellable) throws Error {
-        Geary.Email.Field required_fields = ConversationViewer.REQUIRED_FIELDS |
-            Geary.ComposedEmail.REQUIRED_REPLY_FIELDS;
-        
-        Gee.ArrayList<Geary.EmailIdentifier> ids = new Gee.ArrayList<Geary.EmailIdentifier>();
-        foreach (Geary.Email email in emails)
-            ids.add(email.id);
-        
-        return yield email_store.list_email_by_sparse_id_async(ids, required_fields,
-            Geary.Folder.ListFlags.NONE, cancellable);
-    }
-    
-    // Given an email, fetch the full version with all required fields.
-    private async Geary.Email fetch_full_email_async(Geary.Email email,
-        Cancellable? cancellable) throws Error {
-        Geary.Email.Field required_fields = ConversationViewer.REQUIRED_FIELDS |
-            Geary.ComposedEmail.REQUIRED_REPLY_FIELDS;
-        
-        return yield email_store.fetch_email_async(email.id, required_fields,
-            Geary.Folder.ListFlags.NONE, cancellable);
-    }
-    
-    // Cancels the current email load, if in progress.
-    private void cancel_load() {
-        Cancellable old_cancellable = cancellable_fetch;
-        cancellable_fetch = new Cancellable();
-        
-        old_cancellable.cancel();
-    }
-
-    private void on_conversation_appended(Geary.App.Conversation conversation, Geary.Email email) {
-        on_conversation_appended_async.begin(conversation, email, on_conversation_appended_complete);
-    }
-    
-    private async void on_conversation_appended_async(Geary.App.Conversation conversation,
-        Geary.Email email) throws Error {
-        add_email(yield fetch_full_email_async(email, cancellable_fetch),
-            conversation.is_in_current_folder(email.id));
-    }
-    
-    private void on_conversation_appended_complete(Object? source, AsyncResult result) {
-        try {
-            on_conversation_appended_async.end(result);
-        } catch (Error err) {
-            debug("Unable to append email to conversation: %s", err.message);
-        }
-    }
-    
-    private void on_conversation_trimmed(Geary.Email email) {
-        remove_email(email);
-    }
-    
-    private void add_email(Geary.Email email, bool is_in_folder) {
-        if (emails.contains(email)) {
-            return;
-        }
-        emails.add(email);
-
-        // XXX Should be able to edit draft emails from any
-        // conversation. This test should be more like "is in drafts
-        // folder"
-        bool is_draft = (
-            current_folder.special_folder_type == Geary.SpecialFolderType.DRAFTS &&
-            is_in_folder
-        );
-
-        ConversationEmail conversation_email = new ConversationEmail(
-            email,
-            current_folder.account.get_contact_store(),
-            is_draft
-        );
-        conversation_email.mark_email.connect(on_mark_email);
-        conversation_email.mark_email_from_here.connect(on_mark_email_from_here);
-        conversation_email.body_selection_changed.connect((email, has_selection) => {
-                this.body_selected_view = has_selection ? email : null;
-            });
-
-        ConversationMessage conversation_message = conversation_email.primary_message;
-        conversation_message.body_box.button_release_event.connect_after((event) => {
-                // Consume all non-consumed clicks so the row is not
-                // inadvertently activated after clicking on the
-                // email body.
-                return true;
-            });
-
-        // Capture key events on the email's web views to allow
-        // scrolling on Space, etc.
-        conversation_message.web_view.key_press_event.connect(on_conversation_key_press);
-        foreach (ConversationMessage attached in conversation_email.attached_messages) {
-            attached.web_view.key_press_event.connect(on_conversation_key_press);
-        }
-
-        EmailRow row = new EmailRow(conversation_email);
-        row.show();
-        email_to_row.set(email.id, row);
-
-        conversation_listbox.add(row);
-
-        if (email.is_unread() == Geary.Trillian.TRUE) {
-            row.expand(false);
-        }
-
-        conversation_email.start_loading.begin(cancellable_fetch);
-        email_row_added(conversation_email);
-
-        // Update the search results
-        //if (conversation_find_bar.visible)
-        //    conversation_find_bar.commence_search();
-
-        return;
-    }
-
-    private void remove_email(Geary.Email email) {
-        Gtk.ListBoxRow row = email_to_row.get(email.id);
-        email_row_removed((ConversationEmail) row.get_child());
-        conversation_listbox.remove(row);
-        email_to_row.get(email.id);
-        emails.remove(email);
-    }
-
-    private void compress_emails() {
-        conversation_listbox.get_style_context().add_class("geary_compressed");
-    }
-
-    //private void decompress_emails() {
-    //  conversation_listbox.get_style_context().remove_class("geary_compressed");
-    //}
-
-    private void on_update_flags(Geary.Email email) {
-        // Nothing to do if we aren't displaying this email.
-        if (!email_to_row.has_key(email.id)) {
-            return;
-        }
-
-        // Get the convo email and update its state.
-        Gtk.ListBoxRow row = email_to_row.get(email.id);
-        ((ConversationEmail) row.get_child()).update_flags(email);
-    }
-
     // State reset.
     private uint on_reset(uint state, uint event, void *user, Object? object) {
-        //web_view.allow_collapsing(true);
-
-        message_view_iterator().foreach((msg_view) => {
-                msg_view.unmark_search_terms();
-                return true;
-            });
-
-        if (search_folder != null) {
-            search_folder = null;
-        }
-
         //if (conversation_find_bar.visible)
         //    fsm.do_post_transition(() => { conversation_find_bar.hide(); }, user, object);
-
         return SearchState.NONE;
     }
 
-    private void on_mark_email(ConversationEmail view,
-                               Geary.NamedFlag? to_add,
-                               Geary.NamedFlag? to_remove) {
-        Gee.Collection<Geary.EmailIdentifier> ids =
-            new Gee.LinkedList<Geary.EmailIdentifier>();
-        ids.add(view.email.id);
-        mark_emails(ids, flag_to_flags(to_add), flag_to_flags(to_remove));
-    }
-
-    private void on_mark_email_from_here(ConversationEmail view,
-                                         Geary.NamedFlag? to_add,
-                                         Geary.NamedFlag? to_remove) {
-        Gee.Collection<Geary.EmailIdentifier> ids =
-            new Gee.LinkedList<Geary.EmailIdentifier>();
-        ids.add(view.email.id);
-        foreach (Geary.Email other in this.emails) {
-            if (Geary.Email.compare_sent_date_ascending(view.email, other) < 0) {
-                ids.add(other.id);
-            }
-        }
-        mark_emails(ids, flag_to_flags(to_add), flag_to_flags(to_remove));
-    }
-
-    private Geary.EmailFlags? flag_to_flags(Geary.NamedFlag? flag) {
-        Geary.EmailFlags flags = null;
-        if (flag != null) {
-            flags = new Geary.EmailFlags();
-            flags.add(flag);
-        }
-        return flags;
+    // Search folder entered.
+    private uint on_enter_search_folder(uint state, uint event, void *user, Object? object) {
+        //search_folder = current_folder as Geary.SearchFolder;
+        //assert(search_folder != null);
+        return SearchState.SEARCH_FOLDER;
     }
 
     // Find bar opened.
@@ -1009,42 +319,15 @@ public class ConversationViewer : Gtk.Stack {
     
     // Find bar closed.
     private uint on_close_find_bar(uint state, uint event, void *user, Object? object) {
-        if (current_folder is Geary.SearchFolder) {
-            highlight_search_terms.begin();
+        // if (current_folder is Geary.SearchFolder) {
+        //     highlight_search_terms.begin();
             
-            return SearchState.SEARCH_FOLDER;
-        } else {
-            //web_view.allow_collapsing(true);
+        //     return SearchState.SEARCH_FOLDER;
+        // } else {
+        //     //web_view.allow_collapsing(true);
             
-            return SearchState.NONE;
-        } 
-    }
-
-    // Search folder entered.
-    private uint on_enter_search_folder(uint state, uint event, void *user, Object? object) {
-        search_folder = current_folder as Geary.SearchFolder;
-        assert(search_folder != null);
-        return SearchState.SEARCH_FOLDER;
-    }
-
-    [GtkCallback]
-    private bool on_conversation_key_press(Gtk.Widget widget, Gdk.EventKey event) {
-        // Override some key bindings to get something that works more
-        // like a browser page.
-        if (event.keyval == Gdk.Key.space) {
-            Gtk.ScrollType dir = Gtk.ScrollType.PAGE_DOWN;
-            if ((event.state & Gdk.ModifierType.SHIFT_MASK) ==
-                Gdk.ModifierType.SHIFT_MASK) {
-                dir = Gtk.ScrollType.PAGE_UP;
-            }
-            conversation_page.scroll_child(dir, false);
-            return true;
-        }
-        return false;
-    }
-
-    private ConversationEmail? conversation_email_for_id(Geary.EmailIdentifier id) {
-        return email_to_row.get(id).view;
+             return SearchState.NONE;
+        // } 
     }
 
 }
diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt
index 9d9d750..c631a9c 100644
--- a/ui/CMakeLists.txt
+++ b/ui/CMakeLists.txt
@@ -15,6 +15,7 @@ set(RESOURCE_LIST
   STRIPBLANKS "conversation-viewer.ui"
               "conversation-web-view.css"
   STRIPBLANKS "edit_alternate_emails.glade"
+  STRIPBLANKS "empty-placeholder.ui"
   STRIPBLANKS "find_bar.glade"
   STRIPBLANKS "folder-popover.ui"
   STRIPBLANKS "login.glade"
diff --git a/ui/conversation-viewer.ui b/ui/conversation-viewer.ui
index d7e2bce..4b1aa92 100644
--- a/ui/conversation-viewer.ui
+++ b/ui/conversation-viewer.ui
@@ -8,25 +8,26 @@
     <property name="can_focus">False</property>
     <property name="transition_type">crossfade</property>
     <child>
-      <object class="GtkImage" id="splash_page">
-        <property name="name">splash_page</property>
+      <object class="GtkSpinner" id="loading_page">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
-        <property name="pixel_size">256</property>
-        <property name="icon_name">mail-inbox-symbolic</property>
+        <property name="active">True</property>
       </object>
       <packing>
-        <property name="name">splash_page</property>
+        <property name="name">loading_page</property>
       </packing>
     </child>
     <child>
-      <object class="GtkSpinner" id="loading_page">
+      <object class="GtkBox" id="no_conversations_page">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
-        <property name="active">True</property>
+        <property name="orientation">vertical</property>
+        <child>
+          <placeholder/>
+        </child>
       </object>
       <packing>
-        <property name="name">loading_page</property>
+        <property name="name">no_conversations_page</property>
         <property name="position">1</property>
       </packing>
     </child>
@@ -34,26 +35,10 @@
       <object class="GtkScrolledWindow" id="conversation_page">
         <property name="visible">True</property>
         <property name="can_focus">True</property>
-        <property name="events">GDK_KEY_PRESS_MASK | GDK_STRUCTURE_MASK</property>
         <property name="hscrollbar_policy">never</property>
         <property name="shadow_type">in</property>
-        <signal name="key-press-event" handler="on_conversation_key_press" swapped="no"/>
         <child>
-          <object class="GtkViewport">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <child>
-              <object class="GtkListBox" id="conversation_listbox">
-                <property name="name">conversation_listbox</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="selection_mode">none</property>
-                <style>
-                  <class name="background"/>
-                </style>
-              </object>
-            </child>
-          </object>
+          <placeholder/>
         </child>
       </object>
       <packing>
@@ -62,32 +47,48 @@
       </packing>
     </child>
     <child>
-      <object class="GtkBox" id="user_message_page">
+      <object class="GtkBox" id="multiple_conversations_page">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
         <property name="orientation">vertical</property>
         <child>
-          <object class="GtkLabel" id="user_message_label">
-            <property name="name">user_message</property>
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="xpad">18</property>
-            <property name="ypad">18</property>
-            <property name="label">🎔</property>
-          </object>
-          <packing>
-            <property name="expand">True</property>
-            <property name="fill">False</property>
-            <property name="position">0</property>
-          </packing>
+          <placeholder/>
         </child>
       </object>
       <packing>
-        <property name="name">user_message_page</property>
+        <property name="name">multiple_conversations_page</property>
         <property name="position">3</property>
       </packing>
     </child>
     <child>
+      <object class="GtkBox" id="empty_folder_page">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="orientation">vertical</property>
+        <child>
+          <placeholder/>
+        </child>
+      </object>
+      <packing>
+        <property name="name">empty_folder_page</property>
+        <property name="position">4</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkBox" id="empty_search_page">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="orientation">vertical</property>
+        <child>
+          <placeholder/>
+        </child>
+      </object>
+      <packing>
+        <property name="name">empty_search_page</property>
+        <property name="position">5</property>
+      </packing>
+    </child>
+    <child>
       <object class="GtkBox" id="composer_page">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
@@ -98,7 +99,7 @@
       </object>
       <packing>
         <property name="name">composer_page</property>
-        <property name="position">4</property>
+        <property name="position">6</property>
       </packing>
     </child>
   </template>
diff --git a/ui/empty-placeholder.ui b/ui/empty-placeholder.ui
new file mode 100644
index 0000000..4602f18
--- /dev/null
+++ b/ui/empty-placeholder.ui
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.20.0 -->
+<interface>
+  <requires lib="gtk+" version="3.20"/>
+  <template class="EmptyPlaceholder" parent="GtkGrid">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="halign">center</property>
+    <property name="valign">center</property>
+    <child>
+      <object class="GtkImage" id="placeholder_image">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="pixel_size">72</property>
+        <property name="icon_name">folder-symbolic</property>
+        <property name="icon_size">6</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="title_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label">Mea navis volitans</property>
+        <style>
+          <class name="title"/>
+        </style>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="subtitle_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label">Mea navis volitans anguillis plena est.</property>
+        <style>
+          <class name="subtitle"/>
+        </style>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+      </packing>
+    </child>
+    <style>
+      <class name="dim-label"/>
+      <class name="geary-empty-placeholder"/>
+    </style>
+  </template>
+</interface>
diff --git a/ui/geary.css b/ui/geary.css
index 0eaa29a..53a13ec 100644
--- a/ui/geary.css
+++ b/ui/geary.css
@@ -52,10 +52,10 @@ row.geary-folder-popover-list-row > label {
   color: @theme_text_color;
 }
 
-#conversation_listbox {
+.conversation-listbox {
   padding: 18px;
 }
-#conversation_listbox > row {
+.conversation-listbox > row {
   margin: 0;
   border: 1px solid @borders;
   border-bottom-width: 0;
@@ -63,18 +63,18 @@ row.geary-folder-popover-list-row > label {
   box-shadow: 0 4px 8px 1px rgba(0,0,0,0.4);
   transition: margin 0.1s;
 }
-#conversation_listbox > row > box {
+.conversation-listbox > row > box {
   background: @theme_base_color;
   transition: background 0.25s;
 }
-#conversation_listbox > row:hover > box {
+.conversation-listbox > row:hover > box {
   background: shade(@theme_base_color, 0.96);
 }
-#conversation_listbox > row.geary-expanded {
+.conversation-listbox > row.geary-expanded {
   margin-bottom: 6px;
   border-bottom-width: 1px;
 }
-#conversation_listbox > row.geary-last {
+.conversation-listbox > row.geary-last {
   margin-bottom: 0;
 }
 
@@ -97,9 +97,9 @@ row.geary-folder-popover-list-row > label {
   border-radius: 0px;
 }
 
-#user_message {
-  border: 1px solid @borders;
-  border-left: 0;
-  border-right: 0;
-  background: @theme_base_color;
+.geary-empty-placeholder > image {
+  margin-bottom: 12px;
+}
+.geary-empty-placeholder > .title {
+  font-weight: bold;
 }


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