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: ...@@ -17,18 +17,21 @@ variables:
# Fedora packages # Fedora packages
FEDORA_DEPS: vala FEDORA_DEPS: vala
meson desktop-file-utils libcanberra-devel libgee-devel meson desktop-file-utils libcanberra-devel
glib2-devel gmime-devel gtk3-devel libnotify-devel sqlite-devel folks-devel libgee-devel glib2-devel gmime-devel
webkitgtk4-devel libsecret-devel libxml2-devel vala-tools gtk3-devel libnotify-devel sqlite-devel
gcr-devel enchant2-devel libunwind-devel iso-codes-devel webkitgtk4-devel libsecret-devel libxml2-devel
gnome-online-accounts-devel itstool json-glib-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 FEDORA_TEST_DEPS: Xvfb tar xz
# Ubuntu packages # Ubuntu packages
UBUNTU_DEPS: valac build-essential UBUNTU_DEPS: valac build-essential
meson desktop-file-utils libcanberra-dev meson desktop-file-utils libcanberra-dev
libgee-0.8-dev libglib2.0-dev libgmime-2.6-dev libgtk-3-dev libfolks-dev libgee-0.8-dev libglib2.0-dev
libsecret-1-dev libxml2-dev libnotify-dev libsqlite3-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 libwebkit2gtk-4.0-dev libgcr-3-dev libenchant-dev
libunwind-dev iso-codes libgoa-1.0-dev itstool gettext libunwind-dev iso-codes libgoa-1.0-dev itstool gettext
libmessaging-menu-dev libunity-dev libjson-glib-dev libmessaging-menu-dev libunity-dev libjson-glib-dev
......
...@@ -41,9 +41,9 @@ Installing dependencies on Fedora ...@@ -41,9 +41,9 @@ Installing dependencies on Fedora
Fedora 25 and later ships with the correct versions of the required Fedora 25 and later ships with the correct versions of the required
libraries. Install them by running this command: libraries. Install them by running this command:
sudo yum install vala meson \ sudo yum install vala meson desktop-file-utils iso-codes-devel \
desktop-file-utils iso-codes-devel libcanberra-devel libgee-devel \ libcanberra-devel folks-devel libgee-devel glib2-devel \
glib2-devel gmime-devel gtk3-devel libnotify-devel sqlite-devel \ gmime-devel gtk3-devel libnotify-devel sqlite-devel \
webkitgtk4-devel libsecret-devel libxml2-devel vala-tools \ webkitgtk4-devel libsecret-devel libxml2-devel vala-tools \
gcr-devel enchant2-devel libunwind-devel json-glib-devel \ gcr-devel enchant2-devel libunwind-devel json-glib-devel \
gnome-online-accounts-devel itstool gnome-online-accounts-devel itstool
...@@ -62,12 +62,12 @@ required libraries. ...@@ -62,12 +62,12 @@ required libraries.
Install them by running this command: Install them by running this command:
sudo apt-get install valac \ sudo apt-get install valac meson desktop-file-utils iso-codes \
meson desktop-file-utils iso-codes libcanberra-dev \ libcanberra-dev libfolks-dev libgee-0.8-dev libglib2.0-dev \
libgee-0.8-dev libglib2.0-dev libgmime-2.6-dev libgtk-3-dev \ libgmime-2.6-dev libgtk-3-dev libsecret-1-dev libxml2-dev \
libsecret-1-dev libxml2-dev libnotify-dev libsqlite3-dev \ libnotify-dev libsqlite3-dev libwebkit2gtk-4.0-dev \
libwebkit2gtk-4.0-dev libgcr-3-dev libenchant-dev \ libgcr-3-dev libenchant-dev libunwind-dev libgoa-1.0-dev \
libunwind-dev libgoa-1.0-dev libjson-glib-dev itstool gettext libjson-glib-dev itstool gettext
And for Ubuntu Unity integration: And for Ubuntu Unity integration:
......
...@@ -74,6 +74,8 @@ ...@@ -74,6 +74,8 @@
<p>Enhancements included in this release:</p> <p>Enhancements included in this release:</p>
<ul> <ul>
<li>Application menu moved to the main window</li> <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>Updated application icons</li>
<li>Improved server compatibility</li> <li>Improved server compatibility</li>
<li>Custom email CSS now applied to Composer view</li> <li>Custom email CSS now applied to Composer view</li>
......
...@@ -123,12 +123,6 @@ ...@@ -123,12 +123,6 @@
<description>The last recorded size of the detached composer window.</description> <description>The last recorded size of the detached composer window.</description>
</key> </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"> <key name="migrated-config" type="b">
<default>false</default> <default>false</default>
<summary>Whether we migrated the old settings</summary> <summary>Whether we migrated the old settings</summary>
......
...@@ -54,6 +54,7 @@ webkit2gtk = dependency('webkit2gtk-4.0', version: '>=' + target_webkit) ...@@ -54,6 +54,7 @@ webkit2gtk = dependency('webkit2gtk-4.0', version: '>=' + target_webkit)
# Secondary deps - keep sorted alphabetically # Secondary deps - keep sorted alphabetically
enchant = dependency('enchant-2', version: '>=2.1', required: false) # see below enchant = dependency('enchant-2', version: '>=2.1', required: false) # see below
folks = dependency('folks', version: '>=0.11')
gck = dependency('gck-1') gck = dependency('gck-1')
gcr = dependency('gcr-3', version: '>= 3.10.1') gcr = dependency('gcr-3', version: '>= 3.10.1')
gdk = dependency('gdk-3.0', version: '>=' + target_gtk) gdk = dependency('gdk-3.0', version: '>=' + target_gtk)
......
...@@ -74,16 +74,6 @@ ...@@ -74,16 +74,6 @@
} }
] ]
}, },
{
"name": "gmime",
"sources": [
{
"type": "git",
"url": "https://github.com/jstedfast/gmime.git",
"branch": "gmime-2-6"
}
]
},
{ {
"name": "gnome-online-accounts", "name": "gnome-online-accounts",
"config-opts": [ "config-opts": [
...@@ -105,6 +95,87 @@ ...@@ -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", "name": "libunwind",
"sources": [ "sources": [
...@@ -121,9 +192,8 @@ ...@@ -121,9 +192,8 @@
"builddir": true, "builddir": true,
"sources": [ "sources": [
{ {
"type": "git", "type": "dir",
"url": "https://gitlab.gnome.org/GNOME/geary.git", "path": "."
"branch": "master"
} }
] ]
} }
......
...@@ -88,6 +88,7 @@ src/client/sidebar/sidebar-common.vala ...@@ -88,6 +88,7 @@ src/client/sidebar/sidebar-common.vala
src/client/sidebar/sidebar-count-cell-renderer.vala src/client/sidebar/sidebar-count-cell-renderer.vala
src/client/sidebar/sidebar-entry.vala src/client/sidebar/sidebar-entry.vala
src/client/sidebar/sidebar-tree.vala src/client/sidebar/sidebar-tree.vala
src/client/util/util-avatar.vala
src/client/util/util-date.vala src/client/util/util-date.vala
src/client/util/util-email.vala src/client/util/util-email.vala
src/client/util/util-files.vala src/client/util/util-files.vala
...@@ -114,6 +115,7 @@ src/engine/api/geary-contact.vala ...@@ -114,6 +115,7 @@ src/engine/api/geary-contact.vala
src/engine/api/geary-credentials-mediator.vala src/engine/api/geary-credentials-mediator.vala
src/engine/api/geary-credentials.vala src/engine/api/geary-credentials.vala
src/engine/api/geary-email-flags.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-identifier.vala
src/engine/api/geary-email-properties.vala src/engine/api/geary-email-properties.vala
src/engine/api/geary-email.vala src/engine/api/geary-email.vala
......
...@@ -12,119 +12,201 @@ ...@@ -12,119 +12,201 @@
public class Application.AvatarStore : Geary.BaseObject { public class Application.AvatarStore : Geary.BaseObject {
// Initiates and manages an avatar load using Gravatar // Max age is low since we really only want to cache between
private class AvatarLoader : Geary.BaseObject { // conversation loads.
private const int64 MAX_CACHE_AGE_US = 5 * 1000 * 1000;
internal Gdk.Pixbuf? avatar = null; // Max size is low since most conversations don't get above the
internal Geary.Nonblocking.Semaphore lock = // low hundreds of messages, and those that do will likely get
new Geary.Nonblocking.Semaphore(); // 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, public static string to_key(Geary.RFC822.MailboxAddress mailbox) {
int pixel_size) { // Use short name as the key, since it will use the name
this.address = address; // first, then the email address, which is especially
this.base_url = base_url; // important for things like GitLab email where the
this.pixel_size = pixel_size; // 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 { throws GLib.Error {
Error? workaround_err = null; Gdk.Pixbuf? pixbuf = null;
if (!Geary.String.is_empty_or_whitespace(this.base_url)) { foreach (Gdk.Pixbuf cached in this.pixbufs) {
string md5 = GLib.Checksum.compute_for_string( if ((cached.height == pixel_size && cached.width >= pixel_size) ||
GLib.ChecksumType.MD5, this.address.address.strip().down() (cached.width == pixel_size && cached.height >= pixel_size)) {
); pixbuf = cached;
Soup.Message message = new Soup.Message( break;
"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;
} }
} }
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) { if (pixbuf == null) {
throw workaround_err; 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) { public void close() {
this.config = config; this.lru_cache.clear();
this.lru_ordering.clear();
}
File avatar_cache_dir = cache_root.get_child("avatars"); public async Gdk.Pixbuf? load(Geary.RFC822.MailboxAddress mailbox,
this.cache = new Soup.Cache( int pixel_size,
avatar_cache_dir.get_path(), GLib.Cancellable cancellable)
Soup.CacheType.SINGLE_USER throws GLib.Error {
); // Normalise the address to improve caching
this.cache.load(); CacheEntry match = yield get_match(mailbox);
this.cache.set_max_size(16 * 1024 * 1024); // 16MB return yield match.load(pixel_size, cancellable);
this.session = new Soup.Session();
this.session.add_feature(this.cache);
} }
public void close() {
this.cache.flush(); private async CacheEntry get_match(Geary.RFC822.MailboxAddress mailbox)
this.cache.dump(); 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, private async Folks.Individual? search_match(string address)
int pixel_size, throws GLib.Error {
Cancellable load_cancelled) Folks.SearchView view = new Folks.SearchView(
throws Error { this.individuals,
string key = address.to_string(); new Folks.SimpleQuery(
AvatarLoader loader = this.loaders.get(key); address,
if (loader == null) { new string[] {
// Haven't started loading the avatar, so do it now Folks.PersonaStore.detail_key(
loader = new AvatarLoader( Folks.PersonaDetail.EMAIL_ADDRESSES
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 view.prepare();
yield loader.lock.wait_async();
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 { ...@@ -29,7 +29,6 @@ public class Configuration {
public const string SEARCH_STRATEGY_KEY = "search-strategy"; public const string SEARCH_STRATEGY_KEY = "search-strategy";
public const string CONVERSATION_VIEWER_ZOOM_KEY = "conversation-viewer-zoom"; public const string CONVERSATION_VIEWER_ZOOM_KEY = "conversation-viewer-zoom";
public const string COMPOSER_WINDOW_SIZE_KEY = "composer-window-size"; public const string COMPOSER_WINDOW_SIZE_KEY = "composer-window-size";
public const string AVATAR_URL = "avatar-url";
public enum DesktopEnvironment { public enum DesktopEnvironment {
...@@ -178,10 +177,6 @@ public class Configuration { ...@@ -178,10 +177,6 @@ public class Configuration {
} }
} }
public string avatar_url {
owned get { return settings.get_string(AVATAR_URL); }
}
// Creates a configuration object. // Creates a configuration object.
public Configuration(string schema_id) { public Configuration(string schema_id) {
// Start GSettings. // Start GSettings.
......
...@@ -260,10 +260,16 @@ public class GearyController : Geary.BaseObject { ...@@ -260,10 +260,16 @@ public class GearyController : Geary.BaseObject {
error("Error loading web resources: %s", err.message); error("Error loading web resources: %s", err.message);
} }
this.avatar_store = new Application.AvatarStore( Folks.IndividualAggregator individuals =
this.application.config, Folks.IndividualAggregator.dup();
this.application.get_user_cache_directory() 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.) // Create the main window (must be done after creating actions.)
main_window = new MainWindow(this.application); main_window = new MainWindow(this.application);
...@@ -2625,7 +2631,7 @@ public class GearyController : Geary.BaseObject { ...@@ -2625,7 +2631,7 @@ public class GearyController : Geary.BaseObject {
// string substitution is a list of recipients of the email. // string substitution is a list of recipients of the email.
string message = _( string message = _(
"Successfully sent mail to %s." "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); InAppNotification notification = new InAppNotification(message);
this.main_window.add_notification(notification); this.main_window.add_notification(notification);
Libnotify.play_sound("message-sent-email"); Libnotify.play_sound("message-sent-email");
......
...@@ -83,7 +83,7 @@ public class ConversationListStore : Gtk.ListStore { ...@@ -83,7 +83,7 @@ public class ConversationListStore : Gtk.ListStore {
Geary.App.Conversation a, b; Geary.App.Conversation a, b;
model.get(aiter, Column.CONVERSATION_OBJECT, out a); model.get(aiter, Column.CONVERSATION_OBJECT, out a);
model.get(biter, Column.CONVERSATION_OBJECT, out b); 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 { ...@@ -225,8 +225,10 @@ public class ConversationListStore : Gtk.ListStore {
// sort the conversations so the previews are fetched from the newest to the oldest, matching // sort the conversations so the previews are fetched from the newest to the oldest, matching
// the user experience // the user experience
Gee.TreeSet<Geary.App.Conversation> sorted_conversations = new Gee.TreeSet<Geary.App.Conversation>( Gee.TreeSet<Geary.App.Conversation> sorted_conversations =
compare_conversation_descending); new Gee.TreeSet<Geary.App.Conversation>(
Util.Email.compare_conversation_descending
);
sorted_conversations.add_all(this.conversations.read_only_view); sorted_conversations.add_all(this.conversations.read_only_view);
foreach (Geary.App.Conversation conversation in sorted_conversations) { foreach (Geary.App.Conversation conversation in sorted_conversations) {
// find oldest unread message for the preview // find oldest unread message for the preview
......
...@@ -111,7 +111,7 @@ public class FormattedConversationData : Geary.BaseObject { ...@@ -111,7 +111,7 @@ public class FormattedConversationData : Geary.BaseObject {
// Load preview-related data. // Load preview-related data.
update_date_string(); 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.body = Geary.String.reduce_whitespace(preview.get_preview_as_string());
this.preview = preview; this.preview = preview;
......
...@@ -289,6 +289,9 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { ...@@ -289,6 +289,9 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
/** Determines if the email is a draft message. */ /** Determines if the email is a draft message. */
public bool is_draft { get; private set; } 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. */ /** The view displaying the email's primary message headers and body. */
public ConversationMessage primary_message { get; private set; } public ConversationMessage primary_message { get; private set; }
...@@ -444,6 +447,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface { ...@@ -444,6 +447,7 @@ public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
base_ref();