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




commit 8c6b3a503ca5181c977a946b75d047a5507157e0
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
    - Get ListBox rendering
    - Implement proper multiselection for ListBox
    - Build row widget
        Unread status
        Multiple sender addresses
        Per-sender unread styling
        Buttons for mark as read and flag
        Allow disabling preview
        Occasionally update timestamp displays
    - Build List widget
        Port all selection (get/set/etc) APIs
        Build ListModel for subscribing to ConversationMonitor
        Context Menu
        Use Gestures when possible
        Drag and Drop
    - Build Scroll widget
        load more when scroll reaches near bottom
        Report currently visible conversations
    
    Fork of John Renner <john jrenner net> merge request !698

 po/POTFILES.in                                     |   8 +-
 src/client/application/application-client.vala     |   2 +-
 .../application/application-main-window.vala       |  99 +--
 .../application-notification-plugin-context.vala   |   3 +-
 .../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 | 127 +++
 .../conversation-list-participant.vala             |  61 ++
 .../conversation-list/conversation-list-row.vala   | 184 ++++
 .../conversation-list/conversation-list-store.vala | 494 -----------
 .../conversation-list/conversation-list-view.vala  | 944 ++++++++-------------
 .../formatted-conversation-data.vala               | 476 -----------
 .../conversation-viewer/conversation-viewer.vala   |   6 +-
 src/client/meson.build                             |   6 +-
 .../sidebar/sidebar-count-cell-renderer.vala       |   2 +-
 ui/application-main-window.ui                      |   6 -
 ui/components-conversation-actions.ui              |  18 +-
 ui/components-headerbar-conversation-list.ui       |  19 +-
 ui/conversation-list-row.ui                        | 255 ++++++
 ui/conversation-list-view.ui                       |  26 +
 ui/geary.css                                       |  97 ++-
 ui/org.gnome.Geary.gresource.xml                   |   2 +
 24 files changed, 1222 insertions(+), 1705 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index bc7192dfe..e243c4295 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
@@ -467,6 +467,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..ddebe6ed4 100644
--- a/src/client/application/application-client.vala
+++ b/src/client/application/application-client.vala
@@ -1203,7 +1203,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 f7793bcac..23daf44fa 100644
--- a/src/client/application/application-main-window.vala
+++ b/src/client/application/application-main-window.vala
@@ -355,7 +355,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,10 +402,10 @@ 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;
 
+    [GtkChild] private unowned Gtk.Frame conversation_frame;
     [GtkChild] private unowned Gtk.Box conversation_viewer_box;
     [GtkChild] private unowned Gtk.Revealer conversation_viewer_actions_revealer;
 
@@ -760,6 +760,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 +778,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 +827,18 @@ 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);
+                //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) {
@@ -1323,15 +1317,13 @@ public class Application.MainWindow :
         this.conversation_list_box.pack_start(
             this.conversation_list_info_bars, false, false, 0
         );
-        this.conversation_list_view = new ConversationListView(
-            this.application.config
-        );
-        this.conversation_list_view.load_more.connect(on_load_more);
+
+        this.conversation_list_view = new ConversationList.View(this.application.config);
         this.conversation_list_view.mark_conversations.connect(on_mark_conversations);
         this.conversation_list_view.conversations_selected.connect(on_conversations_selected);
         this.conversation_list_view.conversation_activated.connect(on_conversation_activated);
-        this.conversation_list_view.visible_conversations_changed.connect(on_visible_conversations_changed);
-        this.conversation_list_scrolled.add(conversation_list_view);
+        this.conversation_list_view.visible_conversations.notify.connect(on_visible_conversations_changed);
+        this.conversation_frame.add(this.conversation_list_view);
 
         // Conversation viewer
         this.conversation_viewer = new ConversationViewer(
@@ -1349,6 +1341,11 @@ 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",
@@ -1371,6 +1368,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
@@ -1540,11 +1539,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();
         }
     }
 
@@ -1719,13 +1714,6 @@ 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);
     }
@@ -1747,7 +1735,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);
                 }
@@ -1836,14 +1824,11 @@ public class Application.MainWindow :
         case NONE:
             this.conversation_list_actions_revealer.reveal_child = false;
             break;
-        case SINGLE:
+        default:
             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);
@@ -2022,7 +2007,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()) &&
@@ -2030,7 +2015,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);
         }
     }
 
@@ -2043,10 +2028,6 @@ public class Application.MainWindow :
         );
     }
 
-    private void on_load_more() {
-        load_more();
-    }
-
     [GtkCallback]
     private void on_map() {
         this.update_ui_timeout.start();
@@ -2288,7 +2269,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
             );
         }
     }
@@ -2322,9 +2303,9 @@ 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);
         }
     }
 
@@ -2342,7 +2323,7 @@ public class Application.MainWindow :
             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
@@ -2424,11 +2405,11 @@ public class Application.MainWindow :
     }
 
     private void on_conversation_up() {
-        this.conversation_list_view.scroll(Gtk.ScrollType.STEP_UP);
+        // this.conversation_list_view.scroll(Gtk.ScrollType.STEP_UP);
     }
 
     private void on_conversation_down() {
-        this.conversation_list_view.scroll(Gtk.ScrollType.STEP_DOWN);
+        // this.conversation_list_view.scroll(Gtk.ScrollType.STEP_DOWN);
     }
 
     private void on_show_mark_menu() {
@@ -2496,7 +2477,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) => {
@@ -2515,7 +2496,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) => {
@@ -2534,7 +2515,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) => {
@@ -2553,7 +2534,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) => {
@@ -2577,7 +2558,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);
@@ -2596,7 +2577,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);
@@ -2616,7 +2597,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);
@@ -2635,7 +2616,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);
@@ -2653,7 +2634,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);
@@ -2669,7 +2650,7 @@ public class Application.MainWindow :
         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,
diff --git a/src/client/application/application-notification-plugin-context.vala 
b/src/client/application/application-notification-plugin-context.vala
index 774a543f2..56bc051bf 100644
--- a/src/client/application/application-notification-plugin-context.vala
+++ b/src/client/application/application-notification-plugin-context.vala
@@ -117,7 +117,8 @@ internal class Application.NotificationPluginContext :
                 window == null ||
                 !window.has_toplevel_focus ||
                 window.selected_folder != folder ||
-                window.conversation_list_view.vadjustment.value > 0.0
+                false
+                //window.conversation_list_view.vadjustment.value > 0.0
             )
         );
     }
diff --git a/src/client/components/components-conversation-actions.vala 
b/src/client/components/components-conversation-actions.vala
index 8030c424b..7ff8f2f28 100644
--- a/src/client/components/components-conversation-actions.vala
+++ b/src/client/components/components-conversation-actions.vala
@@ -95,6 +95,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..d74cf1f0d
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-model.vala
@@ -0,0 +1,127 @@
+// 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);
+    }
+
+    /**
+     * Informs observers that batch of updates is complete.
+     *
+     * GTK's ListModel interface reports updates as additions and subtractions
+     * at a specific index, meaning the results of a scan can require several
+     * invocations of items_changed. This signal allows consumers to know when
+     * those invocations have stopped.
+     */
+    internal signal void update_complete();
+
+    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;
+        update_complete();
+    }
+
+    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);
+        int added = 0;
+        foreach (Geary.App.Conversation conversation in conversations) {
+            if (upsert_conversation(conversation)) {
+                added++;
+            }
+        }
+        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);
+        int removed = 0;
+        foreach (Geary.App.Conversation conversation in conversations) {
+            if (remove_conversation(conversation)) {
+                removed++;
+            }
+        }
+        if (!this.scanning)
+            update_complete();
+        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..0f0fe9095
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-participant.vala
@@ -0,0 +1,61 @@
+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..3a222bea0
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-row.vala
@@ -0,0 +1,184 @@
+[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.Button unread;
+    [GtkChild] unowned Gtk.Button flagged;
+    [GtkChild] unowned Gtk.Label count_badge;
+
+    [GtkChild] unowned Gtk.Image unread_icon;
+    [GtkChild] unowned Gtk.Image read_icon;
+    [GtkChild] unowned Gtk.Image flagged_icon;
+    [GtkChild] unowned Gtk.Image unflagged_icon;
+
+    [GtkChild] unowned Gtk.Stack stack;
+    [GtkChild] unowned Gtk.CheckButton selected_button;
+
+    internal Geary.App.Conversation conversation;
+    private Application.Configuration config;
+    private DateTime? recv_time;
+
+    internal signal void toggle_flag(ConversationList.Row row,
+                                     Geary.NamedFlag flag);
+
+    internal signal void toggle_selected(ConversationList.Row row,
+                                         bool selected);
+
+
+    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();
+            this.selected_button.set_active(this.is_selected());
+            this.stack.set_visible_child_name("selection-button");
+        } else {
+            this.stack.set_visible_child_name("buttons");
+            this.selected_button.hide();
+            this.selected_button.set_active(false);
+        }
+    }
+
+    internal void update_state() {
+        bool selected = this.is_selected();
+        bool active = this.selected_button.get_active();
+        if (selected && !active)
+            this.selected_button.set_active(true);
+        else if (!selected && active)
+            this.selected_button.set_active(false);
+    }
+
+    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 update_flags(Geary.Email? email) {
+        if (conversation.is_unread()) {
+            get_style_context().add_class("unread");
+            unread.set_image(unread_icon);
+        } else {
+            get_style_context().remove_class("unread");
+            unread.set_image(read_icon);
+        }
+
+        if (conversation.is_flagged()) {
+            get_style_context().add_class("flagged");
+            flagged.set_image(flagged_icon);
+        } else {
+            get_style_context().remove_class("flagged");
+            flagged.set_image(unflagged_icon);
+        }
+    }
+
+    [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);
+    }
+
+    [GtkCallback] private void on_selected_button_clicked() {
+        toggle_selected(this, this.selected_button.active);
+    }
+
+
+    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..3701628fa 100644
--- a/src/client/conversation-list/conversation-list-view.vala
+++ b/src/client/conversation-list/conversation-list-view.vala
@@ -1,666 +1,470 @@
-/* Copyright 2016 Software Freedom Conservancy Inc.
- *
- * This software is licensed under the GNU Lesser General Public License
- * (version 2.1 or later).  See the COPYING file in this distribution.
- */
-
-public class ConversationListView : Gtk.TreeView, Geary.BaseInterface {
-    const int LOAD_MORE_HEIGHT = 100;
-
+[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 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;
-
+    [GtkChild] private unowned Gtk.ListBox list;
 
-    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);
-
-
-    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);
+        this.notify["selection-mode-enabled"].connect(on_selection_mode_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);
+        this.list.selected_rows_changed.connect(() => {
+            conversations_selected(get_selected());
+        });
+        this.list.row_selected.connect((row) => {
+            ((Row) row).update_state();
+        });
 
-        // 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);
-
-        // GtkTreeView binds Ctrl+N to "move cursor to next".  Not so interested in that, so we'll
-        // remove it.
-        unowned Gtk.BindingSet? binding_set = Gtk.BindingSet.find("GtkTreeView");
-        assert(binding_set != null);
-        Gtk.BindingEntry.remove(binding_set, Gdk.Key.N, Gdk.ModifierType.CONTROL_MASK);
-
-        this.selection_update = new Geary.IdleManager(do_selection_changed);
-        this.selection_update.priority = Geary.IdleManager.Priority.LOW;
+        this.list.set_header_func(header_func);
 
-        this.visible = true;
-    }
+        this.vadjustment.value_changed.connect(maybe_load_more);
+        this.vadjustment.value_changed.connect(update_visible_conversations);
 
-    ~ConversationListView() {
-        base_unref();
-    }
+        this.press_gesture = new Gtk.GestureMultiPress(this.list);
+        this.press_gesture.set_button(3);
+        this.press_gesture.released.connect((n_press, x, y) => {
+            var row = (Row) this.list.get_row_at_y((int) y);
+            context_menu(row);
+        });
 
-    public override void destroy() {
-        this.selection_update.reset();
-        base.destroy();
-    }
-
-    public new ConversationListStore? get_model() {
-        return base.get_model() as ConversationListStore;
-    }
+        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) => {
+            var row = (Row) this.list.get_row_at_y((int) y);
+            context_menu(row);
+        });
 
-    public new void set_model(ConversationListStore? new_store) {
-        ConversationListStore? old_store = get_model();
-        if (old_store != null) {
-            old_store.conversations.scan_started.disconnect(on_scan_started);
-            old_store.conversations.scan_completed.disconnect(on_scan_completed);
-
-            old_store.conversations_added.disconnect(on_conversations_added);
-            old_store.conversations_removed.disconnect(on_conversations_removed);
-            old_store.row_inserted.disconnect(on_rows_changed);
-            old_store.rows_reordered.disconnect(on_rows_changed);
-            old_store.row_changed.disconnect(on_rows_changed);
-            old_store.row_deleted.disconnect(on_rows_changed);
-            old_store.destroy();
-        }
-
-        if (new_store != null) {
-            new_store.conversations.scan_started.connect(on_scan_started);
-            new_store.conversations.scan_completed.connect(on_scan_completed);
-
-            new_store.row_inserted.connect(on_rows_changed);
-            new_store.rows_reordered.connect(on_rows_changed);
-            new_store.row_changed.connect(on_rows_changed);
-            new_store.row_deleted.connect(on_rows_changed);
-            new_store.conversations_removed.connect(on_conversations_removed);
-            new_store.conversations_added.connect(on_conversations_added);
-        }
-
-        // Disconnect the selection handler since we don't want to
-        // fire selection signals while changing the model.
-        Gtk.TreeSelection selection = get_selection();
-        selection.changed.disconnect(on_selection_changed);
-        base.set_model(new_store);
-        this.selected.clear();
-        selection.changed.connect(on_selection_changed);
-    }
-
-    /** Returns a read-only iteration of the current selection. */
-    public Gee.Set<Geary.App.Conversation> get_selected() {
-        return this.selected.read_only_view;
-    }
-
-    /** 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;
+        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);
     }
 
-    public void inhibit_next_autoselect() {
-        this.should_inhibit_autoselect = true;
+    static construct {
+        set_css_name("conversation-list");
     }
 
-    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;
-            }
-
-            set_cursor(target_path, null, false);
+    // -------
+    //   UI
+    // -------
+    private void header_func(Gtk.ListBoxRow row, Gtk.ListBoxRow? before) {
+        if (before != null) {
+            var sep = new Gtk.Separator(Gtk.Orientation.HORIZONTAL);
+            sep.show();
+            row.set_header(sep);
         }
     }
 
-    private void check_load_more() {
-        ConversationListStore? model = get_model();
-        Geary.App.ConversationMonitor? conversations = (model != null)
-            ? model.conversations
-            : null;
-        if (conversations != null) {
-            // Check if we're at the very bottom of the list. If we
-            // are, it's time to issue a load_more signal.
-            Gtk.Adjustment adjustment = ((Gtk.Scrollable) this).get_vadjustment();
-            double upper = adjustment.get_upper();
-            double threshold = upper - adjustment.page_size - LOAD_MORE_HEIGHT;
-            if (this.is_visible() &&
-                conversations.can_load_more &&
-                adjustment.get_value() >= threshold) {
-                load_more();
-            }
-
-            schedule_visible_conversations_changed();
+    /**
+     * 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.update_complete.disconnect(on_model_items_changed);
         }
-    }
-
-    private void on_scan_started() {
-        this.enable_load_more = false;
-    }
-
-    private void on_scan_completed() {
-        this.enable_load_more = true;
-        check_load_more();
-
-        // Select the first conversation, if autoselect is enabled,
-        // nothing has been selected yet and we're not showing a
-        // composer.
-        if (this.config.autoselect &&
-            !this.should_inhibit_autoselect &&
-            get_selection().count_selected_rows() == 0) {
-            var parent = get_toplevel() as Application.MainWindow;
-            if (parent != null && !parent.has_composer) {
-                set_cursor(new Gtk.TreePath.from_indices(0, -1), null, false);
-            }
+        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.update_complete.connect(on_model_items_changed);
         }
-
-        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);
+    /**
+     * Attempt to load more conversations from the current monitor
+     */
+    public void load_more(int request) {
+        if (model != null) {
+            model.load_more(request);
         }
-        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);
-        }
+    private Gtk.Widget row_factory(Object convo_obj) {
+        var convo = (Geary.App.Conversation) convo_obj;
+        var row = new Row(config, convo, this.selection_mode_enabled);
+        row.toggle_flag.connect(on_toggle_flags);
+        row.toggle_selected.connect(on_toggle_selected);
+        return row;
     }
 
-    private Gtk.Adjustment? get_adjustment() {
-        Gtk.ScrolledWindow? parent = get_parent() as Gtk.ScrolledWindow;
-        if (parent == null) {
-            debug("Parent was not scrolled window");
-            return null;
-        }
 
-        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);
-
-        // If the user clicked in an empty area, do nothing.
-        if (path == null)
-            return;
-
-        Geary.App.Conversation? c = get_model().get_conversation_at_path(path);
-        if (c == null)
-            return;
-
-        Gdk.Event event = gesture.get_last_event(gesture.get_current_sequence());
-        Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask();
-
-        Gdk.ModifierType state_mask;
-        event.get_state(out state_mask);
-
-        if ((state_mask & modifiers) == 0 && n_press == 1) {
-            conversation_activated(c, true);
-        } else if ((state_mask & modifiers) == Gdk.ModifierType.SHIFT_MASK && n_press == 2) {
-            conversation_activated(c);
+    // --------------------
+    //  Right-click Popup
+    // --------------------
+    private void context_menu(Row row) {
+        if (!row.is_selected()) {
+            this.list.unselect_all();
+            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;
+        var popup_menu = construct_popover(row, this.list.get_selected_rows().length());
+        popup_menu.popup();
     }
 
-    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;
-            }
-        }
-
-        // Check if changing the selection will require any composers
-        // to be closed, but only on the first click of a
-        // double/triple click, so that double-clicking a draft
-        // doesn't attempt to load it then close it straight away.
-        if (event.type == Gdk.EventType.BUTTON_PRESS &&
-            !get_selection().path_is_selected(path)) {
-            var parent = get_toplevel() as Application.MainWindow;
-            if (parent != null && !parent.close_composer(false)) {
-                return true;
-            }
-        }
-
-        if (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())
+    private Gtk.Popover construct_popover(Row row, uint selection_size) {
+        GLib.Menu context_menu_model = new GLib.Menu();
+        var main = get_toplevel() as Application.MainWindow;
+        if (main != null) {
+            if (!main.is_shift_down) {
                 context_menu_model.append(
-                    _("Mark as _Read"),
+                    /// Translators: Context menu item
+                    ngettext(
+                        "Move conversation to _Trash",
+                        "Move conversations to _Trash",
+                        selection_size
+                    ),
                     Action.Window.prefix(
-                        Application.MainWindow.ACTION_MARK_AS_READ
-                    )
-                );
-
-            if (conversation.has_any_read_message())
-                context_menu_model.append(
-                    _("Mark as _Unread"),
-                    Action.Window.prefix(
-                        Application.MainWindow.ACTION_MARK_AS_UNREAD
-                    )
-                );
-
-            if (conversation.is_flagged()) {
-                context_menu_model.append(
-                    _("U_nstar"),
-                    Action.Window.prefix(
-                        Application.MainWindow.ACTION_MARK_AS_UNSTARRED
+                        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;
-    }
-
-    private void on_style_changed() {
-        // Recalculate dimensions of child cells.
-        ConversationListCellRenderer.style_changed(this);
-
-        schedule_visible_conversations_changed();
-    }
-
-    private void on_value_changed() {
-        if (this.enable_load_more) {
-            check_load_more();
+        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 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);
+        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);
+
+    /**
+     * Emitted when one or more conversations are activated
+     *
+     * If more than one conversation is activated, this signal is emitted
+     * multiple times with the single flag false
+     */
+    public signal void conversation_activated(Geary.App.Conversation activated, bool single = false);
 
-        if (width != 0) {
-            view_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED);
-            view_column.set_fixed_width(width);
+    /**
+     * 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>();
+        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);
+            }
 
-        return view_column;
+        });
     }
 
-    private List<Gtk.TreePath> get_all_selected_paths() {
-        Gtk.TreeModel model;
-        return get_selection().get_selected_rows(out model);
+    /**
+     * Unselects all conversations
+     */
+    public void unselect_all() {
+        this.list.unselect_all();
     }
 
-    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();
-    }
+    // -----------------
+    //  Button Actions
+    // ----------------
 
-    // 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);
-            }
+    /**
+     * 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);
         }
+    }
 
-        // 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);
+    private void on_toggle_selected(ConversationList.Row row, bool selected) {
+        if (selected)
+            this.list.select_row(row);
+        else
+            this.list.unselect_row(row);
+    }
+
+    // ----------------
+    //  Visibility
+    // ---------------
+
+    /**
+     * If the number of pixels between the bottom of the viewport and the bottom of
+     * of the listbox is less than LOAD_MORE_THRESHOLD, request more from the
+     * monitor.
+     */
+    private double LOAD_MORE_THRESHOLD = 100;
+    private int LOAD_MORE_COUNT = 50;
+
+    /**
+     * Called on scroll to possibly load more conversations from the model
+     */
+    private void maybe_load_more(Gtk.Adjustment adjustment) {
+        double upper = adjustment.get_upper();
+        double threshold = upper - adjustment.page_size - LOAD_MORE_THRESHOLD;
+        if (this.is_visible() && adjustment.get_value() >= threshold) {
+            this.load_more(LOAD_MORE_COUNT);
         }
     }
 
-    public Gee.Set<Geary.App.Conversation> get_visible_conversations() {
-        Gee.HashSet<Geary.App.Conversation> visible_conversations = new 
Gee.HashSet<Geary.App.Conversation>();
+    /**
+     * 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();
+        }
 
-        Gtk.TreePath start_path;
-        Gtk.TreePath end_path;
-        if (!get_visible_range(out start_path, out end_path))
-            return visible_conversations;
+        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;
+            }
 
-        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);
+            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()
+            );
 
-            start_path.next();
-        }
+            for (uint i = start_index; i < end_index; i++) {
+                visible_conversations.add(this.model.store.get_item(i) as Geary.App.Conversation);
+            }
 
-        return visible_conversations;
+            this.visible_conversations = visible;
+            return Source.REMOVE;
+        }, GLib.Priority.DEFAULT_IDLE);
     }
 
-    // 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;
-        }
-        return changed;
-    }
+    // ------------
+    // Autoselect
+    // ------------
+    private bool should_inhibit_autoselect = false;
 
-    private void schedule_visible_conversations_changed() {
-        scheduled_update_visible_conversations = Geary.Scheduler.on_idle(update_visible_conversations);
+    /**
+     * Informs the listbox to suppress autoselect behavior on the next update
+     */
+    public void inhibit_next_autoselect() {
+        should_inhibit_autoselect = true;
     }
 
-    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);
-                    }
-                }
+    private void on_model_items_changed() {
+        if (this.config.autoselect &&
+            !this.should_inhibit_autoselect &&
+            this.list.get_selected_rows().length() == 0) {
+
+            Gtk.ListBoxRow first_row = this.list.get_row_at_index(0);
+            if (first_row != null) {
+                this.list.select_row(first_row);
             }
         }
+        this.should_inhibit_autoselect = false;
     }
 
-    private void on_rows_changed() {
-        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 void on_display_preview_changed() {
-        style_updated();
-        model.foreach(refresh_path);
+    private void on_drag_begin(Gdk.DragContext ctx) {
+        int screen_x, screen_y;
+        Gdk.ModifierType _modifier;
+        this.get_window().get_device_position(ctx.get_device(), out screen_x, out screen_y, out _modifier);
 
-        schedule_visible_conversations_changed();
-    }
+        // 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.is_selected()) {
+            this.list.unselect_all();
+            this.list.select_row(row);
+        }
 
-    private bool refresh_path(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) {
-        model.row_changed(path, iter);
-        return false;
-    }
+        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;
 
-    // Enable/disable hover effect on all selected cells.
-    private void set_hover_selected(bool hover) {
-        ConversationListCellRenderer.set_hover_selected(hover);
-        queue_draw();
+        int hot_x, hot_y;
+        this.translate_coordinates(row, screen_x, screen_y, out hot_x, out hot_y);
+        Gtk.drag_set_icon_widget(ctx, this.drag_widget, hot_x, hot_y);
     }
 
-    private bool on_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);
-
-            set_hover_selected(path != null && get_selection().path_is_selected(path));
+    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);
-        }
-        return Gdk.EVENT_PROPAGATE;
-
-    }
+    private void on_selection_mode_changed() {
+        if (this.selection_mode_enabled)
+            this.list.set_selection_mode(Gtk.SelectionMode.MULTIPLE);
+        else
+            this.list.set_selection_mode(Gtk.SelectionMode.SINGLE);
 
-    private void on_vadjustment_changed() {
-        this.vadjustment.value_changed.connect(on_value_changed);
+        this.list.foreach((child) => {
+            var row = (Row) child;
+            row.set_selection_enabled(this.selection_mode_enabled);
+        });
     }
-
 }
diff --git a/src/client/conversation-viewer/conversation-viewer.vala 
b/src/client/conversation-viewer/conversation-viewer.vala
index 983d6539e..0a4f576d5 100644
--- a/src/client/conversation-viewer/conversation-viewer.vala
+++ b/src/client/conversation-viewer/conversation-viewer.vala
@@ -156,9 +156,9 @@ public class ConversationViewer : Gtk.Stack, Geary.BaseInterface {
 
             // XXX move the ConversationListView management code into
             // MainWindow or somewhere more appropriate
-            ConversationListView conversation_list = main_window.conversation_list_view;
-            this.selection_while_composing = conversation_list.copy_selected();
-            conversation_list.get_selection().unselect_all();
+            ConversationList.View conversation_list = main_window.conversation_list_view;
+            this.selection_while_composing = conversation_list.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 b0b11e87c..dfc3af2e4 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/ui/application-main-window.ui b/ui/application-main-window.ui
index 36ae7a780..1a6295ee0 100644
--- a/ui/application-main-window.ui
+++ b/ui/application-main-window.ui
@@ -99,12 +99,6 @@
                             <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>
diff --git a/ui/components-conversation-actions.ui b/ui/components-conversation-actions.ui
index f823dfcf4..7cdb538bd 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,15 +120,15 @@
           </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>
           </object>
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..6c07638bc
--- /dev/null
+++ b/ui/conversation-list-row.ui
@@ -0,0 +1,255 @@
+<?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>
+    <property name="can-focus">False</property>
+    <property name="icon-name">starred-symbolic</property>
+    <property name="use-fallback">True</property>
+    <style>
+      <class name="conversation-row-button"/>
+    </style>
+  </object>
+  <object class="GtkImage" id="read_icon">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="icon-name">mail-read-symbolic</property>
+    <property name="use-fallback">True</property>
+  </object>
+  <object class="GtkImage" id="unflagged_icon">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="icon-name">non-starred-symbolic</property>
+    <property name="use-fallback">True</property>
+  </object>
+  <object class="GtkImage" id="unread_icon">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="pixel-size">16</property>
+    <property name="icon-name">mail-unread-symbolic</property>
+    <property name="use-fallback">True</property>
+  </object>
+  <template class="ConversationListRow" parent="GtkListBoxRow">
+    <property name="can-focus">False</property>
+    <child>
+      <object class="GtkEventBox" id="eventbox">
+        <property name="visible">True</property>
+        <property name="can-focus">False</property>
+        <child>
+          <object class="GtkBox" id="container">
+            <property name="visible">True</property>
+            <property name="can-focus">False</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="can-focus">False</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">True</property>
+                        <property name="focus-on-click">False</property>
+                        <property name="receives-default">True</property>
+                        <property name="image">unread_icon</property>
+                        <property name="relief">none</property>
+                        <property name="valign">center</property>
+                        <property name="always-show-image">True</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">True</property>
+                        <property name="receives-default">True</property>
+                        <property name="image">flagged_icon</property>
+                        <property name="relief">none</property>
+                        <property name="valign">center</property>
+                        <property name="always-show-image">True</property>
+                        <signal name="clicked" handler="on_flagged_button_clicked"/>
+                        <style>
+                          <class name="conversation-ephemeral-button"/>
+                          <class name="flagged-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="can-focus">True</property>
+                    <property name="receives-default">True</property>
+                    <signal name="toggled" handler="on_selected_button_clicked"/>
+                    <style>
+                      <class name="selection-mode"/>
+                    </style>
+                  </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="can-focus">False</property>
+                <property name="orientation">vertical</property>
+                <property name="baseline-position">top</property>
+                <child>
+                  <object class="GtkBox" id="Header">
+                    <property name="visible">True</property>
+                    <property name="can-focus">False</property>
+                    <child>
+                      <object class="GtkLabel" id="participants">
+                        <property name="visible">True</property>
+                        <property name="can-focus">False</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>
+                        <property name="can-focus">False</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="can-focus">False</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>
+                    <property name="can-focus">False</property>
+                    <child>
+                      <object class="GtkLabel" id="preview">
+                        <property name="visible">True</property>
+                        <property name="can-focus">False</property>
+                        <property name="halign">start</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="can-focus">False</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..016eb69d7
--- /dev/null
+++ b/ui/conversation-list-view.ui
@@ -0,0 +1,26 @@
+<?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">True</property>
+    <child>
+      <object class="GtkViewport">
+        <property name="visible">True</property>
+        <property name="can-focus">False</property>
+        <property name="shadow-type">none</property>
+        <child>
+          <object class="GtkListBox" id="list">
+            <property name="name">conversation-list</property>
+            <property name="visible">True</property>
+            <property name="can-focus">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 84533f5ff..3e21bda32 100644
--- a/ui/geary.css
+++ b/ui/geary.css
@@ -57,7 +57,7 @@ geary-conversation-viewer {
 }
 
 infobar flowboxchild {
-       padding: 0px;
+    padding: 0px;
 }
 
 revealer components-conversation-actions {
@@ -66,6 +66,92 @@ 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.flagged .flagged-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 .selection-mode > check  {
+  border-radius: 50%;
+  padding: 6px;
+  margin: 6px;
+}
+
+
 /* FolderPopover */
 
 row.geary-folder-popover-list-row {
@@ -407,11 +493,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 */
@@ -420,6 +506,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;
 }
diff --git a/ui/org.gnome.Geary.gresource.xml b/ui/org.gnome.Geary.gresource.xml
index d25e3b596..c98d476f8 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]