avatar: Use HdyAvatar for displaying avatars

This commit is contained in:
Julian Sparber 2021-01-14 17:33:58 +01:00 committed by Michael James Gratton
parent baf29a9214
commit 6a052031df
11 changed files with 66 additions and 273 deletions

View file

@ -1,166 +0,0 @@
/*
* 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.
*/
/**
* 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.
*/
internal class Application.AvatarStore : Geary.BaseObject {
// 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
private const uint MAX_CACHE_SIZE = 128;
private class CacheEntry {
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 source.to_short_display().normalize().casefold();
}
public Contact contact;
public Geary.RFC822.MailboxAddress source;
private Gee.List<Gdk.Pixbuf> pixbufs = new Gee.LinkedList<Gdk.Pixbuf>();
public CacheEntry(Contact contact,
Geary.RFC822.MailboxAddress source) {
this.contact = contact;
this.source = source;
contact.changed.connect(on_contact_changed);
}
~CacheEntry() {
this.contact.changed.disconnect(on_contact_changed);
}
public async Gdk.Pixbuf? load(int pixel_size,
GLib.Cancellable cancellable)
throws GLib.Error {
Gdk.Pixbuf? pixbuf = null;
foreach (Gdk.Pixbuf cached in this.pixbufs) {
if ((cached.height == pixel_size && cached.width >= pixel_size) ||
(cached.width == pixel_size && cached.height >= pixel_size)) {
pixbuf = cached;
break;
}
}
if (pixbuf == null) {
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
);
pixbuf = Util.Avatar.round_image(pixbuf);
this.pixbufs.add(pixbuf);
}
}
if (pixbuf == null) {
string? name = null;
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);
}
return pixbuf;
}
private void on_contact_changed() {
this.pixbufs.clear();
}
}
// 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);
/** Closes the store, flushing all caches. */
public void close() {
this.folks_cache.clear();
this.name_cache.clear();
}
public async Gdk.Pixbuf? load(Contact contact,
Geary.RFC822.MailboxAddress source,
int pixel_size,
GLib.Cancellable cancellable)
throws GLib.Error {
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 (hit == null) {
string key = CacheEntry.to_name_key(source);
hit = this.name_cache.get_entry(key);
if (hit == null) {
hit = new CacheEntry(contact, source);
this.name_cache.set_entry(key, hit);
}
}
return yield hit.load(pixel_size, cancellable);
}
}

View file

@ -44,7 +44,6 @@ public class Application.ContactStore : Geary.BaseObject {
public Geary.Account account { get; private set; } public Geary.Account account { get; private set; }
internal Folks.IndividualAggregator individuals; internal Folks.IndividualAggregator individuals;
internal AvatarStore avatars;
// Cache for storing Folks individuals by email address. Store // Cache for storing Folks individuals by email address. Store
// nulls so that negative lookups are cached as well. // nulls so that negative lookups are cached as well.
@ -62,14 +61,12 @@ public class Application.ContactStore : Geary.BaseObject {
/** Constructs a new contact store for an account. */ /** Constructs a new contact store for an account. */
internal ContactStore(Geary.Account account, internal ContactStore(Geary.Account account,
Folks.IndividualAggregator individuals, Folks.IndividualAggregator individuals) {
AvatarStore avatars) {
this.account = account; this.account = account;
this.individuals = individuals; this.individuals = individuals;
this.individuals.individuals_changed_detailed.connect( this.individuals.individuals_changed_detailed.connect(
on_individuals_changed on_individuals_changed
); );
this.avatars = avatars;
} }
~ContactStore() { ~ContactStore() {

View file

@ -19,6 +19,14 @@ public class Application.Contact : Geary.BaseObject {
/** The human-readable name of the contact. */ /** The human-readable name of the contact. */
public string display_name { get; private set; } public string display_name { get; private set; }
/** The avatar of the contact. */
public GLib.LoadableIcon? avatar { get {
if (this.individual != null)
return this.individual.avatar;
else
return null;
}}
/** Determines if {@link display_name} the same as its email address. */ /** Determines if {@link display_name} the same as its email address. */
public bool display_name_is_email { get; private set; default = false; } public bool display_name_is_email { get; private set; default = false; }
@ -265,21 +273,6 @@ public class Application.Contact : Geary.BaseObject {
); );
} }
/** Returns the avatar for this contact. */
public async Gdk.Pixbuf? load_avatar(Geary.RFC822.MailboxAddress source,
int pixel_size,
GLib.Cancellable cancellable)
throws GLib.Error {
Gdk.Pixbuf? avatar = null;
ContactStore? store = this.store;
if (store != null) {
avatar = yield store.avatars.load(
this, source, pixel_size, cancellable
);
}
return avatar;
}
/** Sets remote resource loading for this contact. */ /** Sets remote resource loading for this contact. */
public async void set_remote_resource_loading(bool enabled, public async void set_remote_resource_loading(bool enabled,
GLib.Cancellable? cancellable) GLib.Cancellable? cancellable)
@ -332,8 +325,13 @@ public class Application.Contact : Geary.BaseObject {
Geary.RFC822.MailboxAddress.is_valid_address(name); Geary.RFC822.MailboxAddress.is_valid_address(name);
} }
private void on_individual_avatar_notify() {
notify_property("avatar");
}
private void update_from_individual(Folks.Individual? replacement) { private void update_from_individual(Folks.Individual? replacement) {
if (this.individual != null) { if (this.individual != null) {
this.individual.notify["avatar"].disconnect(this.on_individual_avatar_notify);
this.individual.notify.disconnect(this.on_individual_notify); this.individual.notify.disconnect(this.on_individual_notify);
this.individual.removed.disconnect(this.on_individual_removed); this.individual.removed.disconnect(this.on_individual_removed);
} }
@ -341,6 +339,7 @@ public class Application.Contact : Geary.BaseObject {
this.individual = replacement; this.individual = replacement;
if (this.individual != null) { if (this.individual != null) {
this.individual.notify["avatar"].connect(this.on_individual_avatar_notify);
this.individual.notify.connect(this.on_individual_notify); this.individual.notify.connect(this.on_individual_notify);
this.individual.removed.connect(this.on_individual_removed); this.individual.removed.connect(this.on_individual_removed);
} }

View file

@ -73,9 +73,6 @@ internal class Application.Controller :
get; private set; get; private set;
} }
// Avatar store for the application.
private Application.AvatarStore avatars = new Application.AvatarStore();
// Primary collection of the application's open accounts // Primary collection of the application's open accounts
private Gee.Map<Geary.AccountInformation,AccountContext> accounts = private Gee.Map<Geary.AccountInformation,AccountContext> accounts =
new Gee.HashMap<Geary.AccountInformation,AccountContext>(); new Gee.HashMap<Geary.AccountInformation,AccountContext>();
@ -317,7 +314,6 @@ internal class Application.Controller :
} catch (GLib.Error err) { } catch (GLib.Error err) {
warning("Error closing plugin manager: %s", err.message); warning("Error closing plugin manager: %s", err.message);
} }
this.avatars.close();
this.pending_mailtos.clear(); this.pending_mailtos.clear();
this.composer_widgets.clear(); this.composer_widgets.clear();
@ -983,7 +979,7 @@ internal class Application.Controller :
account, account,
new Geary.App.SearchFolder(account, account.local_folder_root), new Geary.App.SearchFolder(account, account.local_folder_root),
new Geary.App.EmailStore(account), new Geary.App.EmailStore(account),
new Application.ContactStore(account, this.folks, this.avatars) new Application.ContactStore(account, this.folks)
); );
this.accounts.set(account.information, context); this.accounts.set(account.information, context);

View file

@ -43,7 +43,8 @@ public class Conversation.ContactPopover : Gtk.Popover {
[GtkChild] private unowned Gtk.Grid contact_pane; [GtkChild] private unowned Gtk.Grid contact_pane;
[GtkChild] private unowned Gtk.Image avatar; [GtkChild]
private Hdy.Avatar avatar;
[GtkChild] private unowned Gtk.Label contact_name; [GtkChild] private unowned Gtk.Label contact_name;
@ -82,6 +83,16 @@ public class Conversation.ContactPopover : Gtk.Popover {
this.load_remote_button.role = CHECK; this.load_remote_button.role = CHECK;
this.contact.bind_property("display-name",
this.avatar,
"text",
BindingFlags.SYNC_CREATE);
this.contact.bind_property("avatar",
this.avatar,
"loadable-icon",
BindingFlags.SYNC_CREATE);
this.actions.add_action_entries(ACTION_ENTRIES, this); this.actions.add_action_entries(ACTION_ENTRIES, this);
insert_action_group(ACTION_GROUP, this.actions); insert_action_group(ACTION_GROUP, this.actions);
@ -92,32 +103,6 @@ public class Conversation.ContactPopover : Gtk.Popover {
/** /**
* Starts loading the avatar for the message's sender. * Starts loading the avatar for the message's sender.
*/ */
public async void load_avatar() {
var main = this.get_toplevel() as Application.MainWindow;
if (main != null) {
int window_scale = get_scale_factor();
int pixel_size = (
Application.Client.AVATAR_SIZE_PIXELS * window_scale
);
try {
Gdk.Pixbuf? avatar_buf = yield contact.load_avatar(
this.mailbox,
pixel_size,
this.load_cancellable
);
if (avatar_buf != null) {
this.avatar.set_from_surface(
Gdk.cairo_surface_create_from_pixbuf(
avatar_buf, window_scale, get_window()
)
);
}
} catch (GLib.Error err) {
debug("Conversation load failed: %s", err.message);
}
}
}
public override void destroy() { public override void destroy() {
this.contact.changed.disconnect(this.on_contact_changed); this.contact.changed.disconnect(this.on_contact_changed);
this.load_cancellable.cancel(); this.load_cancellable.cancel();

View file

@ -335,7 +335,7 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
private GLib.DateTime? local_date = null; private GLib.DateTime? local_date = null;
[GtkChild] private unowned Gtk.Image avatar; [GtkChild] private unowned Hdy.Avatar avatar;
[GtkChild] private unowned Gtk.Revealer compact_revealer; [GtkChild] private unowned Gtk.Revealer compact_revealer;
[GtkChild] private unowned Gtk.Label compact_from; [GtkChild] private unowned Gtk.Label compact_from;
@ -822,31 +822,18 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
this.primary_originator, cancellable this.primary_originator, cancellable
); );
int window_scale = get_scale_factor(); if (this.primary_contact != null) {
int pixel_size = this.primary_contact.bind_property("display-name",
Application.Client.AVATAR_SIZE_PIXELS * window_scale; this.avatar,
Gdk.Pixbuf? avatar_buf = yield this.primary_contact.load_avatar( "text",
this.primary_originator, BindingFlags.SYNC_CREATE);
pixel_size, this.primary_contact.bind_property("avatar",
cancellable this.avatar,
); "loadable-icon",
if (avatar_buf != null) { BindingFlags.SYNC_CREATE);
this.avatar.set_from_surface(
Gdk.cairo_surface_create_from_pixbuf(
avatar_buf, window_scale, get_window()
)
);
} }
} else {
this.avatar.set_from_icon_name(
"avatar-default-symbolic", Gtk.IconSize.DIALOG
);
this.avatar.set_pixel_size(
Application.Client.AVATAR_SIZE_PIXELS
);
} }
// Preview headers // Preview headers
this.compact_from.set_text( this.compact_from.set_text(
yield format_originator_compact(cancellable) yield format_originator_compact(cancellable)
@ -1268,7 +1255,6 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
address_child.contact, address_child.contact,
address address
); );
popover.load_avatar.begin();
popover.set_position(Gtk.PositionType.BOTTOM); popover.set_position(Gtk.PositionType.BOTTOM);
popover.load_remote_resources_changed.connect((enabled) => { popover.load_remote_resources_changed.connect((enabled) => {
if (this.primary_contact.equal_to(address_child.contact) && if (this.primary_contact.equal_to(address_child.contact) &&

View file

@ -14,7 +14,6 @@ client_vala_sources = files(
'application/application-account-context.vala', 'application/application-account-context.vala',
'application/application-account-interface.vala', 'application/application-account-interface.vala',
'application/application-attachment-manager.vala', 'application/application-attachment-manager.vala',
'application/application-avatar-store.vala',
'application/application-certificate-manager.vala', 'application/application-certificate-manager.vala',
'application/application-client.vala', 'application/application-client.vala',
'application/application-command.vala', 'application/application-command.vala',

View file

@ -97,7 +97,7 @@ public class Plugin.DesktopNotifications :
Email email Email email
) throws GLib.Error { ) throws GLib.Error {
string title = to_notitication_title(folder.account, total); string title = to_notitication_title(folder.account, total);
Gdk.Pixbuf? icon = null; GLib.Icon icon = null;
Geary.RFC822.MailboxAddress? originator = email.get_primary_originator(); Geary.RFC822.MailboxAddress? originator = email.get_primary_originator();
if (originator != null) { if (originator != null) {
ContactStore contacts = ContactStore contacts =
@ -112,19 +112,7 @@ public class Plugin.DesktopNotifications :
: originator.to_short_display() : originator.to_short_display()
); );
int window_scale = 1; icon = contact.avatar;
Gdk.Display? display = Gdk.Display.get_default();
if (display != null) {
Gdk.Monitor? monitor = display.get_primary_monitor();
if (monitor != null) {
window_scale = monitor.scale_factor;
}
}
icon = yield contact.load_avatar(
originator,
global::Application.Client.AVATAR_SIZE_PIXELS * window_scale,
this.cancellable
);
} }
string body = Util.Email.strip_subject_prefixes(email); string body = Util.Email.strip_subject_prefixes(email);
@ -144,10 +132,24 @@ public class Plugin.DesktopNotifications :
); );
} }
int window_scale = 1;
Gdk.Display? display = Gdk.Display.get_default();
if (display != null) {
Gdk.Monitor? monitor = display.get_primary_monitor();
if (monitor != null) {
window_scale = monitor.scale_factor;
}
}
var avatar = new Hdy.Avatar(32, title, true);
avatar.loadable_icon = icon as GLib.LoadableIcon;
icon = yield avatar.draw_to_pixbuf_async(32, window_scale, null);
issue_arrived_notification(title, body, icon, folder, email.identifier); issue_arrived_notification(title, body, icon, folder, email.identifier);
} }
private void notify_general(Folder folder, int total, int added) { private void notify_general(Folder folder, int total, int added) {
GLib.Icon icon = new GLib.ThemedIcon("%s-symbolic".printf(global::Application.Client.APP_ID));
string title = to_notitication_title(folder.account, total); string title = to_notitication_title(folder.account, total);
string body = ngettext( string body = ngettext(
/// Notification body when multiple messages have been /// Notification body when multiple messages have been
@ -170,12 +172,12 @@ public class Plugin.DesktopNotifications :
).printf(body, total); ).printf(body, total);
} }
issue_arrived_notification(title, body, null, folder, null); issue_arrived_notification(title, body, icon, folder, null);
} }
private void issue_arrived_notification(string summary, private void issue_arrived_notification(string summary,
string body, string body,
Gdk.Pixbuf? icon, GLib.Icon icon,
Folder folder, Folder folder,
EmailIdentifier? id) { EmailIdentifier? id) {
// only one outstanding notification at a time // only one outstanding notification at a time
@ -204,15 +206,9 @@ public class Plugin.DesktopNotifications :
private GLib.Notification issue_notification(string id, private GLib.Notification issue_notification(string id,
string summary, string summary,
string body, string body,
Gdk.Pixbuf? avatar, GLib.Icon icon,
string? action, string? action,
GLib.Variant? action_target) { GLib.Variant? action_target) {
GLib.Icon icon = avatar;
if (avatar == null) {
icon = new GLib.ThemedIcon(
"%s-symbolic".printf(global::Application.Client.APP_ID)
);
}
GLib.Notification notification = new GLib.Notification(summary); GLib.Notification notification = new GLib.Notification(summary);
notification.set_body(body); notification.set_body(body);
notification.set_icon(icon); notification.set_icon(icon);

View file

@ -125,8 +125,7 @@ public class Composer.WidgetTest : TestCase {
new Geary.App.EmailStore(mock_account), new Geary.App.EmailStore(mock_account),
new Application.ContactStore( new Application.ContactStore(
mock_account, mock_account,
Folks.IndividualAggregator.dup(), Folks.IndividualAggregator.dup()
new Application.AvatarStore()
) )
); );
} }

View file

@ -60,9 +60,11 @@
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkImage" id="avatar"> <object class="HdyAvatar" id="avatar">
<property name="visible">True</property> <property name="visible">True</property>
<property name="pixel_size">48</property> <property name="can_focus">False</property>
<property name="show-initials">True</property>
<property name="size">48</property>
</object> </object>
<packing> <packing>
<property name="left_attach">0</property> <property name="left_attach">0</property>

View file

@ -2,6 +2,7 @@
<!-- Generated with glade 3.22.2 --> <!-- Generated with glade 3.22.2 -->
<interface> <interface>
<requires lib="gtk+" version="3.14"/> <requires lib="gtk+" version="3.14"/>
<requires lib="libhandy" version="1.0"/>
<template class="ConversationMessage" parent="GtkGrid"> <template class="ConversationMessage" parent="GtkGrid">
<property name="visible">True</property> <property name="visible">True</property>
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
@ -11,12 +12,11 @@
<property name="hexpand">True</property> <property name="hexpand">True</property>
<property name="column_spacing">6</property> <property name="column_spacing">6</property>
<child> <child>
<object class="GtkImage" id="avatar"> <object class="HdyAvatar" id="avatar">
<property name="width_request">18</property>
<property name="height_request">18</property>
<property name="visible">True</property> <property name="visible">True</property>
<property name="valign">start</property> <property name="valign">start</property>
<property name="pixel_size">48</property> <property name="show-initials">True</property>
<property name="size">48</property>
</object> </object>
<packing> <packing>
<property name="left_attach">0</property> <property name="left_attach">0</property>