[geary/gnumdk/stable] client: conversation-list: Migrate from `TreeView` to `ListBox`




commit 72ae913f248be2282c8279f46a0373f44c5b80ad
Author: Cédric Bellegarde <cedric bellegarde adishatz org>
Date:   Wed Sep 14 15:12:49 2022 +0200

    client: conversation-list: Migrate from `TreeView` to `ListBox`
    
    - Replace ConversationListStore with ConversationListModel
    - Replace GtkTreeView with GtkListBox
    - Implement proper multiselection for ListBox
    - Rework navigation to be touch friendly
    
    Fork of John Renner <john jrenner net> merge request !698

 po/POTFILES.in                                     |    8 +-
 src/client/application/application-client.vala     |   13 +-
 .../application/application-main-window.vala       |  176 +--
 .../components-conversation-actions.vala           |    7 +
 .../components-headerbar-conversation-list.vala    |    7 +
 src/client/components/count-badge.vala             |    5 +-
 .../conversation-list-cell-renderer.vala           |   73 -
 .../conversation-list/conversation-list-model.vala |  139 ++
 .../conversation-list-participant.vala             |   68 +
 .../conversation-list/conversation-list-row.vala   |  211 +++
 .../conversation-list/conversation-list-store.vala |  494 ------
 .../conversation-list/conversation-list-view.vala  | 1061 +++++++------
 .../formatted-conversation-data.vala               |  476 ------
 .../conversation-list-box.vala.orig                | 1593 ++++++++++++++++++++
 .../conversation-viewer/conversation-viewer.vala   |    6 +-
 src/client/meson.build                             |    6 +-
 .../sidebar/sidebar-count-cell-renderer.vala       |    2 +-
 src/engine/util/util-numeric.vala                  |   14 +
 ui/application-main-window.ui                      |   43 +-
 ui/components-conversation-actions.ui              |   21 +-
 ui/components-headerbar-conversation-list.ui       |   19 +-
 ui/conversation-list-row.ui                        |  214 +++
 ui/conversation-list-view.ui                       |   25 +
 ui/geary.css                                       |  122 +-
 ui/org.gnome.Geary.gresource.xml                   |    2 +
 25 files changed, 3046 insertions(+), 1759 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index d8301ec4f..20c6005f6 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -70,10 +70,10 @@ src/client/composer/composer-widget.vala
 src/client/composer/composer-window.vala
 src/client/composer/contact-entry-completion.vala
 src/client/composer/spell-check-popover.vala
-src/client/conversation-list/conversation-list-cell-renderer.vala
-src/client/conversation-list/conversation-list-store.vala
+src/client/conversation-list/conversation-list-model.vala
+src/client/conversation-list/conversation-list-row.vala
 src/client/conversation-list/conversation-list-view.vala
-src/client/conversation-list/formatted-conversation-data.vala
+src/client/conversation-list/conversation-list-participant.vala
 src/client/conversation-viewer/conversation-email.vala
 src/client/conversation-viewer/conversation-list-box.vala
 src/client/conversation-viewer/conversation-message.vala
@@ -468,6 +468,8 @@ ui/components-placeholder-pane.ui
 ui/conversation-contact-popover.ui
 ui/conversation-email.ui
 ui/conversation-email-menus.ui
+ui/conversation-list-row.ui
+ui/conversation-list-view.ui
 ui/conversation-message-link-popover.ui
 ui/conversation-message-menus.ui
 ui/conversation-message.ui
diff --git a/src/client/application/application-client.vala b/src/client/application/application-client.vala
index 04b73f8d0..a5ac04223 100644
--- a/src/client/application/application-client.vala
+++ b/src/client/application/application-client.vala
@@ -399,17 +399,6 @@ public class Application.Client : Gtk.Application {
         add_edit_accelerators(Action.Edit.REDO, { "<Ctrl><Shift>Z" });
         add_edit_accelerators(Action.Edit.UNDO, { "<Ctrl>Z" });
 
-        // Set up custom keybindings
-        unowned Gtk.BindingSet bindings = Gtk.BindingSet.by_class(
-            (ObjectClass) typeof(Gtk.ListBoxRow).class_ref()
-        );
-        Gtk.BindingEntry.add_signal(
-            bindings, Gdk.Key.Right, MOD1_MASK, "activate", 0
-        );
-        Gtk.BindingEntry.add_signal(
-            bindings, Gdk.Key.Forward, 0, "activate", 0
-        );
-
         // Load Geary GTK CSS
         var provider = new Gtk.CssProvider();
         Gtk.StyleContext.add_provider_for_screen(
@@ -1203,7 +1192,7 @@ public class Application.Client : Gtk.Application {
         MainWindow? current = this.last_active_main_window;
         if (current != null) {
             folder = current.selected_folder;
-            conversations = current.conversation_list_view.copy_selected();
+            conversations = current.conversation_list_view.get_selected();
         }
         this.new_window.begin(folder, conversations);
     }
diff --git a/src/client/application/application-main-window.vala 
b/src/client/application/application-main-window.vala
index 49a882a7f..6b9be281a 100644
--- a/src/client/application/application-main-window.vala
+++ b/src/client/application/application-main-window.vala
@@ -207,6 +207,12 @@ public class Application.MainWindow :
             "navigate", 1,
             typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_LEFT
         );
+        Gtk.BindingEntry.add_signal(
+            bindings,
+            Gdk.Key.Escape, 0,
+            "navigate", 1,
+            typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_LEFT
+        );
         Gtk.BindingEntry.add_signal(
             bindings,
             Gdk.Key.Back, 0,
@@ -355,7 +361,7 @@ public class Application.MainWindow :
     // Widget descendants
     public FolderList.Tree folder_list { get; private set; default = new FolderList.Tree(); }
     public SearchBar search_bar { get; private set; }
-    public ConversationListView conversation_list_view  { get; private set; }
+    public ConversationList.View conversation_list_view  { get; private set; }
     public ConversationViewer conversation_viewer { get; private set; }
 
     public Components.InfoBarStack conversation_list_info_bars {
@@ -402,7 +408,6 @@ public class Application.MainWindow :
     [GtkChild] private unowned Gtk.ScrolledWindow folder_list_scrolled;
 
     [GtkChild] private unowned Gtk.Box conversation_list_box;
-    [GtkChild] private unowned Gtk.ScrolledWindow conversation_list_scrolled;
     [GtkChild] private unowned Gtk.Revealer conversation_list_actions_revealer;
     [GtkChild] private unowned Components.ConversationActions conversation_list_actions;
 
@@ -659,7 +664,9 @@ public class Application.MainWindow :
         cert_retry.clicked.connect(on_cert_problem_retry);
         this.cert_problem_infobar.get_action_area().add(cert_retry);
 
-        this.conversation_list_view.grab_focus();
+        this.map.connect(() => {
+            this.folder_list.grab_focus();
+        });
 
         foreach (var actions in this.folder_conversation_actions) {
             actions.mark_message_button_toggled.connect(on_show_mark_menu);
@@ -760,6 +767,8 @@ public class Application.MainWindow :
             this.folder_open.cancel();
             var cancellable = this.folder_open = new GLib.Cancellable();
 
+            this.conversation_list_headerbar.selection_open = false;
+
             // Dispose of all existing objects for the currently
             // selected model.
 
@@ -776,11 +785,7 @@ public class Application.MainWindow :
                 this.progress_monitor.remove(this.conversations.progress_monitor);
                 close_conversation_monitor(this.conversations);
                 this.conversations = null;
-            }
-            var conversations_model = this.conversation_list_view.get_model();
-            if (conversations_model != null) {
-                this.progress_monitor.remove(conversations_model.preview_monitor);
-                this.conversation_list_view.set_model(null);
+                this.conversation_list_view.set_monitor(null);
             }
 
             this.conversation_list_info_bars.remove_all();
@@ -829,22 +834,17 @@ public class Application.MainWindow :
                     // Include fields for the conversation viewer as well so
                     // conversations can be displayed without having to go
                     // back to the db
-                    ConversationListStore.REQUIRED_FIELDS |
+                    ConversationList.View.REQUIRED_FIELDS |
                     ConversationListBox.REQUIRED_FIELDS |
                     ConversationEmail.REQUIRED_FOR_CONSTRUCT,
                     MIN_CONVERSATION_COUNT
                 );
                 this.progress_monitor.add(this.conversations.progress_monitor);
 
-                conversations_model = new ConversationListStore(
-                    this.conversations, this.application.config
-
-                );
-                this.progress_monitor.add(conversations_model.preview_monitor);
                 if (inhibit_autoselect) {
                     this.conversation_list_view.inhibit_next_autoselect();
                 }
-                this.conversation_list_view.set_model(conversations_model);
+                this.conversation_list_view.set_monitor(this.conversations);
 
                 // disable copy/move to the new folder
                 foreach (var menu in this.folder_popovers) {
@@ -930,7 +930,6 @@ public class Application.MainWindow :
                     Gee.Collection.empty<Geary.EmailIdentifier>(),
                     is_interactive
                 );
-            } else {
             }
         }
     }
@@ -1335,15 +1334,16 @@ public class Application.MainWindow :
         this.conversation_list_box.pack_start(
             this.conversation_list_info_bars, false, false, 0
         );
-        this.conversation_list_view = new ConversationListView(
-            this.application.config
-        );
-        this.conversation_list_view.load_more.connect(on_load_more);
+
+        this.conversation_list_view = new ConversationList.View(this.application.config);
         this.conversation_list_view.mark_conversations.connect(on_mark_conversations);
         this.conversation_list_view.conversations_selected.connect(on_conversations_selected);
-        this.conversation_list_view.conversation_activated.connect(on_conversation_activated);
-        this.conversation_list_view.visible_conversations_changed.connect(on_visible_conversations_changed);
-        this.conversation_list_scrolled.add(conversation_list_view);
+        this.conversation_list_view.conversation_alt_action.connect(on_conversation_alt_action);
+        this.conversation_list_view.visible_conversations.notify.connect(on_visible_conversations_changed);
+
+        this.conversation_list_box.pack_start(
+            this.conversation_list_view, true, true, 0
+        );
 
         // Conversation viewer
         this.conversation_viewer = new ConversationViewer(
@@ -1361,11 +1361,25 @@ public class Application.MainWindow :
             this.search_bar, "search-mode-enabled",
             SYNC_CREATE | BIDIRECTIONAL
         );
+        this.conversation_list_headerbar.bind_property(
+            "selection-open",
+            this.conversation_list_view, "selection-mode-enabled",
+            SYNC_CREATE | BIDIRECTIONAL
+        );
         this.conversation_headerbar.bind_property(
             "find-open",
             this.conversation_viewer.conversation_find_bar, "search-mode-enabled",
             SYNC_CREATE | BIDIRECTIONAL
         );
+        this.conversation_list_headerbar.notify["selection-open"].connect(
+            () => {
+                if (this.conversation_list_view.selection_mode_enabled)
+                    this.conversation_list_actions_revealer.reveal_child = (
+                        this.outer_leaflet.folded);
+                else
+                    this.conversation_list_actions_revealer.reveal_child = false;
+            }
+        );
         this.conversation_headerbar.notify["shown-actions"].connect(
             () => {
                 this.conversation_viewer_actions_revealer.reveal_child = (
@@ -1383,6 +1397,8 @@ public class Application.MainWindow :
         this.status_bar.add(this.spinner);
         this.status_bar.show_all();
 
+        this.conversation_list_actions.set_mark_inverted();
+
         this.folder_conversation_actions = {
             this.conversation_headerbar.full_actions,
             this.conversation_list_actions
@@ -1552,11 +1568,7 @@ public class Application.MainWindow :
                 this.conversation_viewer.current_list.update_display();
             }
 
-            ConversationListStore? list_store =
-                this.conversation_list_view.get_model() as ConversationListStore;
-            if (list_store != null) {
-                list_store.update_display();
-            }
+            this.conversation_list_view.refresh_times();
         }
     }
 
@@ -1646,6 +1658,9 @@ public class Application.MainWindow :
                             context.contacts,
                             start_mark_timer
                         );
+                        if (is_interactive) {
+                            focus_next_pane(true);
+                        }
                     } catch (Geary.EngineError.NOT_FOUND err) {
                         // The first interesting email from the
                         // conversation wasn't found. If the
@@ -1750,15 +1765,14 @@ public class Application.MainWindow :
         }
     }
 
-    private void load_more() {
-        if (this.is_conversation_list_shown &&
-            this.conversations != null) {
-            this.conversations.min_window_count += MIN_CONVERSATION_COUNT;
-        }
-    }
-
-    private void on_conversations_selected(Gee.Set<Geary.App.Conversation> selected) {
-        this.select_conversations.begin(selected, Gee.Collection.empty(), true);
+    private void on_conversations_selected(Gee.Set<Geary.App.Conversation> selected, bool is_interactive) {
+        this.select_conversations.begin(selected, Gee.Collection.empty(), is_interactive);
+        if (this.conversation_list_view.selection_mode_enabled)
+            if (selected.size > 0)
+                this.conversation_list_actions_revealer.reveal_child = (
+                    this.outer_leaflet.folded);
+            else
+                this.conversation_list_actions_revealer.reveal_child = false;
     }
 
     private void on_conversation_count_changed() {
@@ -1778,7 +1792,7 @@ public class Application.MainWindow :
                 // conversations_selected firing from the convo list,
                 // so we need to stop the loading spinner here.
                 if (!this.application.config.autoselect &&
-                    this.conversation_list_view.get_selection().count_selected_rows() == 0) {
+                    this.conversation_list_view.get_selected().size == 0) {
                     this.conversation_viewer.show_none_selected();
                     update_conversation_actions(NONE);
                 }
@@ -1863,20 +1877,6 @@ public class Application.MainWindow :
             sensitive && (this.selected_folder is Geary.FolderSupport.Remove)
         );
 
-        switch (count) {
-        case NONE:
-            this.conversation_list_actions_revealer.reveal_child = false;
-            break;
-        case SINGLE:
-            this.conversation_list_actions_revealer.reveal_child = (
-                this.outer_leaflet.folded
-            );
-            break;
-        case MULTIPLE:
-            this.conversation_list_actions_revealer.reveal_child = true;
-            break;
-        }
-
         this.update_context_dependent_actions.begin(sensitive);
     }
 
@@ -1954,7 +1954,7 @@ public class Application.MainWindow :
         }
     }
 
-    private void focus_next_pane() {
+    private void focus_next_pane(bool no_conversations_focus=false) {
         var focus = get_focus();
 
         if (this.outer_leaflet.folded) {
@@ -1976,8 +1976,9 @@ public class Application.MainWindow :
             if (focus == this.folder_list ||
                 focus.is_ancestor(this.folder_list)) {
                 focus = this.conversation_list_view;
-            } else if (focus == this.conversation_list_view ||
-                       focus.is_ancestor(this.conversation_list_view)) {
+            } else if (!no_conversations_focus && (
+                        focus == this.conversation_list_view ||
+                        focus.is_ancestor(this.conversation_list_view))) {
                 focus = this.conversation_viewer.visible_child;
             } else if (focus == this.conversation_viewer ||
                        focus.is_ancestor(this.conversation_viewer)) {
@@ -1994,7 +1995,6 @@ public class Application.MainWindow :
 
     private void focus_previous_pane() {
         var focus = get_focus();
-
         if (this.outer_leaflet.folded) {
             if (this.outer_leaflet.visible_child_name == INNER_LEAFLET) {
                 if (this.inner_leaflet.folded) {
@@ -2003,7 +2003,8 @@ public class Application.MainWindow :
                         focus = this.folder_list;
                     }
                 } else {
-                    if (focus == this.conversation_list_view)
+                     if (focus == this.conversation_list_view ||
+                         focus.is_ancestor(this.conversation_list_view))
                         focus = this.folder_list;
                     else
                         focus = this.conversation_list_view;
@@ -2053,7 +2054,7 @@ public class Application.MainWindow :
         // Done scanning.  Check if we have enough messages to fill
         // the conversation list; if not, trigger a load_more();
         Gtk.Scrollbar? scrollbar = (
-            this.conversation_list_scrolled.get_vscrollbar() as Gtk.Scrollbar
+            this.conversation_list_view.get_vscrollbar() as Gtk.Scrollbar
         );
         if (is_visible() &&
             (scrollbar == null || !scrollbar.get_visible()) &&
@@ -2061,7 +2062,7 @@ public class Application.MainWindow :
             monitor.can_load_more) {
             debug("Not enough messages, loading more for folder %s",
                   this.selected_folder.to_string());
-            load_more();
+            this.conversation_list_view.load_more(MIN_CONVERSATION_COUNT);
         }
     }
 
@@ -2074,10 +2075,6 @@ public class Application.MainWindow :
         );
     }
 
-    private void on_load_more() {
-        load_more();
-    }
-
     [GtkCallback]
     private void on_map() {
         this.update_ui_timeout.start();
@@ -2319,7 +2316,7 @@ public class Application.MainWindow :
         if (this.selected_folder != null) {
             this.controller.clear_new_messages(
                 this.selected_folder,
-                this.conversation_list_view.get_visible_conversations()
+                this.conversation_list_view.visible_conversations
             );
         }
     }
@@ -2353,27 +2350,24 @@ public class Application.MainWindow :
         }
     }
 
-    private void on_visible_conversations_changed(Gee.Set<Geary.App.Conversation> visible) {
+    private void on_visible_conversations_changed() {
         if (this.selected_folder != null) {
-            this.controller.clear_new_messages(this.selected_folder, visible);
+            this.controller.clear_new_messages(this.selected_folder, 
this.conversation_list_view.visible_conversations);
         }
     }
 
     private void on_folder_activated(Geary.Folder? folder) {
-        if (folder != null)
+        if (folder != null) {
             focus_next_pane();
+        }
     }
 
-    private void on_conversation_activated(Geary.App.Conversation activated, bool single) {
-        if (single) {
-            if (this.outer_leaflet.folded) {
-                focus_next_pane();
-            }
-        } else if (this.selected_folder != null) {
+    private void on_conversation_alt_action(Geary.App.Conversation activated) {
+        if (this.selected_folder != null) {
             if (this.selected_folder.used_as != DRAFTS) {
                 this.application.new_window.begin(
                     this.selected_folder,
-                    this.conversation_list_view.copy_selected()
+                    this.conversation_list_view.get_selected()
                     );
             } else {
                 // TODO: Determine how to map between conversations
@@ -2527,7 +2521,7 @@ public class Application.MainWindow :
         if (location != null) {
             this.controller.mark_conversations.begin(
                 location,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 Geary.EmailFlags.UNREAD,
                 false,
                 (obj, res) => {
@@ -2539,6 +2533,7 @@ public class Application.MainWindow :
                 }
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_mark_as_unread() {
@@ -2546,7 +2541,7 @@ public class Application.MainWindow :
         if (location != null) {
             this.controller.mark_conversations.begin(
                 location,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 Geary.EmailFlags.UNREAD,
                 true,
                 (obj, res) => {
@@ -2558,6 +2553,7 @@ public class Application.MainWindow :
                 }
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_mark_as_starred() {
@@ -2565,7 +2561,7 @@ public class Application.MainWindow :
         if (location != null) {
             this.controller.mark_conversations.begin(
                 location,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 Geary.EmailFlags.FLAGGED,
                 true,
                 (obj, res) => {
@@ -2577,6 +2573,7 @@ public class Application.MainWindow :
                 }
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_mark_as_unstarred() {
@@ -2584,7 +2581,7 @@ public class Application.MainWindow :
         if (location != null) {
             this.controller.mark_conversations.begin(
                 location,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 Geary.EmailFlags.FLAGGED,
                 false,
                 (obj, res) => {
@@ -2596,6 +2593,7 @@ public class Application.MainWindow :
                 }
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_mark_as_junk_toggle() {
@@ -2608,7 +2606,7 @@ public class Application.MainWindow :
             this.controller.move_conversations_special.begin(
                 source,
                 destination,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 (obj, res) => {
                     try {
                         this.controller.move_conversations_special.end(res);
@@ -2618,6 +2616,7 @@ public class Application.MainWindow :
                 }
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_move_conversation(Geary.Folder destination) {
@@ -2627,7 +2626,7 @@ public class Application.MainWindow :
             this.controller.move_conversations.begin(
                 source,
                 destination,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 (obj, res) => {
                     try {
                         this.controller.move_conversations.end(res);
@@ -2638,6 +2637,7 @@ public class Application.MainWindow :
             );
 
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_copy_conversation(Geary.Folder destination) {
@@ -2647,7 +2647,7 @@ public class Application.MainWindow :
             this.controller.copy_conversations.begin(
                 source,
                 destination,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 (obj, res) => {
                     try {
                         this.controller.copy_conversations.end(res);
@@ -2658,6 +2658,7 @@ public class Application.MainWindow :
             );
 
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_archive_conversation() {
@@ -2666,7 +2667,7 @@ public class Application.MainWindow :
             this.controller.move_conversations_special.begin(
                 source,
                 ARCHIVE,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 (obj, res) => {
                     try {
                         this.controller.move_conversations_special.end(res);
@@ -2676,6 +2677,7 @@ public class Application.MainWindow :
                 }
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_trash_conversation() {
@@ -2684,7 +2686,7 @@ public class Application.MainWindow :
             this.controller.move_conversations_special.begin(
                 source,
                 TRASH,
-                this.conversation_list_view.copy_selected(),
+                this.conversation_list_view.get_selected(),
                 (obj, res) => {
                     try {
                         this.controller.move_conversations_special.end(res);
@@ -2694,13 +2696,14 @@ public class Application.MainWindow :
                 }
             );
         }
+        // No need to disable selection mode, handled by model change
     }
 
     private void on_delete_conversation() {
         Geary.FolderSupport.Remove target =
             this.selected_folder as Geary.FolderSupport.Remove;
         Gee.Collection<Geary.App.Conversation> conversations =
-            this.conversation_list_view.copy_selected();
+            this.conversation_list_view.get_selected();
         if (target != null && this.prompt_delete_conversations(conversations.size)) {
             this.controller.delete_conversations.begin(
                 target,
@@ -2714,6 +2717,7 @@ public class Application.MainWindow :
                 }
             );
         }
+        // No need to disable selection mode, handled by model change
     }
 
     private void on_email_loaded(ConversationListBox view,
@@ -2755,6 +2759,7 @@ public class Application.MainWindow :
                 }
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_email_reply_to_sender(Geary.Email target, string? quote) {
@@ -2763,6 +2768,7 @@ public class Application.MainWindow :
                 this.selected_account, REPLY_SENDER, target, quote
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_email_reply_to_all(Geary.Email target, string? quote) {
@@ -2771,6 +2777,7 @@ public class Application.MainWindow :
                 this.selected_account, REPLY_ALL, target, quote
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_email_forward(Geary.Email target, string? quote) {
@@ -2779,6 +2786,7 @@ public class Application.MainWindow :
                 this.selected_account, FORWARD, target, quote
             );
         }
+        this.conversation_list_view.selection_mode_enabled = false;
     }
 
     private void on_email_trash(ConversationListBox view, Geary.Email target) {
diff --git a/src/client/components/components-conversation-actions.vala 
b/src/client/components/components-conversation-actions.vala
index 7adb6afc3..0483b9f37 100644
--- a/src/client/components/components-conversation-actions.vala
+++ b/src/client/components/components-conversation-actions.vala
@@ -98,6 +98,13 @@ public class Components.ConversationActions : Gtk.Box {
         this.copy_message_button.clicked();
     }
 
+    public void set_mark_inverted() {
+        var image = new Gtk.Image.from_icon_name(
+            "pan-up-symbolic", Gtk.IconSize.BUTTON
+        );
+        this.mark_message_button.set_image(image);
+    }
+
     public void update_trash_button(bool show_trash) {
         this.show_trash_button = show_trash;
         update_conversation_buttons();
diff --git a/src/client/components/components-headerbar-conversation-list.vala 
b/src/client/components/components-headerbar-conversation-list.vala
index 319b2a4ef..78f24d351 100644
--- a/src/client/components/components-headerbar-conversation-list.vala
+++ b/src/client/components/components-headerbar-conversation-list.vala
@@ -19,8 +19,10 @@ public class Components.ConversationListHeaderBar : Hdy.HeaderBar {
     public string account { get; set; }
     public string folder { get; set; }
     public bool search_open { get; set; default = false; }
+    public bool selection_open { get; set; default = false; }
 
     [GtkChild] private unowned Gtk.ToggleButton search_button;
+    [GtkChild] private unowned Gtk.ToggleButton selection_button;
     [GtkChild] public unowned Gtk.Button back_button;
 
 
@@ -33,5 +35,10 @@ public class Components.ConversationListHeaderBar : Hdy.HeaderBar {
             this.search_button, "active",
             SYNC_CREATE | BIDIRECTIONAL
         );
+        this.bind_property(
+            "selection-open",
+            this.selection_button, "active",
+            SYNC_CREATE | BIDIRECTIONAL
+        );
     }
 }
diff --git a/src/client/components/count-badge.vala b/src/client/components/count-badge.vala
index a0a1963d3..b5a833b01 100644
--- a/src/client/components/count-badge.vala
+++ b/src/client/components/count-badge.vala
@@ -9,6 +9,7 @@
  */
 public class CountBadge : Geary.BaseObject {
     public const string UNREAD_BG_COLOR = "#888888";
+       public const int SPACING = 6;
 
     private const int FONT_SIZE_MESSAGE_COUNT = 8;
 
@@ -63,7 +64,7 @@ public class CountBadge : Geary.BaseObject {
         Pango.Rectangle? logical_rect;
         layout_num.get_pixel_extents(out ink_rect, out logical_rect);
         if (ctx != null) {
-            double bg_width = logical_rect.width + FormattedConversationData.SPACING;
+            double bg_width = logical_rect.width + SPACING;
             double bg_height = logical_rect.height;
             double radius = bg_height / 2.0;
             double degrees = Math.PI / 180.0;
@@ -87,7 +88,7 @@ public class CountBadge : Geary.BaseObject {
             Pango.cairo_show_layout(ctx, layout_num);
         }
 
-        width = logical_rect.width + FormattedConversationData.SPACING;
+        width = logical_rect.width + SPACING;
         height = logical_rect.height;
     }
 }
diff --git a/src/client/conversation-list/conversation-list-model.vala 
b/src/client/conversation-list/conversation-list-model.vala
new file mode 100644
index 000000000..4f2850f71
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-model.vala
@@ -0,0 +1,139 @@
+/*
+ * Copyright © 2022 John Renner <john jrenner net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// The whole goal of this class to wrap the ConversationMonitor with a view that presents a sorted list
+public class ConversationList.Model {
+    internal ListStore store = new ListStore(typeof(Geary.App.Conversation));
+    internal Geary.App.ConversationMonitor monitor { get; set; }
+
+    private bool scanning = false;
+
+    internal Model (Geary.App.ConversationMonitor monitor) {
+        this.monitor = monitor;
+        foreach (Geary.App.Conversation convo in monitor.read_only_view) {
+            insert(convo);
+        }
+
+        monitor.conversations_added.connect(on_conversations_added);
+        monitor.conversation_appended.connect(on_conversation_updated);
+        monitor.conversation_trimmed.connect(on_conversation_updated);
+        monitor.conversations_removed.connect(on_conversations_removed);
+        monitor.scan_started.connect(on_scan_started);
+        monitor.scan_completed.connect(on_scan_completed);
+    }
+
+    ~Model() {
+        this.monitor.conversations_added.disconnect(on_conversations_added);
+        this.monitor.conversation_appended.disconnect(on_conversation_updated);
+        this.monitor.conversation_trimmed.disconnect(on_conversation_updated);
+        this.monitor.conversations_removed.disconnect(on_conversations_removed);
+        this.monitor.scan_started.disconnect(on_scan_started);
+        this.monitor.scan_completed.disconnect(on_scan_completed);
+    }
+
+    public signal void conversations_added(bool start);
+    public signal void conversations_removed(bool start);
+    public signal void conversations_loaded();
+
+    private static int compare(Object a, Object b) {
+        return Util.Email.compare_conversation_descending(a as Geary.App.Conversation, b as 
Geary.App.Conversation);
+    }
+
+    private void insert(Geary.App.Conversation convo)  {
+        store.insert_sorted(convo, compare);
+    }
+
+    // ------------------------
+    //  Scanning and load_more
+    // ------------------------
+
+
+    private void on_scan_started(Geary.App.ConversationMonitor source) {
+        this.scanning = true;
+    }
+    private void on_scan_completed(Geary.App.ConversationMonitor source) {
+        this.scanning = false;
+        conversations_loaded();
+    }
+
+    public bool load_more(int amount) {
+        if (this.scanning) {
+            return false;
+        }
+
+        this.monitor.min_window_count += amount;
+        return true;
+    }
+
+
+    // Monitor Lifecycle handles
+    private void on_conversations_added(Gee.Collection<Geary.App.Conversation> conversations) {
+        debug("Adding %d conversations.", conversations.size);
+        if (!this.scanning) {
+            conversations_added(true);
+        }
+        int added = 0;
+        foreach (Geary.App.Conversation conversation in conversations) {
+            if (upsert_conversation(conversation)) {
+                added++;
+            }
+        }
+        if (!this.scanning) {
+            conversations_added(false);
+        }
+        debug("Added %d/%d conversations.", added, conversations.size);
+    }
+
+    private void on_conversations_removed(Gee.Collection<Geary.App.Conversation> conversations) {
+        debug("Removing %d conversations.", conversations.size);
+
+        if (!this.scanning) {
+            conversations_removed(true);
+        }
+        int removed = 0;
+        foreach (Geary.App.Conversation conversation in conversations) {
+            if (remove_conversation(conversation)) {
+                removed++;
+            }
+        }
+        if (!this.scanning) {
+            conversations_removed(false);
+        }
+        debug("Removed %d/%d conversations.", removed, conversations.size);
+    }
+
+    private void on_conversation_updated(Geary.App.ConversationMonitor sender, Geary.App.Conversation convo, 
Gee.Collection<Geary.Email> emails) {
+        upsert_conversation(convo);
+    }
+
+    // Monitor helpers
+    private bool upsert_conversation(Geary.App.Conversation convo) {
+        // The conversation may be bogus, if so don't do anything
+        Geary.Email? last_email = convo.get_latest_recv_email(Geary.App.Conversation.Location.ANYWHERE);
+
+        if (last_email == null) {
+            debug("Cannot add conversation: last email is null");
+            return false;
+        }
+
+        remove_conversation(convo);
+        insert(convo);
+
+        return true;
+    }
+
+    private bool remove_conversation(Geary.App.Conversation conversation) {
+        uint index;
+        if (store.find(conversation, out index)) {
+            store.remove(index);
+            return true;
+        }
+
+        return false;
+    }
+
+}
diff --git a/src/client/conversation-list/conversation-list-participant.vala 
b/src/client/conversation-list/conversation-list-participant.vala
new file mode 100644
index 000000000..9219d09c9
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-participant.vala
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2022 John Renner <john jrenner net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+internal class ConversationList.Participant : Geary.BaseObject, Gee.Hashable<Participant> {
+       private const string ME = "Me";
+    public Geary.RFC822.MailboxAddress address;
+
+    public Participant(Geary.RFC822.MailboxAddress address) {
+        this.address = address;
+    }
+
+    public string get_full_markup(Gee.List<Geary.RFC822.MailboxAddress> account_mailboxes) {
+        return get_as_markup((address in account_mailboxes) ? ME : address.to_short_display());
+    }
+
+    public string get_short_markup(Gee.List<Geary.RFC822.MailboxAddress> account_mailboxes) {
+        if (address in account_mailboxes)
+            return get_as_markup(ME);
+
+        if (address.is_spoofed()) {
+            return get_full_markup(account_mailboxes);
+        }
+
+        string short_address = Markup.escape_text(address.to_short_display());
+
+        if (", " in short_address) {
+            // assume address is in Last, First format
+            string[] tokens = short_address.split(", ", 2);
+            short_address = tokens[1].strip();
+            if (Geary.String.is_empty(short_address))
+                return get_full_markup(account_mailboxes);
+        }
+
+        // use first name as delimited by a space
+        string[] tokens = short_address.split(" ", 2);
+        if (tokens.length < 1)
+            return get_full_markup(account_mailboxes);
+
+        string first_name = tokens[0].strip();
+        if (Geary.String.is_empty_or_whitespace(first_name))
+            return get_full_markup(account_mailboxes);
+
+        return get_as_markup(first_name);
+    }
+
+    private string get_as_markup(string participant) {
+        string markup = Geary.HTML.escape_markup(participant);
+
+        if (this.address.is_spoofed()) {
+            markup = "<s>%s</s>".printf(markup);
+        }
+
+        return markup;
+    }
+
+    public bool equal_to(Participant other) {
+        return address.equal_to(other.address)
+            && address.name == other.address.name;
+    }
+
+    public uint hash() {
+        return address.hash();
+    }
+}
diff --git a/src/client/conversation-list/conversation-list-row.vala 
b/src/client/conversation-list/conversation-list-row.vala
new file mode 100644
index 000000000..9e24cbdd3
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-row.vala
@@ -0,0 +1,211 @@
+/*
+ * Copyright © 2022 John Renner <john jrenner net>
+ * Copyright © 2022 Cédric Bellegarde <cedric bellegarde adishatz org>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+
+/**
+ * A conversation list row displaying an email summary
+ */
+[GtkTemplate (ui = "/org/gnome/Geary/conversation-list-row.ui")]
+internal class ConversationList.Row : Gtk.ListBoxRow {
+
+    private Gee.List<Geary.RFC822.MailboxAddress>? user_accounts  {
+        owned get {
+            return conversation.base_folder.account.information.sender_mailboxes;
+        }
+    }
+
+    [GtkChild] unowned Gtk.Label preview;
+    [GtkChild] unowned Gtk.Box preview_row;
+    [GtkChild] unowned Gtk.Label subject;
+    [GtkChild] unowned Gtk.Label participants;
+    [GtkChild] unowned Gtk.Label date;
+    [GtkChild] unowned Gtk.Label count_badge;
+
+    [GtkChild] unowned Gtk.Image read_icon;
+    [GtkChild] unowned Gtk.Image flagged_icon;
+
+    [GtkChild] unowned Gtk.Stack stack;
+    [GtkChild] unowned Gtk.CheckButton selected_button;
+
+    internal Geary.App.Conversation conversation;
+    private Application.Configuration config;
+    private DateTime? recv_time;
+
+    internal signal void toggle_flag(ConversationList.Row row,
+                                     Geary.NamedFlag flag);
+
+    internal Row(Application.Configuration config,
+                 Geary.App.Conversation conversation,
+                 bool selection_mode_enabled) {
+        this.config = config;
+        this.conversation = conversation;
+
+        Geary.Email? last_email = conversation.get_latest_recv_email(
+            Geary.App.Conversation.Location.ANYWHERE
+        );
+        if (last_email != null) {
+            var text = Util.Email.strip_subject_prefixes(last_email);
+            this.subject.set_text(text);
+            this.preview.set_text(last_email.get_preview_as_string());
+            this.recv_time = last_email.properties.date_received.to_local();
+            refresh_time();
+        }
+
+        this.participants.set_markup(get_participants());
+
+        var count = conversation.get_count();
+        if (count > 1) {
+            this.count_badge.set_text(conversation.get_count().to_string());
+        } else {
+            this.count_badge.hide();
+        }
+
+        conversation.email_flags_changed.connect(update_flags);
+        update_flags(null);
+
+        config.bind(Application.Configuration.DISPLAY_PREVIEW_KEY,
+                    this.preview_row, "visible");
+
+        if (selection_mode_enabled) {
+            set_selection_enabled(true);
+        }
+    }
+
+    internal void set_selection_enabled(bool enabled) {
+        if (enabled) {
+            this.selected_button.show();
+            set_button_active(this.is_selected());
+            this.state_flags_changed.connect(update_button);
+            this.selected_button.toggled.connect(update_state_flags);
+            this.stack.set_visible_child_name("selection-button");
+        } else {
+            this.stack.set_visible_child_name("buttons");
+            this.state_flags_changed.disconnect(update_button);
+            this.selected_button.toggled.disconnect(update_state_flags);
+            set_button_active(false);
+            this.selected_button.hide();
+        }
+    }
+
+    internal void refresh_time() {
+        if (this.recv_time != null) {
+            // conversation list store sorts by date-received, so display that
+            // instead of the sent time
+            this.date.set_text(Util.Date.pretty_print(
+                this.recv_time,
+                this.config.clock_format
+            ));
+        }
+    }
+
+    private void set_button_active(bool active) {
+        this.selected_button.set_active(active);
+        if (active) {
+            this.get_style_context().add_class("selected");
+            this.set_state_flags(Gtk.StateFlags.SELECTED, false);
+        } else {
+            this.get_style_context().remove_class("selected");
+            this.unset_state_flags(Gtk.StateFlags.SELECTED);
+        }
+    }
+    private void update_button() {
+        bool is_selected = (
+            this.get_state_flags() & Gtk.StateFlags.SELECTED
+        ) == Gtk.StateFlags.SELECTED;
+
+        this.selected_button.toggled.disconnect(update_state_flags);
+        set_button_active(is_selected);
+        this.selected_button.toggled.connect(update_state_flags);
+
+    }
+
+    private void update_state_flags() {
+        this.state_flags_changed.disconnect(update_button);
+
+        if (this.selected_button.get_active()) {
+            this.set_state_flags(Gtk.StateFlags.SELECTED, false);
+            this.get_style_context().add_class("selected");
+        } else {
+            this.unset_state_flags(Gtk.StateFlags.SELECTED);
+            this.get_style_context().remove_class("selected");
+        }
+
+        this.state_flags_changed.connect(update_button);
+    }
+
+    private void update_flags(Geary.Email? email) {
+        if (conversation.is_unread()) {
+            get_style_context().add_class("unread");
+            read_icon.set_from_icon_name("mail-unread-symbolic", Gtk.IconSize.BUTTON);
+        } else {
+            get_style_context().remove_class("unread");
+            read_icon.set_from_icon_name("mail-read-symbolic", Gtk.IconSize.BUTTON);
+        }
+
+        if (conversation.is_flagged()) {
+            flagged_icon.set_from_icon_name("starred-symbolic", Gtk.IconSize.BUTTON);
+        } else {
+            flagged_icon.set_from_icon_name("non-starred-symbolic", Gtk.IconSize.BUTTON);
+        }
+    }
+
+    [GtkCallback] private void on_unread_button_clicked() {
+        toggle_flag(this, Geary.EmailFlags.UNREAD);
+    }
+
+    [GtkCallback] private void on_flagged_button_clicked() {
+        toggle_flag(this, Geary.EmailFlags.FLAGGED);
+    }
+
+    private string get_participants() {
+        var participants = new Gee.ArrayList<Participant>();
+        Gee.List<Geary.Email> emails = conversation.get_emails(
+                          Geary.App.Conversation.Ordering.RECV_DATE_ASCENDING);
+
+        foreach (Geary.Email message in emails) {
+            Geary.RFC822.MailboxAddresses? addresses =
+                conversation.base_folder.used_as.is_outgoing()
+                ? new Geary.RFC822.MailboxAddresses.single(Util.Email.get_primary_originator(message))
+                : message.from;
+
+            if (addresses == null) {
+                continue;
+            }
+
+            foreach (Geary.RFC822.MailboxAddress address in addresses) {
+                Participant participant_display = new Participant(address);
+                int existing_index = participants.index_of(participant_display);
+                if (existing_index < 0) {
+                    participants.add(participant_display);
+                    continue;
+                }
+            }
+        }
+
+        if (participants.size == 0) {
+            return "";
+        }
+
+        if(participants.size == 1) {
+            return participants[0].get_full_markup(this.user_accounts);
+        }
+
+        StringBuilder builder = new StringBuilder();
+        bool first = true;
+        foreach (Participant participant in participants) {
+            if (!first) {
+                builder.append(", ");
+            }
+
+            builder.append(participant.get_short_markup(this.user_accounts));
+            first = false;
+        }
+
+        return builder.str;
+    }
+}
diff --git a/src/client/conversation-list/conversation-list-view.vala 
b/src/client/conversation-list/conversation-list-view.vala
index 37a2a6401..99736b654 100644
--- a/src/client/conversation-list/conversation-list-view.vala
+++ b/src/client/conversation-list/conversation-list-view.vala
@@ -1,666 +1,639 @@
-/* Copyright 2016 Software Freedom Conservancy Inc.
+/*
+ * Copyright © 2022 John Renner <john jrenner net>
+ * Copyright © 2022 Cédric Bellegarde <cedric bellegarde adishatz org>
  *
  * This software is licensed under the GNU Lesser General Public License
- * (version 2.1 or later).  See the COPYING file in this distribution.
+ * (version 2.1 or later). See the COPYING file in this distribution.
  */
 
-public class ConversationListView : Gtk.TreeView, Geary.BaseInterface {
-    const int LOAD_MORE_HEIGHT = 100;
-
+/**
+ * Represents in folder conversations list.
+ *
+ */
+[GtkTemplate (ui = "/org/gnome/Geary/conversation-list-view.ui")]
+public class ConversationList.View : Gtk.ScrolledWindow, Geary.BaseInterface {
+    /**
+     * The fields that must be available on any ConversationMonitor
+     * passed to ConversationList.View
+     */
+    public const Geary.Email.Field REQUIRED_FIELDS = (
+        Geary.Email.Field.ENVELOPE |
+        Geary.Email.Field.FLAGS |
+        Geary.Email.Field.PROPERTIES
+    );
+
+    public bool selection_mode_enabled { get; set; default = false; }
 
     private Application.Configuration config;
+    private Gtk.GestureMultiPress press_gesture;
+    private Gtk.GestureLongPress long_press_gesture;
+    private Gtk.EventControllerKey key_event_controller;
 
-    private bool enable_load_more = true;
-
-    private bool reset_adjustment = false;
-    private Gee.Set<Geary.App.Conversation>? current_visible_conversations = null;
-    private Geary.Scheduler.Scheduled? scheduled_update_visible_conversations = null;
-    private Gee.Set<Geary.App.Conversation> selected = new Gee.HashSet<Geary.App.Conversation>();
-    private Geary.IdleManager selection_update;
-    private Gtk.GestureMultiPress gesture;
-
-    // Determines if the next folder scan should avoid selecting a
-    // conversation when autoselect is enabled
-    private bool should_inhibit_autoselect = false;
-
-
-    public signal void conversations_selected(Gee.Set<Geary.App.Conversation> selected);
-
-    // Signal for when a conversation has been double-clicked, or selected and enter is pressed.
-    public signal void conversation_activated(Geary.App.Conversation activated, bool single = false);
-
-    public virtual signal void load_more() {
-        enable_load_more = false;
-    }
-
-    public signal void mark_conversations(Gee.Collection<Geary.App.Conversation> conversations,
-                                          Geary.NamedFlag flag);
-
-    public signal void visible_conversations_changed(Gee.Set<Geary.App.Conversation> visible);
+    [GtkChild] private unowned Gtk.ListBox list;
 
+    /*
+     * Use to restore selected row when exiting selection/edition
+     */
+    private Gtk.ListBoxRow? to_restore_row = null;
 
-    public ConversationListView(Application.Configuration config) {
-        base_ref();
-        set_show_expanders(false);
-        set_headers_visible(false);
-        set_grid_lines(Gtk.TreeViewGridLines.HORIZONTAL);
-
+    public View(Application.Configuration config) {
         this.config = config;
 
-        append_column(create_column(ConversationListStore.Column.CONVERSATION_DATA,
-            new ConversationListCellRenderer(), ConversationListStore.Column.CONVERSATION_DATA.to_string(),
-            0));
-
-        Gtk.TreeSelection selection = get_selection();
-        selection.set_mode(Gtk.SelectionMode.MULTIPLE);
-        style_updated.connect(on_style_changed);
-
-        notify["vadjustment"].connect(on_vadjustment_changed);
-
-        key_press_event.connect(on_key_press);
-        button_press_event.connect(on_button_press);
-        gesture = new Gtk.GestureMultiPress(this);
-        gesture.pressed.connect(on_gesture_pressed);
-
-        // Set up drag and drop.
-        Gtk.drag_source_set(this, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST,
-            Gdk.DragAction.COPY | Gdk.DragAction.MOVE);
-
-        this.config.settings.changed[
-            Application.Configuration.DISPLAY_PREVIEW_KEY
-        ].connect(on_display_preview_changed);
-
-        // Watch for mouse events.
-        motion_notify_event.connect(on_motion_notify_event);
-        leave_notify_event.connect(on_leave_notify_event);
+        this.notify["selection-mode-enabled"].connect(on_selection_mode_changed);
 
-        // GtkTreeView binds Ctrl+N to "move cursor to next".  Not so interested in that, so we'll
-        // remove it.
-        unowned Gtk.BindingSet? binding_set = Gtk.BindingSet.find("GtkTreeView");
-        assert(binding_set != null);
-        Gtk.BindingEntry.remove(binding_set, Gdk.Key.N, Gdk.ModifierType.CONTROL_MASK);
+        this.list.selected_rows_changed.connect(on_selected_rows_changed);
+        this.list.row_activated.connect(on_row_activated);
 
-        this.selection_update = new Geary.IdleManager(do_selection_changed);
-        this.selection_update.priority = Geary.IdleManager.Priority.LOW;
+        this.list.set_header_func(header_func);
 
-        this.visible = true;
-    }
-
-    ~ConversationListView() {
-        base_unref();
-    }
-
-    public override void destroy() {
-        this.selection_update.reset();
-        base.destroy();
-    }
-
-    public new ConversationListStore? get_model() {
-        return base.get_model() as ConversationListStore;
-    }
-
-    public new void set_model(ConversationListStore? new_store) {
-        ConversationListStore? old_store = get_model();
-        if (old_store != null) {
-            old_store.conversations.scan_started.disconnect(on_scan_started);
-            old_store.conversations.scan_completed.disconnect(on_scan_completed);
-
-            old_store.conversations_added.disconnect(on_conversations_added);
-            old_store.conversations_removed.disconnect(on_conversations_removed);
-            old_store.row_inserted.disconnect(on_rows_changed);
-            old_store.rows_reordered.disconnect(on_rows_changed);
-            old_store.row_changed.disconnect(on_rows_changed);
-            old_store.row_deleted.disconnect(on_rows_changed);
-            old_store.destroy();
-        }
-
-        if (new_store != null) {
-            new_store.conversations.scan_started.connect(on_scan_started);
-            new_store.conversations.scan_completed.connect(on_scan_completed);
-
-            new_store.row_inserted.connect(on_rows_changed);
-            new_store.rows_reordered.connect(on_rows_changed);
-            new_store.row_changed.connect(on_rows_changed);
-            new_store.row_deleted.connect(on_rows_changed);
-            new_store.conversations_removed.connect(on_conversations_removed);
-            new_store.conversations_added.connect(on_conversations_added);
-        }
-
-        // Disconnect the selection handler since we don't want to
-        // fire selection signals while changing the model.
-        Gtk.TreeSelection selection = get_selection();
-        selection.changed.disconnect(on_selection_changed);
-        base.set_model(new_store);
-        this.selected.clear();
-        selection.changed.connect(on_selection_changed);
-    }
+        this.vadjustment.value_changed.connect(maybe_load_more);
+        this.vadjustment.value_changed.connect(update_visible_conversations);
 
-    /** Returns a read-only iteration of the current selection. */
-    public Gee.Set<Geary.App.Conversation> get_selected() {
-        return this.selected.read_only_view;
-    }
+        this.press_gesture = new Gtk.GestureMultiPress(this.list);
+        this.press_gesture.set_button(0);
+        this.press_gesture.released.connect(on_press_gesture_released);
 
-    /** Returns a copy of the current selection. */
-    public Gee.Set<Geary.App.Conversation> copy_selected() {
-        var copy = new Gee.HashSet<Geary.App.Conversation>();
-        copy.add_all(this.selected);
-        return copy;
-    }
-
-    public void inhibit_next_autoselect() {
-        this.should_inhibit_autoselect = true;
-    }
-
-    public void scroll(Gtk.ScrollType where) {
-        Gtk.TreeSelection selection = get_selection();
-        weak Gtk.TreeModel model;
-        GLib.List<Gtk.TreePath> selected = selection.get_selected_rows(out model);
-        Gtk.TreePath? target_path = null;
-        Gtk.TreeIter? target_iter = null;
-        if (selected.length() > 0) {
-            switch (where) {
-            case STEP_UP:
-                target_path = selected.first().data;
-                model.get_iter(out target_iter, target_path);
-                if (model.iter_previous(ref target_iter)) {
-                    target_path = model.get_path(target_iter);
-                } else {
-                    this.get_window().beep();
-                }
-                break;
-
-            case STEP_DOWN:
-                target_path = selected.last().data;
-                model.get_iter(out target_iter, target_path);
-                if (model.iter_next(ref target_iter)) {
-                    target_path = model.get_path(target_iter);
-                } else {
-                    this.get_window().beep();
-                }
-                break;
-
-            default:
-                // no-op
-                break;
+        this.long_press_gesture = new Gtk.GestureLongPress(this.list);
+        this.long_press_gesture.propagation_phase = CAPTURE;
+        this.long_press_gesture.pressed.connect((n_press, x, y) => {
+            Row? row = (Row) this.list.get_row_at_y((int) y);
+            if (row != null) {
+                context_menu(row);
             }
+        });
 
-            set_cursor(target_path, null, false);
-        }
-    }
-
-    private void check_load_more() {
-        ConversationListStore? model = get_model();
-        Geary.App.ConversationMonitor? conversations = (model != null)
-            ? model.conversations
-            : null;
-        if (conversations != null) {
-            // Check if we're at the very bottom of the list. If we
-            // are, it's time to issue a load_more signal.
-            Gtk.Adjustment adjustment = ((Gtk.Scrollable) this).get_vadjustment();
-            double upper = adjustment.get_upper();
-            double threshold = upper - adjustment.page_size - LOAD_MORE_HEIGHT;
-            if (this.is_visible() &&
-                conversations.can_load_more &&
-                adjustment.get_value() >= threshold) {
-                load_more();
-            }
+        this.key_event_controller = new Gtk.EventControllerKey(this.list);
+        this.key_event_controller.key_pressed.connect(on_key_event_controller_key_pressed);
 
-            schedule_visible_conversations_changed();
-        }
+        Gtk.drag_source_set(this.list, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST,
+            Gdk.DragAction.COPY | Gdk.DragAction.MOVE);
+        this.list.drag_begin.connect(on_drag_begin);
+        this.list.drag_end.connect(on_drag_end);
     }
 
-    private void on_scan_started() {
-        this.enable_load_more = false;
+    static construct {
+        set_css_name("conversation-list");
     }
 
-    private void on_scan_completed() {
-        this.enable_load_more = true;
-        check_load_more();
-
-        // Select the first conversation, if autoselect is enabled,
-        // nothing has been selected yet and we're not showing a
-        // composer.
-        if (this.config.autoselect &&
-            !this.should_inhibit_autoselect &&
-            get_selection().count_selected_rows() == 0) {
-            var parent = get_toplevel() as Application.MainWindow;
-            if (parent != null && !parent.has_composer) {
-                set_cursor(new Gtk.TreePath.from_indices(0, -1), null, false);
-            }
+    // -------
+    //   UI
+    // -------
+    private void header_func(Gtk.ListBoxRow row, Gtk.ListBoxRow? before) {
+        if (before != null) {
+            var sep = new Gtk.Separator(Gtk.Orientation.HORIZONTAL);
+            sep.show();
+            row.set_header(sep);
         }
-
-        this.should_inhibit_autoselect = false;
     }
 
-    private void on_conversations_added(bool start) {
-        Gtk.Adjustment? adjustment = get_adjustment();
-        if (start) {
-            // If we were at the top, we want to stay there after
-            // conversations are added.
-            this.reset_adjustment = adjustment != null && adjustment.get_value() == 0;
-        } else if (this.reset_adjustment && adjustment != null) {
-            // Pump the loop to make sure the new conversations are
-            // taking up space in the window.  Without this, setting
-            // the adjustment here is a no-op because as far as it's
-            // concerned, it's already at the top.
-            while (Gtk.events_pending())
-                Gtk.main_iteration();
-
-            adjustment.set_value(0);
+    /**
+     * Updates the display of the received time on each list row.
+     *
+     * Because the received time is displayed as relative to the current time,
+     * it must be periodically updated. ConversationList.View does not do this
+     * automatically but instead it must be externally scheduled
+     */
+    public void refresh_times() {
+        this.list.foreach((child) => {
+            var row = (Row) child;
+            row.refresh_time();
+        });
+    }
+
+    // -------------------
+    //  Model Management
+    // -------------------
+
+    /**
+     * The currently bound model
+     */
+    private Model? model;
+
+    /**
+     * Set the conversation monitor which the listview is displaying
+     */
+    public void set_monitor(Geary.App.ConversationMonitor? monitor) {
+        if (this.model != null) {
+            this.model.conversations_loaded.disconnect(on_conversations_loaded);
+            this.model.conversations_removed.disconnect(on_conversations_removed);
         }
-        this.reset_adjustment = false;
-    }
-
-    private void on_conversations_removed(bool start) {
-        if (!this.config.autoselect) {
-            Gtk.SelectionMode mode = start
-                // Stop GtkTreeView from automatically selecting the
-                // next row after the removed rows
-                ? Gtk.SelectionMode.NONE
-                // Allow the user to make selections again
-                : Gtk.SelectionMode.MULTIPLE;
-            get_selection().set_mode(mode);
+        if (monitor == null) {
+            this.model = null;
+            this.list.bind_model(null, row_factory);
+        } else {
+            this.model = new Model(monitor);
+            this.list.bind_model(this.model.store, row_factory);
+            this.model.conversations_loaded.connect(on_conversations_loaded);
+            this.model.conversations_removed.connect(on_conversations_removed);
         }
     }
 
-    private Gtk.Adjustment? get_adjustment() {
-        Gtk.ScrolledWindow? parent = get_parent() as Gtk.ScrolledWindow;
-        if (parent == null) {
-            debug("Parent was not scrolled window");
-            return null;
+    /**
+     * Attempt to load more conversations from the current monitor
+     */
+    public void load_more(int request) {
+        if (model != null) {
+            model.load_more(request);
         }
-
-        return parent.get_vadjustment();
     }
 
-    private void on_gesture_pressed(int n_press, double x, double y) {
-        if (gesture.get_current_button() != Gdk.BUTTON_PRIMARY)
-            return;
-
-        Gtk.TreePath? path;
-        get_path_at_pos((int) x, (int) y, out path, null, null, null);
+    public void scroll(Gtk.ScrollType scroll_type) {
+        Gtk.ListBoxRow row = this.list.get_selected_row();
 
-        // If the user clicked in an empty area, do nothing.
-        if (path == null)
+        if (row == null) {
             return;
+        }
 
-        Geary.App.Conversation? c = get_model().get_conversation_at_path(path);
-        if (c == null)
-            return;
-
-        Gdk.Event event = gesture.get_last_event(gesture.get_current_sequence());
-        Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask();
-
-        Gdk.ModifierType state_mask;
-        event.get_state(out state_mask);
+        int index = row.get_index();
+        if (scroll_type == Gtk.ScrollType.STEP_UP) {
+            row = this.list.get_row_at_index(index + 1);
+        } else {
+            row = this.list.get_row_at_index(index - 1);
+        }
 
-        if ((state_mask & modifiers) == 0 && n_press == 1) {
-            conversation_activated(c, true);
-        } else if ((state_mask & modifiers) == Gdk.ModifierType.SHIFT_MASK && n_press == 2) {
-            conversation_activated(c);
+        if (row != null) {
+            this.list.select_row(row);
         }
     }
 
-    private bool on_key_press(Gdk.EventKey event) {
-        if (this.selected.size != 1)
-            return false;
-
-        Geary.App.Conversation? c = this.selected.to_array()[0];
-        if (c == null)
-            return false;
-
-        Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask();
-
-        if (event.keyval == Gdk.Key.Return ||
-            event.keyval == Gdk.Key.ISO_Enter ||
-            event.keyval == Gdk.Key.KP_Enter ||
-            event.keyval == Gdk.Key.space ||
-            event.keyval == Gdk.Key.KP_Space)
-            conversation_activated(c, !((event.state & modifiers) == Gdk.ModifierType.SHIFT_MASK));
-        return false;
+    private Gtk.Widget row_factory(Object convo_obj) {
+        var convo = (Geary.App.Conversation) convo_obj;
+        var row = new Row(config, convo, this.selection_mode_enabled);
+        row.toggle_flag.connect(on_toggle_flags);
+        return row;
     }
 
-    private bool on_button_press(Gdk.EventButton event) {
-        // Get the coordinates on the cell as well as the clicked path.
-        int cell_x;
-        int cell_y;
-        Gtk.TreePath? path;
-        get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y);
-
-        // If the user clicked in an empty area, do nothing.
-        if (path == null)
-            return false;
-
-        // Handle clicks to toggle read and starred status.
-        if ((event.state & Gdk.ModifierType.SHIFT_MASK) == 0 &&
-            (event.state & Gdk.ModifierType.CONTROL_MASK) == 0 &&
-            event.type == Gdk.EventType.BUTTON_PRESS) {
-
-            // Click positions depend on whether the preview is enabled.
-            bool read_clicked = false;
-            bool star_clicked = false;
-            if (this.config.display_preview) {
-                read_clicked = cell_x < 25 && cell_y >= 14 && cell_y <= 30;
-                star_clicked = cell_x < 25 && cell_y >= 40 && cell_y <= 62;
-            } else {
-                read_clicked = cell_x < 25 && cell_y >= 8 && cell_y <= 22;
-                star_clicked = cell_x < 25 && cell_y >= 28 && cell_y <= 43;
-            }
 
-            // Get the current conversation.  If it's selected, we'll apply the mark operation to
-            // all selected conversations; otherwise, it just applies to this one.
-            Geary.App.Conversation conversation = get_model().get_conversation_at_path(path);
-            Gee.Collection<Geary.App.Conversation> to_mark = (
-                this.selected.contains(conversation)
-                ? copy_selected()
-                : Geary.Collection.single(conversation)
-            );
-
-            if (read_clicked) {
-                mark_conversations(to_mark, Geary.EmailFlags.UNREAD);
-                return true;
-            } else if (star_clicked) {
-                mark_conversations(to_mark, Geary.EmailFlags.FLAGGED);
-                return true;
-            }
+    // --------------------
+    //  Right-click Popup
+    // --------------------
+    private void context_menu(Row row, Gdk.Rectangle? rect=null) {
+        if (!row.is_selected()) {
+            this.list.unselect_all();
+            this.list.select_row(row);
         }
 
-        // Check if changing the selection will require any composers
-        // to be closed, but only on the first click of a
-        // double/triple click, so that double-clicking a draft
-        // doesn't attempt to load it then close it straight away.
-        if (event.type == Gdk.EventType.BUTTON_PRESS &&
-            !get_selection().path_is_selected(path)) {
-            var parent = get_toplevel() as Application.MainWindow;
-            if (parent != null && !parent.close_composer(false)) {
-                return true;
-            }
+        var popup_menu = construct_popover(row, this.list.get_selected_rows().length());
+        if (rect != null) {
+            popup_menu.set_pointing_to(rect);
         }
+        popup_menu.popup();
+    }
 
-        if (event.button == 3 && event.type == Gdk.EventType.BUTTON_PRESS) {
-            Geary.App.Conversation conversation = get_model().get_conversation_at_path(path);
-
-            GLib.Menu context_menu_model = new GLib.Menu();
-            var main = get_toplevel() as Application.MainWindow;
-            if (main != null) {
-                if (!main.is_shift_down) {
-                    context_menu_model.append(
-                        /// Translators: Context menu item
-                        ngettext(
-                            "Move conversation to _Trash",
-                            "Move conversations to _Trash",
-                            this.selected.size
-                        ),
-                        Action.Window.prefix(
-                            Application.MainWindow.ACTION_TRASH_CONVERSATION
-                        )
-                    );
-                } else {
-                    context_menu_model.append(
-                        /// Translators: Context menu item
-                        ngettext(
-                            "_Delete conversation",
-                            "_Delete conversations",
-                            this.selected.size
-                        ),
-                        Action.Window.prefix(
-                            Application.MainWindow.ACTION_DELETE_CONVERSATION
-                        )
-                    );
-                }
-            }
-
-            if (conversation.is_unread())
-                context_menu_model.append(
-                    _("Mark as _Read"),
-                    Action.Window.prefix(
-                        Application.MainWindow.ACTION_MARK_AS_READ
-                    )
-                );
-
-            if (conversation.has_any_read_message())
-                context_menu_model.append(
-                    _("Mark as _Unread"),
-                    Action.Window.prefix(
-                        Application.MainWindow.ACTION_MARK_AS_UNREAD
-                    )
-                );
+    private Gtk.Popover construct_popover(Row row, uint selection_size) {
+        GLib.Menu context_menu_model = new GLib.Menu();
+        var main = get_toplevel() as Application.MainWindow;
 
-            if (conversation.is_flagged()) {
+        if (main != null) {
+            if (!main.is_shift_down) {
                 context_menu_model.append(
-                    _("U_nstar"),
+                    /// Translators: Context menu item
+                    ngettext(
+                        "Move conversation to _Trash",
+                        "Move conversations to _Trash",
+                        selection_size
+                    ),
                     Action.Window.prefix(
-                        Application.MainWindow.ACTION_MARK_AS_UNSTARRED
+                        Application.MainWindow.ACTION_TRASH_CONVERSATION
                     )
                 );
             } else {
                 context_menu_model.append(
-                    _("_Star"),
-                    Action.Window.prefix(
-                        Application.MainWindow.ACTION_MARK_AS_STARRED
-                    )
-                );
-            }
-            if ((conversation.base_folder.used_as != ARCHIVE) && (conversation.base_folder.used_as != 
ALL_MAIL)) {
-                context_menu_model.append(
-                    _("Archive conversation"),
+                    /// Translators: Context menu item
+                    ngettext(
+                        "_Delete conversation",
+                        "_Delete conversations",
+                        selection_size
+                    ),
                     Action.Window.prefix(
-                        Application.MainWindow.ACTION_ARCHIVE_CONVERSATION
+                        Application.MainWindow.ACTION_DELETE_CONVERSATION
                     )
                 );
             }
+        }
 
-            Menu actions_section = new Menu();
-            actions_section.append(
-                _("_Reply"),
+        if (row.conversation.is_unread()) {
+            context_menu_model.append(
+                _("Mark as _Read"),
                 Action.Window.prefix(
-                    Application.MainWindow.ACTION_REPLY_CONVERSATION
+                    Application.MainWindow.ACTION_MARK_AS_READ
                 )
             );
-            actions_section.append(
-                _("R_eply All"),
+        }
+
+        if (row.conversation.has_any_read_message()) {
+            context_menu_model.append(
+                _("Mark as _Unread"),
                 Action.Window.prefix(
-                    Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION
+                    Application.MainWindow.ACTION_MARK_AS_UNREAD
                 )
             );
-            actions_section.append(
-                _("_Forward"),
+        }
+
+        if (row.conversation.is_flagged()) {
+            context_menu_model.append(
+                _("U_nstar"),
                 Action.Window.prefix(
-                    Application.MainWindow.ACTION_FORWARD_CONVERSATION
+                    Application.MainWindow.ACTION_MARK_AS_UNSTARRED
                 )
             );
-            context_menu_model.append_section(null, actions_section);
-
-            // Use a popover rather than a regular context menu since
-            // the latter grabs the event queue, so the MainWindow
-            // will not receive events if the user releases Shift,
-            // making the trash/delete header bar state wrong.
-            Gtk.Popover context_menu = new Gtk.Popover.from_model(
-                this, context_menu_model
+        } else {
+            context_menu_model.append(
+                _("_Star"),
+                Action.Window.prefix(
+                    Application.MainWindow.ACTION_MARK_AS_STARRED
+                )
             );
-            Gdk.Rectangle dest = Gdk.Rectangle();
-            dest.x = (int) event.x;
-            dest.y = (int) event.y;
-            context_menu.set_pointing_to(dest);
-            context_menu.popup();
-
-            // When the conversation under the mouse is selected, stop event propagation
-            return get_selection().path_is_selected(path);
         }
 
-        return false;
-    }
+        if ((row.conversation.base_folder.used_as != ARCHIVE) &&
+            (row.conversation.base_folder.used_as != ALL_MAIL)) {
+                context_menu_model.append(
+                    ngettext(
+                        "_Archive conversation",
+                        "_Archive conversations",
+                        selection_size
+                    ),
+                    Action.Window.prefix(
+                        Application.MainWindow.ACTION_ARCHIVE_CONVERSATION
+                    )
+                );
+        }
 
-    private void on_style_changed() {
-        // Recalculate dimensions of child cells.
-        ConversationListCellRenderer.style_changed(this);
+        Menu actions_section = new Menu();
+        actions_section.append(
+            _("_Reply"),
+            Action.Window.prefix(
+                Application.MainWindow.ACTION_REPLY_CONVERSATION
+            )
+        );
+        actions_section.append(
+            _("R_eply All"),
+            Action.Window.prefix(
+                Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION
+            )
+        );
+        actions_section.append(
+            _("_Forward"),
+            Action.Window.prefix(
+                Application.MainWindow.ACTION_FORWARD_CONVERSATION
+            )
+        );
+        context_menu_model.append_section(null, actions_section);
+
+        // Use a popover rather than a regular context menu since
+        // the latter grabs the event queue, so the MainWindow
+        // will not receive events if the user releases Shift,
+        // making the trash/delete header bar state wrong.
+        Gtk.Popover context_menu = new Gtk.Popover.from_model(
+            row, context_menu_model
+        );
+
+        return context_menu;
+    }
+
+    // -------------------
+    //  Selection
+    // -------------------
+
+    /**
+     * Emitted when one or more conversations are selected
+     */
+    public signal void conversations_selected(Gee.Set<Geary.App.Conversation> selected, bool 
is_interactive=true);
+
+    /**
+     * Emitted when one or more conversations are activated
+     */
+    public signal void conversation_alt_action(Geary.App.Conversation activated);
+
+    /**
+     * Gets the conversations represented by the current selection in the ListBox
+     */
+    public Gee.Set<Geary.App.Conversation> get_selected() {
+        var selected = new Gee.HashSet<Geary.App.Conversation>();
 
-        schedule_visible_conversations_changed();
+        foreach (var row in this.list.get_selected_rows()) {
+            selected.add(((Row) row).conversation);
+        }
+        return selected;
+    }
+
+    /**
+     * Selects the rows for a given collection of conversations
+     *
+     * If a conversation is not present in the ListBox, it is ignored.
+     */
+    public void select_conversations(Gee.Collection<Geary.App.Conversation> selection) {
+        this.list.foreach((child) => {
+            var row = (Row) child;
+            Geary.App.Conversation conversation = row.conversation;
+            if (selection.contains(conversation)) {
+                this.list.select_row(row);
+            }
+        });
     }
 
-    private void on_value_changed() {
-        if (this.enable_load_more) {
-            check_load_more();
-        }
+    /**
+     * Unselects all conversations
+     */
+    public void unselect_all() {
+        this.list.unselect_all();
     }
 
-    private static Gtk.TreeViewColumn create_column(ConversationListStore.Column column,
-        Gtk.CellRenderer renderer, string attr, int width = 0) {
-        Gtk.TreeViewColumn view_column = new Gtk.TreeViewColumn.with_attributes(column.to_string(),
-            renderer, attr, column);
-        view_column.set_resizable(true);
+    // -----------------
+    //  Button Actions
+    // ----------------
 
-        if (width != 0) {
-            view_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED);
-            view_column.set_fixed_width(width);
+    /**
+     * Emitted when the user expresses intent to update the flags on a set of conversations
+     */
+    public signal void mark_conversations(Gee.Collection<Geary.App.Conversation> conversations,
+                                          Geary.NamedFlag flag);
+
+
+    private void on_toggle_flags(ConversationList.Row row, Geary.NamedFlag flag) {
+        if (row.is_selected()) {
+            mark_conversations(get_selected(), flag);
+        } else {
+            mark_conversations(Geary.Collection.single(row.conversation), flag);
         }
+    }
+
+    // ----------------
+    //  Visibility
+    // ---------------
 
-        return view_column;
+    /**
+     * If the number of pixels between the bottom of the viewport and the bottom of
+     * of the listbox is less than LOAD_MORE_THRESHOLD, request more from the
+     * monitor.
+     */
+    private double LOAD_MORE_THRESHOLD = 100;
+    private int LOAD_MORE_COUNT = 50;
+
+    /**
+     * Called on scroll to possibly load more conversations from the model
+     */
+    private void maybe_load_more(Gtk.Adjustment adjustment) {
+        double upper = adjustment.get_upper();
+        double threshold = upper - adjustment.page_size - LOAD_MORE_THRESHOLD;
+
+        if (this.is_visible() && adjustment.get_value() >= threshold) {
+            this.load_more(LOAD_MORE_COUNT);
+        }
     }
 
-    private List<Gtk.TreePath> get_all_selected_paths() {
-        Gtk.TreeModel model;
-        return get_selection().get_selected_rows(out model);
+    /**
+     * Time in milliseconds to delay updating the set of visible conversations.
+     * If another update is triggered during this delay, it will be discarded
+     * and the delay begins again.
+     */
+    private int VISIBILITY_UPDATE_DELAY_MS = 1000;
+
+       /**
+        * The set of all conversations currently displayed in the viewport
+        */
+    public Gee.Set<Geary.App.Conversation> visible_conversations {get; private set; default = new 
Gee.HashSet<Geary.App.Conversation>(); }
+    private Geary.Scheduler.Scheduled? scheduled_visible_update;
+
+    /**
+     * Called on scroll to update the set of visible conversations
+     */
+    private void update_visible_conversations() {
+        if(scheduled_visible_update != null) {
+            scheduled_visible_update.cancel();
+        }
+
+        scheduled_visible_update = Geary.Scheduler.after_msec(VISIBILITY_UPDATE_DELAY_MS, () => {
+            var visible = new Gee.HashSet<Geary.App.Conversation>();
+            Gtk.ListBoxRow? first = this.list.get_row_at_y((int) this.vadjustment.value);
+
+            if (first == null) {
+                this.visible_conversations = visible;
+                return Source.REMOVE;
+            }
+
+            uint start_index = ((uint) first.get_index());
+            uint end_index = uint.min(
+                // Assume that all messages are the same height
+                start_index + (uint) (this.vadjustment.page_size / first.get_allocated_height()),
+                this.model.store.get_n_items()
+            );
+
+            for (uint i = start_index; i < end_index; i++) {
+                visible_conversations.add(
+                    this.model.store.get_item(i) as Geary.App.Conversation
+                );
+            }
+
+            this.visible_conversations = visible;
+            return Source.REMOVE;
+        }, GLib.Priority.DEFAULT_IDLE);
     }
 
-    private void on_selection_changed() {
-        // Schedule processing selection changes at low idle for
-        // two reasons: (a) if a lot of changes come in
-        // back-to-back, this allows for all that activity to
-        // settle before updating state and firing signals (which
-        // results in a lot of I/O), and (b) it means the
-        // ConversationMonitor's signals may be processed in any
-        // order by this class and the ConversationListView and
-        // not result in a lot of screen flashing and (again)
-        // unnecessary I/O as both classes update selection state.
-        this.selection_update.schedule();
+    // ------------
+    // Model
+    // ------------
+    private bool should_inhibit_autoactivate = false;
+
+    /**
+     * Informs the listbox to suppress autoactivate behavior on the next update
+     */
+    public void inhibit_next_autoselect() {
+        should_inhibit_autoactivate = true;
     }
 
-    // Gtk.TreeSelection can fire its "changed" signal even when
-    // nothing's changed, so look for that to avoid subscribers from
-    // doing the same things (in particular, I/O) multiple times
-    private void do_selection_changed() {
-        Gee.HashSet<Geary.App.Conversation> new_selection =
-            new Gee.HashSet<Geary.App.Conversation>();
-        List<Gtk.TreePath> paths = get_all_selected_paths();
-        if (paths.length() != 0) {
-            // Conversations are selected, so collect them and
-            // signal if different
-            foreach (Gtk.TreePath path in paths) {
-                Geary.App.Conversation? conversation =
-                get_model().get_conversation_at_path(path);
-                if (conversation != null)
-                    new_selection.add(conversation);
+    /**
+     * Find a selectable conversation near current selection
+     */
+    private Gtk.ListBoxRow? get_next_conversation(bool asc=true) {
+        int index = asc ? 0 : int.MAX;
+
+        foreach (Gtk.ListBoxRow row in this.list.get_selected_rows().copy()) {
+            if ((asc && row.get_index() > index) ||
+                (!asc && row.get_index() < index)) {
+                index = row.get_index();
             }
         }
-
-        // only notify if different than what was previously reported
-        if (this.selected.size != new_selection.size ||
-            !this.selected.contains_all(new_selection)) {
-            this.selected = new_selection;
-            conversations_selected(this.selected.read_only_view);
+        if (asc) {
+            index += 1;
+        } else {
+            index -= 1;
         }
+        Gtk.ListBoxRow? row = this.list.get_row_at_index(index);
+        return row != null || !asc ? row : get_next_conversation(false);
     }
 
-    public Gee.Set<Geary.App.Conversation> get_visible_conversations() {
-        Gee.HashSet<Geary.App.Conversation> visible_conversations = new 
Gee.HashSet<Geary.App.Conversation>();
-
-        Gtk.TreePath start_path;
-        Gtk.TreePath end_path;
-        if (!get_visible_range(out start_path, out end_path))
-            return visible_conversations;
-
-        while (start_path.compare(end_path) <= 0) {
-            Geary.App.Conversation? conversation = get_model().get_conversation_at_path(start_path);
-            if (conversation != null)
-                visible_conversations.add(conversation);
+    private void on_conversations_loaded() {
+        if (this.config.autoselect &&
+            !this.should_inhibit_autoactivate &&
+            this.list.get_selected_rows().length() == 0) {
 
-            start_path.next();
+            Gtk.ListBoxRow first_row = this.list.get_row_at_index(0);
+            if (first_row != null) {
+                this.list.select_row(first_row);
+                conversations_selected(get_selected(), true);
+            }
         }
 
-        return visible_conversations;
+        this.should_inhibit_autoactivate = false;
     }
 
-    // Always returns false, so it can be used as a one-time SourceFunc
-    private bool update_visible_conversations() {
-        bool changed = false;
-        Gee.Set<Geary.App.Conversation> visible_conversations = get_visible_conversations();
-        if (this.current_visible_conversations == null ||
-            this.current_visible_conversations.size != visible_conversations.size ||
-            !this.current_visible_conversations.contains_all(visible_conversations)) {
-            this.current_visible_conversations = visible_conversations;
-            visible_conversations_changed(
-                this.current_visible_conversations.read_only_view
-            );
-            changed = true;
+    /*
+     * Select next conversation
+     */
+    private void on_conversations_removed(bool start) {
+        // Before model update, just find a conversation
+        if (start) {
+            this.to_restore_row = get_next_conversation();
+        // If in selection mode, leaving will do the job
+        } else if (this.selection_mode_enabled) {
+            this.selection_mode_enabled = false;
+        // Set next conversation
+        } else if (this.to_restore_row != null) {
+            this.list.select_row(this.to_restore_row);
+            conversations_selected(get_selected(), false);
+            this.to_restore_row.grab_focus();
+            this.to_restore_row = null;
         }
-        return changed;
     }
 
-    private void schedule_visible_conversations_changed() {
-        scheduled_update_visible_conversations = Geary.Scheduler.on_idle(update_visible_conversations);
-    }
 
-    public void select_conversations(Gee.Collection<Geary.App.Conversation> new_selection) {
-        if (this.selected.size != new_selection.size ||
-            !this.selected.contains_all(new_selection)) {
-            var selection = get_selection();
-            selection.unselect_all();
-            var model = get_model();
-            if (model != null) {
-                foreach (var conversation in new_selection) {
-                    var path = model.get_path_for_conversation(conversation);
-                    if (path != null) {
-                        selection.select_path(path);
-                    }
+    // ----------
+    // Gestures
+    // ----------
+
+    private void on_press_gesture_released(int n_press, double x, double y) {
+        if (this.press_gesture.get_current_button() == 1) {
+            Gdk.EventSequence sequence = this.press_gesture.get_current_sequence();
+            Gdk.Event event = this.press_gesture.get_last_event(sequence);
+            Gdk.ModifierType modifier_type;
+            event.get_state(out modifier_type);
+            // Do shift range selection
+            if ((modifier_type & Gdk.ModifierType.SHIFT_MASK) ==
+                    Gdk.ModifierType.SHIFT_MASK) {
+                this.selection_mode_enabled = true;
+            // Do control multiple selection
+            } else if ((modifier_type & Gdk.ModifierType.CONTROL_MASK) ==
+                    Gdk.ModifierType.CONTROL_MASK) {
+                this.selection_mode_enabled = true;
+            }
+        } else {
+            Row? row = (Row) this.list.get_row_at_y((int) y);
+            if (row != null) {
+                if (this.press_gesture.get_current_button() == 2) {
+                    conversation_alt_action(row.conversation);
+                } else if (this.press_gesture.get_current_button() == 3) {
+                    var rect = Gdk.Rectangle();
+                    row.translate_coordinates(this.list, 0, 0, out rect.x, out rect.y);
+                    rect.x = (int) x;
+                    rect.y = (int) y - rect.y;
+                    rect.width = rect.height = 0;
+                    context_menu(row, rect);
                 }
             }
         }
     }
 
-    private void on_rows_changed() {
-        schedule_visible_conversations_changed();
+    private bool on_key_event_controller_key_pressed(uint keyval, uint keycode, Gdk.ModifierType 
modifier_type) {
+        switch (keyval) {
+        case Gdk.Key.Up:
+        case Gdk.Key.Down:
+            if ((modifier_type & Gdk.ModifierType.SHIFT_MASK) ==
+                    Gdk.ModifierType.SHIFT_MASK) {
+                this.selection_mode_enabled = true;
+            }
+            break;
+        case Gdk.Key.Escape:
+            if (this.selection_mode_enabled) {
+                this.selection_mode_enabled = false;
+                return true;
+            }
+            break;
+        }
+        return false;
     }
 
-    private void on_display_preview_changed() {
-        style_updated();
-        model.foreach(refresh_path);
 
-        schedule_visible_conversations_changed();
-    }
+       /**
+        * Widgets used as drag icons have to be explicitly destroyed after the drag
+        * so we track the widget as a private member
+        */
+    private Row? drag_widget = null;
 
-    private bool refresh_path(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) {
-        model.row_changed(path, iter);
-        return false;
-    }
+    private void on_drag_begin(Gdk.DragContext ctx) {
+        int screen_x, screen_y;
+        Gdk.ModifierType _modifier;
 
-    // Enable/disable hover effect on all selected cells.
-    private void set_hover_selected(bool hover) {
-        ConversationListCellRenderer.set_hover_selected(hover);
-        queue_draw();
-    }
+        this.get_window().get_device_position(ctx.get_device(), out screen_x, out screen_y, out _modifier);
 
-    private bool on_motion_notify_event(Gdk.EventMotion event) {
-        if (get_selection().count_selected_rows() > 0) {
-            Gtk.TreePath? path = null;
-            int cell_x, cell_y;
-            get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y);
+        // If the user has a selection but drags starting from an unselected
+        // row, we need to set the selection to that row
+        Row? row = this.list.get_row_at_y(screen_y + (int) this.vadjustment.value) as Row?;
+        if (row != null && !row.is_selected()) {
+            this.list.unselect_all();
+            this.list.select_row(row);
+        }
 
-            set_hover_selected(path != null && get_selection().path_is_selected(path));
+        this.drag_widget = new Row(this.config, row.conversation, false);
+        this.drag_widget.width_request = row.get_allocated_width();
+        this.drag_widget.get_style_context().add_class("drag-n-drop");
+        this.drag_widget.visible = true;
+
+        int hot_x, hot_y;
+        this.translate_coordinates(row, screen_x, screen_y, out hot_x, out hot_y);
+        Gtk.drag_set_icon_widget(ctx, this.drag_widget, hot_x, hot_y);
+    }
+
+    private void on_drag_end(Gdk.DragContext ctx) {
+        if (this.drag_widget != null) {
+            this.drag_widget.destroy();
+            this.drag_widget = null;
         }
-        return Gdk.EVENT_PROPAGATE;
     }
 
-    private bool on_leave_notify_event() {
-        if (get_selection().count_selected_rows() > 0) {
-            set_hover_selected(false);
+    private void on_selected_rows_changed() {
+        GLib.List<unowned Gtk.ListBoxRow> rows = this.list.get_selected_rows();
+
+        if (rows.length() > 1) {
+            this.selection_mode_enabled = true;
         }
-        return Gdk.EVENT_PROPAGATE;
 
+        if (this.selection_mode_enabled) {
+            conversations_selected(get_selected(), false);
+        }
     }
 
-    private void on_vadjustment_changed() {
-        this.vadjustment.value_changed.connect(on_value_changed);
+    private void on_row_activated() {
+        if (!this.selection_mode_enabled) {
+            conversations_selected(get_selected());
+        }
     }
 
+    private void on_selection_mode_changed() {
+        if (this.list.get_activate_on_single_click() != this.selection_mode_enabled) {
+            return;
+        }
+
+        this.list.foreach((child) => {
+            var row = (Row) child;
+            row.set_selection_enabled(this.selection_mode_enabled);
+        });
+
+        if (this.selection_mode_enabled) {
+            this.to_restore_row = this.list.get_selected_row();
+            this.list.set_selection_mode(Gtk.SelectionMode.MULTIPLE);
+            this.list.set_activate_on_single_click(false);
+        } else {
+            this.list.set_selection_mode(Gtk.SelectionMode.SINGLE);
+            this.list.set_activate_on_single_click(true);
+            this.list.unselect_all();
+            if (this.to_restore_row != null) {
+                this.list.select_row(this.to_restore_row);
+                this.to_restore_row.grab_focus();
+                conversations_selected(get_selected(), false);
+                this.to_restore_row = null;
+            }
+        }
+    }
 }
diff --git a/src/client/conversation-viewer/conversation-list-box.vala.orig 
b/src/client/conversation-viewer/conversation-list-box.vala.orig
new file mode 100644
index 000000000..b8a7c0643
--- /dev/null
+++ b/src/client/conversation-viewer/conversation-list-box.vala.orig
@@ -0,0 +1,1593 @@
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2016,2019 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, Geary.BaseInterface {
+
+    /** Fields that must be available for listing conversation email. */
+    public const Geary.Email.Field REQUIRED_FIELDS = (
+        // Sorting the conversation
+        Geary.Email.Field.DATE |
+        // Determine unread/starred, etc
+        Geary.Email.Field.FLAGS |
+        // Determine if the message is from the sender or not
+        Geary.Email.Field.ORIGINATORS
+    );
+
+    internal const string EMAIL_ACTION_GROUP_NAME = "eml";
+
+    internal const string ACTION_DELETE = "delete";
+    internal const string ACTION_FORWARD = "forward";
+    internal const string ACTION_MARK_LOAD_REMOTE = "mark-load-remote";
+    internal const string ACTION_MARK_READ = "mark-read";
+    internal const string ACTION_MARK_STARRED = "mark-starred";
+    internal const string ACTION_MARK_UNREAD = "mark-unread";
+    internal const string ACTION_MARK_UNREAD_DOWN = "mark-unread-down";
+    internal const string ACTION_MARK_UNSTARRED = "mark-unstarred";
+    internal const string ACTION_PRINT = "print";
+    internal const string ACTION_REPLY_ALL = "reply-all";
+    internal const string ACTION_REPLY_SENDER = "reply-sender";
+    internal const string ACTION_SAVE_ALL_ATTACHMENTS = "save-all-attachments";
+    internal const string ACTION_TRASH = "trash";
+    internal const string ACTION_VIEW_SOURCE = "view-source";
+
+    // Offset from the top of the list box which emails views will
+    // scrolled to, so the user can see there are additional messages
+    // above it. XXX This is currently approx 0.5 times the height of
+    // 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 = 32;
+
+    // Amount of time to wait after the user took some action that may
+    // be interpreted as marking the email as read before actually
+    // checking
+    private const int MARK_READ_TIMEOUT_MSEC = 250;
+
+    // Amount of pixels that need to be shown of an email's body to
+    // mark it as read
+    private const int MARK_READ_PADDING = 50;
+
+    private const string ACTION_TARGET_TYPE = (
+        Geary.EmailIdentifier.BASE_VARIANT_TYPE
+    );
+    private const ActionEntry[] email_action_entries = {
+        { ACTION_DELETE, on_email_delete, ACTION_TARGET_TYPE },
+        { ACTION_FORWARD, on_email_forward, ACTION_TARGET_TYPE },
+        { ACTION_MARK_LOAD_REMOTE, on_email_load_remote, ACTION_TARGET_TYPE },
+        { ACTION_MARK_READ, on_email_mark_read, ACTION_TARGET_TYPE },
+        { ACTION_MARK_STARRED, on_email_mark_starred, ACTION_TARGET_TYPE },
+        { ACTION_MARK_UNREAD, on_email_mark_unread, ACTION_TARGET_TYPE },
+        { ACTION_MARK_UNREAD_DOWN, on_email_mark_unread_down, ACTION_TARGET_TYPE },
+        { ACTION_MARK_UNSTARRED, on_email_mark_unstarred, ACTION_TARGET_TYPE },
+        { ACTION_PRINT, on_email_print, ACTION_TARGET_TYPE },
+        { ACTION_REPLY_ALL, on_email_reply_all, ACTION_TARGET_TYPE },
+        { ACTION_REPLY_SENDER, on_email_reply_sender, ACTION_TARGET_TYPE },
+        { ACTION_SAVE_ALL_ATTACHMENTS, on_email_save_all_attachments, ACTION_TARGET_TYPE },
+        { ACTION_TRASH, on_email_trash, ACTION_TARGET_TYPE },
+        { ACTION_VIEW_SOURCE, on_email_view_source, ACTION_TARGET_TYPE },
+    };
+
+
+    /** Manages find/search term matching in a conversation. */
+    public class SearchManager : Geary.BaseObject {
+
+
+        // The list that owns this manager
+        private weak ConversationListBox list;
+
+        // Conversation being managed
+        private Geary.App.Conversation conversation;
+
+        // Cached search terms to apply to new messages
+        private Gee.Set<string>? terms = null;
+
+        // Total number of search matches found
+        private uint matches_found = 0;
+
+        // Cancellable used when highlighting search matches
+        private GLib.Cancellable highlight_cancellable = new GLib.Cancellable();
+
+
+        /** Fired when the number of matching emails has changed. */
+        public signal void matches_updated(uint matches);
+
+
+        internal SearchManager(ConversationListBox list,
+                               Geary.App.Conversation conversation) {
+            this.list = list;
+            this.conversation = conversation;
+        }
+
+
+        /**
+         * Loads search term matches for this list's emails.
+         */
+        public async void highlight_matching_email(Geary.SearchQuery query,
+                                                   bool enable_scroll)
+            throws GLib.Error {
+            cancel();
+
+            // Keep a copy of the current cancellable so it can't get
+            // changed out from underneath the execution of this method
+            GLib.Cancellable cancellable = this.highlight_cancellable;
+
+            Geary.Account account = this.conversation.base_folder.account;
+            Gee.Collection<Geary.EmailIdentifier>? matching =
+            yield account.local_search_async(
+                query,
+                this.conversation.get_count(),
+                0,
+                null,
+                this.conversation.get_email_ids(),
+                cancellable
+            );
+
+            if (matching != null) {
+                Gee.Set<string>? terms =
+                    yield account.get_search_matches_async(
+                        query, matching, cancellable
+                    );
+
+                if (cancellable.is_cancelled()) {
+                    throw new GLib.IOError.CANCELLED(
+                        "Search term highlighting cancelled"
+                    );
+                }
+
+                if (terms != null && !terms.is_empty) {
+                    this.terms = terms;
+
+                    // Scroll to the first matching row first
+                    EmailRow? first = null;
+                    foreach (Geary.EmailIdentifier id in matching) {
+                        EmailRow? row = this.list.get_email_row_by_id(id);
+                        if (row != null &&
+                            (first == null || row.get_index() < first.get_index())) {
+                            first = row;
+                        }
+                    }
+                    if (first != null && enable_scroll) {
+                        this.list.scroll_to_row(first);
+                    }
+
+                    // Now expand them all
+                    foreach (Geary.EmailIdentifier id in matching) {
+                        EmailRow? row = this.list.get_email_row_by_id(id);
+                        if (row != null) {
+                            apply_terms(row, terms, cancellable);
+                            row.expand.begin();
+                        }
+                    }
+                }
+            }
+        }
+
+        /**
+         * Highlights matching terms in the given email row, if any.
+         */
+        internal void highlight_row_if_matching(EmailRow row) {
+            if (this.terms != null) {
+                apply_terms(row, this.terms, this.highlight_cancellable);
+            }
+        }
+
+        /**
+         * Removes search term highlighting from all messages.
+         */
+        public void unmark_terms() {
+            cancel();
+
+            this.list.foreach((child) => {
+                    EmailRow? row = child as EmailRow;
+                    if (row != null) {
+                        if (row.is_search_match) {
+                            row.is_search_match = false;
+                            foreach (ConversationMessage msg_view in row.view) {
+                                msg_view.unmark_search_terms();
+                            }
+                        }
+                    }
+                });
+        }
+
+        public void cancel() {
+            this.highlight_cancellable.cancel();
+            this.highlight_cancellable = new Cancellable();
+            this.terms = null;
+            this.matches_found = 0;
+            notify_matches_updated();
+        }
+
+        private void apply_terms(EmailRow row,
+                                 Gee.Set<string>? terms,
+                                 GLib.Cancellable cancellable) {
+            if (row.view.message_body_state == COMPLETED) {
+                this.apply_terms_impl.begin(
+                    row, terms, cancellable, apply_terms_impl_finished
+                );
+            } else {
+                row.view.notify["message-body-state"].connect(() => {
+                        this.apply_terms_impl.begin(
+                            row, terms, cancellable, apply_terms_impl_finished
+                        );
+                    });
+            }
+        }
+
+        // This should only be called from apply_terms above
+        private async uint apply_terms_impl(EmailRow row,
+                                            Gee.Set<string>? terms,
+                                            GLib.Cancellable cancellable)
+            throws GLib.IOError.CANCELLED {
+            uint count = 0;
+            foreach (ConversationMessage view in row.view) {
+                if (cancellable.is_cancelled()) {
+                    throw new GLib.IOError.CANCELLED(
+                        "Applying search terms cancelled"
+                    );
+                }
+                count += yield view.highlight_search_terms(terms, cancellable);
+            }
+
+            row.is_search_match = (count > 0);
+            return count;
+        }
+
+        private void apply_terms_impl_finished(GLib.Object? obj,
+                                               GLib.AsyncResult res) {
+            try {
+                this.matches_found += this.apply_terms_impl.end(res);
+                notify_matches_updated();
+            } catch (GLib.IOError.CANCELLED err) {
+                // All good
+            }
+        }
+
+        private inline void notify_matches_updated() {
+            matches_updated(this.matches_found);
+        }
+
+    }
+
+
+    // Base class for list rows in the list box
+    internal abstract class ConversationRow : Gtk.ListBoxRow, Geary.BaseInterface {
+
+
+        protected const string EXPANDED_CLASS = "geary-expanded";
+
+
+        // The email being displayed by this row, if any
+        public Geary.Email? email { get; private set; default = null; }
+
+        // Is the row showing the email's message body or just headers?
+        public bool is_expanded {
+            get {
+                return this._is_expanded;
+            }
+            protected set {
+                this._is_expanded = value;
+                notify_property("is-expanded");
+            }
+        }
+        private bool _is_expanded = false;
+
+
+        // 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();
+
+        // Emitted when an email is loaded for the first time
+        public signal void email_loaded(Geary.Email email);
+
+
+        protected ConversationRow(Geary.Email? email) {
+            base_ref();
+            this.email = email;
+            notify["is-expanded"].connect(update_css_class);
+            show();
+        }
+
+        ~ConversationRow() {
+            base_unref();
+        }
+
+        // Request the row be expanded, if supported.
+        public virtual new async void expand()
+            throws GLib.Error {
+            // Not supported by default
+        }
+
+        // Request the row be collapsed, if supported.
+        public virtual void collapse() {
+            // Not supported by default
+        }
+
+        // Enables firing the should_scroll signal when this row is
+        // allocated a size
+        public void enable_should_scroll() {
+            this.size_allocate.connect(on_size_allocate);
+        }
+
+        private void update_css_class() {
+            if (this.is_expanded)
+                get_style_context().add_class(EXPANDED_CLASS);
+            else
+                get_style_context().remove_class(EXPANDED_CLASS);
+
+            update_previous_sibling_css_class();
+        }
+
+        // This is mostly taken form libhandy HdyExpanderRow
+        private Gtk.Widget? get_previous_sibling() {
+            if (this.parent is Gtk.Container) {
+                var siblings = this.parent.get_children();
+                unowned List<weak Gtk.Widget> l;
+                for (l = siblings; l != null && l.next != null && l.next.data != this; l = l.next);
+
+                if (l != null && l.next != null && l.next.data == this) {
+                    return l.data;
+                }
+            }
+
+            return null;
+        }
+
+        private void update_previous_sibling_css_class() {
+            var previous_sibling = get_previous_sibling();
+            if (previous_sibling != null) {
+                if (this.is_expanded)
+                    previous_sibling.get_style_context().add_class("geary-expanded-previous-sibling");
+                else
+                    previous_sibling.get_style_context().remove_class("geary-expanded-previous-sibling");
+            }
+        }
+
+        protected inline void set_style_context_class(string class_name, bool value) {
+            if (value) {
+                get_style_context().add_class(class_name);
+            } else {
+                get_style_context().remove_class(class_name);
+            }
+        }
+
+        protected void on_size_allocate() {
+            // Disable should_scroll so we don't keep on scrolling
+            // later, like when the window has been resized.
+            this.size_allocate.disconnect(on_size_allocate);
+            should_scroll();
+        }
+
+    }
+
+
+    // Displays a single ConversationEmail in the list box
+    internal class EmailRow : ConversationRow {
+
+
+        private const string MATCH_CLASS = "geary-matched";
+
+
+        // Has the row been temporarily expanded to show search matches?
+        public bool is_pinned { get; private set; default = false; }
+
+        // Does the row contain an email matching the current search?
+        public bool is_search_match {
+            get { return get_style_context().has_class(MATCH_CLASS); }
+            set {
+                set_style_context_class(MATCH_CLASS, value);
+                this.is_pinned = value;
+                update_row_expansion();
+            }
+        }
+
+
+        // The email view for this row, if any
+        public ConversationEmail view { get; private set; }
+
+
+        public EmailRow(ConversationEmail view) {
+            base(view.email);
+            this.view = view;
+            add(view);
+        }
+
+        public override async void expand()
+            throws GLib.Error {
+            this.is_expanded = true;
+            update_row_expansion();
+            if (this.view.message_body_state == NOT_STARTED) {
+                yield this.view.load_body();
+                email_loaded(this.view.email);
+            }
+        }
+
+        public override void collapse() {
+            this.is_expanded = false;
+            this.is_pinned = false;
+            update_row_expansion();
+        }
+
+        private inline void update_row_expansion() {
+            if (this.is_expanded || this.is_pinned) {
+                this.view.expand_email();
+            } else {
+                this.view.collapse_email();
+            }
+        }
+
+    }
+
+
+    // Displays a loading widget in the list box
+    internal class LoadingRow : ConversationRow {
+
+
+        protected const string LOADING_CLASS = "geary-loading";
+
+
+        public LoadingRow() {
+            base(null);
+            get_style_context().add_class(LOADING_CLASS);
+
+            Gtk.Spinner spinner = new Gtk.Spinner();
+            spinner.height_request = 16;
+            spinner.width_request = 16;
+            spinner.show();
+            spinner.start();
+            add(spinner);
+        }
+
+    }
+
+
+    // Displays a single embedded composer in the list box
+    internal class ComposerRow : ConversationRow {
+
+        // The embedded composer for this row
+        public Composer.Embed view { get; private set; }
+
+
+        public ComposerRow(Composer.Embed view) {
+            base(view.referred);
+            this.view = view;
+            this.is_expanded = true;
+            add(this.view);
+
+            this.focus_on_click = false;
+        }
+
+    }
+
+
+    static construct {
+        // Set up custom keybindings
+        unowned Gtk.BindingSet bindings = Gtk.BindingSet.by_class(
+            (ObjectClass) typeof(ConversationListBox).class_ref()
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.space, 0, "focus-next", 0
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.KP_Space, 0, "focus-next", 0
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.space, Gdk.ModifierType.SHIFT_MASK, "focus-prev", 0
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.KP_Space, Gdk.ModifierType.SHIFT_MASK, "focus-prev", 0
+        );
+
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.Up, 0, "scroll", 1,
+            typeof(Gtk.ScrollType), Gtk.ScrollType.STEP_UP
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.Down, 0, "scroll", 1,
+            typeof(Gtk.ScrollType), Gtk.ScrollType.STEP_DOWN
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.Page_Up, 0, "scroll", 1,
+            typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_UP
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.Page_Down, 0, "scroll", 1,
+            typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_DOWN
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.Home, 0, "scroll", 1,
+            typeof(Gtk.ScrollType), Gtk.ScrollType.START
+        );
+        Gtk.BindingEntry.add_signal(
+            bindings, Gdk.Key.End, 0, "scroll", 1,
+            typeof(Gtk.ScrollType), Gtk.ScrollType.END
+        );
+    }
+
+    private static int on_sort(Gtk.ListBoxRow row1, Gtk.ListBoxRow row2) {
+        Geary.Email? email1 = ((ConversationRow) row1).email;
+        Geary.Email? email2 = ((ConversationRow) row2).email;
+
+        if (email1 == null) {
+            return 1;
+        }
+        if (email2 == null) {
+            return -1;
+        }
+        return Geary.Email.compare_sent_date_ascending(email1, email2);
+    }
+
+
+    /** Conversation being displayed. */
+    public Geary.App.Conversation conversation { get; private set; }
+
+    /** Search manager for highlighting search terms in this list. */
+    public SearchManager search { get; private set; }
+
+    /** Specifies if this list box currently has an embedded composer. */
+    public bool has_composer {
+        get { return this.current_composer != null; }
+    }
+
+    // Used to load messages in conversation.
+    private Geary.App.EmailStore email_store;
+
+    // Store from which to lookup contacts
+    private Application.ContactStore contacts;
+
+    // App config
+    private Application.Configuration config;
+
+    // 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 rows.
+    private Gee.Map<Geary.EmailIdentifier,EmailRow> email_rows =
+        new Gee.HashMap<Geary.EmailIdentifier,EmailRow>();
+
+    // The current composer, if any
+    private ComposerRow? current_composer = null;
+
+    // The id of the draft referred to by the current composer.
+    private Geary.EmailIdentifier? draft_id = null;
+
+    private bool suppress_mark_timer;
+    private Geary.TimeoutManager mark_read_timer;
+
+    private GLib.SimpleActionGroup email_actions = new GLib.SimpleActionGroup();
+
+
+    /** Keyboard action to scroll the conversation. */
+    [Signal (action=true)]
+    public virtual signal void scroll(Gtk.ScrollType type) {
+
+        // If there is an embedded composer, check to see if one of
+        // its non-web view widgets is focused and give the key press
+        // to that instead. If not, then standard nav
+        var handled = false;
+        var composer = this.current_composer;
+        if (composer != null) {
+            var window = get_toplevel() as Gtk.Window;
+            if (window != null) {
+                var focused = window.get_focus();
+                if (focused != null &&
+                    focused.is_ancestor(composer) &&
+                    !(focused is Composer.WebView)) {
+                    switch (type) {
+                    case Gtk.ScrollType.STEP_UP:
+                        composer.focus(UP);
+                        handled = true;
+                        break;
+                    case Gtk.ScrollType.STEP_DOWN:
+                        composer.focus(DOWN);
+                        handled = true;
+                        break;
+                    default:
+                        // no-op
+                        break;
+                    }
+                }
+            }
+        }
+
+        if (!handled) {
+            Gtk.Adjustment adj = get_adjustment();
+            double value = adj.get_value();
+            switch (type) {
+            case Gtk.ScrollType.STEP_UP:
+                value -= adj.get_step_increment();
+                break;
+            case Gtk.ScrollType.STEP_DOWN:
+                value += adj.get_step_increment();
+                break;
+            case Gtk.ScrollType.PAGE_UP:
+                value -= adj.get_page_increment();
+                break;
+            case Gtk.ScrollType.PAGE_DOWN:
+                value += adj.get_page_increment();
+                break;
+            case Gtk.ScrollType.START:
+                value = 0.0;
+                break;
+            case Gtk.ScrollType.END:
+                value = adj.get_upper();
+                break;
+            default:
+                // no-op
+                break;
+            }
+            adj.set_value(value);
+            this.mark_read_timer.start();
+        }
+    }
+
+    /** Keyboard action to shift focus to the next message, if any. */
+    [Signal (action=true)]
+    public virtual signal void focus_next() {
+        this.move_cursor(Gtk.MovementStep.DISPLAY_LINES, 1);
+        this.mark_read_timer.start();
+    }
+
+    /** Keyboard action to shift focus to the prev message, if any. */
+    [Signal (action=true)]
+    public virtual signal void focus_prev() {
+        this.move_cursor(Gtk.MovementStep.DISPLAY_LINES, -1);
+        this.mark_read_timer.start();
+    }
+
+    /** Fired when an email is fully loaded in the list box. */
+    public signal void email_loaded(Geary.Email email);
+
+    /** Fired when the user clicks "reply" in the message menu. */
+    public signal void reply_to_sender_email(Geary.Email email, string? quote);
+
+    /** Fired when the user clicks "reply all" in the message menu. */
+    public signal void reply_to_all_email(Geary.Email email, string? quote);
+
+    /** Fired when the user clicks "forward" in the message menu. */
+    public signal void forward_email(Geary.Email email, string? quote);
+
+    /** Emitted when email message flags are to be updated. */
+    public signal void mark_email(Gee.Collection<Geary.EmailIdentifier> email,
+                                  Geary.NamedFlag? to_add,
+                                  Geary.NamedFlag? to_remove);
+
+    /** Fired when the user clicks "trash" in the message menu. */
+    public signal void trash_email(Geary.Email email);
+
+    /** Fired when the user clicks "delete" in the message menu. */
+    public signal void delete_email(Geary.Email email);
+
+
+    /**
+     * Constructs a new conversation list box instance.
+     */
+    public ConversationListBox(Geary.App.Conversation conversation,
+                               bool suppress_mark_timer,
+                               Geary.App.EmailStore email_store,
+                               Application.ContactStore contacts,
+                               Application.Configuration config,
+                               Gtk.Adjustment adjustment) {
+        base_ref();
+        this.conversation = conversation;
+        this.email_store = email_store;
+        this.contacts = contacts;
+        this.config = config;
+
+        this.search = new SearchManager(this, conversation);
+
+        this.suppress_mark_timer = suppress_mark_timer;
+        this.mark_read_timer = new Geary.TimeoutManager.milliseconds(
+            MARK_READ_TIMEOUT_MSEC, this.check_mark_read
+        );
+
+        this.selection_mode = NONE;
+
+        get_style_context().add_class("content");
+        get_style_context().add_class("background");
+        get_style_context().add_class("conversation-listbox");
+
+        /* we need to update the previous sibling style class when rows are added or removed */
+        add.connect(update_previous_sibling_css_class);
+        remove.connect(update_previous_sibling_css_class);
+
+        set_adjustment(adjustment);
+        set_sort_func(ConversationListBox.on_sort);
+
+        this.email_actions.add_action_entries(email_action_entries, this);
+        insert_action_group(EMAIL_ACTION_GROUP_NAME, this.email_actions);
+
+        this.row_activated.connect(on_row_activated);
+
+        this.conversation.appended.connect(on_conversation_appended);
+        this.conversation.trimmed.connect(on_conversation_trimmed);
+        this.conversation.email_flags_changed.connect(on_update_flags);
+    }
+
+    ~ConversationListBox() {
+        base_unref();
+    }
+
+    public override void destroy() {
+        this.search.cancel();
+        this.cancellable.cancel();
+        this.email_rows.clear();
+        this.mark_read_timer.reset();
+        base.destroy();
+    }
+
+    // For some reason insert doesn't emit the add event
+    public new void insert(Gtk.Widget child, int position) {
+      base.insert(child, position);
+      update_previous_sibling_css_class();
+    }
+
+    // This is mostly taken form libhandy HdyExpanderRow
+    private void update_previous_sibling_css_class() {
+        var siblings = this.get_children();
+        unowned List<weak Gtk.Widget> l;
+        for (l = siblings; l != null && l.next != null && l.next.data != this; l = l.next) {
+            if (l != null && l.next != null) {
+                var row = l.next.data as ConversationRow;
+                if (row != null) {
+                    if (row.is_expanded) {
+                        l.data.get_style_context().add_class("geary-expanded-previous-sibling");
+                    } else {
+                        l.data.get_style_context().remove_class("geary-expanded-previous-sibling");
+                    }
+                }
+            }
+        }
+    }
+
+    public async void load_conversation(Gee.Collection<Geary.EmailIdentifier> scroll_to,
+                                        Geary.SearchQuery? query)
+        throws GLib.Error {
+        set_sort_func(null);
+
+        Gee.Collection<Geary.Email>? all_email = this.conversation.get_emails(
+            Geary.App.Conversation.Ordering.SENT_DATE_ASCENDING
+        );
+
+        // Work out what the first interesting email is, and load it
+        // before all of the email before and after that so we can
+        // load them in an optimal order.
+        Gee.LinkedList<Geary.Email> uninteresting =
+            new Gee.LinkedList<Geary.Email>();
+        Geary.Email? first_interesting = null;
+        Gee.LinkedList<Geary.Email> post_interesting =
+            new Gee.LinkedList<Geary.Email>();
+
+        if (!scroll_to.is_empty) {
+            var valid_scroll_to = Geary.traverse(scroll_to).filter(
+                id => this.conversation.contains_email_by_id(id)
+            ).to_array_list();
+            valid_scroll_to.sort((a, b) => a.natural_sort_comparator(b));
+            var first_scroll = Geary.Collection.first(valid_scroll_to);
+
+            if (first_scroll != null) {
+                foreach (Geary.Email email in all_email) {
+                    if (first_interesting == null) {
+                        if (email.id == first_scroll) {
+                            first_interesting = email;
+                        } else {
+                            // Inserted reversed so most recent uninteresting
+                            // rows are added first.
+                            uninteresting.insert(0, email);
+                        }
+                    } else {
+                        post_interesting.add(email);
+                    }
+                }
+            }
+        }
+
+        if (first_interesting == null) {
+            foreach (Geary.Email email in all_email) {
+                if (first_interesting == null) {
+                    if (is_interesting(email)) {
+                        first_interesting = email;
+                    } else {
+                        // Inserted reversed so most recent uninteresting
+                        // rows are added first.
+                        uninteresting.insert(0, email);
+                    }
+                } else {
+                    post_interesting.add(email);
+                }
+            }
+        }
+
+        if (first_interesting == null) {
+            // No interesting messages found so use the last one.
+            first_interesting = uninteresting.remove_at(0);
+        }
+        EmailRow interesting_row = add_email(first_interesting);
+
+        // If we have at least one uninteresting and one
+        // post-interesting to load afterwards, show a spinner above
+        // the interesting row to act as a placeholder.
+        if (!uninteresting.is_empty && !post_interesting.is_empty) {
+            insert(new LoadingRow(), 0);
+        }
+
+        // Load the interesting row completely up front, and load the
+        // remaining in the background so we can return fast.
+        yield interesting_row.view.load_contacts();
+        yield interesting_row.expand();
+        this.finish_loading.begin(
+            query, scroll_to.is_empty, uninteresting, post_interesting
+        );
+    }
+
+    /** Cancels loading the current conversation, if still in progress */
+    public void cancel_conversation_load() {
+        this.cancellable.cancel();
+    }
+
+    /** Scrolls to the closest message in the current conversation. */
+    public void scroll_to_messages(Gee.Collection<Geary.EmailIdentifier> targets) {
+        // Get the currently displayed email, allowing for some
+        // padding at the top
+        Gtk.ListBoxRow? current_child = get_row_at_y(32);
+
+        // Find the row currently at the top of the viewport
+        EmailRow? current = null;
+        if (current_child != null) {
+            int pos = current_child.get_index();
+            do {
+                current = current_child as EmailRow;
+                current_child = get_row_at_index(--pos);
+            } while (current == null && pos > 0);
+        }
+
+        EmailRow? best = null;
+        // Find the message closest to the current message, preferring
+        // an earlier one. If there's no current message, the list is
+        // empty and we don't have anything to scroll to anyway.
+        if (current != null) {
+            uint closest_distance = uint.MAX;
+            foreach (var id in targets) {
+                EmailRow? target = this.email_rows[id];
+                if (target != null) {
+                    uint distance = (
+                        current.get_index() - target.get_index()
+                    ).abs();
+                    if (distance < closest_distance ||
+                        (distance == closest_distance &&
+                         Geary.Email.compare_sent_date_ascending(
+                             target.email, best.email
+                         ) < 0)) {
+                        closest_distance = distance;
+                        best = target;
+                    }
+
+                }
+            }
+        }
+
+        if (best != null) {
+            scroll_to_row(best);
+            best.expand.begin();
+        }
+    }
+
+    /**
+     * Returns the email view to be replied to, if any.
+     *
+     * If an email view has a visible body and selected text, that
+     * view will be returned. Else the last message by sort order will
+     * be returned, if any.
+     */
+    public ConversationEmail? get_reply_target() {
+        ConversationEmail? view = get_selection_view();
+        if (view == null) {
+            EmailRow? last = null;
+            this.foreach((child) => {
+                    EmailRow? row = child as EmailRow;
+                    if (row != null) {
+                        last = row;
+                    }
+                });
+
+            if (last != null) {
+                view = last.view;
+            }
+        }
+        return view;
+    }
+
+    /**
+     * Returns the email view with a visible user selection, if any.
+     *
+     * If an email view has selected body text.
+     */
+    public ConversationEmail? get_selection_view() {
+        ConversationEmail? view = this.body_selected_view;
+        if (view != null) {
+            if (view.is_collapsed) {
+                // A collapsed email can't be visible
+                view = null;
+            } else {
+                // XXX check the selected text is actually on screen
+            }
+        }
+        return view;
+    }
+
+    /**
+     * Adds an an embedded composer to the view.
+     */
+    public void add_embedded_composer(Composer.Embed embed, bool is_draft) {
+        if (is_draft) {
+            this.draft_id = embed.referred.id;
+            EmailRow? draft = this.email_rows.get(embed.referred.id);
+            if (draft != null) {
+                remove_email(draft.email);
+            }
+        }
+
+        ComposerRow row = new ComposerRow(embed);
+        row.enable_should_scroll();
+        // Use row param rather than row var from closure to avoid a
+        // circular ref.
+        row.should_scroll.connect((row) => { scroll_to_row(row); });
+        add(row);
+        this.current_composer = row;
+
+        embed.composer.notify["saved-id"].connect(
+            (id) => { this.draft_id = embed.composer.saved_id; }
+        );
+        embed.vanished.connect(() => {
+                this.current_composer = null;
+                this.draft_id = null;
+                remove(row);
+                if (is_draft &&
+                    row.email != null &&
+                    !this.cancellable.is_cancelled()) {
+                    load_full_email.begin(row.email.id);
+                }
+            });
+    }
+
+    /**
+     * Marks all email with a visible body read.
+     */
+    public void mark_visible_read() {
+        this.mark_read_timer.start();
+    }
+
+    /**
+     * Displays an email as being read, regardless of its actual flags.
+     */
+    public void mark_manual_read(Geary.EmailIdentifier id) {
+        EmailRow? row = this.email_rows.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.email_rows.get(id);
+        if (row != null) {
+            row.view.is_manually_read = false;
+        }
+    }
+
+    /** Adds an info bar to the given email, if any. */
+    public void add_email_info_bar(Geary.EmailIdentifier id,
+                                   Components.InfoBar info_bar) {
+        var row = this.email_rows.get(id);
+        if (row != null) {
+            row.view.primary_message.info_bars.add(info_bar);
+        }
+    }
+
+    /** Adds an info bar to the given email, if any. */
+    public void remove_email_info_bar(Geary.EmailIdentifier id,
+                                      Components.InfoBar info_bar) {
+        var row = this.email_rows.get(id);
+        if (row != null) {
+            row.view.primary_message.info_bars.remove(info_bar);
+        }
+    }
+
+    /**
+     * Increases the magnification level used for displaying messages.
+     */
+    public void zoom_in() {
+        message_view_iterator().foreach((msg_view) => {
+                msg_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.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.zoom_reset();
+                return true;
+            });
+    }
+
+    /**
+     * Updates the displayed date for each conversation row.
+     */
+    public void update_display() {
+        message_view_iterator().foreach((msg_view) => {
+                msg_view.update_display();
+                return true;
+            });
+    }
+
+    /** Returns the email row for the given id, if any. */
+    internal EmailRow? get_email_row_by_id(Geary.EmailIdentifier id) {
+        return this.email_rows.get(id);
+    }
+
+    private async void finish_loading(Geary.SearchQuery? query,
+                                      bool enable_query_scroll,
+                                      Gee.LinkedList<Geary.Email> to_insert,
+                                      Gee.LinkedList<Geary.Email> to_append)
+        throws GLib.Error {
+        // Add emails to append first because if the first interesting
+        // message was short, these will show up in the UI under it,
+        // filling the empty space.
+        foreach (Geary.Email email in to_append) {
+            EmailRow row = add_email(email);
+            yield row.view.load_contacts();
+            if (is_interesting(email)) {
+                yield row.expand();
+            }
+            yield throttle_loading();
+        }
+
+        // Since first rows may have extra margin, remove that from
+        // the height of rows when adjusting scrolling.
+        Gtk.ListBoxRow initial_row = get_row_at_index(0);
+        int loading_height = 0;
+        if (initial_row is LoadingRow) {
+            loading_height = Util.Gtk.get_border_box_height(initial_row);
+            remove(initial_row);
+            // Adjust for the changed margin of the first row
+            var first_row = get_row_at_index(0);
+            var style = first_row.get_style_context();
+            var margin = style.get_margin(style.get_state());
+            loading_height -= margin.top;
+        }
+
+        // None of these will be interesting, so just add them all,
+        // but keep the scrollbar adjusted so that the first
+        // interesting message remains visible.
+        Gtk.Adjustment listbox_adj = get_adjustment();
+        int i_mail_loaded = 0;
+        foreach (Geary.Email email in to_insert) {
+            EmailRow row = add_email(email, false);
+            // Since uninteresting rows are inserted above the
+            // first expanded, adjust the scrollbar as they are
+            // inserted so as to keep the list scrolled to the
+            // same place.
+            row.enable_should_scroll();
+            row.should_scroll.connect(() => {
+                    listbox_adj.value += (Util.Gtk.get_border_box_height(row) - loading_height);
+                    // Only adjust for the loading row going away once
+                    loading_height = 0;
+                });
+
+            yield row.view.load_contacts();
+            if (i_mail_loaded % 10 == 0)
+                yield throttle_loading();
+            ++i_mail_loaded;
+        }
+
+        set_sort_func(on_sort);
+
+        if (query != null) {
+            // XXX this sucks for large conversations because it can take
+            // a long time for the load to complete and hence for
+            // matches to show up.
+            yield this.search.highlight_matching_email(
+                query, enable_query_scroll
+            );
+        }
+    }
+
+    private inline async void throttle_loading() throws GLib.IOError {
+        // Give GTK a moment to process newly added rows, so when
+        // updating the adjustment below the values are
+        // valid. Priority must be low otherwise other async tasks
+        // (like cancelling loading if another conversation is
+        // selected) won't get a look in until this is done.
+        GLib.Idle.add(
+            this.throttle_loading.callback, GLib.Priority.LOW
+        );
+        yield;
+
+        // Check for cancellation after resuming in case the load was
+        // cancelled in the mean time.
+        if (this.cancellable.is_cancelled()) {
+            throw new GLib.IOError.CANCELLED(
+                "Conversation load cancelled"
+            );
+        }
+    }
+
+    // Loads full version of an email, adds it to the listbox
+    private async void load_full_email(Geary.EmailIdentifier id)
+        throws GLib.Error {
+        // Even though it would save a around-trip, don't load the
+        // full email here so that ConverationEmail can handle it if
+        // the full email isn't actually available in the same way as
+        // any other.
+        Geary.Email full_email = yield this.email_store.fetch_email_async(
+            id,
+            REQUIRED_FIELDS | ConversationEmail.REQUIRED_FOR_CONSTRUCT,
+            Geary.Folder.ListFlags.NONE,
+            this.cancellable
+        );
+
+        if (!this.cancellable.is_cancelled()) {
+            EmailRow row = add_email(full_email);
+            yield row.view.load_contacts();
+            if (is_interesting(full_email)) {
+                yield row.expand();
+            }
+            this.search.highlight_row_if_matching(row);
+        }
+    }
+
+    // Constructs a row and view for an email, adds it to the listbox
+    private EmailRow add_email(Geary.Email email, bool append_row = true) {
+        bool is_sent = false;
+        Geary.Account account = this.conversation.base_folder.account;
+        if (email.from != null) {
+            foreach (Geary.RFC822.MailboxAddress from in email.from) {
+                if (account.information.has_sender_mailbox(from)) {
+                    is_sent = true;
+                    break;
+                }
+            }
+        }
+
+        ConversationEmail view = new ConversationEmail(
+            conversation,
+            email,
+            this.email_store,
+            this.contacts,
+            this.config,
+            is_sent,
+            is_draft(email),
+            this.cancellable
+        );
+        view.internal_link_activated.connect(on_internal_link_activated);
+        view.body_selection_changed.connect((email, has_selection) => {
+                this.body_selected_view = has_selection ? email : null;
+            });
+        view.notify["message-body-state"].connect(
+            on_message_body_state_notify
+        );
+
+        ConversationMessage conversation_message = view.primary_message;
+        conversation_message.body_container.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;
+            });
+
+        EmailRow row = new EmailRow(view);
+        row.email_loaded.connect((e) => { email_loaded(e); });
+        this.email_rows.set(email.id, row);
+
+        if (append_row) {
+            add(row);
+        } else {
+            insert(row, 0);
+        }
+
+        return row;
+    }
+
+    // Removes the email's row from the listbox, if any
+    private void remove_email(Geary.Email email) {
+        EmailRow? row = null;
+        if (this.email_rows.unset(email.id, out row)) {
+            remove(row);
+        }
+    }
+
+    private void scroll_to_row(ConversationRow 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;
+        }
+
+        // Use set_value rather than clamp_value since we want to
+        // scroll to the top of the window.
+        get_adjustment().set_value(y);
+    }
+
+    private void scroll_to_anchor(EmailRow row, int anchor_y) {
+        Gtk.Allocation? alloc = null;
+        row.get_allocation(out alloc);
+
+        int x = 0, y = 0;
+        row.view.primary_message.web_view_translate_coordinates(row, x, anchor_y, out x, out y);
+
+        Gtk.Adjustment adj = get_adjustment();
+        y = alloc.y + y;
+        adj.set_value(y);
+
+    }
+
+    /**
+     * Finds any currently visible messages, marks them as being read.
+     */
+    private void check_mark_read() {
+        Gee.List<Geary.EmailIdentifier> email_ids =
+            new Gee.LinkedList<Geary.EmailIdentifier>();
+        Gtk.Adjustment adj = get_adjustment();
+        int top_bound = (int) adj.value;
+        int bottom_bound = top_bound + (int) adj.page_size;
+
+        this.foreach((child) => {
+            // 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.
+            EmailRow? row = child as EmailRow;
+            ConversationEmail? view = (row != null) ? row.view : null;
+            Geary.Email? email = (view != null) ? view.email : null;
+            if (row != null &&
+                row.is_expanded &&
+                view.message_body_state == COMPLETED &&
+                !view.is_manually_read &&
+                email.is_unread().is_certain()) {
+                ConversationMessage conversation_message = view.primary_message;
+                 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_height = conversation_message.web_view_get_allocated_height();
+                 int body_bottom = body_top + body_height;
+
+                 // Only mark the email as read if it's actually visible
+                 if (body_height > 0 &&
+                     body_bottom > top_bound &&
+                     body_top + MARK_READ_PADDING < bottom_bound) {
+                     email_ids.add(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
+                     view.is_manually_read = true;
+                 }
+             }
+        });
+
+        if (email_ids.size > 0) {
+            mark_email(email_ids, null, Geary.EmailFlags.UNREAD);
+        }
+    }
+
+    /**
+     * Returns an new Iterable over all email views in the viewer
+     */
+    private Gee.Iterator<ConversationEmail> email_view_iterator() {
+        return this.email_rows.values.map<ConversationEmail>((row) => {
+                return ((EmailRow) row).view;
+            });
+    }
+
+    /**
+     * 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.iterator(); }
+            )
+        );
+    }
+
+    /** Determines if an email should be expanded by default. */
+    private inline bool is_interesting(Geary.Email email) {
+        return (
+            email.is_unread().is_certain() ||
+            email.is_flagged().is_certain() ||
+            is_draft(email)
+        );
+    }
+
+    /** Determines if an email should be considered to be a draft. */
+    private inline bool is_draft(Geary.Email email) {
+        // XXX should be able to edit draft emails from any
+        // conversation. This test should be more like "is in drafts
+        // folder"
+        Geary.Folder.SpecialUse use = this.conversation.base_folder.used_as;
+        bool is_in_folder = this.conversation.is_in_base_folder(email.id);
+
+        return (
+            is_in_folder && use == DRAFTS // ||
+            //email.flags.is_draft()
+        );
+    }
+
+    private ConversationEmail action_target_to_view(GLib.Variant target) {
+        Geary.EmailIdentifier? id = null;
+        try {
+            id = this.conversation.base_folder.account.to_email_identifier(target);
+        } catch (Geary.EngineError err) {
+            debug("Failed to get email id for action target: %s", err.message);
+        }
+        EmailRow? row = (id != null) ? this.email_rows[id] : null;
+        return (row != null) ? row.view : null;
+    }
+
+    private void on_conversation_appended(Geary.App.Conversation conversation,
+                                          Geary.Email email) {
+        on_conversation_appended_async.begin(conversation, email);
+    }
+
+    private async void on_conversation_appended_async(
+        Geary.App.Conversation conversation, Geary.Email part_email) {
+        // Don't add rows that are already present, or that are
+        // currently being edited.
+        if (!this.email_rows.has_key(part_email.id) &&
+            part_email.id != this.draft_id) {
+            load_full_email.begin(part_email.id, (obj, ret) => {
+                    try {
+                        load_full_email.end(ret);
+                    } 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.email_rows.has_key(email.id)) {
+            return;
+        }
+
+        EmailRow row = this.email_rows.get(email.id);
+        row.view.update_flags(email);
+    }
+
+    private void on_message_body_state_notify(GLib.Object obj,
+                                              GLib.ParamSpec param) {
+        ConversationEmail? view = obj as ConversationEmail;
+        if (view != null && view.message_body_state == COMPLETED) {
+            if (!this.suppress_mark_timer) {
+                this.mark_read_timer.start();
+            }
+            this.suppress_mark_timer = false;
+        }
+    }
+
+    private void on_row_activated(Gtk.ListBoxRow widget) {
+        EmailRow? row = widget as EmailRow;
+        if (row != null) {
+            // Allow non-last rows to be expanded/collapsed, but also let
+            // the last row to be expanded since appended sent emails will
+            // be appended last. Finally, don't let rows with active
+            // composers be collapsed.
+            if (row.is_expanded) {
+                if (get_row_at_index(row.get_index() + 1) != null) {
+                    row.collapse();
+                }
+            } else {
+                row.expand.begin();
+            }
+        }
+    }
+
+    private void on_internal_link_activated(ConversationEmail email, int y) {
+        EmailRow row = get_email_row_by_id(email.email.id);
+        scroll_to_anchor(row, y);
+    }
+
+    // Email action callbacks
+
+    private void on_email_reply_sender(GLib.SimpleAction action,
+                                       GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.get_selection_for_quoting.begin((obj, res) => {
+                    string? quote = view.get_selection_for_quoting.end(res);
+                    reply_to_sender_email(view.email, quote);
+                });
+        }
+    }
+
+    private void on_email_reply_all(GLib.SimpleAction action,
+                                    GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.get_selection_for_quoting.begin((obj, res) => {
+                    string? quote = view.get_selection_for_quoting.end(res);
+                    reply_to_all_email(view.email, quote);
+                });
+        }
+    }
+
+    private void on_email_forward(GLib.SimpleAction action,
+                                  GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.get_selection_for_quoting.begin((obj, res) => {
+                    string? quote = view.get_selection_for_quoting.end(res);
+                    forward_email(view.email, quote);
+                });
+        }
+    }
+
+    private void on_email_mark_read(GLib.SimpleAction action,
+                                    GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                null,
+                Geary.EmailFlags.UNREAD
+            );
+        }
+    }
+
+    private void on_email_mark_unread(GLib.SimpleAction action,
+                                      GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                Geary.EmailFlags.UNREAD,
+                null
+            );
+        }
+    }
+
+    private void on_email_mark_unread_down(GLib.SimpleAction action,
+                                           GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            Geary.Email email = view.email;
+            var ids = new Gee.LinkedList<Geary.EmailIdentifier>();
+            ids.add(email.id);
+            this.foreach((row) => {
+                    if (row.get_visible()) {
+                        Geary.Email other = ((EmailRow) row).view.email;
+                        if (Geary.Email.compare_sent_date_ascending(
+                                email, other) < 0) {
+                            ids.add(other.id);
+                        }
+                    }
+                });
+            mark_email(ids, Geary.EmailFlags.UNREAD, null);
+        }
+    }
+
+    private void on_email_mark_starred(GLib.SimpleAction action,
+                                       GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                Geary.EmailFlags.FLAGGED,
+                null
+            );
+        }
+    }
+
+    private void on_email_mark_unstarred(GLib.SimpleAction action,
+                                         GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                null,
+                Geary.EmailFlags.FLAGGED
+            );
+        }
+    }
+
+    private void on_email_load_remote(GLib.SimpleAction action,
+                                      GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            mark_email(
+                Geary.Collection.single(view.email.id),
+                Geary.EmailFlags.LOAD_REMOTE_IMAGES,
+                null
+            );
+        }
+    }
+
+    private void on_email_trash(GLib.SimpleAction action,
+                                GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            trash_email(view.email);
+        }
+    }
+
+    private void on_email_delete(GLib.SimpleAction action,
+                                 GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            delete_email(view.email);
+        }
+    }
+
+    private void on_email_save_all_attachments(GLib.SimpleAction action,
+                                               GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null && view.attachments_pane != null) {
+            view.attachments_pane.save_all();
+        }
+    }
+
+    private void on_email_print(GLib.SimpleAction action,
+                                GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.print.begin();
+        }
+    }
+
+    private void on_email_view_source(GLib.SimpleAction action,
+                                      GLib.Variant? param) {
+        ConversationEmail? view = action_target_to_view(param);
+        if (view != null) {
+            view.view_source.begin();
+        }
+    }
+
+}
diff --git a/src/client/conversation-viewer/conversation-viewer.vala 
b/src/client/conversation-viewer/conversation-viewer.vala
index f8782f73c..fb0785584 100644
--- a/src/client/conversation-viewer/conversation-viewer.vala
+++ b/src/client/conversation-viewer/conversation-viewer.vala
@@ -152,9 +152,9 @@ public class ConversationViewer : Gtk.Stack, Geary.BaseInterface {
 
             // XXX move the ConversationListView management code into
             // MainWindow or somewhere more appropriate
-            ConversationListView conversation_list = main_window.conversation_list_view;
-            this.selection_while_composing = conversation_list.copy_selected();
-            conversation_list.get_selection().unselect_all();
+            ConversationList.View conversation_list = main_window.conversation_list_view;
+            this.selection_while_composing = conversation_list.get_selected();
+            conversation_list.unselect_all();
 
             box.vanished.connect(on_composer_closed);
             this.composer_page.add(box);
diff --git a/src/client/meson.build b/src/client/meson.build
index 08027b3d4..92aee2a62 100644
--- a/src/client/meson.build
+++ b/src/client/meson.build
@@ -88,10 +88,10 @@ client_vala_sources = files(
   'composer/contact-entry-completion.vala',
   'composer/spell-check-popover.vala',
 
-  'conversation-list/conversation-list-cell-renderer.vala',
-  'conversation-list/conversation-list-store.vala',
+  'conversation-list/conversation-list-model.vala',
+  'conversation-list/conversation-list-participant.vala',
+  'conversation-list/conversation-list-row.vala',
   'conversation-list/conversation-list-view.vala',
-  'conversation-list/formatted-conversation-data.vala',
 
   'conversation-viewer/conversation-contact-popover.vala',
   'conversation-viewer/conversation-email.vala',
diff --git a/src/client/sidebar/sidebar-count-cell-renderer.vala 
b/src/client/sidebar/sidebar-count-cell-renderer.vala
index 615cb8641..c6bd1bfb5 100644
--- a/src/client/sidebar/sidebar-count-cell-renderer.vala
+++ b/src/client/sidebar/sidebar-count-cell-renderer.vala
@@ -23,7 +23,7 @@ public class SidebarCountCellRenderer : Gtk.CellRenderer {
 
     public override void get_preferred_width(Gtk.Widget widget, out int minimum_size, out int natural_size) {
         unread_count.count = counter;
-        minimum_size = unread_count.get_width(widget) + FormattedConversationData.SPACING;
+        minimum_size = unread_count.get_width(widget) + CountBadge.SPACING;
         natural_size = minimum_size;
     }
 
diff --git a/src/engine/util/util-numeric.vala b/src/engine/util/util-numeric.vala
index 4c1ac9d79..646f2525e 100644
--- a/src/engine/util/util-numeric.vala
+++ b/src/engine/util/util-numeric.vala
@@ -56,5 +56,19 @@ public int int64_compare(void* a, void *b) {
         return 0;
 }
 
+public int max(int a, int b) {
+    if (a > b)
+        return a;
+    else
+        return b;
+}
+
+public int min(int a, int b) {
+    if (a < b)
+        return a;
+    else
+        return b;
+}
+
 }
 
diff --git a/ui/application-main-window.ui b/ui/application-main-window.ui
index 36ae7a780..a10fcd53a 100644
--- a/ui/application-main-window.ui
+++ b/ui/application-main-window.ui
@@ -45,20 +45,13 @@
                           </packing>
                         </child>
                         <child>
-                          <object class="GtkFrame" id="folder_frame">
-                            <property name="visible">True</property>
-                            <property name="vexpand">True</property>
-                            <property name="label_xalign">0</property>
-                            <property name="shadow_type">none</property>
-                            <child>
-                              <object class="GtkScrolledWindow" id="folder_list_scrolled">
-                                <property name="visible">True</property>
-                                <property name="hscrollbar_policy">never</property>
-                              </object>
-                            </child>
-                            <style>
-                              <class name="geary-folder-frame"/>
-                            </style>
+                           <object class="GtkScrolledWindow" id="folder_list_scrolled">
+                              <property name="visible">True</property>
+                              <property name="vexpand">True</property>
+                              <property name="hscrollbar_policy">never</property>
+                              <style>
+                                <class name="geary-folder"/>
+                              </style>
                           </object>
                           <packing>
                             <property name="fill">True</property>
@@ -94,28 +87,6 @@
                             <property name="position">0</property>
                           </packing>
                         </child>
-                        <child>
-                          <object class="GtkFrame" id="conversation_frame">
-                            <property name="visible">True</property>
-                            <property name="label_xalign">0</property>
-                            <property name="shadow_type">none</property>
-                            <child>
-                              <object class="GtkScrolledWindow" id="conversation_list_scrolled">
-                                <property name="width_request">250</property>
-                                <property name="visible">True</property>
-                              </object>
-                            </child>
-                            <style>
-                              <class name="geary-conversation-frame"/>
-                            </style>
-                          </object>
-                          <packing>
-                            <property name="expand">True</property>
-                            <property name="fill">True</property>
-                            <property name="pack_type">end</property>
-                            <property name="position">1</property>
-                          </packing>
-                        </child>
                         <child>
                           <object class="GtkRevealer" id="conversation_list_actions_revealer">
                             <property name="visible">True</property>
diff --git a/ui/components-conversation-actions.ui b/ui/components-conversation-actions.ui
index f823dfcf4..744a464c1 100644
--- a/ui/components-conversation-actions.ui
+++ b/ui/components-conversation-actions.ui
@@ -82,15 +82,15 @@
       <object class="GtkBox" id="mark_copy_move_buttons">
         <property name="visible">True</property>
         <child>
-          <object class="GtkMenuButton" id="mark_message_button">
+          <object class="GtkMenuButton" id="copy_message_button">
             <property name="visible">True</property>
             <property name="focus_on_click">False</property>
             <property name="receives_default">False</property>
             <property name="always_show_image">True</property>
             <child>
-              <object class="GtkImage" id="mark_message_image">
+              <object class="GtkImage" id="copy_message_image">
                 <property name="visible">True</property>
-                <property name="icon_name">checkbox-checked-symbolic</property>
+                <property name="icon_name">tag-symbolic</property>
               </object>
             </child>
           </object>
@@ -101,15 +101,15 @@
           </packing>
         </child>
         <child>
-          <object class="GtkMenuButton" id="copy_message_button">
+          <object class="GtkMenuButton" id="move_message_button">
             <property name="visible">True</property>
             <property name="focus_on_click">False</property>
             <property name="receives_default">False</property>
             <property name="always_show_image">True</property>
             <child>
-              <object class="GtkImage" id="copy_message_image">
+              <object class="GtkImage" id="move_message_image">
                 <property name="visible">True</property>
-                <property name="icon_name">tag-symbolic</property>
+                <property name="icon_name">folder-symbolic</property>
               </object>
             </child>
           </object>
@@ -120,17 +120,20 @@
           </packing>
         </child>
         <child>
-          <object class="GtkMenuButton" id="move_message_button">
+          <object class="GtkMenuButton" id="mark_message_button">
             <property name="visible">True</property>
             <property name="focus_on_click">False</property>
             <property name="receives_default">False</property>
             <property name="always_show_image">True</property>
             <child>
-              <object class="GtkImage" id="move_message_image">
+              <object class="GtkImage" id="mark_message_image">
                 <property name="visible">True</property>
-                <property name="icon_name">folder-symbolic</property>
+                <property name="icon_name">pan-down-symbolic</property>
               </object>
             </child>
+            <style>
+              <class name="thin-button"/>
+            </style>
           </object>
           <packing>
             <property name="expand">False</property>
diff --git a/ui/components-headerbar-conversation-list.ui b/ui/components-headerbar-conversation-list.ui
index 9addaab67..0abf89fc4 100644
--- a/ui/components-headerbar-conversation-list.ui
+++ b/ui/components-headerbar-conversation-list.ui
@@ -60,7 +60,24 @@
       </object>
       <packing>
         <property name="pack_type">end</property>
-        <property name="position">3</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkToggleButton" id="selection_button">
+        <property name="visible">True</property>
+        <property name="focus_on_click">False</property>
+        <property name="receives_default">False</property>
+        <property name="tooltip_text" translatable="yes">Selection conversations</property>
+        <property name="always_show_image">True</property>
+        <child>
+          <object class="GtkImage" id="selection_button_image">
+            <property name="visible">True</property>
+            <property name="icon_name">selection-mode-symbolic</property>
+          </object>
+        </child>
+      </object>
+      <packing>
+        <property name="pack_type">end</property>
       </packing>
     </child>
   </template>
diff --git a/ui/conversation-list-row.ui b/ui/conversation-list-row.ui
new file mode 100644
index 000000000..d3611cde2
--- /dev/null
+++ b/ui/conversation-list-row.ui
@@ -0,0 +1,214 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.38.2 -->
+<interface>
+  <requires lib="gtk+" version="3.24"/>
+  <object class="GtkImage" id="flagged_icon">
+    <property name="visible">True</property>
+  </object>
+  <object class="GtkImage" id="read_icon">
+    <property name="visible">True</property>
+  </object>
+  <template class="ConversationListRow" parent="GtkListBoxRow">
+    <property name="can-focus">True</property>
+    <child>
+      <object class="GtkEventBox" id="eventbox">
+        <property name="visible">True</property>
+        <child>
+          <object class="GtkBox" id="container">
+            <property name="visible">True</property>
+            <property name="has-tooltip">True</property>
+            <property name="spacing">5</property>
+            <property name="baseline-position">top</property>
+            <child>
+              <object class="GtkStack" id="stack">
+                <property name="visible">True</property>
+                <child>
+                  <object class="GtkBox" id="buttons">
+                    <property name="width-request">36</property>
+                    <property name="visible">True</property>
+                    <property name="vexpand">True</property>
+                    <property name="orientation">vertical</property>
+                    <property name="homogeneous">True</property>
+                    <child>
+                      <object class="GtkButton" id="unread">
+                        <property name="visible">True</property>
+                        <property name="can-focus">False</property>
+                        <property name="valign">center</property>
+                        <property name="relief">none</property>
+                        <property name="image">read_icon</property>
+                        <signal name="clicked" handler="on_unread_button_clicked"/>
+                        <style>
+                          <class name="conversation-ephemeral-button"/>
+                          <class name="unread-button"/>
+                        </style>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkButton" id="flagged">
+                        <property name="visible">True</property>
+                        <property name="can-focus">False</property>
+                        <property name="valign">center</property>
+                        <property name="relief">none</property>
+                        <property name="image">flagged_icon</property>
+                        <signal name="clicked" handler="on_flagged_button_clicked"/>
+                        <style>
+                          <class name="conversation-ephemeral-button"/>
+                        </style>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">1</property>
+                      </packing>
+                    </child>
+                  </object>
+                  <packing>
+                    <property name="name">buttons</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkCheckButton" id="selected_button">
+                    <property name="receives-default">True</property>
+                    <property name="valign">center</property>
+                    <property name="can-focus">False</property>
+                  </object>
+                  <packing>
+                    <property name="name">selection-button</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkBox" id="Details">
+                <property name="visible">True</property>
+                <property name="orientation">vertical</property>
+                <property name="baseline-position">top</property>
+                <child>
+                  <object class="GtkBox" id="Header">
+                    <property name="visible">True</property>
+                    <child>
+                      <object class="GtkLabel" id="participants">
+                        <property name="visible">True</property>
+                        <property name="use-markup">True</property>
+                        <property name="ellipsize">end</property>
+                        <property name="xalign">0</property>
+                        <style>
+                          <class name="participants"/>
+                        </style>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkLabel" id="date">
+                        <property name="visible">True</property>
+                        <style>
+                          <class name="date"/>
+                          <class name="tertiary"/>
+                        </style>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">False</property>
+                        <property name="pack-type">end</property>
+                        <property name="position">1</property>
+                      </packing>
+                    </child>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">False</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkLabel" id="subject">
+                    <property name="visible">True</property>
+                    <property name="halign">start</property>
+                    <property name="ellipsize">end</property>
+                    <property name="single-line-mode">True</property>
+                    <property name="xalign">0</property>
+                    <style>
+                      <class name="subject"/>
+                    </style>
+                  </object>
+                  <packing>
+                    <property name="expand">True</property>
+                    <property name="fill">False</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkBox" id="preview_row">
+                    <property name="visible">True</property>
+                    <child>
+                      <object class="GtkLabel" id="preview">
+                        <property name="visible">True</property>
+                        <property name="halign">start</property>
+                        <property name="hexpand">True</property>
+                        <property name="wrap">True</property>
+                        <property name="wrap-mode">word-char</property>
+                        <property name="ellipsize">end</property>
+                        <property name="lines">1</property>
+                        <property name="xalign">0</property>
+                        <style>
+                          <class name="preview"/>
+                          <class name="tertiary"/>
+                        </style>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkLabel" id="count_badge">
+                        <property name="visible">True</property>
+                        <property name="valign">center</property>
+                        <style>
+                          <class name="count-badge"/>
+                        </style>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">1</property>
+                      </packing>
+                    </child>
+                  </object>
+                  <packing>
+                    <property name="expand">True</property>
+                    <property name="fill">False</property>
+                    <property name="position">2</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">3</property>
+              </packing>
+            </child>
+          </object>
+        </child>
+      </object>
+    </child>
+    <style>
+      <class name="conversation-list"/>
+    </style>
+  </template>
+</interface>
diff --git a/ui/conversation-list-view.ui b/ui/conversation-list-view.ui
new file mode 100644
index 000000000..d34929576
--- /dev/null
+++ b/ui/conversation-list-view.ui
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.38.2 -->
+<interface>
+  <requires lib="gtk+" version="3.24"/>
+  <template class="ConversationListView" parent="GtkScrolledWindow">
+    <property name="width-request">250</property>
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <child>
+      <object class="GtkViewport">
+        <property name="visible">True</property>
+        <property name="shadow-type">none</property>
+        <property name="can-focus">False</property>
+        <child>
+          <object class="GtkListBox" id="list">
+            <property name="name">conversation-list</property>
+            <property name="visible">True</property>
+            <property name="selection-mode">single</property>
+            <property name="activate-on-single-click">True</property>
+          </object>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/ui/geary.css b/ui/geary.css
index 35cefbb21..2e01f7b72 100644
--- a/ui/geary.css
+++ b/ui/geary.css
@@ -8,13 +8,7 @@
 
 /* MainWindow */
 
-.geary-folder-frame > border {
-  border-left-width: 0;
-  border-top-width: 0;
-  border-right-width: 0;
-}
-
-.geary-folder-frame {
+.geary-folder {
   min-width: 300px;
 }
 
@@ -22,10 +16,7 @@ geary-conversation-list revealer {
   margin: 6px;
 }
 
-.geary-conversation-frame > border {
-  border-left-width: 0;
-  border-top-width: 0;
-  border-right-width: 0;
+geary-conversation-list {
   min-width: 360px;
 }
 
@@ -33,10 +24,6 @@ geary-conversation-viewer {
   min-width: 360px;
 }
 
-.geary-sidebar-pane-separator.vertical .conversation-frame > border {
-  border-bottom-width: 0;
-}
-
 .geary-overlay {
   background-color: @theme_base_color;
   padding: 2px 6px;
@@ -57,7 +44,7 @@ geary-conversation-viewer {
 }
 
 infobar flowboxchild {
-       padding: 0px;
+  padding: 0px;
 }
 
 revealer components-conversation-actions {
@@ -66,6 +53,93 @@ revealer components-conversation-actions {
   padding: 6px;
 }
 
+
+/* Conversation List */
+row.conversation-list {
+  padding-top: 0.5em;
+  padding-bottom: 0.5em;
+  padding-right: 0.5em;
+}
+
+row.conversation-list.drag-n-drop {
+  background: @theme_base_color;
+  opacity: 0.7;
+  box-shadow: none;
+}
+
+row.conversation-list label {
+  margin-bottom: .4em;
+}
+
+row.conversation-list .tertiary {
+  opacity: 0.7;
+  font-size: 0.8em;
+}
+
+row.conversation-list .subject {
+  font-size: 0.9em;
+}
+
+row.conversation-list .date {
+  margin-left: 1em;
+}
+
+/* Unread styling */
+row.conversation-list.unread .preview {
+  opacity: 1;
+}
+
+row.conversation-list.unread .subject {
+  font-weight: bold;
+}
+
+row.conversation-list.unread .participants {
+  font-weight: bold;
+}
+
+row.conversation-list.unread .unread-button {
+  opacity: 1;
+}
+
+/* Hover buttons */
+row.conversation-list .conversation-ephemeral-button {
+  opacity: 0;
+  margin: 2px;
+  border-radius: 50%;
+  border: none;
+}
+
+row.conversation-list:hover .conversation-ephemeral-button {
+  opacity: 1;
+}
+
+row.conversation-list:selected .conversation-ephemeral-button {
+  opacity: 1;
+}
+
+row.conversation-list .count-badge {
+  background: #888888;
+  color: white;
+  min-width: 1.5em;
+  border-radius: 1em;
+  font-size: .8em;
+  font-weight: bold;
+}
+
+row.conversation-list check  {
+  border-radius: 50%;
+  padding: 2px;
+  margin: 6px;
+}
+
+row.selected.conversation-list  {
+  background: alpha(@theme_selected_bg_color, 0.1);
+}
+
+row.selected.conversation-list:hover  {
+  background: alpha(@theme_selected_bg_color, 0.2);
+}
+
 /* FolderPopover */
 
 .geary-folder-popover-list {
@@ -439,11 +513,11 @@ popover.geary-editor > grid > button.geary-setting-remove {
 }
 
 dialog.geary-remove-confirm .dialog-vbox {
-       margin: 12px;
+    margin: 12px;
 }
 
 dialog.geary-remove-confirm .dialog-action-box {
-       margin: 6px;
+    margin: 6px;
 }
 
 /* FolderList.Tree */
@@ -452,6 +526,11 @@ treeview.sidebar {
   border: none;
 }
 
+treeview:selected.sidebar {
+  background: alpha(@theme_text_color, 0.1);
+  color: @theme_text_color;
+}
+
 treeview.sidebar .cell {
   padding: 9px 6px;
 }
@@ -488,3 +567,10 @@ dialog.geary-upgrade grid {
 dialog.geary-upgrade label {
   margin-top: 12px;
 }
+
+/* Misc */
+
+.thin-button {
+  padding-left: 4px;
+  padding-right: 4px;
+}
diff --git a/ui/org.gnome.Geary.gresource.xml b/ui/org.gnome.Geary.gresource.xml
index a486d1fa8..3e314b92c 100644
--- a/ui/org.gnome.Geary.gresource.xml
+++ b/ui/org.gnome.Geary.gresource.xml
@@ -33,6 +33,8 @@
     <file compressed="true">composer-web-view.css</file>
     <file compressed="true">composer-web-view.js</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-contact-popover.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks">conversation-list-row.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks">conversation-list-view.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-email.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-email-menus.ui</file>
     <file compressed="true" preprocess="xml-stripblanks">conversation-message.ui</file>


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