Commit 791c321a authored by Michael Gratton's avatar Michael Gratton 🤞

Merge branch 'wip/3.32-avatars' into 'master'

3.32 Avatars

Closes #269

See merge request !154
parents d11a5088 6c8f1921
Pipeline #66687 passed with stages
in 38 minutes and 55 seconds
......@@ -17,18 +17,21 @@ variables:
# Fedora packages
FEDORA_DEPS: vala
meson desktop-file-utils libcanberra-devel libgee-devel
glib2-devel gmime-devel gtk3-devel libnotify-devel sqlite-devel
webkitgtk4-devel libsecret-devel libxml2-devel vala-tools
gcr-devel enchant2-devel libunwind-devel iso-codes-devel
gnome-online-accounts-devel itstool json-glib-devel
meson desktop-file-utils libcanberra-devel
folks-devel libgee-devel glib2-devel gmime-devel
gtk3-devel libnotify-devel sqlite-devel
webkitgtk4-devel libsecret-devel libxml2-devel
vala-tools gcr-devel enchant2-devel libunwind-devel
iso-codes-devel gnome-online-accounts-devel itstool
json-glib-devel
FEDORA_TEST_DEPS: Xvfb tar xz
# Ubuntu packages
UBUNTU_DEPS: valac build-essential
meson desktop-file-utils libcanberra-dev
libgee-0.8-dev libglib2.0-dev libgmime-2.6-dev libgtk-3-dev
libsecret-1-dev libxml2-dev libnotify-dev libsqlite3-dev
libfolks-dev libgee-0.8-dev libglib2.0-dev
libgmime-2.6-dev libgtk-3-dev libsecret-1-dev
libxml2-dev libnotify-dev libsqlite3-dev
libwebkit2gtk-4.0-dev libgcr-3-dev libenchant-dev
libunwind-dev iso-codes libgoa-1.0-dev itstool gettext
libmessaging-menu-dev libunity-dev libjson-glib-dev
......
......@@ -41,9 +41,9 @@ Installing dependencies on Fedora
Fedora 25 and later ships with the correct versions of the required
libraries. Install them by running this command:
sudo yum install vala meson \
desktop-file-utils iso-codes-devel libcanberra-devel libgee-devel \
glib2-devel gmime-devel gtk3-devel libnotify-devel sqlite-devel \
sudo yum install vala meson desktop-file-utils iso-codes-devel \
libcanberra-devel folks-devel libgee-devel glib2-devel \
gmime-devel gtk3-devel libnotify-devel sqlite-devel \
webkitgtk4-devel libsecret-devel libxml2-devel vala-tools \
gcr-devel enchant2-devel libunwind-devel json-glib-devel \
gnome-online-accounts-devel itstool
......@@ -62,12 +62,12 @@ required libraries.
Install them by running this command:
sudo apt-get install valac \
meson desktop-file-utils iso-codes libcanberra-dev \
libgee-0.8-dev libglib2.0-dev libgmime-2.6-dev libgtk-3-dev \
libsecret-1-dev libxml2-dev libnotify-dev libsqlite3-dev \
libwebkit2gtk-4.0-dev libgcr-3-dev libenchant-dev \
libunwind-dev libgoa-1.0-dev libjson-glib-dev itstool gettext
sudo apt-get install valac meson desktop-file-utils iso-codes \
libcanberra-dev libfolks-dev libgee-0.8-dev libglib2.0-dev \
libgmime-2.6-dev libgtk-3-dev libsecret-1-dev libxml2-dev \
libnotify-dev libsqlite3-dev libwebkit2gtk-4.0-dev \
libgcr-3-dev libenchant-dev libunwind-dev libgoa-1.0-dev \
libjson-glib-dev itstool gettext
And for Ubuntu Unity integration:
......
......@@ -74,6 +74,8 @@
<p>Enhancements included in this release:</p>
<ul>
<li>Application menu moved to the main window</li>
<li>Desktop contacts are used for sender images</li>
<li>Unknown contacts are given personalised initials and colour</li>
<li>Updated application icons</li>
<li>Improved server compatibility</li>
<li>Custom email CSS now applied to Composer view</li>
......
......@@ -123,12 +123,6 @@
<description>The last recorded size of the detached composer window.</description>
</key>
<key name="avatar-url" type="s">
<default>"https://secure.gravatar.com/avatar"</default>
<summary>Base URL to look up contact avatars</summary>
<description>A Gravatar or Libravatar compatible URL, set to the empty string to disable.</description>
</key>
<key name="migrated-config" type="b">
<default>false</default>
<summary>Whether we migrated the old settings</summary>
......
......@@ -54,6 +54,7 @@ webkit2gtk = dependency('webkit2gtk-4.0', version: '>=' + target_webkit)
# Secondary deps - keep sorted alphabetically
enchant = dependency('enchant-2', version: '>=2.1', required: false) # see below
folks = dependency('folks', version: '>=0.11')
gck = dependency('gck-1')
gcr = dependency('gcr-3', version: '>= 3.10.1')
gdk = dependency('gdk-3.0', version: '>=' + target_gtk)
......
......@@ -74,16 +74,6 @@
}
]
},
{
"name": "gmime",
"sources": [
{
"type": "git",
"url": "https://github.com/jstedfast/gmime.git",
"branch": "gmime-2-6"
}
]
},
{
"name": "gnome-online-accounts",
"config-opts": [
......@@ -105,6 +95,87 @@
}
]
},
{
"name": "libical",
"cleanup": [
"/lib/cmake"
],
"buildsystem": "cmake-ninja",
"config-opts": [
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_INSTALL_LIBDIR=lib",
"-DBUILD_SHARED_LIBS:BOOL=ON"
],
"sources": [
{
"type": "archive",
"url": "https://github.com/libical/libical/releases/download/v2.0.0/libical-2.0.0.tar.gz",
"sha256": "654c11f759c19237be39f6ad401d917e5a05f36f1736385ed958e60cf21456da"
}
]
},
{
"name": "evolution-data-server",
"cleanup": [
"/lib/cmake",
"/lib/evolution-data-server/*-backends",
"/libexec",
"/share/dbus-1/services"
],
"config-opts": [
"-DCMAKE_BUILD_TYPE=Release",
"-DENABLE_GTK=ON",
"-DENABLE_GOA=ON",
"-DENABLE_UOA=OFF",
"-DENABLE_GOOGLE_AUTH=OFF",
"-DENABLE_GOOGLE=OFF",
"-DENABLE_WITH_PHONENUMBER=OFF",
"-DENABLE_VALA_BINDINGS=ON",
"-DENABLE_WEATHER=OFF",
"-DWITH_OPENLDAP=OFF",
"-DWITH_LIBDB=OFF",
"-DENABLE_INTROSPECTION=ON",
"-DENABLE_INSTALLED_TESTS=OFF",
"-DENABLE_GTK_DOC=OFF",
"-DENABLE_EXAMPLES=OFF"
],
"buildsystem": "cmake-ninja",
"sources": [
{
"type": "git",
"url": "https://gitlab.gnome.org/GNOME/evolution-data-server.git"
}
]
},
{
"name": "folks",
"cleanup": [
"/bin",
"/share/GConf"
],
"config-opts": [
"--disable-telepathy-backend",
"--disable-inspect-tool",
"--disable-import-tool",
"--disable-fatal-warnings"
],
"sources": [
{
"type": "git",
"url": "https://gitlab.gnome.org/GNOME/folks.git"
}
]
},
{
"name": "gmime",
"sources": [
{
"type": "git",
"url": "https://github.com/jstedfast/gmime.git",
"branch": "gmime-2-6"
}
]
},
{
"name": "libunwind",
"sources": [
......@@ -121,9 +192,8 @@
"builddir": true,
"sources": [
{
"type": "git",
"url": "https://gitlab.gnome.org/GNOME/geary.git",
"branch": "master"
"type": "dir",
"path": "."
}
]
}
......
......@@ -88,6 +88,7 @@ src/client/sidebar/sidebar-common.vala
src/client/sidebar/sidebar-count-cell-renderer.vala
src/client/sidebar/sidebar-entry.vala
src/client/sidebar/sidebar-tree.vala
src/client/util/util-avatar.vala
src/client/util/util-date.vala
src/client/util/util-email.vala
src/client/util/util-files.vala
......@@ -114,6 +115,7 @@ src/engine/api/geary-contact.vala
src/engine/api/geary-credentials-mediator.vala
src/engine/api/geary-credentials.vala
src/engine/api/geary-email-flags.vala
src/engine/api/geary-email-header-set.vala
src/engine/api/geary-email-identifier.vala
src/engine/api/geary-email-properties.vala
src/engine/api/geary-email.vala
......
......@@ -12,119 +12,201 @@
public class Application.AvatarStore : Geary.BaseObject {
// Initiates and manages an avatar load using Gravatar
private class AvatarLoader : Geary.BaseObject {
// Max age is low since we really only want to cache between
// conversation loads.
private const int64 MAX_CACHE_AGE_US = 5 * 1000 * 1000;
internal Gdk.Pixbuf? avatar = null;
internal Geary.Nonblocking.Semaphore lock =
new Geary.Nonblocking.Semaphore();
// 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 string base_url;
private Geary.RFC822.MailboxAddress address;
private int pixel_size;
private class CacheEntry {
internal AvatarLoader(Geary.RFC822.MailboxAddress address,
string base_url,
int pixel_size) {
this.address = address;
this.base_url = base_url;
this.pixel_size = pixel_size;
public static string to_key(Geary.RFC822.MailboxAddress mailbox) {
// 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);
}
internal async void load(Soup.Session session,
Cancellable load_cancelled)
public string key;
public Geary.RFC822.MailboxAddress mailbox;
// Store nulls so we can also cache avatars not found
public Folks.Individual? individual;
public int64 last_used;
private Gee.List<Gdk.Pixbuf> pixbufs = new Gee.LinkedList<Gdk.Pixbuf>();
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;
}
public async Gdk.Pixbuf? load(int pixel_size,
GLib.Cancellable cancellable)
throws GLib.Error {
Error? workaround_err = null;
if (!Geary.String.is_empty_or_whitespace(this.base_url)) {
string md5 = GLib.Checksum.compute_for_string(
GLib.ChecksumType.MD5, this.address.address.strip().down()
);
Soup.Message message = new Soup.Message(
"GET",
"%s/%s?d=%s&s=%d".printf(
this.base_url, md5, "404", this.pixel_size
)
);
try {
// We want to just pass load_cancelled to send_async
// here, but per Bug 778720 this is causing some
// crashy race in libsoup's cache implementation, so
// for now just let the load go through and manually
// check to see if the load has been cancelled before
// setting the avatar
InputStream data = yield session.send_async(
message,
null // should be 'load_cancelled'
);
if (message.status_code == 200 &&
data != null &&
!load_cancelled.is_cancelled()) {
this.avatar = yield new Gdk.Pixbuf.from_stream_at_scale_async(
data, pixel_size, pixel_size, true, load_cancelled
);
}
} catch (Error err) {
workaround_err = err;
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;
}
}
this.lock.blind_notify();
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
);
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 (workaround_err != null) {
throw workaround_err;
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();
// }
pixbuf = Util.Avatar.generate_user_picture(name, pixel_size);
pixbuf = Util.Avatar.round_image(pixbuf);
this.pixbufs.add(pixbuf);
}
return pixbuf;
}
}
private Configuration config;
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);
private Soup.Session session;
private Soup.Cache cache;
private Gee.Map<string,AvatarLoader> loaders =
new Gee.HashMap<string,AvatarLoader>();
public AvatarStore(Folks.IndividualAggregator individuals) {
this.individuals = individuals;
}
public AvatarStore(Configuration config, GLib.File cache_root) {
this.config = config;
public void close() {
this.lru_cache.clear();
this.lru_ordering.clear();
}
File avatar_cache_dir = cache_root.get_child("avatars");
this.cache = new Soup.Cache(
avatar_cache_dir.get_path(),
Soup.CacheType.SINGLE_USER
);
this.cache.load();
this.cache.set_max_size(16 * 1024 * 1024); // 16MB
this.session = new Soup.Session();
this.session.add_feature(this.cache);
public async Gdk.Pixbuf? load(Geary.RFC822.MailboxAddress mailbox,
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);
}
public void close() {
this.cache.flush();
this.cache.dump();
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;
}
}
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;
}
public async Gdk.Pixbuf? load(Geary.RFC822.MailboxAddress address,
int pixel_size,
Cancellable load_cancelled)
throws Error {
string key = address.to_string();
AvatarLoader loader = this.loaders.get(key);
if (loader == null) {
// Haven't started loading the avatar, so do it now
loader = new AvatarLoader(
address, this.config.avatar_url, pixel_size
);
this.loaders.set(key, loader);
yield loader.load(this.session, load_cancelled);
} else {
// Load has already started, so wait for it to finish
yield loader.lock.wait_async();
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();
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);
}
return loader.avatar;
return match;
}
}
......@@ -29,7 +29,6 @@ public class Configuration {
public const string SEARCH_STRATEGY_KEY = "search-strategy";
public const string CONVERSATION_VIEWER_ZOOM_KEY = "conversation-viewer-zoom";
public const string COMPOSER_WINDOW_SIZE_KEY = "composer-window-size";
public const string AVATAR_URL = "avatar-url";
public enum DesktopEnvironment {
......@@ -178,10 +177,6 @@ public class Configuration {
}
}
public string avatar_url {
owned get { return settings.get_string(AVATAR_URL); }
}
// Creates a configuration object.
public Configuration(string schema_id) {
// Start GSettings.
......
......@@ -260,10 +260,16 @@ public class GearyController : Geary.BaseObject {
error("Error loading web resources: %s", err.message);
}
this.avatar_store = new Application.AvatarStore(
this.application.config,
this.application.get_user_cache_directory()
);
Folks.IndividualAggregator individuals =
Folks.IndividualAggregator.dup();
if (!individuals.is_prepared) {
try {
yield individuals.prepare();
} catch (GLib.Error err) {
error("Error preparing Folks: %s", err.message);
}
}
this.avatar_store = new Application.AvatarStore(individuals);
// Create the main window (must be done after creating actions.)
main_window = new MainWindow(this.application);
......@@ -2625,7 +2631,7 @@ public class GearyController : Geary.BaseObject {
// string substitution is a list of recipients of the email.
string message = _(
"Successfully sent mail to %s."
).printf(EmailUtil.to_short_recipient_display(rfc822.to));
).printf(Util.Email.to_short_recipient_display(rfc822.to));
InAppNotification notification = new InAppNotification(message);
this.main_window.add_notification(notification);
Libnotify.play_sound("message-sent-email");
......
......@@ -83,7 +83,7 @@ public class ConversationListStore : Gtk.ListStore {
Geary.App.Conversation a, b;
model.get(aiter, Column.CONVERSATION_OBJECT, out a);
model.get(biter, Column.CONVERSATION_OBJECT, out b);
return compare_conversation_ascending(a, b);
return Util.Email.compare_conversation_ascending(a, b);
}
......@@ -225,8 +225,10 @@ public class ConversationListStore : Gtk.ListStore {
// sort the conversations so the previews are fetched from the newest to the oldest, matching
// the user experience
Gee.TreeSet<Geary.App.Conversation> sorted_conversations = new Gee.TreeSet<Geary.App.Conversation>(
compare_conversation_descending);
Gee.TreeSet<Geary.App.Conversation> sorted_conversations =
new Gee.TreeSet<Geary.App.Conversation>(
Util.Email.compare_conversation_descending
);
sorted_conversations.add_all(this.conversations.read_only_view);
foreach (Geary.App.Conversation conversation in sorted_conversations) {
// find oldest unread message for the preview
......
......@@ -111,7 +111,7 @@ public class FormattedConversationData : Geary.BaseObject {
// Load preview-related data.
update_date_string();
this.subject = EmailUtil.strip_subject_prefixes(preview);
this.subject = Util.Email.strip_subject_prefixes(preview);
this.body = Geary.String.reduce_whitespace(preview.get_preview_as_string());
this.preview = preview;
......
......@@ -289,6 +289,9 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
/** Determines if the email is a draft message. */
public bool is_draft { get; private set; }
/** The email's primary originator, if any. */
public Geary.RFC822.MailboxAddress? primary_originator { get; private set; }
/** The view displaying the email's primary message headers and body. */
public ConversationMessage primary_message { get; private set; }
......@@ -444,6 +447,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
base_ref();
this.email = email;
this.is_draft = is_draft;
this.primary_originator = Util.Email.get_primary_originator(email);
this.email_store = email_store;
this.contact_store = email_store.account.get_contact_store();
this.avatar_store = avatar_store;
......@@ -507,9 +511,11 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
// Construct the view for the primary message, hook into it
bool load_images = email.load_remote_images().is_certain();
Geary.Contact contact = this.contact_store.get_by_rfc822(
email.get_primary_originator()
);
Geary.Contact? contact = null;
if (this.primary_originator != null) {
contact = this.contact_store.get_by_rfc822(this.primary_originator);
}
if (contact != null) {
load_images |= contact.always_load_remote_images();
}
......@@ -577,7 +583,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
} catch (IOError.CANCELLED err) {
// okay
} catch (Error err) {
Geary.RFC822.MailboxAddress? from = this.email.get_primary_originator();
Geary.RFC822.MailboxAddress? from = this.primary_originator;
debug("Avatar load failed for \"%s\": %s",
from != null ? from.to_string() : "<unknown>", err.message);
}
......@@ -1038,7 +1044,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
private void on_remember_remote_images(ConversationMessage view) {
Geary.RFC822.MailboxAddress? sender = this.email.get_primary_originator();
Geary.RFC822.MailboxAddress? sender = this.primary_originator;
if (sender != null) {
Geary.Contact? contact = this.contact_store.get_by_rfc822(sender);
if (contact != null) {
......
......@@ -289,7 +289,7 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
bool load_remote_images,
Configuration config) {
this(
email.get_primary_originator(),
Util.Email.get_primary_originator(email),
email.from,
email.reply_to,
email.sender,
......@@ -315,7 +315,7 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
bool load_remote_images,
Configuration config) {
this(
message.get_primary_originator(),
Util.Email.get_primary_originator(message),
message.from,
message.reply_to,
message.sender,
......@@ -613,15 +613,14 @@ public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
throw new GLib.IOError.CANCELLED("Conversation load cancelled");
}
const int PIXEL_SIZE = 32;
// We occasionally get crashes calling as below
// Gtk.Image.get_pixel_size() when the image is null. There's
// perhaps some race going on there. So we need to hard-code
// the size here and keep it in sync with
// ui/conversation-message.ui. :(
const int PIXEL_SIZE = 48;
if (this.primary_originator != null) {
int window_scale = get_scale_factor();
// We occasionally get crashes calling as below
// Gtk.Image.get_pixel_size() when the image is
// null. There's perhaps some race going on there. So we
// need to hard-code the size and keep it in sync with
// ui/conversation-message.ui. :(
//
//int pixel_size = this.avatar.get_pixel_size() * window_scale;
int pixel_size = PIXEL_SIZE * window_scale;
Gdk.Pixbuf? avatar_buf = yield loader.load(
......
......@@ -92,6 +92,7 @@ geary_client_vala_sources = files(
'sidebar/sidebar-entry.vala',
'sidebar/sidebar-tree.vala',
'util/util-avatar.vala',
'util/util-date.vala',
'util/util-email.vala',
'util/util-files.vala',
......@@ -110,6 +111,7 @@ geary_client_sources = [
geary_client_dependencies = [
libmath,
enchant,
folks,
gck,
gcr,
gee,
......
......@@ -95,7 +95,8 @@ public class Libnotify : Geary.BaseObject {
return;