From 43a5b6152b9c896e5e97ca33788dbbafed70810f Mon Sep 17 00:00:00 2001 From: Eric Gregory Date: Tue, 1 Nov 2011 15:49:06 -0700 Subject: [PATCH] Conversation view. Closes #3808 --- src/client/ui/main-window.vala | 149 +++++++++++++----------- src/client/ui/message-list-store.vala | 133 +++++++++------------ src/client/ui/message-list-view.vala | 10 +- src/client/ui/message-viewer.vala | 21 ++-- src/client/util/util-email.vala | 14 +++ src/client/wscript_build | 3 +- src/engine/api/geary-conversation.vala | 28 +++-- src/engine/api/geary-conversations.vala | 43 ++++++- 8 files changed, 233 insertions(+), 168 deletions(-) create mode 100644 src/client/util/util-email.vala diff --git a/src/client/ui/main-window.vala b/src/client/ui/main-window.vala index 048240e7..ab10b9ee 100644 --- a/src/client/ui/main-window.vala +++ b/src/client/ui/main-window.vala @@ -16,19 +16,21 @@ public class MainWindow : Gtk.Window { private MessageViewer message_viewer = new MessageViewer(); private Geary.EngineAccount? account = null; private Geary.Folder? current_folder = null; + private Geary.Conversations? current_conversations = null; private bool second_list_pass_required = false; private int window_width; private int window_height; private bool window_maximized; private Gtk.HPaned folder_paned = new Gtk.HPaned(); private Gtk.HPaned messages_paned = new Gtk.HPaned(); - private Cancellable cancellable = new Cancellable(); + private Cancellable cancellable_folder = new Cancellable(); + private Cancellable cancellable_message = new Cancellable(); public MainWindow() { title = GearyApplication.NAME; message_list_view = new MessageListView(message_list_store); - message_list_view.message_selected.connect(on_message_selected); + message_list_view.conversation_selected.connect(on_conversation_selected); folder_list_view = new FolderListView(folder_list_store); folder_list_view.folder_selected.connect(on_folder_selected); @@ -136,12 +138,12 @@ public class MainWindow : Gtk.Window { // message viewer Gtk.ScrolledWindow message_viewer_scrolled = new Gtk.ScrolledWindow(null, null); message_viewer_scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); - message_viewer_scrolled.add_with_viewport(message_viewer); + message_viewer_scrolled.add(message_viewer); // three-pane display: message list left of current message on bottom separated by // grippable messages_paned.pack1(message_list_scrolled, false, false); - messages_paned.pack2(message_viewer_scrolled, true, false); + messages_paned.pack2(message_viewer_scrolled, true, true); // three-pane display: folder list on left and messages on right separated by grippable folder_paned.pack1(folder_list_scrolled, false, false); @@ -166,61 +168,77 @@ public class MainWindow : Gtk.Window { } private async void do_select_folder(Geary.Folder folder) throws Error { - cancel(); + cancel_folder(); message_list_store.clear(); if (current_folder != null) { - current_folder.messages_appended.disconnect(on_folder_messages_appended); yield current_folder.close_async(); } current_folder = folder; - current_folder.messages_appended.connect(on_folder_messages_appended); - yield current_folder.open_async(true, cancellable); + yield current_folder.open_async(true, cancellable_folder); + + current_conversations = new Geary.Conversations(current_folder, + MessageListStore.REQUIRED_FIELDS); + + current_conversations.monitor_new_messages(cancellable_folder); + + current_conversations.scan_started.connect(on_scan_started); + current_conversations.scan_error.connect(on_scan_error); + current_conversations.scan_completed.connect(on_scan_completed); + current_conversations.conversations_added.connect(on_conversations_added); + current_conversations.conversation_appended.connect(on_conversation_appended); + current_conversations.updated_placeholders.connect(on_updated_placeholders); // Do a quick-list of the messages (which should return what's in the local store) if // supported by the Folder, followed by a complete list if needed second_list_pass_required = current_folder.get_supported_list_flags().is_all_set(Geary.Folder.ListFlags.FAST); - current_folder.lazy_list_email(-1, FETCH_EMAIL_CHUNK_COUNT, MessageListStore.REQUIRED_FIELDS, - current_folder.get_supported_list_flags() & Geary.Folder.ListFlags.FAST, - on_list_email_ready, cancellable); + + // Load all conversations from the DB. + current_conversations.lazy_load(-1, -1, Geary.Folder.ListFlags.FAST, cancellable_folder); } - private void on_list_email_ready(Gee.List? email, Error? err) { - if (email != null && email.size > 0) { - int low = int.MAX; - int high = int.MIN; - foreach (Geary.Email envelope in email) { - if (envelope.location.position < low) - low = envelope.location.position; - - if (envelope.location.position > high) - high = envelope.location.position; - - if (!message_list_store.has_envelope(envelope)) - message_list_store.append_envelope(envelope); - } - debug("Listed %d emails from position %d to %d", email.size, low, high); - } + public void on_scan_started(int low, int count) { + debug("on scan started"); + } + + public void on_scan_error(Error err) { + debug("Scan error: %s", err.message); + } + + public void on_scan_completed() { + debug("on scan completed"); - if (err != null) { - debug("Error while listing email: %s", err.message); - - // TODO: Better error handling here - return; + do_fetch_previews.begin(cancellable_message); + } + + public void on_conversations_added(Gee.Collection conversations) { + debug("on conversation added"); + foreach (Geary.Conversation c in conversations) { + if (!message_list_store.has_conversation(c)) + message_list_store.append_conversation(c); } - - // end of list, go get the previews for them - if (email == null) - do_fetch_previews.begin(cancellable); + } + + public void on_conversation_appended(Geary.Conversation conversation, + Gee.Collection email) { + message_list_store.update_conversation(conversation); + } + + public void on_updated_placeholders(Geary.Conversation conversation, + Gee.Collection email) { + message_list_store.update_conversation(conversation); } private async void do_fetch_previews(Cancellable? cancellable) throws Error { int count = message_list_store.get_count(); for (int ctr = 0; ctr < count; ctr++) { - Geary.Email? email = message_list_store.get_message_at_index(ctr); + Geary.Email? email = message_list_store.get_newest_message_at_index(ctr); + if (email == null) + continue; + Geary.Email? body = yield current_folder.fetch_email_async(email.id, Geary.Email.Field.HEADER | Geary.Email.Field.BODY | Geary.Email.Field.ENVELOPE | Geary.Email.Field.PROPERTIES, cancellable); @@ -231,8 +249,8 @@ public class MainWindow : Gtk.Window { if (second_list_pass_required) { second_list_pass_required = false; debug("Doing second list pass now"); - current_folder.lazy_list_email(-1, FETCH_EMAIL_CHUNK_COUNT, MessageListStore.REQUIRED_FIELDS, - Geary.Folder.ListFlags.NONE, on_list_email_ready, cancellable); + current_conversations.lazy_load(-1, FETCH_EMAIL_CHUNK_COUNT, Geary.Folder.ListFlags.NONE, + cancellable); } } @@ -244,25 +262,26 @@ public class MainWindow : Gtk.Window { } } - private void on_message_selected(Geary.Email? email) { - if (email != null) - do_select_message.begin(email, on_select_message_completed); + private void on_conversation_selected(Geary.Conversation? conversation) { + if (conversation != null) + do_select_message.begin(conversation, on_select_message_completed); } - private async void do_select_message(Geary.Email email) throws Error { + private async void do_select_message(Geary.Conversation conversation) throws Error { if (current_folder == null) { - debug("Message %s selected with no folder selected", email.to_string()); + debug("Conversation selected with no folder selected"); return; } - debug("Fetching email %s", email.to_string()); - - Geary.Email full_email = yield current_folder.fetch_email_async(email.id, - MessageViewer.REQUIRED_FIELDS, cancellable); - + cancel_message(); message_viewer.clear(); - message_viewer.add_message(full_email); + foreach (Geary.Email email in conversation.get_pool_sorted(compare_email)) { + Geary.Email full_email = yield current_folder.fetch_email_async(email.id, + MessageViewer.REQUIRED_FIELDS, cancellable_message); + + message_viewer.add_message(full_email); + } } private void on_select_message_completed(Object? source, AsyncResult result) { @@ -298,22 +317,6 @@ public class MainWindow : Gtk.Window { } } - private void on_folder_messages_appended() { - int high = message_list_store.get_highest_folder_position(); - if (high < 0) { - debug("Unable to find highest message position in %s", current_folder.to_string()); - - return; - } - - debug("Message(s) appended to %s, fetching email at %d and above", current_folder.to_string(), - high + 1); - - // Want to get the one *after* the highest position in the message list - current_folder.lazy_list_email(high + 1, -1, MessageListStore.REQUIRED_FIELDS, - Geary.Folder.ListFlags.NONE, on_list_email_ready, cancellable); - } - private async void search_folders_for_children(Gee.Collection folders) { Gee.ArrayList accumulator = new Gee.ArrayList(); foreach (Geary.Folder folder in folders) { @@ -330,9 +333,17 @@ public class MainWindow : Gtk.Window { on_folders_added_removed(accumulator, null); } - private void cancel() { - Cancellable old_cancellable = cancellable; - cancellable = new Cancellable(); + private void cancel_folder() { + Cancellable old_cancellable = cancellable_folder; + cancellable_folder = new Cancellable(); + cancel_message(); + + old_cancellable.cancel(); + } + + private void cancel_message() { + Cancellable old_cancellable = cancellable_message; + cancellable_message = new Cancellable(); old_cancellable.cancel(); } diff --git a/src/client/ui/message-list-store.vala b/src/client/ui/message-list-store.vala index 5a660160..170237c7 100644 --- a/src/client/ui/message-list-store.vala +++ b/src/client/ui/message-list-store.vala @@ -5,6 +5,7 @@ */ public class MessageListStore : Gtk.TreeStore { + public const Geary.Email.Field REQUIRED_FIELDS = Geary.Email.Field.ENVELOPE | Geary.Email.Field.PROPERTIES; @@ -23,7 +24,7 @@ public class MessageListStore : Gtk.TreeStore { public static Type[] get_types() { return { typeof (FormattedMessageData), // MESSAGE_DATA - typeof (Geary.Email) // MESSAGE_OBJECT + typeof (Geary.Conversation) // MESSAGE_OBJECT }; } @@ -47,39 +48,53 @@ public class MessageListStore : Gtk.TreeStore { } // The Email should've been fetched with REQUIRED_FIELDS. - public void append_envelope(Geary.Email envelope) { - assert(envelope.fields.fulfills(REQUIRED_FIELDS)); - + public void append_conversation(Geary.Conversation conversation) { Gtk.TreeIter iter; append(out iter, null); - set(iter, - Column.MESSAGE_DATA, new FormattedMessageData.from_email(envelope), - Column.MESSAGE_OBJECT, envelope - ); + Gee.SortedSet? pool = conversation.get_pool_sorted(compare_email); - envelope.location.position_deleted.connect(on_email_position_deleted); + if (pool != null) + set(iter, + Column.MESSAGE_DATA, new FormattedMessageData.from_email(pool.first()), + Column.MESSAGE_OBJECT, conversation + ); } - // The Email should've been fetched with REQUIRED_FIELDS. - public bool has_envelope(Geary.Email envelope) { - assert(envelope.fields.fulfills(REQUIRED_FIELDS)); + public void update_conversation(Geary.Conversation conversation) { + Gtk.TreeIter iter; + if (!find_conversation(conversation, out iter)) { + // Unknown conversation, attempt to append it. + append_conversation(conversation); + + return; + } + Gee.SortedSet? pool = conversation.get_pool_sorted(compare_email); + + // Update the preview. + set(iter, Column.MESSAGE_DATA, new FormattedMessageData.from_email(pool.first())); + } + + public bool has_conversation(Geary.Conversation conversation) { int count = get_count(); for (int ctr = 0; ctr < count; ctr++) { - Geary.Email? email = get_message_at_index(ctr); - if (email == null) - break; - - if (email.location.position == envelope.location.position) + if (conversation == get_conversation_at_index(ctr)) return true; } return false; } - public Geary.Email? get_message_at_index(int index) { - return get_message_at(new Gtk.TreePath.from_indices(index, -1)); + public Geary.Conversation? get_conversation_at_index(int index) { + return get_conversation_at(new Gtk.TreePath.from_indices(index, -1)); + } + + public Geary.Email? get_newest_message_at_index(int index) { + Geary.Conversation? c = get_conversation_at_index(index); + Gee.SortedSet? pool = c.get_pool_sorted(compare_email); + + return pool != null ? pool.first() : null; } public void set_preview_at_index(int index, Geary.Email email) { @@ -97,77 +112,43 @@ public class MessageListStore : Gtk.TreeStore { return iter_n_children(null); } - public Geary.Email? get_message_at(Gtk.TreePath path) { - Gtk.TreeIter iter; + public Geary.Conversation? get_conversation_at(Gtk.TreePath path) { + Gtk.TreeIter iter; if (!get_iter(out iter, path)) return null; - Geary.Email email; - get(iter, Column.MESSAGE_OBJECT, out email); + Geary.Conversation? conversation; + get(iter, Column.MESSAGE_OBJECT, out conversation); - return email; + return conversation; } - // Returns -1 if the list is empty. - public int get_highest_folder_position() { - Gtk.TreeIter iter; - if (!get_iter_first(out iter)) - return -1; - - int high = int.MIN; - - // TODO: It would be more efficient to maintain highest and lowest values in a table or - // as items are added and removed; this will do for now. - do { - Geary.Email email; - get(iter, Column.MESSAGE_OBJECT, out email); - - if (email.location.position > high) - high = email.location.position; - } while (iter_next(ref iter)); - - return high; - } - - private bool remove_at_position(int position) { - Gtk.TreeIter iter; - if (!get_iter_first(out iter)) - return false; - - do { - Geary.Email email; - get(iter, Column.MESSAGE_OBJECT, out email); - - if (email.location.position == position) { - remove(iter); - - email.location.position_deleted.disconnect(on_email_position_deleted); - - return true; - } - } while (iter_next(ref iter)); + private bool find_conversation(Geary.Conversation conversation, out Gtk.TreeIter iter) { + iter = Gtk.TreeIter(); + int count = get_count(); + for (int ctr = 0; ctr < count; ctr++) { + if (conversation == get_conversation_at_index(ctr)) + return get_iter(out iter, new Gtk.TreePath.from_indices(ctr, -1)); + } return false; } private int sort_by_date(Gtk.TreeModel model, Gtk.TreeIter aiter, Gtk.TreeIter biter) { - Geary.Email aenvelope; - get(aiter, Column.MESSAGE_OBJECT, out aenvelope); + Geary.Conversation a, b; - Geary.Email benvelope; - get(biter, Column.MESSAGE_OBJECT, out benvelope); + get(aiter, Column.MESSAGE_OBJECT, out a); + get(biter, Column.MESSAGE_OBJECT, out b); - int diff = aenvelope.date.value.compare(benvelope.date.value); - if (diff != 0) - return diff; + Gee.SortedSet? apool = a.get_pool_sorted(compare_email); + Gee.SortedSet? bpool = b.get_pool_sorted(compare_email); - // stabilize sort by using the mail's position, which is always unique in a folder - return aenvelope.location.position - benvelope.location.position; - } - - private void on_email_position_deleted(int position) { - if (!remove_at_position(position)) - debug("on_email_position_deleted: unable to find email at position %d", position); + if (apool == null || apool.first() == null) + return -1; + else if (bpool == null || bpool.first() == null) + return 1; + + return compare_email(apool.first(), bpool.first()); } } diff --git a/src/client/ui/message-list-view.vala b/src/client/ui/message-list-view.vala index 7094f380..3037e361 100644 --- a/src/client/ui/message-list-view.vala +++ b/src/client/ui/message-list-view.vala @@ -5,7 +5,7 @@ */ public class MessageListView : Gtk.TreeView { - public signal void message_selected(Geary.Email? email); + public signal void conversation_selected(Geary.Conversation? conversation); public MessageListView(MessageListStore store) { set_model(store); @@ -48,14 +48,14 @@ public class MessageListView : Gtk.TreeView { Gtk.TreeModel model; Gtk.TreePath? path = get_selection().get_selected_rows(out model).nth_data(0); if (path == null) { - message_selected(null); + conversation_selected(null); return; } - Geary.Email? email = get_store().get_message_at(path); - if (email != null) - message_selected(email); + Geary.Conversation? conversation = get_store().get_conversation_at(path); + if (conversation != null) + conversation_selected(conversation); } } diff --git a/src/client/ui/message-viewer.vala b/src/client/ui/message-viewer.vala index 301e94d6..2e0c3e76 100644 --- a/src/client/ui/message-viewer.vala +++ b/src/client/ui/message-viewer.vala @@ -4,7 +4,7 @@ * (version 2.1 or later). See the COPYING file in this distribution. */ -public class MessageViewer : Gtk.EventBox { +public class MessageViewer : Gtk.Viewport { public const Geary.Email.Field REQUIRED_FIELDS = Geary.Email.Field.HEADER | Geary.Email.Field.BODY @@ -15,6 +15,7 @@ public class MessageViewer : Gtk.EventBox { private const int HEADER_COL_SPACING = 10; private const int HEADER_ROW_SPACING = 3; + private const int MESSAGE_BOX_MARGIN = 10; // List of emails corresponding with VBox. private Gee.LinkedList messages = new Gee.LinkedList(); @@ -48,7 +49,7 @@ public class MessageViewer : Gtk.EventBox { border-color: #cccccc; border-style: solid; border-width: 1; - -GtkWidget-separator-height: 1; + -GtkWidget-separator-height: 2; } """; @@ -77,12 +78,10 @@ public class MessageViewer : Gtk.EventBox { } // Only include to string if it's not just this account. - // TODO: ? get_replies(Geary.ConversationNode node); - /** - * Returns all ConversationNodes in the conversation, which can then be sorted by the caller's - * own requirements. - * + /** + * Returns all emails in the conversation. + * Only returns nodes that have an e-mail. */ - public virtual Gee.Collection? get_pool() { - Gee.HashSet pool = new Gee.HashSet(); + public virtual Gee.Set? get_pool() { + Gee.HashSet pool = new Gee.HashSet(); gather(pool, get_origin()); return (pool.size > 0) ? pool : null; } - private void gather(Gee.Set pool, ConversationNode? current) { + /** + * Returns all emails in the conversation, sorted by compare_func. + * Only returns nodes that have an e-mail. + */ + public virtual Gee.SortedSet? get_pool_sorted(CompareFunc? + compare_func = null) { + Gee.TreeSet pool = new Gee.TreeSet(compare_func); + gather(pool, get_origin()); + + return (pool.size > 0) ? pool : null; + } + + private void gather(Gee.Set pool, ConversationNode? current) { if (current == null) return; - pool.add(current); + if (current.get_email() != null) + pool.add(current.get_email()); Gee.Collection? children = get_replies(current); if (children != null) { diff --git a/src/engine/api/geary-conversations.vala b/src/engine/api/geary-conversations.vala index 4d10cfa6..78891411 100644 --- a/src/engine/api/geary-conversations.vala +++ b/src/engine/api/geary-conversations.vala @@ -166,6 +166,8 @@ public class Geary.Conversations : Object { private Gee.Map id_map = new Gee.HashMap(Geary.Hashable.hash_func, Geary.Equalable.equal_func); private Gee.Set conversations = new Gee.HashSet(); + private bool monitor_new = false; + private Cancellable? cancellable_monitor = null; public virtual signal void scan_started(int low, int count) { } @@ -196,6 +198,9 @@ public class Geary.Conversations : Object { // Manually detach all the weak refs in the Conversation objects foreach (ImplConversation conversation in conversations) conversation.owner = null; + + if (monitor_new) + folder.messages_appended.disconnect(on_folder_messages_appended); } protected virtual void notify_scan_started(int low, int count) { @@ -247,6 +252,16 @@ public class Geary.Conversations : Object { folder.lazy_list_email(low, count, required_fields, flags, on_email_listed, cancellable); } + public bool monitor_new_messages(Cancellable? cancellable = null) { + if (monitor_new) + return false; + + monitor_new = true; + cancellable_monitor = cancellable; + folder.messages_appended.connect(on_folder_messages_appended); + return true; + } + private void on_email_listed(Gee.List? emails, Error? err) { if (err != null) notify_scan_error(err); @@ -340,7 +355,7 @@ public class Geary.Conversations : Object { RFC822.MessageID ancestor_id = ancestors[ctr]; if (seen.contains(ancestor_id)) { - warning("Loop detected in conversation: %s seen twice", ancestor_id.to_string()); + message("Loop detected in conversation: %s seen twice", ancestor_id.to_string()); continue; } @@ -453,5 +468,31 @@ public class Geary.Conversations : Object { } } } + + private void on_folder_messages_appended() { + // Find highest position. + // TODO: optimize. + int high = -1; + foreach (Conversation c in conversations) + foreach (Email e in c.get_pool()) + if (e.location.position > high) + high = e.location.position; + + if (high < 0) { + debug("Unable to find highest message position in %s", folder.to_string()); + + return; + } + + debug("Message(s) appended to %s, fetching email at %d and above", folder.to_string(), + high + 1); + + // Want to get the one *after* the highest position in the list + try { + lazy_load(high + 1, -1, Folder.ListFlags.NONE, cancellable_monitor); + } catch (Error e) { + warning("Error getting new mail: %s", e.message); + } + } }