New mail notification: Closes #3828

Adds new mail notification via libnotify for new previously-unseen
messages.  Various minor bugs fixed in here as well that were
relevant to this operation (including notifying of locally
appended messages during folder normalization and reusing Folder
objects inside Account).
This commit is contained in:
Christian Dywan 2012-06-14 16:47:54 -07:00 committed by Jim Nelson
parent dc7d751999
commit 2c0a210656
11 changed files with 323 additions and 34 deletions

4
debian/control vendored
View file

@ -7,6 +7,8 @@ Build-Depends: debhelper (>= 7),
libglib2.0-dev (>= 2.26.0),
libgtk-3-dev (>= 3.2.0),
libunique-3.0-dev (>= 3.0.0),
libnotify-dev (>=0.7.5),
libcanberra-dev (>= 0.28),
libwebkitgtk-3.0-dev (>= 1.4.3),
libgmime-2.6-dev (>= 2.6.0),
valac-0.16 (>= 0.16.0),
@ -23,6 +25,8 @@ Depends: ${shlibs:Depends}, ${misc:Depends},
libglib2.0-0 (>= 2.26.0),
libgtk-3-0 (>= 3.2.0),
libunique-3.0-0 (>= 3.0.0),
libnotify4 (>= 0.7.5),
libcanberra0 (>= 0.28),
libwebkitgtk-3.0-0 (>= 1.4.3),
libxml2 (>= 2.6.32),
libsqlheavy0.1-0 (>= 0.1.1)

View file

@ -193,6 +193,7 @@ client/ui/message-viewer.vala
client/ui/password-dialog.vala
client/ui/preferences-dialog.vala
client/ui/webview-edit-fixer.vala
client/ui/notification-bubble.vala
client/ui/sidebar/sidebar-branch.vala
client/ui/sidebar/sidebar-common.vala
@ -200,6 +201,7 @@ client/ui/sidebar/sidebar-entry.vala
client/ui/sidebar/sidebar-tree.vala
client/util/util-email.vala
client/util/util-gravatar.vala
client/util/util-keyring.vala
client/util/util-menu.vala
client/util/util-webkit.vala
@ -237,6 +239,8 @@ pkg_check_modules(DEPS REQUIRED
gtk+-3.0>=3.2.0
gee-1.0>=0.6.0
unique-3.0>=3.0.0
libnotify>=0.7.5
libcanberra>=0.28
sqlite3>=3.7.4
sqlheavy-0.1>=0.1.1
gmime-2.6>=2.6.0
@ -249,7 +253,7 @@ set(ENGINE_PACKAGES
)
set(CLIENT_PACKAGES
gtk+-3.0 gnome-keyring-1 webkitgtk-3.0
gtk+-3.0 gnome-keyring-1 webkitgtk-3.0 libnotify libcanberra
)
set(CONSOLE_PACKAGES

View file

@ -66,8 +66,10 @@ public class GearyController {
public bool enable_load_more { get; set; default = true; }
private Cancellable cancellable_folder = new Cancellable();
private Cancellable cancellable_inbox = new Cancellable();
private Cancellable cancellable_message = new Cancellable();
private Geary.Folder? current_folder = null;
private Geary.Folder? inbox_folder = null;
private Geary.ConversationMonitor? current_conversations = null;
private bool loading_local_only = true;
private int busy_count = 0;
@ -250,6 +252,7 @@ public class GearyController {
// Disconnect the old account, if any.
if (account != null) {
cancel_folder();
cancel_inbox();
cancel_message();
account.folders_added_removed.disconnect(on_folders_added_removed);
@ -263,6 +266,16 @@ public class GearyController {
main_window.folder_list.remove_all_branches();
if (inbox_folder != null) {
try {
yield inbox_folder.close_async(cancellable);
} catch (Error close_inbox_err) {
debug("Unable to close monitored inbox: %s", close_inbox_err.message);
}
inbox_folder.email_locally_appended.disconnect(on_inbox_new_email);
}
try {
yield account.close_async(cancellable);
} catch (Error close_err) {
@ -340,13 +353,23 @@ public class GearyController {
if (cancellable.is_cancelled())
return;
// If inbox is specified, select that
// If inbox is available (should be!), monitor it and select it for the user
Geary.SpecialFolder? inbox = special_folders.get_folder(Geary.SpecialFolderType.INBOX);
if (inbox != null)
if (inbox != null) {
// create and leave open the Inbox, which is constantly monitored for notifications
inbox_folder = yield account.fetch_folder_async(inbox.path, cancellable_inbox);
assert(inbox_folder != null);
yield inbox_folder.open_async(false, cancellable_inbox);
inbox_folder.email_locally_appended.connect(on_inbox_new_email);
// select the inbox and get the show started
main_window.folder_list.select_path(inbox.path);
}
}
// pull down the root-level user folders
// pull down the root-level user folders and recursively add to sidebar
Gee.Collection<Geary.Folder> folders = yield account.list_folders_async(null);
if (folders != null)
on_folders_added_removed(folders, null);
@ -374,14 +397,18 @@ public class GearyController {
cancel_folder();
main_window.message_list_store.clear();
// stop monitoring for conversations and close the folder
// stop monitoring for conversations and close the folder (but only if not the inbox_folder,
// which we leave open for notifications)
if (current_conversations != null) {
yield current_conversations.stop_monitoring_async(true, null);
yield current_conversations.stop_monitoring_async((current_folder != inbox_folder), null);
current_conversations = null;
} else if (current_folder != null) {
} else if (current_folder != null && current_folder != inbox_folder) {
yield current_folder.close_async();
}
if (folder != null)
debug("switching to %s", folder.to_string());
current_folder = folder;
main_window.message_list_store.set_current_folder(current_folder);
@ -497,7 +524,44 @@ public class GearyController {
debug("Unable to fetch preview: %s", err.message);
}
}
public void on_inbox_new_email(Gee.Collection<Geary.EmailIdentifier> email_ids) {
debug("on_inbox_new_email: %d locally appended", email_ids.size);
do_notify_new_email.begin(email_ids);
}
public async void do_notify_new_email(Gee.Collection<Geary.EmailIdentifier> email_ids) {
try {
Gee.List<Geary.Email>? list = yield inbox_folder.list_email_by_sparse_id_async(email_ids,
NotificationBubble.REQUIRED_FIELDS | Geary.Email.Field.FLAGS, Geary.Folder.ListFlags.NONE,
cancellable_inbox);
if (list == null || list.size == 0) {
debug("Warning: %d new emails, but none could be listed", email_ids.size);
return;
}
int unread = 0;
Geary.Email? last_unread = null;
foreach (Geary.Email email in list) {
if (email.email_flags.is_unread()) {
unread++;
last_unread = email;
}
}
debug("do_notify_new_email: %d messages listed, %d unread", list.size, unread);
NotificationBubble notification = new NotificationBubble();
if (unread == 1 && last_unread != null)
notification.notify_one_message(last_unread);
else if (unread > 0)
notification.notify_new_mail(unread);
} catch (Error err) {
debug("Unable to notify of new email: %s", err.message);
}
}
public void on_conversations_added(Gee.Collection<Geary.Conversation> conversations) {
Gtk.Adjustment adjustment = (main_window.message_list_view.get_parent() as Gtk.ScrolledWindow)
.get_vadjustment();
@ -739,6 +803,12 @@ public class GearyController {
old_cancellable.cancel();
}
private void cancel_inbox() {
Cancellable old_cancellable = cancellable_inbox;
cancellable_inbox = new Cancellable();
old_cancellable.cancel();
}
private void cancel_message() {
Cancellable old_cancellable = cancellable_message;

View file

@ -335,17 +335,18 @@ public class MessageViewer : WebKit.WebView {
insert_header_date(ref header, _("Date:"), email.date.value, true);
// Add the avatar.
try {
WebKit.DOM.HTMLImageElement icon = Util.DOM.select(div_message, ".avatar")
as WebKit.DOM.HTMLImageElement;
string checksum = GLib.Checksum.compute_for_string (
GLib.ChecksumType.MD5, email.sender.get(0).address);
string gravatar = "http://www.gravatar.com/avatar/%s?d=mm&size=48".printf (checksum);
icon.set_attribute("src", gravatar);
} catch (Error error) {
warning("Failed to load avatar: %s", error.message);
Geary.RFC822.MailboxAddress? primary = email.get_primary_originator();
if (primary != null) {
try {
WebKit.DOM.HTMLImageElement icon = Util.DOM.select(div_message, ".avatar")
as WebKit.DOM.HTMLImageElement;
icon.set_attribute("src",
Gravatar.get_image_uri(primary, Gravatar.Default.MYSTERY_MAN, 48));
} catch (Error error) {
warning("Failed to load avatar: %s", error.message);
}
}
// Insert the preview text.
try {
WebKit.DOM.HTMLElement preview =

View file

@ -0,0 +1,94 @@
/* Copyright 2011-2012 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
// Displays a notification bubble
public class NotificationBubble : GLib.Object {
public const Geary.Email.Field REQUIRED_FIELDS =
Geary.Email.Field.ORIGINATORS | Geary.Email.Field.SUBJECT;
private static Canberra.Context? sound_context = null;
private Notify.Notification notification;
private unowned List<string> caps;
public NotificationBubble() {
if (!Notify.is_initted()) {
if (!Notify.init(GearyApplication.PRGNAME))
critical("Failed to initialize libnotify.");
}
init_sound();
caps = Notify.get_server_caps();
// Avoid constructor due to ABI change
notification = (Notify.Notification) GLib.Object.new(
typeof (Notify.Notification),
"icon-name", "geary",
"summary", GLib.Environment.get_application_name());
notification.set_hint_string("desktop-entry", "geary");
if (caps.find("actions") != null)
notification.add_action("default", _("Open"), on_default_action);
}
private static void init_sound() {
if (sound_context == null)
Canberra.Context.create(out sound_context);
}
private void on_default_action(Notify.Notification notification, string action) {
GearyApplication.instance.activate(new string[0]);
}
public void notify_new_mail(int count) throws GLib.Error {
notification.set_category("email.arrived");
prepare_notification(ngettext("%d new message", "%d new messages", count).printf(count),
"message-new-email");
notification.show();
}
public void notify_one_message(Geary.Email email) throws GLib.Error {
assert(email.fields.fulfills(REQUIRED_FIELDS));
// possible to receive email with no originator
Geary.RFC822.MailboxAddress? primary = email.get_primary_originator();
if (primary == null) {
notify_new_mail(1);
return;
}
notification.set_category("email.arrived");
notification.set("summary", primary.get_short_address());
string message;
if (email.fields.fulfills(Geary.Email.Field.PREVIEW)) {
message = "%s %s".printf(email.get_subject_as_string(),
Geary.String.reduce_whitespace(email.get_preview_as_string()));
} else {
message = email.get_subject_as_string();
}
prepare_notification(message, "message-new-email");
notification.show();
}
private void prepare_notification(string message, string sound) throws GLib.Error {
notification.set("body", message);
if (caps.find("sound") != null)
notification.set_hint_string("sound-name", sound);
else
play_sound(sound);
}
public static void play_sound(string sound) {
init_sound();
sound_context.play(0, Canberra.PROP_EVENT_ID, sound);
}
}

View file

@ -0,0 +1,61 @@
/* Copyright 2012 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
namespace Gravatar {
public const int MIN_SIZE = 1;
public const int MAX_SIZE = 512;
public const int DEFAULT_SIZE = 80;
public enum Default {
NOT_FOUND,
MYSTERY_MAN,
IDENTICON,
MONSTER_ID,
WAVATAR,
RETRO;
public string to_param() {
switch (this) {
case NOT_FOUND:
return "404";
case MYSTERY_MAN:
return "mm";
case IDENTICON:
return "identicon";
case MONSTER_ID:
return "monsterid";
case WAVATAR:
return "wavatar";
case RETRO:
return "retro";
default:
assert_not_reached();
}
}
}
/**
* Returns a URI for the mailbox address specified. size may be any value from MIN_SIZE to
* MAX_SIZE, representing pixels. This function does not attempt to clamp size to this range or
* return an error of any kind if it's outside this range.
*
* TODO: More parameters are available and could be incorporated. See
* https://en.gravatar.com/site/implement/images/
*/
public string get_image_uri(Geary.RFC822.MailboxAddress addr, Default def, int size = DEFAULT_SIZE) {
return "http://www.gravatar.com/avatar/%s?d=%s&s=%d".printf(
Checksum.compute_for_string(ChecksumType.MD5, addr.address), def.to_param(), size);
}
}

View file

@ -13,6 +13,10 @@ public unowned string C_(string context, string text) {
return text;
}
public unowned string ngettext (string msgid, string msgid_plural, ulong n) {
return n > 1 ? msgid_plural : msgid;
}
public const string TRANSLATABLE = "TRANSLATABLE";
namespace Intl {

View file

@ -59,6 +59,11 @@ public interface Geary.Account : Object {
* all the root folders. If the parent path cannot be found, EngineError.NOT_FOUND is thrown.
* If no folders exist in the root, EngineError.NOT_FOUND may be thrown as well. However,
* the caller should be prepared to deal with an empty list being returned instead.
*
* The same Geary.Folder objects (instances) will be returned if the same path is submitted
* multiple times. This means that multiple callers may be holding references to the same
* Folders. This is important when thinking of opening and closing folders and signal
* notifications.
*/
public abstract async Gee.Collection<Geary.Folder> list_folders_async(Geary.FolderPath? parent,
Cancellable? cancellable = null) throws Error;
@ -74,6 +79,11 @@ public interface Geary.Account : Object {
/**
* Fetches a Folder object corresponding to the supplied path. If the backing medium does
* not have a record of a folder at the path, EngineError.NOT_FOUND will be thrown.
*
* The same Geary.Folder object (instance) will be returned if the same path is submitted
* multiple times. This means that multiple callers may be holding references to the same
* Folders. This is important when thinking of opening and closing folders and signal
* notifications.
*/
public abstract async Geary.Folder fetch_folder_async(Geary.FolderPath path,
Cancellable? cancellable = null) throws Error;

View file

@ -304,7 +304,26 @@ public class Geary.Email : Object {
public string get_subject_as_string() {
return (subject != null) ? subject.value : "";
}
/**
* Returns the primary originator of an email, which is defined as the first mailbox address
* in From:, Sender:, or Reply-To:, in that order, depending on availability.
*
* Returns null if no originators are present.
*/
public RFC822.MailboxAddress? get_primary_originator() {
if (from != null && from.size > 0)
return from[0];
if (sender != null && sender.size > 0)
return sender[0];
if (reply_to != null && reply_to.size > 0)
return reply_to[0];
return null;
}
public string to_string() {
StringBuilder builder = new StringBuilder();

View file

@ -10,6 +10,8 @@ private abstract class Geary.GenericImapAccount : Geary.EngineAccount {
private Gee.HashMap<FolderPath, Imap.FolderProperties> properties_map = new Gee.HashMap<
FolderPath, Imap.FolderProperties>(Hashable.hash_func, Equalable.equal_func);
private SmtpOutboxFolder? outbox = null;
private Gee.HashMap<FolderPath, GenericImapFolder> existing_folders = new Gee.HashMap<
FolderPath, GenericImapFolder>(Hashable.hash_func, Equalable.equal_func);
public GenericImapAccount(string name, string username, AccountInformation? account_info,
File user_data_dir, Imap.Account remote, Sqlite.Account local) {
@ -73,6 +75,18 @@ private abstract class Geary.GenericImapAccount : Geary.EngineAccount {
throw remote_err;
}
private GenericImapFolder build_folder(Sqlite.Folder local_folder) {
GenericImapFolder? folder = existing_folders.get(local_folder.get_path());
if (folder != null)
return folder;
folder = new GenericImapFolder(this, remote, local, local_folder,
get_special_folder(local_folder.get_path()));
existing_folders.set(folder.get_path(), folder);
return folder;
}
public override async Gee.Collection<Geary.Folder> list_folders_async(Geary.FolderPath? parent,
Cancellable? cancellable = null) throws Error {
Gee.Collection<Geary.Sqlite.Folder>? local_list = null;
@ -86,10 +100,8 @@ private abstract class Geary.GenericImapAccount : Geary.EngineAccount {
Gee.Collection<Geary.Folder> engine_list = new Gee.ArrayList<Geary.Folder>();
if (local_list != null && local_list.size > 0) {
foreach (Geary.Sqlite.Folder local_folder in local_list) {
engine_list.add(new GenericImapFolder(this, remote, local, local_folder,
get_special_folder(local_folder.get_path())));
}
foreach (Geary.Sqlite.Folder local_folder in local_list)
engine_list.add(build_folder(local_folder));
}
background_update_folders.begin(parent, engine_list, cancellable);
@ -112,11 +124,8 @@ private abstract class Geary.GenericImapAccount : Geary.EngineAccount {
if (path.equals(outbox.get_path()))
return outbox;
Sqlite.Folder? local_folder = null;
try {
local_folder = (Sqlite.Folder) yield local.fetch_folder_async(path, cancellable);
return new GenericImapFolder(this, remote, local, local_folder,
get_special_folder(local_folder.get_path()));
return build_folder((Sqlite.Folder) yield local.fetch_folder_async(path, cancellable));
} catch (EngineError err) {
// don't thrown NOT_FOUND's, that means we need to fall through and clone from the
// server
@ -139,9 +148,7 @@ private abstract class Geary.GenericImapAccount : Geary.EngineAccount {
}
// Fetch the local account's version of the folder for the GenericImapFolder
local_folder = (Sqlite.Folder) yield local.fetch_folder_async(path, cancellable);
return new GenericImapFolder(this, remote, local, local_folder,
get_special_folder(local_folder.get_path()));
return build_folder((Sqlite.Folder) yield local.fetch_folder_async(path, cancellable));
}
private async void background_update_folders(Geary.FolderPath? parent,
@ -201,10 +208,8 @@ private abstract class Geary.GenericImapAccount : Geary.EngineAccount {
engine_added = new Gee.ArrayList<Geary.Folder>();
foreach (Geary.Imap.Folder remote_folder in to_add) {
try {
Sqlite.Folder local_folder = (Sqlite.Folder) yield local.fetch_folder_async(
remote_folder.get_path(), cancellable);
engine_added.add(new GenericImapFolder(this, remote, local, local_folder,
get_special_folder(local_folder.get_path())));
engine_added.add(build_folder((Sqlite.Folder) yield local.fetch_folder_async(
remote_folder.get_path(), cancellable)));
} catch (Error convert_err) {
error("Unable to fetch local folder: %s", convert_err.message);
}

View file

@ -264,6 +264,16 @@ private class Geary.GenericImapFolder : Geary.AbstractFolder {
// throw the first exception, if one occurred
batch.throw_first_exception();
// look for local additions (email not known to the local store)
Gee.ArrayList<Geary.EmailIdentifier> locally_appended = new Gee.ArrayList<Geary.EmailIdentifier>();
foreach (int id in batch.get_ids()) {
CreateLocalEmailOperation? create_op = batch.get_operation(id) as CreateLocalEmailOperation;
if (create_op != null) {
if (create_op.created)
locally_appended.add(create_op.email.id);
}
}
// notify emails that have been removed (see note above about why not all Creates are
// signalled)
if (removed_ids.size > 0) {
@ -271,6 +281,13 @@ private class Geary.GenericImapFolder : Geary.AbstractFolder {
notify_email_removed(removed_ids);
}
// notify local additions
if (locally_appended.size > 0) {
debug("Notifying of %d locally appended emails since %s last seen", locally_appended.size,
to_string());
notify_email_locally_appended(locally_appended);
}
// notify additions
if (appended_ids.size > 0) {
debug("Notifying of %d appended emails since %s last seen", appended_ids.size, to_string());