[geary/wip/135-contact-popovers: 57/62] Update AvatarStore to load from contacts rather than mailboxes



commit d637623d7a75e82deb5aabcca51a6afe377f03ac
Author: Michael Gratton <mike vee net>
Date:   Sun Mar 17 13:51:21 2019 +1100

    Update AvatarStore to load from contacts rather than mailboxes
    
    Require a Contact instance when loading avatars and fix call sites,
    remove Folks lookup and just get individuals from contacts instead,
    convert to use external LRU cache implementation.
    
    Provide a means for notication libs to access AvatarStore and
    ContactStore instances via NewMessagesMonitor, so they can lookup and
    display contacts and avatars.

 .../application/application-avatar-store.vala      | 204 ++++++++-------------
 src/client/application/application-contact.vala    |   4 +-
 src/client/application/geary-controller.vala       |  22 ++-
 .../conversation-contact-popover.vala              |   1 +
 .../conversation-viewer/conversation-message.vala  |   5 +-
 src/client/notification/libnotify.vala             |  80 ++++----
 src/client/notification/new-messages-monitor.vala  |  38 +++-
 7 files changed, 178 insertions(+), 176 deletions(-)
---
diff --git a/src/client/application/application-avatar-store.vala 
b/src/client/application/application-avatar-store.vala
index e57bcf34..03dfdd09 100644
--- a/src/client/application/application-avatar-store.vala
+++ b/src/client/application/application-avatar-store.vala
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016-2018 Michael Gratton <mike vee net>
+ * Copyright 2016-2019 Michael Gratton <mike vee net>
  *
  * This software is licensed under the GNU Lesser General Public License
  * (version 2.1 or later). See the COPYING file in this distribution.
@@ -8,17 +8,25 @@
 
 /**
  * Email address avatar loader and cache.
+ *
+ * Avatars are loaded from a {@link Contact} object's Folks individual
+ * if present, else one will be generated using initials and
+ * background colour based on the of the source mailbox's name if
+ * present, or address. Avatars are cached at each requested logical
+ * pixel size, per Folks individual and then per source mailbox
+ * name. This strategy allows avatar bitmaps to reflect the desktop
+ * address-book's user picture if present, else provide individualised
+ * avatars, even for mail sent by software like Mailman and Discourse.
+ *
+ * Unlike {@link ContactStore}, once store instance is useful for
+ * loading and caching avatars across accounts.
  */
 public class Application.AvatarStore : Geary.BaseObject {
 
 
-    /** Default size of avatar images, in toolkit pixels */
+    /** Default size of avatar images, in virtual pixels */
     public const int PIXEL_SIZE = 48;
 
-    // Max age is low since we really only want to cache between
-    // conversation loads.
-    private const int64 MAX_CACHE_AGE_US = 5 * 1000 * 1000;
-
     // Max size is low since most conversations don't get above the
     // low hundreds of messages, and those that do will likely get
     // many repeated participants
@@ -28,39 +36,31 @@ public class Application.AvatarStore : Geary.BaseObject {
     private class CacheEntry {
 
 
-        public static string to_key(Geary.RFC822.MailboxAddress mailbox) {
+        public static string to_name_key(Geary.RFC822.MailboxAddress source) {
             // Use short name as the key, since it will use the name
             // first, then the email address, which is especially
             // important for things like GitLab email where the
             // address is always the same, but the name changes. This
             // ensures that each such user gets different initials.
-            return mailbox.to_short_display().normalize().casefold();
-        }
-
-        public static int lru_compare(CacheEntry a, CacheEntry b) {
-            return (a.key == b.key)
-                ? 0 : (int) (a.last_used - b.last_used);
+            return source.to_short_display().normalize().casefold();
         }
 
+        public Contact contact;
+        public Geary.RFC822.MailboxAddress source;
 
-        public string key;
-
-        public Geary.RFC822.MailboxAddress mailbox;
+        private Gee.List<Gdk.Pixbuf> pixbufs = new Gee.LinkedList<Gdk.Pixbuf>();
 
-        // Store nulls so we can also cache avatars not found
-        public Folks.Individual? individual;
 
-        public int64 last_used;
+        public CacheEntry(Contact contact,
+                          Geary.RFC822.MailboxAddress source) {
+            this.contact = contact;
+            this.source = source;
 
-        private Gee.List<Gdk.Pixbuf> pixbufs = new Gee.LinkedList<Gdk.Pixbuf>();
+            contact.changed.connect(on_contact_changed);
+        }
 
-        public CacheEntry(Geary.RFC822.MailboxAddress mailbox,
-                          Folks.Individual? individual,
-                          int64 last_used) {
-            this.key = to_key(mailbox);
-            this.mailbox = mailbox;
-            this.individual = individual;
-            this.last_used = last_used;
+        ~CacheEntry() {
+            this.contact.changed.disconnect(on_contact_changed);
         }
 
         public async Gdk.Pixbuf? load(int pixel_size,
@@ -76,11 +76,13 @@ public class Application.AvatarStore : Geary.BaseObject {
             }
 
             if (pixbuf == null) {
-                Folks.Individual? individual = this.individual;
-                if (individual != null && individual.avatar != null) {
-                    GLib.InputStream data = yield individual.avatar.load_async(
-                        pixel_size, cancellable
-                    );
+                Folks.Individual? individual = contact.individual;
+                if (individual != null &&
+                    individual.avatar != null) {
+                    GLib.InputStream data =
+                        yield individual.avatar.load_async(
+                            pixel_size, cancellable
+                        );
                     pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async(
                         data, pixel_size, pixel_size, true, cancellable
                     );
@@ -91,23 +93,14 @@ public class Application.AvatarStore : Geary.BaseObject {
 
             if (pixbuf == null) {
                 string? name = null;
-                // XXX should really be using the folks display name
-                // here as below, but since we should the name from
-                // the email address if present in
-                // ConversationMessage, and since that might not match
-                // the folks display name, it is confusing when the
-                // initials are one thing and the name is
-                // another. Re-enable below when we start using the
-                // folks display name in ConversationEmail
-                name = this.mailbox.to_short_display();
-                // if (this.individual != null) {
-                //     name = this.individual.display_name;
-                // } else {
-                //     // Use short display because it will clean up the
-                //     // string, use the name if present and fall back
-                //     // on the address if not.
-                //     name = this.mailbox.to_short_display();
-                // }
+                if (this.contact.is_trusted) {
+                    name = this.contact.display_name;
+                } else {
+                    // Use short display because it will clean up the
+                    // string, use the name if present and fall back
+                    // on the address if not.
+                    name = this.source.to_short_display();
+                }
                 pixbuf = Util.Avatar.generate_user_picture(name, pixel_size);
                 pixbuf = Util.Avatar.round_image(pixbuf);
                 this.pixbufs.add(pixbuf);
@@ -116,100 +109,61 @@ public class Application.AvatarStore : Geary.BaseObject {
             return pixbuf;
         }
 
+        private void on_contact_changed() {
+            this.pixbufs.clear();
+        }
+
     }
 
 
-    private Folks.IndividualAggregator individuals;
-    private Gee.Map<string,CacheEntry> lru_cache =
-        new Gee.HashMap<string,CacheEntry>();
-    private Gee.SortedSet<CacheEntry> lru_ordering =
-        new Gee.TreeSet<CacheEntry>(CacheEntry.lru_compare);
+    // Folks cache used for storing contacts backed by a Folk
+    // individual. This is the primary cache since we want to use the
+    // details for avatar and display name lookup from the desktop
+    // address-book if available.
+    private Util.Cache.Lru<CacheEntry> folks_cache =
+        new Util.Cache.Lru<CacheEntry>(MAX_CACHE_SIZE);
 
+    // Name cache uses the source mailbox's short name as the key,
+    // since this will make avatar initials match well. It is used to
+    // cache avatars for contacts not saved in the desktop
+    // address-book.
+    private Util.Cache.Lru<CacheEntry> name_cache =
+        new Util.Cache.Lru<CacheEntry>(MAX_CACHE_SIZE);
 
-    public AvatarStore(Folks.IndividualAggregator individuals) {
-        this.individuals = individuals;
-    }
 
+    /** Closes the store, flushing all caches. */
     public void close() {
-        this.lru_cache.clear();
-        this.lru_ordering.clear();
+        this.folks_cache.clear();
+        this.name_cache.clear();
     }
 
-    public async Gdk.Pixbuf? load(Geary.RFC822.MailboxAddress mailbox,
+    public async Gdk.Pixbuf? load(Contact contact,
+                                  Geary.RFC822.MailboxAddress source,
                                   int pixel_size,
                                   GLib.Cancellable cancellable)
         throws GLib.Error {
-        // Normalise the address to improve caching
-        CacheEntry match = yield get_match(mailbox);
-        return yield match.load(pixel_size, cancellable);
-    }
-
-
-    private async CacheEntry get_match(Geary.RFC822.MailboxAddress mailbox)
-        throws GLib.Error {
-        string key = CacheEntry.to_key(mailbox);
-        int64 now = GLib.get_monotonic_time();
-        CacheEntry? entry = this.lru_cache.get(key);
-        if (entry != null) {
-            if (entry.last_used + MAX_CACHE_AGE_US >= now) {
-                // Need to remove the entry from the ordering before
-                // updating the last used time since doing so changes
-                // the ordering
-                this.lru_ordering.remove(entry);
-                entry.last_used = now;
-                this.lru_ordering.add(entry);
-            } else {
-                this.lru_cache.unset(key);
-                this.lru_ordering.remove(entry);
-                entry = null;
+        CacheEntry hit = null;
+        if (contact.is_desktop_contact && contact.is_trusted) {
+            string key = contact.individual.id;
+            hit = this.folks_cache.get_entry(key);
+
+            if (hit == null) {
+                hit = new CacheEntry(contact, source);
+                this.folks_cache.set_entry(key, hit);
             }
         }
 
-        if (entry == null) {
-            Folks.Individual? match = yield search_match(mailbox.address);
-            entry = new CacheEntry(mailbox, match, now);
-            this.lru_cache.set(key, entry);
-            this.lru_ordering.add(entry);
-
-            // Prune the cache if needed
-            if (this.lru_cache.size > MAX_CACHE_SIZE) {
-                CacheEntry oldest = this.lru_ordering.first();
-                this.lru_cache.unset(oldest.key);
-                this.lru_ordering.remove(oldest);
-            }
-        }
-
-        return entry;
-    }
-
-    private async Folks.Individual? search_match(string address)
-        throws GLib.Error {
-        Folks.SearchView view = new Folks.SearchView(
-            this.individuals,
-            new Folks.SimpleQuery(
-                address,
-                new string[] {
-                    Folks.PersonaStore.detail_key(
-                        Folks.PersonaDetail.EMAIL_ADDRESSES
-                    )
-                }
-            )
-        );
-
-        yield view.prepare();
+        if (hit == null) {
+            string key = CacheEntry.to_name_key(source);
+            hit = this.name_cache.get_entry(key);
 
-        Folks.Individual? match = null;
-        if (!view.individuals.is_empty) {
-            match = view.individuals.first();
-        }
-
-        try {
-            yield view.unprepare();
-        } catch (GLib.Error err) {
-            warning("Error unpreparing Folks search: %s", err.message);
+            if (hit == null) {
+                hit = new CacheEntry(contact, source);
+                this.name_cache.set_entry(key, hit);
+            }
         }
 
-        return match;
+        return yield hit.load(pixel_size, cancellable);
     }
 
 }
diff --git a/src/client/application/application-contact.vala b/src/client/application/application-contact.vala
index 67c8d661..124bb01e 100644
--- a/src/client/application/application-contact.vala
+++ b/src/client/application/application-contact.vala
@@ -59,8 +59,10 @@ public class Application.Contact : Geary.BaseObject {
     public signal void changed();
 
 
+    /** The Folks individual for the contact, if any. */
+    internal Folks.Individual? individual { get; private set; }
+
     private weak ContactStore store;
-    private Folks.Individual? individual;
     private Geary.Contact? contact;
 
 
diff --git a/src/client/application/geary-controller.vala b/src/client/application/geary-controller.vala
index bf57245f..11bb6051 100644
--- a/src/client/application/geary-controller.vala
+++ b/src/client/application/geary-controller.vala
@@ -125,7 +125,7 @@ public class GearyController : Geary.BaseObject {
     public AutostartManager? autostart_manager { get; private set; default = null; }
 
     public Application.AvatarStore? avatars {
-        get; private set; default = null;
+        get; private set; default = new Application.AvatarStore();
     }
 
     public ContactListStoreCache contact_list_store_cache {
@@ -276,7 +276,6 @@ public class GearyController : Geary.BaseObject {
                 error("Error preparing Folks: %s", err.message);
             }
         }
-        this.avatars = new Application.AvatarStore(this.folks);
 
         // Create the main window (must be done after creating actions.)
         main_window = new MainWindow(this.application);
@@ -305,7 +304,11 @@ public class GearyController : Geary.BaseObject {
         main_window.conversation_viewer.conversation_added.connect(
             on_conversation_view_added
         );
-        new_messages_monitor = new NewMessagesMonitor(should_notify_new_messages);
+        this.new_messages_monitor = new NewMessagesMonitor(
+            this.avatars,
+            this.get_contact_store_for_account,
+            this.should_notify_new_messages
+        );
         main_window.folder_list.set_new_messages_monitor(new_messages_monitor);
 
         // New messages indicator (Ubuntuism)
@@ -316,9 +319,7 @@ public class GearyController : Geary.BaseObject {
 
         unity_launcher = new UnityLauncher(new_messages_monitor);
 
-        this.libnotify = new Libnotify(
-            this.new_messages_monitor, this.avatars
-        );
+        this.libnotify = new Libnotify(this.new_messages_monitor);
         this.libnotify.invoked.connect(on_libnotify_invoked);
 
         this.main_window.conversation_list_view.grab_focus();
@@ -1012,6 +1013,8 @@ public class GearyController : Geary.BaseObject {
         main_window.folder_list.remove_account(account);
 
         context.cancellable.cancel();
+        context.contacts.close();
+
         Geary.Folder? inbox = context.inbox;
         if (inbox != null) {
             try {
@@ -2821,7 +2824,12 @@ public class GearyController : Geary.BaseObject {
 
     private inline Geary.App.EmailStore? get_email_store_for_folder(Geary.Folder target) {
         AccountContext? context = this.accounts.get(target.account.information);
-        return context != null ? context.emails : null;
+        return (context != null) ? context.emails : null;
+    }
+
+    private Application.ContactStore? get_contact_store_for_account(Geary.Account target) {
+        AccountContext? context = this.accounts.get(target.information);
+        return (context != null) ? context.contacts : null;
     }
 
     private bool should_add_folder(Gee.Collection<Geary.Folder>? all,
diff --git a/src/client/conversation-viewer/conversation-contact-popover.vala 
b/src/client/conversation-viewer/conversation-contact-popover.vala
index 38c5615b..b22dc697 100644
--- a/src/client/conversation-viewer/conversation-contact-popover.vala
+++ b/src/client/conversation-viewer/conversation-contact-popover.vala
@@ -96,6 +96,7 @@ public class Conversation.ContactPopover : Gtk.Popover {
             int pixel_size = Application.AvatarStore.PIXEL_SIZE * window_scale;
             try {
                 Gdk.Pixbuf? avatar_buf = yield loader.load(
+                    this.contact,
                     this.mailbox,
                     pixel_size,
                     this.load_cancellable
diff --git a/src/client/conversation-viewer/conversation-message.vala 
b/src/client/conversation-viewer/conversation-message.vala
index e7daec28..a61db46d 100644
--- a/src/client/conversation-viewer/conversation-message.vala
+++ b/src/client/conversation-viewer/conversation-message.vala
@@ -644,7 +644,10 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
                 int window_scale = get_scale_factor();
                 int pixel_size = Application.AvatarStore.PIXEL_SIZE * window_scale;
                 Gdk.Pixbuf? avatar_buf = yield loader.load(
-                    this.primary_originator, pixel_size, cancellable
+                    this.primary_contact,
+                    this.primary_originator,
+                    pixel_size,
+                    cancellable
                 );
                 if (avatar_buf != null) {
                     this.avatar.set_from_surface(
diff --git a/src/client/notification/libnotify.vala b/src/client/notification/libnotify.vala
index 11bf793f..dbce30bf 100644
--- a/src/client/notification/libnotify.vala
+++ b/src/client/notification/libnotify.vala
@@ -9,12 +9,9 @@ public class Libnotify : Geary.BaseObject {
     public const Geary.Email.Field REQUIRED_FIELDS =
         Geary.Email.Field.ORIGINATORS | Geary.Email.Field.SUBJECT;
 
-    private const int AVATAR_SIZE = 32;
-
     private static Canberra.Context? sound_context = null;
 
     private weak NewMessagesMonitor monitor;
-    private weak Application.AvatarStore avatars;
     private Notify.Notification? current_notification = null;
     private Notify.Notification? error_notification = null;
     private Geary.Folder? folder = null;
@@ -23,10 +20,8 @@ public class Libnotify : Geary.BaseObject {
 
     public signal void invoked(Geary.Folder? folder, Geary.Email? email);
 
-    public Libnotify(NewMessagesMonitor monitor,
-                     Application.AvatarStore avatars) {
+    public Libnotify(NewMessagesMonitor monitor) {
         this.monitor = monitor;
-        this.avatars = avatars;
 
         monitor.add_required_fields(REQUIRED_FIELDS);
 
@@ -51,8 +46,11 @@ public class Libnotify : Geary.BaseObject {
     private void on_new_messages_arrived(Geary.Folder folder, int total, int added) {
         if (added == 1 && monitor.last_new_message_folder != null &&
             monitor.last_new_message != null) {
-            notify_one_message_async.begin(monitor.last_new_message_folder,
-                monitor.last_new_message, null);
+            notify_one_message_async.begin(
+                monitor.last_new_message_folder,
+                monitor.last_new_message,
+                null
+            );
         } else if (added > 0) {
             notify_new_mail(folder, added);
         }
@@ -82,10 +80,9 @@ public class Libnotify : Geary.BaseObject {
         issue_current_notification(folder.account.information.display_name, body, null);
     }
 
-    private async void notify_one_message_async(Geary.Folder folder, Geary.Email email,
-        GLib.Cancellable? cancellable) throws GLib.Error {
-        assert(email.fields.fulfills(REQUIRED_FIELDS));
-
+    private async void notify_one_message_async(Geary.Folder folder,
+                                                Geary.Email email,
+                                                GLib.Cancellable? cancellable) throws GLib.Error {
         // used if notification is invoked
         this.folder = folder;
         this.email = email;
@@ -94,34 +91,45 @@ public class Libnotify : Geary.BaseObject {
             !monitor.should_notify_new_messages(folder))
             return;
 
-        // possible to receive email with no originator
-        Geary.RFC822.MailboxAddress? primary =
+        Geary.RFC822.MailboxAddress? originator =
             Util.Email.get_primary_originator(email);
-        if (primary == null) {
-            notify_new_mail(folder, 1);
-
-            return;
-        }
+        if (originator != null) {
+            Application.ContactStore contacts =
+                this.monitor.get_contact_store(folder.account);
+            Application.Contact contact = yield contacts.load(
+                originator, cancellable
+            );
+
+            string body;
+            int count = monitor.get_new_message_count(folder);
+            if (count <= 1) {
+                body = Util.Email.strip_subject_prefixes(email);
+            } else {
+                body = ngettext(
+                    "%s\n(%d other new message for %s)",
+                    "%s\n(%d other new messages for %s)", count - 1).printf(
+                        Util.Email.strip_subject_prefixes(email),
+                        count - 1,
+                        folder.account.information.display_name
+                    );
+            }
 
-        string body;
-        int count = monitor.get_new_message_count(folder);
-        if (count <= 1) {
-            body = Util.Email.strip_subject_prefixes(email);
+            Gdk.Pixbuf? avatar = yield this.monitor.avatars.load(
+                contact,
+                originator,
+                Application.AvatarStore.PIXEL_SIZE,
+                cancellable
+            );
+
+            issue_current_notification(
+                contact.is_trusted
+                    ? contact.display_name : originator.to_short_display(),
+                body,
+                avatar
+            );
         } else {
-            body = ngettext(
-                "%s\n(%d other new message for %s)",
-                "%s\n(%d other new messages for %s)", count - 1).printf(
-                    Util.Email.strip_subject_prefixes(email),
-                    count - 1,
-                    folder.account.information.display_name
-                );
+            notify_new_mail(folder, 1);
         }
-
-        Gdk.Pixbuf? avatar = yield this.avatars.load(
-            primary, AVATAR_SIZE, cancellable
-        );
-
-        issue_current_notification(primary.to_short_display(), body, avatar);
     }
 
     private void issue_current_notification(string summary, string body, Gdk.Pixbuf? icon) {
diff --git a/src/client/notification/new-messages-monitor.vala 
b/src/client/notification/new-messages-monitor.vala
index c18c2edc..71c2e807 100644
--- a/src/client/notification/new-messages-monitor.vala
+++ b/src/client/notification/new-messages-monitor.vala
@@ -12,8 +12,17 @@
 // flags to the object with add_required_fields().
 
 public class NewMessagesMonitor : Geary.BaseObject {
+
+
+    /** Monitor hook for obtaining a contact store for an account. */
+    public delegate Application.ContactStore? GetContactStore(
+        Geary.Account account
+    );
+
+    /** Monitor hook to determine if a folder should be notified about. */
     public delegate bool ShouldNotifyNewMessages(Geary.Folder folder);
 
+
     private class MonitorInformation : Geary.BaseObject {
         public Geary.Folder folder;
         public Cancellable? cancellable = null;
@@ -32,9 +41,16 @@ public class NewMessagesMonitor : Geary.BaseObject {
     public Geary.Folder? last_new_message_folder { get; private set; default = null; }
     public Geary.Email? last_new_message { get; private set; default = null; }
 
-    private Gee.HashMap<Geary.Folder, MonitorInformation> folder_information
-        = new Gee.HashMap<Geary.Folder, MonitorInformation>();
-    private unowned ShouldNotifyNewMessages? _should_notify_new_messages;
+    /** Returns an avatar store to lookup avatars for notifications. */
+    public Application.AvatarStore avatars { get; private set; }
+
+
+    private Gee.Map<Geary.Folder, MonitorInformation> folder_information =
+        new Gee.HashMap<Geary.Folder, MonitorInformation>();
+
+    private unowned GetContactStore contact_store_delegate;
+    private unowned ShouldNotifyNewMessages notify_delegate;
+
 
     public signal void folder_added(Geary.Folder folder);
 
@@ -51,12 +67,22 @@ public class NewMessagesMonitor : Geary.BaseObject {
      */
     public signal void new_messages_retired(Geary.Folder folder, int total);
 
-    public NewMessagesMonitor(ShouldNotifyNewMessages? should_notify_new_messages) {
-        _should_notify_new_messages = should_notify_new_messages;
+    public NewMessagesMonitor(Application.AvatarStore avatars,
+                              GetContactStore contact_store_delegate,
+                              ShouldNotifyNewMessages notify_delegate) {
+        this.avatars = avatars;
+        this.contact_store_delegate = contact_store_delegate;
+        this.notify_delegate = notify_delegate;
     }
 
+    /** Determines if notifications should be made for a specific folder. */
     public bool should_notify_new_messages(Geary.Folder folder) {
-        return (_should_notify_new_messages == null ? true : _should_notify_new_messages(folder));
+        return this.notify_delegate(folder);
+    }
+
+    /** Returns a contact store to lookup contacts for notifications. */
+    public Application.ContactStore? get_contact_store(Geary.Account account) {
+        return this.contact_store_delegate(account);
     }
 
     public void add_folder(Geary.Folder folder, Cancellable? cancellable = null) {


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