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




commit 12b47ce5d5709b6994d19550da2a442ed9a169c7
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-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 +
 24 files changed, 1453 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..1a953fe71 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(), false);
+            }
         }
 
-        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-viewer.vala 
b/src/client/conversation-viewer/conversation-viewer.vala
index 0e507a021..90142537d 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 309b756bb..4c6e1aeff 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 {
@@ -415,11 +489,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 */
@@ -428,6 +502,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;
 }
@@ -464,3 +543,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]