geary/src/client/application/application-controller.vala

2817 lines
101 KiB
Vala
Raw Normal View History

Reenable displaying sub-messages. Geary currently displays RFC 822 attachments inline, below the email's primary message body, using the same HTML chrome for the headers and email body as for the primary body. Taking the same approach but using GTK+ widgets meant splitting ConversationMessage up into a ConversationEmail class that manages the UI for displaying an email in its entirety, and a ConversationMessage to manage the only header widgets and webview for displaying an individual RFC 822 message, usable for both the primary body and any sub-messages. Thus, this is a big change. One behavioural change is that each sub-message with remote images now requires individual approval, rather than being dependant on the containing message's sender and/or approval. This prevents some attacks e.g. a trusted sender forwarding a spam/malware message, but does not prevent it if the message is forwarded inline, obviosuly. * src/client/conversation-viewer/conversation-email.vala (ConversationEmail): New class for managing the UI for an overall email message. This replaces the old ConversationMessage and contains much of it's code and widgets - anything from that class which does not directly support displaying headers or a message body. * src/client/conversation-viewer/conversation-message.vala: (ConversationMessage): Same class as before, but now with its scope narrowed to only display message headers and body. The draft infobar remains here rather than being put ConversationEmail where it belongs since it's bit of a pain to insert in the right place and doesn't really hurt. (::email): Moved this property and any code that depends on it to ConversationEmail. (::always_load_remote_images): New property passed in via the ctor, allowing one dependency on the old ::email property to be removed. (::inlined_content_ids): Moved to ConversationEmail, since that is the class that keeps track of attachments to display. Add the signal attachment_displayed_inline to allow ConversationEmail to be notified of inlined attachments instead. (::flag_remote_images, ::remember_remote_images): New signals to notify ConversationEmail that the user has flagged this message or the message's sender for loading remote images. This is passed through since in the former's case we may need to set flags on the email itself, the latter because it is one less use of the contact_store property, which should be removed from this class at some point. * src/client/conversation-viewer/conversation-viewer.vala: Chase API changes from the above. In general, replace use of the term "message" with "email" since this class is now mostly dealing with ConversationEmail instances, rather than ConversationMessage instances. (ConversationViewer::check_mark_read): Only consider the ConversationEmail's primary message body when checking for visibility rather than that and any submessages to keep things simple. (ConversationViewer::show_message, ::hide_message): Renamed to expand_email/collapse_email respectively since we don't ever actually hide it. Carry that change on to same methods on ConversationEmail. * src/engine/rfc822/rfc822-message.vala (Geary.RFC822.Message): Add get_primary_originator(), almost vermatim from Geary.Email, to support determining the sender for remembering remote message loading for senders of sub-emails. * src/client/components/main-window.vala (MainWindow::set_styling): Fix background transition for collapsed emails. * src/client/application/geary-controller.vala: Chase API name changes. * src/CMakeLists.txt: Include new ConversationEmail source file. * ui/conversation-email.ui: New UI for ConversationEmail, move the email action box, attachments box amd sub-messages box here from conversation-message.ui. * ui/CMakeLists.txt: Include new UI in compiled resources. * po/POTFILES.in: Add new UI for transation.
2016-04-19 16:52:34 +10:00
/*
* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2016-2019 Michael Gratton <mike@vee.net>
*
* This software is licensed under the GNU Lesser General Public License
Reenable displaying sub-messages. Geary currently displays RFC 822 attachments inline, below the email's primary message body, using the same HTML chrome for the headers and email body as for the primary body. Taking the same approach but using GTK+ widgets meant splitting ConversationMessage up into a ConversationEmail class that manages the UI for displaying an email in its entirety, and a ConversationMessage to manage the only header widgets and webview for displaying an individual RFC 822 message, usable for both the primary body and any sub-messages. Thus, this is a big change. One behavioural change is that each sub-message with remote images now requires individual approval, rather than being dependant on the containing message's sender and/or approval. This prevents some attacks e.g. a trusted sender forwarding a spam/malware message, but does not prevent it if the message is forwarded inline, obviosuly. * src/client/conversation-viewer/conversation-email.vala (ConversationEmail): New class for managing the UI for an overall email message. This replaces the old ConversationMessage and contains much of it's code and widgets - anything from that class which does not directly support displaying headers or a message body. * src/client/conversation-viewer/conversation-message.vala: (ConversationMessage): Same class as before, but now with its scope narrowed to only display message headers and body. The draft infobar remains here rather than being put ConversationEmail where it belongs since it's bit of a pain to insert in the right place and doesn't really hurt. (::email): Moved this property and any code that depends on it to ConversationEmail. (::always_load_remote_images): New property passed in via the ctor, allowing one dependency on the old ::email property to be removed. (::inlined_content_ids): Moved to ConversationEmail, since that is the class that keeps track of attachments to display. Add the signal attachment_displayed_inline to allow ConversationEmail to be notified of inlined attachments instead. (::flag_remote_images, ::remember_remote_images): New signals to notify ConversationEmail that the user has flagged this message or the message's sender for loading remote images. This is passed through since in the former's case we may need to set flags on the email itself, the latter because it is one less use of the contact_store property, which should be removed from this class at some point. * src/client/conversation-viewer/conversation-viewer.vala: Chase API changes from the above. In general, replace use of the term "message" with "email" since this class is now mostly dealing with ConversationEmail instances, rather than ConversationMessage instances. (ConversationViewer::check_mark_read): Only consider the ConversationEmail's primary message body when checking for visibility rather than that and any submessages to keep things simple. (ConversationViewer::show_message, ::hide_message): Renamed to expand_email/collapse_email respectively since we don't ever actually hide it. Carry that change on to same methods on ConversationEmail. * src/engine/rfc822/rfc822-message.vala (Geary.RFC822.Message): Add get_primary_originator(), almost vermatim from Geary.Email, to support determining the sender for remembering remote message loading for senders of sub-emails. * src/client/components/main-window.vala (MainWindow::set_styling): Fix background transition for collapsed emails. * src/client/application/geary-controller.vala: Chase API name changes. * src/CMakeLists.txt: Include new ConversationEmail source file. * ui/conversation-email.ui: New UI for ConversationEmail, move the email action box, attachments box amd sub-messages box here from conversation-message.ui. * ui/CMakeLists.txt: Include new UI in compiled resources. * po/POTFILES.in: Add new UI for transation.
2016-04-19 16:52:34 +10:00
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
2019-06-26 03:35:27 +00:00
* Primary controller for an application instance.
*
* A single instance of this class is constructed by {@link
* GearyAplication} when the primary application instance is started.
*/
internal class Application.Controller : Geary.BaseObject {
Reimplement in-conversation find. * src/client/application/geary-controller.vala (GearyController): Remove ACTION_FIND_NEXT_IN_CONVERSATION and ACTION_FIND_PREVIOUS_IN_CONVERSATION arctions and callbacks since they will be taken care of by the search entry & search bar buttons, and remove from accelerators.ui. Add ACTION_TOGGLE_FIND action to handle toggling find bar in the same way as the search bar. * src/client/components/main-toolbar.vala (MainToolbar): Add new button and infrastrcuture for toggling the find bar. * src/client/conversation-viewer/conversation-viewer.vala (ConversationViewer): Convert ::conversation_page to be grid, add new ::conversation_scroller property for the scrollbar, update call sites. Add props for accessing find widgets, remove old find methods and add callbacks for handling find start, change, etc. * src/client/conversation-viewer/conversation-email.vala, src/client/conversation-viewer/conversation-message.vala: Add methods for accessing selected text for find. * src/client/conversation-viewer/conversation-listbox.vala (ConversationListBox::highlight_search_terms): Updated to return a flag specifiying whether any search results were found, and to expand/collapse messsages depending on whether they have any. * src/client/conversation-viewer/conversation-message.vala (ConversationMessage::highlight_search_terms): Keep track of how many results were found, and return that. * ui/conversation-viewer.ui: Convert conversation_page to be a grid, add a search bar and search widgets to it, and move conversation ScrolledWindow to it.
2016-08-22 11:42:54 +10:00
private const uint MAX_AUTH_ATTEMPTS = 3;
2020-01-08 14:49:53 +01:00
private const uint CLEANUP_CHECK_AFTER_IDLE_BACKGROUND_MINUTES = 5;
/** Determines if conversations can be trashed from the given folder. */
public static bool does_folder_support_trash(Geary.Folder? target) {
return (
target != null &&
target.special_folder_type != TRASH &&
!target.properties.is_local_only &&
(target as Geary.FolderSupport.Move) != null
);
}
/** Determines if folders should be added to main windows. */
public static bool should_add_folder(Gee.Collection<Geary.Folder>? all,
Geary.Folder folder) {
// if folder is openable, add it
if (folder.properties.is_openable != Geary.Trillian.FALSE)
return true;
else if (folder.properties.has_children == Geary.Trillian.FALSE)
return false;
// if folder contains children, we must ensure that there is at least one of the same type
Geary.SpecialFolderType type = folder.special_folder_type;
foreach (Geary.Folder other in all) {
if (other.special_folder_type == type && other.path.parent == folder.path)
return true;
}
return false;
}
/** Determines if the controller is open. */
public bool is_open {
get {
return !this.controller_open.is_cancelled();
}
}
/** The primary application instance that owns this controller. */
public weak Client application { get; private set; } // circular ref
/** Account management for the application. */
public Accounts.Manager account_manager { get; private set; }
/** Certificate management for the application. */
public Application.CertificateManager certificate_manager {
get; private set;
}
/** Avatar store for the application. */
public Application.AvatarStore avatars {
get; private set; default = new Application.AvatarStore();
}
// Primary collection of the application's open accounts
private Gee.Map<Geary.AccountInformation,AccountContext> accounts =
new Gee.HashMap<Geary.AccountInformation,AccountContext>();
// Cancelled if the controller is closed
private GLib.Cancellable controller_open;
private UpgradeDialog upgrade_dialog;
private Folks.IndividualAggregator folks;
private PluginManager plugin_manager;
// List composers that have not yet been closed
private Gee.Collection<Composer.Widget> composer_widgets =
new Gee.LinkedList<Composer.Widget>();
// Requested mailto composers not yet fullfulled
private Gee.List<string?> pending_mailtos = new Gee.ArrayList<string>();
2020-01-08 11:00:01 +01:00
// Timeout to do work in idle after all windows have been sent to the background
2020-01-09 12:36:47 +01:00
private Geary.TimeoutManager all_windows_backgrounded_timeout;
2020-01-08 11:00:01 +01:00
// Whether we're fully in the background
public bool all_windows_backgrounded { get; private set; default = false; }
/**
* Emitted when an account is added or is enabled.
*
* This will be emitted after an account is opened and added to
* the controller.
*/
public signal void account_available(AccountContext context);
/**
* Emitted when an account is removed or is disabled.
*
* This will be emitted after the account is removed from the
* controller's collection of accounts, but before the {@link
* AccountContext.cancellable} is cancelled and before the account
* itself is closed.
*
* The `is_shutdown` argument will be true if the application is
* in the middle of quitting, otherwise if the account was simply
* removed but the application will keep running, then it will be
* false.
*/
public signal void account_unavailable(AccountContext context,
bool is_shutdown);
/**
* Constructs a new instance of the controller.
*/
public async Controller(Client application,
GLib.Cancellable cancellable)
throws GLib.Error {
this.application = application;
this.controller_open = cancellable;
// This initializes the IconFactory, important to do before
// the actions are created (as they refer to some of Geary's
// custom icons)
IconFactory.init(application.get_resource_directory());
2013-07-08 14:22:23 -07:00
// Create DB upgrade dialog.
this.upgrade_dialog = new UpgradeDialog(application);
// Initialise WebKit and WebViews
Components.WebView.init_web_context(
this.application.config,
this.application.get_web_extensions_dir(),
this.application.get_user_cache_directory().get_child("web-resources")
);
Components.WebView.load_resources(
this.application.get_user_config_directory()
);
Composer.WebView.load_resources();
ConversationWebView.load_resources();
Accounts.SignatureWebView.load_resources();
2020-01-09 12:36:47 +01:00
this.all_windows_backgrounded_timeout =
new Geary.TimeoutManager.seconds(CLEANUP_CHECK_AFTER_IDLE_BACKGROUND_MINUTES * 60, on_unfocused_idle);
this.folks = Folks.IndividualAggregator.dup();
if (!this.folks.is_prepared) {
// Do this in the background since it can take a long time
// on some systems and the GUI shouldn't be blocked by it
this.folks.prepare.begin((obj, res) => {
try {
this.folks.prepare.end(res);
} catch (GLib.Error err) {
warning("Error preparing Folks: %s", err.message);
}
});
}
this.plugin_manager = new PluginManager(application);
this.plugin_manager.notifications = new NotificationContext(
this.avatars,
this.get_contact_store_for_account,
this.should_notify_new_messages
);
this.plugin_manager.load();
// Migrate configuration if necessary.
Migrate.xdg_config_dir(this.application.get_user_data_directory(),
this.application.get_user_config_directory());
// Hook up cert, accounts and credentials machinery
this.certificate_manager = yield new Application.CertificateManager(
this.application.get_user_data_directory().get_child("pinned-certs"),
cancellable
);
SecretMediator? libsecret = yield new SecretMediator(cancellable);
application.engine.account_available.connect(on_account_available);
this.account_manager = new Accounts.Manager(
libsecret,
this.application.get_user_config_directory(),
this.application.get_user_data_directory()
);
this.account_manager.account_added.connect(
on_account_added
);
this.account_manager.account_status_changed.connect(
on_account_status_changed
);
2018-06-17 18:03:09 +10:00
this.account_manager.account_removed.connect(
on_account_removed
);
this.account_manager.report_problem.connect(
on_report_problem
);
yield this.account_manager.connect_goa(cancellable);
// Start loading accounts
yield this.account_manager.load_accounts(cancellable);
2018-06-17 18:03:09 +10:00
// Expunge any deleted accounts in the background, so we're
// not blocking the app continuing to open.
this.expunge_accounts.begin();
}
/** Returns a context for an account, if any. */
internal AccountContext? get_context_for_account(Geary.AccountInformation account) {
return this.accounts.get(account);
}
/** Returns a read-only collection of contexts each active account. */
internal Gee.Collection<AccountContext> get_account_contexts() {
return this.accounts.values.read_only_view;
}
/** Closes all windows and accounts, releasing held resources. */
public async void close() {
// Stop listening for account changes up front so we don't
// attempt to add new accounts while shutting down.
this.account_manager.account_added.disconnect(
on_account_added
);
this.account_manager.account_status_changed.disconnect(
on_account_status_changed
);
this.account_manager.account_removed.disconnect(
on_account_removed
);
this.application.engine.account_available.disconnect(
on_account_available
);
foreach (MainWindow window in this.application.get_main_windows()) {
window.sensitive = false;
}
// Close any open composers up-front before anything else is
// shut down so any pending operations have a chance to
// complete.
var composer_barrier = new Geary.Nonblocking.CountingSemaphore(null);
// Take a copy of the collection of composers since
// closing any will cause the underlying collection to change.
var composers = new Gee.LinkedList<Composer.Widget>();
composers.add_all(this.composer_widgets);
foreach (var composer in composers) {
if (composer.current_mode != CLOSED) {
composer_barrier.acquire();
composer.close.begin(
(obj, res) => {
composer.close.end(res);
composer_barrier.blind_notify();
}
);
}
}
try {
yield composer_barrier.wait_async();
} catch (GLib.Error err) {
debug("Error waiting at composer barrier: %s", err.message);
}
2018-06-17 18:03:09 +10:00
// Now that all composers are closed, we can shut down the
// rest of the client and engine. Cancel internal processes
// first so they don't block shutdown.
this.controller_open.cancel();
// Release folder and conversations in main windows before
// closing them so we know they are released before closing
// the accounts
var window_barrier = new Geary.Nonblocking.CountingSemaphore(null);
foreach (MainWindow window in this.application.get_main_windows()) {
window_barrier.acquire();
window.select_folder.begin(
null,
false,
true,
(obj, res) => {
window.select_folder.end(res);
window.close();
window_barrier.blind_notify();
}
);
}
try {
yield window_barrier.wait_async();
} catch (GLib.Error err) {
debug("Error waiting at window barrier: %s", err.message);
}
// Release general resources now there's no more UI
this.plugin_manager.notifications.clear_folders();
this.avatars.close();
this.pending_mailtos.clear();
this.composer_widgets.clear();
// Create a copy of known accounts so the loop below does not
// explode if accounts are removed while iterating.
var closing_accounts = new Gee.LinkedList<AccountContext>();
closing_accounts.add_all(this.accounts.values);
var account_barrier = new Geary.Nonblocking.CountingSemaphore(null);
foreach (AccountContext context in closing_accounts) {
account_barrier.acquire();
this.close_account.begin(
context.account.information,
true,
(obj, ret) => {
this.close_account.end(ret);
account_barrier.blind_notify();
}
);
}
try {
yield account_barrier.wait_async();
} catch (GLib.Error err) {
debug("Error waiting at account barrier: %s", err.message);
}
debug("Closed Application.Controller");
}
/**
* Opens or queues a new composer addressed to a specific email address.
*/
public void compose(string? mailto = null) {
MainWindow? window = this.application.last_active_main_window;
if (window != null && window.selected_account != null) {
create_compose_widget(
window,
window.selected_account,
NEW_MESSAGE,
mailto,
null,
null,
false
);
} else {
// Schedule the send for after we have an account open.
this.pending_mailtos.add(mailto);
}
}
/**
* Opens new composer with an existing message as context.
*/
public void compose_with_context_email(MainWindow to_show,
Geary.Account account,
Composer.Widget.ComposeType type,
Geary.Email context,
string? quote,
bool is_draft) {
create_compose_widget(
to_show, account, type, null, context, quote, is_draft
);
}
/** Adds a new composer to be kept track of. */
public void add_composer(Composer.Widget widget) {
debug(@"Added composer of type $(widget.compose_type); $(this.composer_widgets.size) composers total");
widget.destroy.connect_after(this.on_composer_widget_destroy);
this.composer_widgets.add(widget);
}
/** Returns a read-only collection of currently open composers .*/
public Gee.Collection<Composer.Widget> get_composers() {
return this.composer_widgets.read_only_view;
}
/** Opens any pending composers. */
public void process_pending_composers() {
foreach (string? mailto in this.pending_mailtos) {
compose(mailto);
}
this.pending_mailtos.clear();
}
/** Queues the email in a composer for delivery. */
public async void send_composed_email(Composer.Widget composer) {
AccountContext? context = this.accounts.get(
composer.account.information
);
if (context != null) {
try {
yield context.commands.execute(
new SendComposerCommand(this.application, context, composer),
context.cancellable
);
} catch (GLib.Error err) {
report_problem(new Geary.ProblemReport(err));
}
}
}
/** Saves the email in a composer as a draft on the server. */
public async void save_composed_email(Composer.Widget composer) {
// XXX this doesn't actually do what it says on the tin, since
// the composer's draft manager is already saving drafts on
// the server. Until we get that saving local-only, this will
// only be around for pushing the composer onto the undo stack
AccountContext? context = this.accounts.get(
composer.account.information
);
if (context != null) {
try {
yield context.commands.execute(
new SaveComposerCommand(this, composer),
context.cancellable
);
} catch (GLib.Error err) {
report_problem(new Geary.ProblemReport(err));
}
}
}
/** Queues a composer to be discarded. */
public async void discard_composed_email(Composer.Widget composer) {
AccountContext? context = this.accounts.get(
composer.account.information
);
if (context != null) {
try {
yield context.commands.execute(
new DiscardComposerCommand(this, composer),
context.cancellable
);
} catch (GLib.Error err) {
report_problem(new Geary.ProblemReport(err));
}
}
}
/** Displays a problem report when an error has been encountered. */
public void report_problem(Geary.ProblemReport report) {
debug("Problem reported: %s", report.to_string());
if (report.error == null ||
!(report.error.thrown is IOError.CANCELLED)) {
MainWindowInfoBar info_bar = new MainWindowInfoBar.for_problem(report);
info_bar.retry.connect(on_retry_problem);
this.application.get_active_main_window().show_infobar(info_bar);
}
Geary.ServiceProblemReport? service_report =
report as Geary.ServiceProblemReport;
if (service_report != null && service_report.service.protocol == SMTP) {
this.application.send_error_notification(
/// Notification title.
_("A problem occurred sending email for %s").printf(
service_report.account.display_name
),
/// Notification body
_("Email will not be sent until re-connected")
);
}
}
/** Returns the contact store for an account, if any. */
public Application.ContactStore?
get_contact_store_for_account(Geary.Account target) {
AccountContext? context = this.accounts.get(target.information);
return (context != null) ? context.contacts : null;
}
/**
* Updates flags for a collection of conversations.
*
* If `prefer_adding` is true, this will add the flag if not set
* on all conversations or else will remove it. If false, this
* will remove the flag if not set on all conversations or else
* add it.
*/
public async void mark_conversations(Geary.Folder location,
Gee.Collection<Geary.App.Conversation> conversations,
Geary.NamedFlag flag,
bool prefer_adding)
throws GLib.Error {
Geary.Iterable<Geary.App.Conversation> selecting =
Geary.traverse(conversations);
Geary.EmailFlags flags = new Geary.EmailFlags();
if (flag.equal_to(Geary.EmailFlags.UNREAD)) {
selecting = selecting.filter(c => prefer_adding ^ c.is_unread());
flags.add(Geary.EmailFlags.UNREAD);
} else if (flag.equal_to(Geary.EmailFlags.FLAGGED)) {
selecting = selecting.filter(c => prefer_adding ^ c.is_flagged());
flags.add(Geary.EmailFlags.FLAGGED);
} else {
throw new Geary.EngineError.UNSUPPORTED(
"Marking as %s is not supported", flag.to_string()
);
}
Gee.Collection<Geary.EmailIdentifier>? messages = null;
Gee.Collection<Geary.App.Conversation> selected =
selecting.to_linked_list();
bool do_add = prefer_adding ^ selected.is_empty;
if (selected.is_empty) {
selected = conversations;
}
if (do_add) {
// Only apply to the latest in-folder message in
// conversations that don't already have the flag, since
// we don't want to flag every message in the conversation
messages = Geary.traverse(selected).map<Geary.EmailIdentifier>(
c => c.get_latest_recv_email(IN_FOLDER_OUT_OF_FOLDER).id
).to_linked_list();
} else {
// Remove the flag from those that have it
messages = new Gee.LinkedList<Geary.EmailIdentifier>();
foreach (Geary.App.Conversation convo in selected) {
foreach (Geary.Email email in
convo.get_emails(RECV_DATE_DESCENDING)) {
if (email.email_flags != null &&
email.email_flags.contains(flag)) {
messages.add(email.id);
}
}
}
}
yield mark_messages(
location,
conversations,
messages,
do_add ? flags : null,
do_add ? null : flags
);
}
/**
* Updates flags for a collection of email.
*
* This should only be used when working with specific messages
* (for example, marking a specific message in a conversation)
* rather than when working with whole conversations. In that
* case, use {@link mark_conversations}.
*/
public async void mark_messages(Geary.Folder location,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> messages,
Geary.EmailFlags? to_add,
Geary.EmailFlags? to_remove)
throws GLib.Error {
AccountContext? context = this.accounts.get(location.account.information);
if (context != null) {
yield context.commands.execute(
new MarkEmailCommand(
location,
conversations,
messages,
context.emails,
to_add,
to_remove,
/// Translators: Label for in-app notification
ngettext(
"Conversation marked",
"Conversations marked",
conversations.size
),
/// Translators: Label for in-app notification
ngettext(
"Conversation un-marked",
"Conversations un-marked",
conversations.size
)
),
context.cancellable
);
}
}
public async void move_conversations(Geary.FolderSupport.Move source,
Geary.Folder destination,
Gee.Collection<Geary.App.Conversation> conversations)
throws GLib.Error {
AccountContext? context = this.accounts.get(source.account.information);
if (context != null) {
yield context.commands.execute(
new MoveEmailCommand(
source,
destination,
conversations,
to_in_folder_email_ids(conversations),
2019-10-24 12:53:38 +11:00
/// Translators: Label for in-app
/// notification. String substitution is the name
/// of the destination folder.
ngettext(
"Conversation moved to %s",
"Conversations moved to %s",
conversations.size
).printf(destination.get_display_name()),
2019-10-24 12:53:38 +11:00
/// Translators: Label for in-app
/// notification. String substitution is the name
/// of the source folder.
ngettext(
"Conversation restored to %s",
"Conversations restored to %s",
conversations.size
).printf(source.get_display_name())
),
context.cancellable
);
}
}
public async void move_conversations_special(Geary.Folder source,
Geary.SpecialFolderType destination,
Gee.Collection<Geary.App.Conversation> conversations)
throws GLib.Error {
AccountContext? context = this.accounts.get(source.account.information);
if (context != null) {
Command? command = null;
Gee.Collection<Geary.EmailIdentifier> messages =
to_in_folder_email_ids(conversations);
/// Translators: Label for in-app notification. String
/// substitution is the name of the destination folder.
string undone_tooltip = ngettext(
"Conversation restored to %s",
"Conversations restored to %s",
messages.size
).printf(source.get_display_name());
if (destination == ARCHIVE) {
Geary.FolderSupport.Archive? archive_source = (
source as Geary.FolderSupport.Archive
);
if (archive_source == null) {
throw new Geary.EngineError.UNSUPPORTED(
"Folder does not support archiving: %s",
source.to_string()
);
}
command = new ArchiveEmailCommand(
archive_source,
conversations,
messages,
/// Translators: Label for in-app notification.
ngettext(
"Conversation archived",
"Conversations archived",
messages.size
),
undone_tooltip
);
} else {
Geary.FolderSupport.Move? move_source = (
source as Geary.FolderSupport.Move
);
if (move_source == null) {
throw new Geary.EngineError.UNSUPPORTED(
"Folder does not support moving: %s",
source.to_string()
);
}
Geary.Folder? dest = source.account.get_special_folder(
destination
);
if (dest == null) {
throw new Geary.EngineError.NOT_FOUND(
"No folder found for: %s", destination.to_string()
);
}
command = new MoveEmailCommand(
move_source,
dest,
conversations,
messages,
/// Translators: Label for in-app
/// notification. String substitution is the name
/// of the destination folder.
ngettext(
"Conversation moved to %s",
"Conversations moved to %s",
messages.size
).printf(destination.get_display_name()),
undone_tooltip
);
}
yield context.commands.execute(command, context.cancellable);
}
}
public async void move_messages_special(Geary.Folder source,
Geary.SpecialFolderType destination,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> messages)
throws GLib.Error {
AccountContext? context = this.accounts.get(source.account.information);
if (context != null) {
Command? command = null;
/// Translators: Label for in-app notification. String
/// substitution is the name of the destination folder.
string undone_tooltip = ngettext(
"Message restored to %s",
"Messages restored to %s",
messages.size
).printf(source.get_display_name());
if (destination == ARCHIVE) {
Geary.FolderSupport.Archive? archive_source = (
source as Geary.FolderSupport.Archive
);
if (archive_source == null) {
throw new Geary.EngineError.UNSUPPORTED(
"Folder does not support archiving: %s",
source.to_string()
);
}
command = new ArchiveEmailCommand(
archive_source,
conversations,
messages,
/// Translators: Label for in-app notification.
ngettext(
"Message archived",
"Messages archived",
messages.size
),
undone_tooltip
);
} else {
Geary.FolderSupport.Move? move_source = (
source as Geary.FolderSupport.Move
);
if (move_source == null) {
throw new Geary.EngineError.UNSUPPORTED(
"Folder does not support moving: %s",
source.to_string()
);
}
Geary.Folder? dest = source.account.get_special_folder(
destination
);
if (dest == null) {
throw new Geary.EngineError.NOT_FOUND(
"No folder found for: %s", destination.to_string()
);
}
command = new MoveEmailCommand(
move_source,
dest,
conversations,
messages,
/// Translators: Label for in-app
/// notification. String substitution is the name
/// of the destination folder.
ngettext(
"Message moved to %s",
"Messages moved to %s",
messages.size
).printf(destination.get_display_name()),
undone_tooltip
);
}
yield context.commands.execute(command, context.cancellable);
}
}
public async void copy_conversations(Geary.FolderSupport.Copy source,
Geary.Folder destination,
Gee.Collection<Geary.App.Conversation> conversations)
throws GLib.Error {
AccountContext? context = this.accounts.get(source.account.information);
if (context != null) {
yield context.commands.execute(
new CopyEmailCommand(
source,
destination,
conversations,
to_in_folder_email_ids(conversations),
2019-10-24 12:53:38 +11:00
/// Translators: Label for in-app
/// notification. String substitution is the name
/// of the destination folder.
ngettext(
"Conversation labelled as %s",
"Conversations labelled as %s",
conversations.size
).printf(destination.get_display_name()),
2019-10-24 12:53:38 +11:00
/// Translators: Label for in-app
/// notification. String substitution is the name
/// of the destination folder.
ngettext(
"Conversation un-labelled as %s",
"Conversations un-labelled as %s",
conversations.size
).printf(destination.get_display_name())
),
context.cancellable
);
}
}
public async void delete_conversations(Geary.FolderSupport.Remove target,
Gee.Collection<Geary.App.Conversation> conversations)
throws GLib.Error {
yield delete_messages(
target, conversations, to_in_folder_email_ids(conversations)
);
}
public async void delete_messages(Geary.FolderSupport.Remove target,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> messages)
throws GLib.Error {
AccountContext? context = this.accounts.get(target.account.information);
if (context != null) {
Command command = new DeleteEmailCommand(
target, conversations, messages
);
command.executed.connect(
() => context.controller_stack.email_removed(target, messages)
);
yield context.commands.execute(command, context.cancellable);
}
}
public async void empty_folder_special(Geary.Account source,
Geary.SpecialFolderType type)
throws GLib.Error {
AccountContext? context = this.accounts.get(source.information);
if (context != null) {
Geary.FolderSupport.Empty? emptyable = (
source.get_special_folder(type)
as Geary.FolderSupport.Empty
);
if (emptyable == null) {
throw new Geary.EngineError.UNSUPPORTED(
"Special folder type not supported %s", type.to_string()
);
}
Command command = new EmptyFolderCommand(emptyable);
command.executed.connect(
// Not quite accurate, but close enough
() => context.controller_stack.folders_removed(
Geary.Collection.single(emptyable)
)
);
yield context.commands.execute(command, context.cancellable);
}
}
internal void register_window(MainWindow window) {
window.retry_service_problem.connect(on_retry_service_problem);
window.folder_list.set_new_messages_monitor(
this.plugin_manager.notifications
);
}
internal void unregister_window(MainWindow window) {
window.retry_service_problem.disconnect(on_retry_service_problem);
}
2018-06-17 18:03:09 +10:00
/** Expunges removed accounts while the controller remains open. */
internal async void expunge_accounts() {
try {
yield this.account_manager.expunge_accounts(this.controller_open);
2018-06-17 18:03:09 +10:00
} catch (GLib.Error err) {
report_problem(new Geary.ProblemReport(err));
}
}
private async void open_account(Geary.Account account) {
AccountContext context = new AccountContext(
account,
new Geary.App.SearchFolder(account, account.local_folder_root),
new Geary.App.EmailStore(account),
new Application.ContactStore(account, this.folks)
);
this.accounts.set(account.information, context);
this.upgrade_dialog.add_account(account, this.controller_open);
account.information.authentication_failure.connect(
on_authentication_failure
);
account.information.untrusted_host.connect(on_untrusted_host);
account.notify["current-status"].connect(
on_account_status_notify
);
account.email_removed.connect(on_account_email_removed);
account.folders_available_unavailable.connect(on_folders_available_unavailable);
2013-06-07 16:23:45 -07:00
account.report_problem.connect(on_report_problem);
Geary.Smtp.ClientService? smtp = (
account.outgoing as Geary.Smtp.ClientService
);
if (smtp != null) {
smtp.email_sent.connect(on_sent);
smtp.sending_monitor.start.connect(on_sending_started);
smtp.sending_monitor.finish.connect(on_sending_finished);
}
bool retry = false;
do {
try {
yield account.open_async(this.controller_open);
retry = false;
} catch (GLib.Error open_err) {
debug("Unable to open account %s: %s", account.to_string(), open_err.message);
if (open_err is Geary.EngineError.CORRUPT) {
retry = yield account_database_error_async(account);
}
if (!retry) {
report_problem(
new Geary.AccountProblemReport(
account.information,
open_err
)
);
this.account_manager.disable_account(account.information);
this.accounts.unset(account.information);
}
}
} while (retry);
account_available(context);
update_account_status();
2013-06-07 16:23:45 -07:00
}
Cache contact list store per account. Bug 771903 This implements new cache for ContactListStore. ContactListStore is created only once per account when account is opened with GearyController::open_account. It is destroyed in GearyController::close_account. ContactListStoreCache class is introduced to manage ContactListStore instances. ComposerWidget receives ContactListStoreCache instance instead of ContactListStore directly in constructor. To increase performance, backwards compatibility breaking changes are introduced to Engine API Geary.ContactStore. Signals: * Gee.ContactStore::contact_added(Contact) * Gee.ContactStore::contact_updated(Contact) are replaced with batch equivalents: * Gee::ContactStore::contacts_added(Gee.Collection<Contact>) * Gee::ContactStore::contacts_updated(Gee.Collection<Contact>) Geary.ComposerWidget::load_entry_completions is no longer async as it does not involve time consuming ContactListStore creation. CONTACT_MARKUP_NAME column is removed from ContactListStore as it used to keep state about highlighted areas of text. This is not possible anymore as ContactListStore is shared between multiple ComposerWidgets. Highlight implementation has been moved to Geary.ContactEntryCompletion instead. Additionally contacts_loaded signal is emitted from Geary.ImapEngine.GenericAccount and propagated to Geary.Account Geary.ContactListStore sort function is set upon receiving contacts_loaded signal instead of after initial contacts are loaded. This speeds up Geary startup for users with long contact lists.
2016-12-11 14:30:59 +01:00
private async void close_account(Geary.AccountInformation config,
bool is_shutdown) {
AccountContext? context = this.accounts.get(config);
if (context != null) {
debug("Closing account: %s", context.account.information.id);
Geary.Account account = context.account;
// Guard against trying to close the account twice
this.accounts.unset(account.information);
this.upgrade_dialog.remove_account(account);
// Stop updating status and showing errors when closing
// the account - the user doesn't care any more
account.report_problem.disconnect(on_report_problem);
account.information.authentication_failure.disconnect(
on_authentication_failure
);
account.information.untrusted_host.disconnect(on_untrusted_host);
account.notify["current-status"].disconnect(
on_account_status_notify
);
account.email_removed.disconnect(on_account_email_removed);
account.folders_available_unavailable.disconnect(on_folders_available_unavailable);
Geary.Smtp.ClientService? smtp = (
account.outgoing as Geary.Smtp.ClientService
);
if (smtp != null) {
smtp.email_sent.disconnect(on_sent);
smtp.sending_monitor.start.disconnect(on_sending_started);
smtp.sending_monitor.finish.disconnect(on_sending_finished);
}
// Now the account is not in the accounts map, reset any
// status notifications for it
update_account_status();
account_unavailable(context, is_shutdown);
// Stop any background processes
context.search.clear();
context.contacts.close();
context.cancellable.cancel();
// Explicitly close the inbox since we explicitly open it
Geary.Folder? inbox = context.inbox;
if (inbox != null) {
try {
yield inbox.close_async(null);
} catch (Error close_inbox_err) {
debug("Unable to close monitored inbox: %s", close_inbox_err.message);
}
context.inbox = null;
}
try {
yield account.close_async(null);
} catch (Error close_err) {
debug("Unable to close account %s: %s", account.to_string(), close_err.message);
}
debug("Account closed: %s", account.to_string());
2013-06-07 16:23:45 -07:00
}
}
private void update_account_status() {
// Start off assuming all accounts are online and error free
// (i.e. no status issues to indicate) and proceed until
// proven incorrect.
Geary.Account.Status effective_status = ONLINE;
bool has_auth_error = false;
bool has_cert_error = false;
Geary.Account? service_problem_source = null;
foreach (AccountContext context in this.accounts.values) {
Geary.Account.Status status = context.get_effective_status();
if (!status.is_online()) {
effective_status &= ~Geary.Account.Status.ONLINE;
}
if (status.has_service_problem()) {
effective_status |= SERVICE_PROBLEM;
if (service_problem_source == null) {
service_problem_source = context.account;
}
}
has_auth_error |= context.authentication_failed;
has_cert_error |= context.tls_validation_failed;
}
foreach (MainWindow window in this.application.get_main_windows()) {
window.update_account_status(
effective_status,
has_auth_error,
has_cert_error,
service_problem_source
);
}
}
private bool is_currently_prompting() {
return this.accounts.values.fold<bool>(
(ctx, seed) => (
ctx.authentication_prompting |
ctx.tls_validation_prompting |
seed
),
false
);
}
private async void prompt_for_password(AccountContext context,
Geary.ServiceInformation service) {
Geary.AccountInformation account = context.account.information;
bool is_incoming = (service == account.incoming);
Geary.Credentials credentials = is_incoming
? account.incoming.credentials
: account.get_outgoing_credentials();
bool handled = true;
if (context.authentication_attempts > MAX_AUTH_ATTEMPTS ||
credentials == null) {
// We have run out of authentication attempts or have
// been asked for creds but don't even have a login. So
// just bail out immediately and flag the account as
// needing attention.
handled = false;
} else if (this.account_manager.is_goa_account(account)) {
context.authentication_prompting = true;
try {
2019-02-18 12:06:06 +11:00
yield account.load_incoming_credentials(context.cancellable);
yield account.load_outgoing_credentials(context.cancellable);
} catch (GLib.Error err) {
// Bail out right away, but probably should be opening
// the GOA control panel.
handled = false;
report_problem(new Geary.AccountProblemReport(account, err));
}
context.authentication_prompting = false;
} else {
context.authentication_prompting = true;
PasswordDialog password_dialog = new PasswordDialog(
this.application.get_active_window(),
account,
service,
credentials
);
if (password_dialog.run()) {
// The update the credentials for the service that the
// credentials actually came from
Geary.ServiceInformation creds_service =
(credentials == account.incoming.credentials)
? account.incoming
: account.outgoing;
creds_service.credentials = credentials.copy_with_token(
password_dialog.password
);
// Update the remember password pref if changed
bool remember = password_dialog.remember_password;
if (creds_service.remember_password != remember) {
creds_service.remember_password = remember;
account.changed();
}
SecretMediator libsecret = (SecretMediator) account.mediator;
try {
// Update the secret using the service where the
// credentials originated, since the service forms
// part of the key's identity
if (creds_service.remember_password) {
yield libsecret.update_token(
account, creds_service, context.cancellable
);
} else {
yield libsecret.clear_token(
account, creds_service, context.cancellable
);
}
} catch (GLib.IOError.CANCELLED err) {
// all good
} catch (GLib.Error err) {
report_problem(
new Geary.ServiceProblemReport(account, service, err)
);
}
context.authentication_attempts++;
} else {
// User cancelled, bail out unconditionally
handled = false;
}
context.authentication_prompting = false;
}
if (handled) {
2019-03-09 20:15:16 +11:00
try {
yield this.application.engine.update_account_service(
account, service, context.cancellable
);
} catch (GLib.Error err) {
report_problem(
new Geary.ServiceProblemReport(account, service, err)
2019-03-09 20:15:16 +11:00
);
}
} else {
context.authentication_attempts = 0;
context.authentication_failed = true;
update_account_status();
}
}
private async void prompt_untrusted_host(AccountContext context,
Geary.ServiceInformation service,
Geary.Endpoint endpoint,
GLib.TlsConnection cx) {
if (this.application.config.revoke_certs) {
// XXX
}
context.tls_validation_prompting = true;
try {
yield this.certificate_manager.prompt_pin_certificate(
this.application.get_active_main_window(),
context.account.information,
service,
endpoint,
false,
context.cancellable
);
context.tls_validation_failed = false;
} catch (Application.CertificateManagerError.UNTRUSTED err) {
// Don't report an error here, the user simply declined.
context.tls_validation_failed = true;
} catch (Application.CertificateManagerError err) {
// Assume validation is now good, but report the error
// since the cert may not have been saved
context.tls_validation_failed = false;
report_problem(
new Geary.ServiceProblemReport(
context.account.information,
service,
err
)
);
}
context.tls_validation_prompting = false;
update_account_status();
}
private void on_account_email_removed(Geary.Folder folder,
Gee.Collection<Geary.EmailIdentifier> ids) {
if (folder.special_folder_type == OUTBOX) {
foreach (MainWindow window in this.application.get_main_windows()) {
window.status_bar.deactivate_message(StatusBar.Message.OUTBOX_SEND_FAILURE);
window.status_bar.deactivate_message(StatusBar.Message.OUTBOX_SAVE_SENT_MAIL_FAILED);
}
}
}
private void on_sending_started() {
foreach (MainWindow window in this.application.get_main_windows()) {
window.status_bar.activate_message(StatusBar.Message.OUTBOX_SENDING);
}
}
private void on_sending_finished() {
foreach (MainWindow window in this.application.get_main_windows()) {
window.status_bar.deactivate_message(StatusBar.Message.OUTBOX_SENDING);
}
}
// Returns true if the caller should try opening the account again
private async bool account_database_error_async(Geary.Account account) {
bool retry = true;
// give the user two options: reset the Account local store, or exit Geary. A third
// could be done to leave the Account in an unopened state, but we don't currently
// have provisions for that.
QuestionDialog dialog = new QuestionDialog(
this.application.get_active_main_window(),
_("Unable to open the database for %s").printf(account.information.id),
_("There was an error opening the local mail database for this account. This is possibly due to corruption of the database file in this directory:\n\n%s\n\nGeary can rebuild the database and re-synchronize with the server or exit.\n\nRebuilding the database will destroy all local email and its attachments. <b>The mail on the your server will not be affected.</b>")
.printf(account.information.data_dir.get_path()),
_("_Rebuild"), _("E_xit"));
dialog.use_secondary_markup(true);
switch (dialog.run()) {
case Gtk.ResponseType.OK:
// don't use Cancellable because we don't want to interrupt this process
try {
yield account.rebuild_async();
} catch (Error err) {
ErrorDialog errdialog = new ErrorDialog(
this.application.get_active_main_window(),
_("Unable to rebuild database for “%s”").printf(account.information.id),
_("Error during rebuild:\n\n%s").printf(err.message));
errdialog.run();
retry = false;
}
break;
default:
retry = false;
break;
}
return retry;
}
private bool is_inbox_descendant(Geary.Folder target) {
bool is_descendent = false;
Geary.Account account = target.account;
Geary.Folder? inbox = account.get_special_folder(Geary.SpecialFolderType.INBOX);
if (inbox != null) {
is_descendent = inbox.path.is_descendant(target.path);
}
return is_descendent;
}
private void on_special_folder_type_changed(Geary.Folder folder,
Geary.SpecialFolderType old_type,
Geary.SpecialFolderType new_type) {
// Update notifications
this.plugin_manager.notifications.remove_folder(folder);
if (folder.special_folder_type == Geary.SpecialFolderType.INBOX ||
(folder.special_folder_type == Geary.SpecialFolderType.NONE &&
is_inbox_descendant(folder))) {
Geary.AccountInformation info = folder.account.information;
this.plugin_manager.notifications.add_folder(
folder, this.accounts.get(info).cancellable
);
}
}
private void on_folders_available_unavailable(
Geary.Account account,
Gee.BidirSortedSet<Geary.Folder>? available,
Gee.BidirSortedSet<Geary.Folder>? unavailable) {
AccountContext context = this.accounts.get(account.information);
if (available != null && available.size > 0) {
foreach (Geary.Folder folder in available) {
if (!Controller.should_add_folder(available, folder)) {
continue;
}
folder.special_folder_type_changed.connect(
on_special_folder_type_changed
);
GLib.Cancellable cancellable = context.cancellable;
switch (folder.special_folder_type) {
case Geary.SpecialFolderType.INBOX:
if (context.inbox == null) {
context.inbox = folder;
}
folder.open_async.begin(NO_DELAY, cancellable);
// Always notify for new messages in the Inbox
this.plugin_manager.notifications.add_folder(
folder, cancellable
);
break;
case Geary.SpecialFolderType.NONE:
// Only notify for new messages in non-special
// descendants of the Inbox
if (is_inbox_descendant(folder)) {
this.plugin_manager.notifications.add_folder(
folder, cancellable
);
}
break;
}
}
}
if (unavailable != null) {
Gee.BidirIterator<Geary.Folder> unavailable_iterator =
unavailable.bidir_iterator();
bool has_prev = unavailable_iterator.last();
while (has_prev) {
Geary.Folder folder = unavailable_iterator.get();
folder.special_folder_type_changed.disconnect(
on_special_folder_type_changed
);
switch (folder.special_folder_type) {
case Geary.SpecialFolderType.INBOX:
context.inbox = null;
this.plugin_manager.notifications.remove_folder(folder);
break;
case Geary.SpecialFolderType.NONE:
// Only notify for new messages in non-special
// descendants of the Inbox
if (is_inbox_descendant(folder)) {
this.plugin_manager.notifications.remove_folder(folder);
}
break;
}
has_prev = unavailable_iterator.previous();
}
// Notify the command stack that folders have gone away
context.controller_stack.folders_removed(unavailable);
}
}
private bool should_notify_new_messages(Geary.Folder folder) {
// A monitored folder must be selected to squelch notifications;
// if conversation list is at top of display, don't display
// and don't display if main window has top-level focus
MainWindow? window = this.application.last_active_main_window;
return (
window != null &&
(folder != window.selected_folder ||
window.conversation_list_view.vadjustment.value != 0.0 ||
!window.has_toplevel_focus)
);
}
// Clears messages if conditions are true: anything in should_notify_new_messages() is
// false and the supplied visible messages are visible in the conversation list view
public void clear_new_messages(string caller,
Gee.Set<Geary.App.Conversation>? supplied) {
MainWindow? window = this.application.last_active_main_window;
Geary.Folder? selected = (
(window != null) ? window.selected_folder : null
);
NotificationContext notifications = this.plugin_manager.notifications;
if (selected != null && (
!notifications.get_folders().contains(selected) ||
should_notify_new_messages(selected))) {
Gee.Set<Geary.App.Conversation> visible =
supplied ?? window.conversation_list_view.get_visible_conversations();
foreach (Geary.App.Conversation conversation in visible) {
try {
if (notifications.are_any_new_messages(selected,
conversation.get_email_ids())) {
debug("Clearing new messages: %s", caller);
notifications.clear_new_messages(selected);
break;
}
} catch (Geary.EngineError.NOT_FOUND err) {
// all good
}
}
}
}
2020-01-08 11:00:01 +01:00
// Track a window receiving focus, for idle background work
public void window_focus_in() {
2020-01-09 12:36:47 +01:00
this.all_windows_backgrounded_timeout.reset();
2020-01-08 11:00:01 +01:00
}
// Track a window going unfocused, for idle background work
public void window_focus_out() {
this.all_windows_backgrounded_timeout.start();
}
private void on_unfocused_idle() {
// Schedule later, catching cases where work should occur later while still in background
2020-01-09 12:36:47 +01:00
this.all_windows_backgrounded_timeout.reset();
this.all_windows_backgrounded = true;
2020-01-08 11:00:01 +01:00
window_focus_out();
debug("Checking for backgrounded idle work");
foreach (AccountContext context in this.accounts.values) {
Geary.Account account = context.account;
account.app_backgrounded_cleanup.begin(context.cancellable);
2020-01-08 11:00:01 +01:00
}
}
/** Displays a composer on the last active main window. */
internal void show_composer(Composer.Widget composer,
Gee.Collection<Geary.EmailIdentifier>? refers_to,
MainWindow? show_on) {
var target = show_on;
if (target == null) {
target = this.application.get_active_main_window();
}
target.show_composer(composer, refers_to);
composer.set_focus();
}
internal bool check_open_composers() {
var do_quit = true;
foreach (var composer in this.composer_widgets) {
if (composer.conditional_close(true, true) == CANCELLED) {
do_quit = false;
break;
2013-08-08 18:47:23 -07:00
}
}
return do_quit;
}
/**
* Creates a composer widget.
*
* Depending on the arguments, this can be inline in the
* conversation or as a new window.
*
* @param compose_type - Whether it's a new message, a reply, a
* forwarded mail, ...
* @param referred - The mail of which we should copy the from/to/...
* addresses
* @param quote - The quote after the mail body
* @param mailto - A "mailto:"-link
* @param is_draft - Whether we're starting from a draft (true) or
* a new mail (false)
*/
private void create_compose_widget(MainWindow show_on,
Geary.Account account,
Composer.Widget.ComposeType compose_type,
string? mailto,
Geary.Email? referred,
string? quote,
bool is_draft) {
// There's a few situations where we can re-use an existing
// composer, check for these first.
if (compose_type == NEW_MESSAGE && !is_draft) {
// We're creating a new message that isn't a draft, if
// there's already an empty composer open, just use
// that
foreach (Composer.Widget existing in this.composer_widgets) {
if (existing != null &&
existing.current_mode == PANED &&
existing.is_blank) {
existing.present();
return;
}
}
} else if (compose_type != NEW_MESSAGE && referred != null) {
// A reply/forward was requested, see whether there is
// already an inline message in the target window that is
// either a reply/forward for that message, or there is a
// quote to insert into it.
foreach (Composer.Widget existing in this.composer_widgets) {
if (existing.get_toplevel() == show_on &&
(existing.current_mode == INLINE ||
existing.current_mode == INLINE_COMPACT) &&
(referred.id in existing.get_referred_ids() ||
quote != null)) {
try {
existing.append_to_email(referred, quote, compose_type);
existing.present();
return;
} catch (Geary.EngineError error) {
report_problem(new Geary.ProblemReport(error));
}
}
}
// Can't re-use an existing composer, so need to create a
// new one. Replies must open inline in the main window,
// so we need to ensure there are no composers open there
// first.
if (!show_on.close_composer(true)) {
return;
}
}
Break out ListBox used to display conversations into standalone widget. The conversation viewer's ListBox is sufficiently complex to warrant its own widget. Use empty placeholders for the list per the HIG, and correctly fix mamagement of empty folder vs no conversations selected this time. * src/client/application/geary-controller.vala (GearyController): Directly manage secondary parts of the conversation viewer, since the controller since it has a better and more timely idea of when a conversation change is due to folder loading status or from the user selecting conversations, and so the viwer doesn't need to hook back into the controller. Remove the now-unused conversations_selected signal and its callers. * src/client/conversation-viewer/conversation-listbox.vala: New widget for displaying the list of emails for a conversation. Moved relevant code from ConversationViewer here. Made adding emails async to get better UI responsiveness. Don't implement anything to handle conversation changes or emptying the list. * src/client/conversation-viewer/conversation-viewer.vala: Replace user messages - empty folder/search & no/multiple messages selected with new EmptyPlaceholder. Remove a lot of the state manage code needed when managing the email listbox. Add a new ConversationListBox for every new conversation and just throw away. * src/client/conversation-list/conversation-list-view.vala (ConversationListView): Clean up firing the conversations_selected signal - don't actually emit it when the model is clearing, and don't bother delaying the check either. * src/client/components/empty-placeholder.vala: New widget for displaying empty list and grid placeholders per the HIG. * src/client/conversation-viewer/conversation-email.vala (ConversationEmail): Make manually read a property, since it effectively is one. * src/CMakeLists.txt: Include new source files. * po/POTFILES.in: Include new source and UI files, and some missing ones. * ui/CMakeLists.txt: Include new UI files. * ui/conversation-viewer.ui: Replace user message and splash page with placeholders for the new empty placeholders(!). * ui/empty-placeholder.ui: UI def for new widget class. * ui/geary.css: Chase widget name/class changes, style new empty placeholder UI.
2016-07-25 10:33:42 +10:00
Composer.Widget widget;
if (mailto != null) {
widget = new Composer.Widget.from_mailto(
this.application, account, mailto
);
} else {
widget = new Composer.Widget(
this.application, account, compose_type
);
}
add_composer(widget);
show_composer(
widget,
referred != null ? Geary.Collection.single(referred.id) : null,
show_on
);
this.load_composer.begin(
account,
widget,
referred,
is_draft,
quote
);
}
private async void load_composer(Geary.Account account,
Composer.Widget widget,
Geary.Email? referred = null,
bool is_draft,
string? quote = null) {
Clean up how composer loads content into its web view. The main gist of this is to ensure that the composer's widgets are constructed seperately to loading its content, and that we only ever call ComposerWebView::load_html precisely once per composer instance. * src/client/composer/composer-widget.vala: Remove referred message, quote text and draft flag param from constructor signature, move any calls that loaded data from them to new load method. Don't load anything into the editor here. Make loading the signature file async, and call new ComposerWebView::updateSignature method on the editor to update it. (ComposerWidget::load): New async message for loading content into the composer. Move related code from the constructor and GearyController here, make methods that were previously public for that private again. Tidy up calls a bit now that we have a single place from which to do it all, and can understand the process a bit better. (ComposerWidget::on_editor_key_press_event): Don't reload the editor to remove the quoted text, call new ComposerWebView::delete_quoted_message method on it instead. * src/client/composer/composer-web-view.vala (ComposerWebView): Add ::delete_quoted_message ::update_signature methods, thunk to JS. (ComposerWebView::load_html): Add quote and is_draft parameters, construct HTML for the composer using apporporate spacing here, instead of relying on all the disparate parts from doing the right thing. * src/client/application/geary-controller.vala (GearyController::create_compose_widget_async): Load composer content after adding it to the widget hierarchy, set focus only after everything is set up. * src/engine/rfc822/rfc822-utils.vala (quote_email_for_reply, quote_email_for_forward): Don't add extra padding around quoted parts - let callers manage their own whitespace. * test/client/components/client-web-view-test-case.vala (TestCase:load_body_fixture): Make HTML param non-nullable, update subclasses. * ui/composer-web-view.js (ComposerPageState): Add ::updateSignature and ::deleteQuotedMessage method stubs.
2017-01-25 11:16:20 +11:00
Geary.Email? full = null;
GLib.Cancellable? cancellable = null;
Clean up how composer loads content into its web view. The main gist of this is to ensure that the composer's widgets are constructed seperately to loading its content, and that we only ever call ComposerWebView::load_html precisely once per composer instance. * src/client/composer/composer-widget.vala: Remove referred message, quote text and draft flag param from constructor signature, move any calls that loaded data from them to new load method. Don't load anything into the editor here. Make loading the signature file async, and call new ComposerWebView::updateSignature method on the editor to update it. (ComposerWidget::load): New async message for loading content into the composer. Move related code from the constructor and GearyController here, make methods that were previously public for that private again. Tidy up calls a bit now that we have a single place from which to do it all, and can understand the process a bit better. (ComposerWidget::on_editor_key_press_event): Don't reload the editor to remove the quoted text, call new ComposerWebView::delete_quoted_message method on it instead. * src/client/composer/composer-web-view.vala (ComposerWebView): Add ::delete_quoted_message ::update_signature methods, thunk to JS. (ComposerWebView::load_html): Add quote and is_draft parameters, construct HTML for the composer using apporporate spacing here, instead of relying on all the disparate parts from doing the right thing. * src/client/application/geary-controller.vala (GearyController::create_compose_widget_async): Load composer content after adding it to the widget hierarchy, set focus only after everything is set up. * src/engine/rfc822/rfc822-utils.vala (quote_email_for_reply, quote_email_for_forward): Don't add extra padding around quoted parts - let callers manage their own whitespace. * test/client/components/client-web-view-test-case.vala (TestCase:load_body_fixture): Make HTML param non-nullable, update subclasses. * ui/composer-web-view.js (ComposerPageState): Add ::updateSignature and ::deleteQuotedMessage method stubs.
2017-01-25 11:16:20 +11:00
if (referred != null) {
AccountContext? context = this.accounts.get(account.information);
if (context != null) {
cancellable = context.cancellable;
try {
full = yield context.emails.fetch_email_async(
referred.id,
2019-11-05 10:59:51 +11:00
Geary.ComposedEmail.REQUIRED_REPLY_FIELDS |
Composer.Widget.REQUIRED_FIELDS,
2019-11-05 10:59:51 +11:00
NONE,
cancellable
);
} catch (Error e) {
message("Could not load full message: %s", e.message);
}
Clean up how composer loads content into its web view. The main gist of this is to ensure that the composer's widgets are constructed seperately to loading its content, and that we only ever call ComposerWebView::load_html precisely once per composer instance. * src/client/composer/composer-widget.vala: Remove referred message, quote text and draft flag param from constructor signature, move any calls that loaded data from them to new load method. Don't load anything into the editor here. Make loading the signature file async, and call new ComposerWebView::updateSignature method on the editor to update it. (ComposerWidget::load): New async message for loading content into the composer. Move related code from the constructor and GearyController here, make methods that were previously public for that private again. Tidy up calls a bit now that we have a single place from which to do it all, and can understand the process a bit better. (ComposerWidget::on_editor_key_press_event): Don't reload the editor to remove the quoted text, call new ComposerWebView::delete_quoted_message method on it instead. * src/client/composer/composer-web-view.vala (ComposerWebView): Add ::delete_quoted_message ::update_signature methods, thunk to JS. (ComposerWebView::load_html): Add quote and is_draft parameters, construct HTML for the composer using apporporate spacing here, instead of relying on all the disparate parts from doing the right thing. * src/client/application/geary-controller.vala (GearyController::create_compose_widget_async): Load composer content after adding it to the widget hierarchy, set focus only after everything is set up. * src/engine/rfc822/rfc822-utils.vala (quote_email_for_reply, quote_email_for_forward): Don't add extra padding around quoted parts - let callers manage their own whitespace. * test/client/components/client-web-view-test-case.vala (TestCase:load_body_fixture): Make HTML param non-nullable, update subclasses. * ui/composer-web-view.js (ComposerPageState): Add ::updateSignature and ::deleteQuotedMessage method stubs.
2017-01-25 11:16:20 +11:00
}
}
2019-11-05 10:59:51 +11:00
try {
yield widget.load(full, is_draft, quote, cancellable);
2019-11-05 10:59:51 +11:00
} catch (GLib.Error err) {
report_problem(new Geary.ProblemReport(err));
}
}
Clean up how composer loads content into its web view. The main gist of this is to ensure that the composer's widgets are constructed seperately to loading its content, and that we only ever call ComposerWebView::load_html precisely once per composer instance. * src/client/composer/composer-widget.vala: Remove referred message, quote text and draft flag param from constructor signature, move any calls that loaded data from them to new load method. Don't load anything into the editor here. Make loading the signature file async, and call new ComposerWebView::updateSignature method on the editor to update it. (ComposerWidget::load): New async message for loading content into the composer. Move related code from the constructor and GearyController here, make methods that were previously public for that private again. Tidy up calls a bit now that we have a single place from which to do it all, and can understand the process a bit better. (ComposerWidget::on_editor_key_press_event): Don't reload the editor to remove the quoted text, call new ComposerWebView::delete_quoted_message method on it instead. * src/client/composer/composer-web-view.vala (ComposerWebView): Add ::delete_quoted_message ::update_signature methods, thunk to JS. (ComposerWebView::load_html): Add quote and is_draft parameters, construct HTML for the composer using apporporate spacing here, instead of relying on all the disparate parts from doing the right thing. * src/client/application/geary-controller.vala (GearyController::create_compose_widget_async): Load composer content after adding it to the widget hierarchy, set focus only after everything is set up. * src/engine/rfc822/rfc822-utils.vala (quote_email_for_reply, quote_email_for_forward): Don't add extra padding around quoted parts - let callers manage their own whitespace. * test/client/components/client-web-view-test-case.vala (TestCase:load_body_fixture): Make HTML param non-nullable, update subclasses. * ui/composer-web-view.js (ComposerPageState): Add ::updateSignature and ::deleteQuotedMessage method stubs.
2017-01-25 11:16:20 +11:00
Separate composer widget from composer window In anticipation of inline composition, we need the composer widget to be separate from the window in which it lives. We introduce a new interface, ComposerContainer, that the thing that holds to ComposerWidget must implement. Separate composer widget from composer window In anticipation of inline composition, we need the composer widget to be separate from the window in which it lives. We introduce a new interface, ComposerContainer, that the thing that holds to ComposerWidget must implement. Basic inline composition Many of the details don't work, or don't work well, but the basics are in place. Allow only a single inline composition at a time With this, we introduce a dialog when you would try to add another. We also use this when changing the selected conversation with a composer open. Compose new messages inline, with no conversation selected Hook up composer accelerators only when focus is in composer editor It would be nice to only activate these accelerators when the composer has focus generally, but that doesn't seem to be easy to detect. Only disconnect accelerators if they're connected Maintain focus when composer is popped out The selection isn't, though. Fix Tab focus for embedded composer There are two things that needed to be fixed: The tab key doesn't usually advance focus for embedded widgets (huh?), so we handle tab presses by hand for ComposerWidgets. Also, the EmailEntrys do their own tab handling, which needs to know about the composer widget, not the toplevel widget in the embedded case. Remove close() from ComposerContainer interface I don't think it was actually doing anything, and it conflicts with the new close() method of Gtk.Window.
2014-02-11 00:26:14 -05:00
private void on_composer_widget_destroy(Gtk.Widget sender) {
Composer.Widget? composer = sender as Composer.Widget;
if (composer != null) {
composer_widgets.remove((Composer.Widget) sender);
debug(@"Composer type $(composer.compose_type) destroyed; " +
@"$(this.composer_widgets.size) composers remaining");
2013-08-08 18:47:23 -07:00
}
}
private void on_sent(Geary.Smtp.ClientService service,
Geary.RFC822.Message sent) {
/// Translators: The label for an in-app notification. The
/// string substitution is a list of recipients of the email.
string message = _(
"Email sent to %s"
).printf(Util.Email.to_short_recipient_display(sent));
Components.InAppNotification notification =
new Components.InAppNotification(message);
foreach (MainWindow window in this.application.get_main_windows()) {
window.add_notification(notification);
}
AccountContext? context = this.accounts.get(service.account);
if (context != null) {
this.plugin_manager.notifications.email_sent(context.account, sent);
}
}
private Gee.Collection<Geary.EmailIdentifier>
to_in_folder_email_ids(Gee.Collection<Geary.App.Conversation> conversations) {
Gee.Collection<Geary.EmailIdentifier> messages =
new Gee.LinkedList<Geary.EmailIdentifier>();
foreach (Geary.App.Conversation conversation in conversations) {
foreach (Geary.Email email in
conversation.get_emails(RECV_DATE_ASCENDING, IN_FOLDER)) {
messages.add(email.id);
}
}
return messages;
}
private void on_account_available(Geary.AccountInformation info) {
Geary.Account? account = null;
try {
account = this.application.engine.get_account(info);
} catch (GLib.Error error) {
report_problem(new Geary.ProblemReport(error));
warning(
"Error creating account %s instance: %s",
info.id,
error.message
);
}
if (account != null) {
this.open_account.begin(account);
}
}
private void on_account_added(Geary.AccountInformation added,
Accounts.Manager.Status status) {
if (status == Accounts.Manager.Status.ENABLED) {
try {
this.application.engine.add_account(added);
} catch (GLib.Error err) {
report_problem(new Geary.AccountProblemReport(added, err));
}
}
}
private void on_account_status_changed(Geary.AccountInformation changed,
Accounts.Manager.Status status) {
switch (status) {
case Accounts.Manager.Status.ENABLED:
if (!this.application.engine.has_account(changed)) {
try {
this.application.engine.add_account(changed);
} catch (GLib.Error err) {
report_problem(new Geary.AccountProblemReport(changed, err));
}
}
break;
case Accounts.Manager.Status.UNAVAILABLE:
case Accounts.Manager.Status.DISABLED:
if (this.application.engine.has_account(changed)) {
this.close_account.begin(
changed,
false,
(obj, res) => {
this.close_account.end(res);
try {
this.application.engine.remove_account(changed);
} catch (GLib.Error err) {
report_problem(
new Geary.AccountProblemReport(changed, err)
);
}
}
);
}
break;
}
}
2018-06-17 18:03:09 +10:00
private void on_account_removed(Geary.AccountInformation removed) {
debug("%s: Closing account for removal", removed.id);
this.close_account.begin(
removed,
false,
2018-06-17 18:03:09 +10:00
(obj, res) => {
this.close_account.end(res);
debug("%s: Account closed", removed.id);
try {
this.application.engine.remove_account(removed);
debug("%s: Account removed from engine", removed.id);
} catch (GLib.Error err) {
report_problem(
new Geary.AccountProblemReport(removed, err)
2018-06-17 18:03:09 +10:00
);
}
}
);
}
private void on_report_problem(Geary.ProblemReport problem) {
report_problem(problem);
}
private void on_retry_problem(MainWindowInfoBar info_bar) {
Geary.ServiceProblemReport? service_report =
info_bar.report as Geary.ServiceProblemReport;
if (service_report != null) {
AccountContext? context = this.accounts.get(service_report.account);
if (context != null && context.account.is_open()) {
switch (service_report.service.protocol) {
case Geary.Protocol.IMAP:
context.account.incoming.restart.begin(context.cancellable);
break;
case Geary.Protocol.SMTP:
context.account.outgoing.restart.begin(context.cancellable);
break;
}
}
}
}
private void on_account_status_notify() {
update_account_status();
}
private void on_authentication_failure(Geary.AccountInformation account,
Geary.ServiceInformation service) {
AccountContext? context = this.accounts.get(account);
if (context != null && !is_currently_prompting()) {
this.prompt_for_password.begin(context, service);
}
}
private void on_untrusted_host(Geary.AccountInformation account,
Geary.ServiceInformation service,
Geary.Endpoint endpoint,
TlsConnection cx) {
AccountContext? context = this.accounts.get(account);
if (context != null && !is_currently_prompting()) {
this.prompt_untrusted_host.begin(context, service, endpoint, cx);
}
}
private void on_retry_service_problem(Geary.ClientService.Status type) {
bool has_restarted = false;
foreach (AccountContext context in this.accounts.values) {
Geary.Account account = context.account;
if (account.current_status.has_service_problem() &&
(account.incoming.current_status == type ||
account.outgoing.current_status == type)) {
Geary.ClientService service =
(account.incoming.current_status == type)
? account.incoming
: account.outgoing;
bool do_restart = true;
switch (type) {
case AUTHENTICATION_FAILED:
if (has_restarted) {
// Only restart at most one at a time, so we
// don't attempt to re-auth multiple bad
// accounts at once.
do_restart = false;
} else {
// Reset so the infobar does not show up again
context.authentication_failed = false;
}
break;
case TLS_VALIDATION_FAILED:
if (has_restarted) {
// Only restart at most one at a time, so we
// don't attempt to re-pin multiple bad
// accounts at once.
do_restart = false;
} else {
// Reset so the infobar does not show up again
context.tls_validation_failed = false;
}
break;
}
if (do_restart) {
has_restarted = true;
service.restart.begin(context.cancellable);
}
}
}
}
Make ConversationMonitor more robust with no/changing connectivity. Conversation monitor was built around older assumptions of how a folder's remote connections work - that once a folder opens it will likely also eventually establish a remote connection, that once the connection is up it will hang around, and so on. This patch removes any public notion of (re)seeding, since it can't be relied to actually happen over the course of the session, ensures that all folder operations are local-only when the folder does not have a working remote connection so it doesn't block, and take the opportunity to reorganise and clean up the monitor API and documentation comments. * src/engine/app/app-conversation-monitor.vala (ConversationMonitor): Remove seed signals, and don't bother running an initial reseed if the folder is already open, since the fill operation will cause any locally incomplete messages to be filled out from the report. Manage and use an internal Cancellable for cancelling internal operations when shutting down. Construct a queue only when starting to monitor conversations, delete it when stopping. Move as much operation-specific code into the operations themselves as reasonably possible, making some methods internal so thy can be accessed from the ops. Ensure all folder listing operations specify LOCAL_ONLY when the remote is not open. Removed LocalLoadOperation since that is now redundant. Update the API for accessing conversations to match Gee conventions and update call sites. Update documentation comments. Hook back up to locally-complete signals so we don't miss emails being filled out by the prefetcher, for now. * src/engine/app/conversation-monitor/app-conversation-set.vala (ConversationSet): Rename conversations property to match Gee conventions, update call sites. * src/engine/app/conversation-monitor/app-conversation-operation.vala (ConversationOperation): Allow operations to specify if they should allow duplicates, and allow the execution method to throw errors, so they can be handled in a uniform way. * src/engine/app/conversation-monitor/app-conversation-operation-queue.vala (ConversationOperationQueue): Accept progress monitor property as a ctor arg rather than constructing on itself, so it is tied to the life-cycle of the ConversationMonitor rather than the queue. Add a signal for notifying of errors thrown when running operations, and use the new operation-independent support for determining if duplicates should be queued. * src/engine/app/conversation-monitor/app-fill-window-operation.vala (FillWindowOperation): Enforce a maximum window size as well as minimum to keep loading large windows semi-responsive. Remove code to handle inserts now that they are handled by their own op. * src/engine/app/conversation-monitor/app-insert-operation.vala (InsertOperation): New operation to manage inserts, handle it them by simply adding them to the conversation if they are newer than the oldest message, rather that relisting all loaded messages.
2018-03-03 10:56:29 +11:00
}
/**
* Collects application state related to a single open account.
*/
internal class Application.AccountContext : Geary.BaseObject {
/** The account for this context. */
public Geary.Account account { get; private set; }
/** The account's Inbox folder */
public Geary.Folder? inbox = null;
/** The account's search folder */
public Geary.App.SearchFolder search = null;
/** The account's email store */
public Geary.App.EmailStore emails { get; private set; }
/** The account's contact store */
public ContactStore contacts { get; private set; }
/** The account's application command stack. */
public CommandStack commands {
get { return this.controller_stack; }
}
/** A cancellable tied to the life-cycle of the account. */
public Cancellable cancellable {
get; private set; default = new Cancellable();
}
/** The account's application command stack. */
internal ControllerCommandStack controller_stack {
get; protected set; default = new ControllerCommandStack();
}
/** Determines if the account has an authentication problem. */
internal bool authentication_failed {
get; private set; default = false;
}
/** Determines if the account is prompting for a pasword. */
internal bool authentication_prompting {
get; private set; default = false;
}
/** Determines if currently prompting for a password. */
internal uint authentication_attempts {
get; private set; default = 0;
}
/** Determines if any TLS certificate errors have been seen. */
internal bool tls_validation_failed {
get; private set; default = false;
}
/** Determines if currently prompting about TLS certificate errors. */
internal bool tls_validation_prompting {
get; private set; default = false;
}
public AccountContext(Geary.Account account,
Geary.App.SearchFolder search,
Geary.App.EmailStore emails,
Application.ContactStore contacts) {
this.account = account;
this.search = search;
this.emails = emails;
this.contacts = contacts;
}
/** Returns the current effective status for the account. */
public Geary.Account.Status get_effective_status() {
Geary.Account.Status current = this.account.current_status;
Geary.Account.Status effective = 0;
if (current.is_online()) {
effective |= ONLINE;
}
if (current.has_service_problem()) {
// Only retain this flag if the problem isn't auth or
// cert related, that is handled elsewhere.
Geary.ClientService.Status incoming =
account.incoming.current_status;
Geary.ClientService.Status outgoing =
account.outgoing.current_status;
if (incoming != AUTHENTICATION_FAILED &&
incoming != TLS_VALIDATION_FAILED &&
outgoing != AUTHENTICATION_FAILED &&
outgoing != TLS_VALIDATION_FAILED) {
effective |= SERVICE_PROBLEM;
}
}
return effective;
}
}
/** Base class for all application controller commands. */
internal class Application.ControllerCommandStack : CommandStack {
private EmailCommand? last_executed = null;
/** {@inheritDoc} */
public override async void execute(Command target,
GLib.Cancellable? cancellable)
throws GLib.Error {
// Guard against things like Delete being held down by only
// executing a command if it is different to the last one.
if (this.last_executed == null || !this.last_executed.equal_to(target)) {
this.last_executed = target as EmailCommand;
yield base.execute(target, cancellable);
}
}
/** {@inheritDoc} */
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
this.last_executed = null;
yield base.undo(cancellable);
}
/** {@inheritDoc} */
public override async void redo(GLib.Cancellable? cancellable)
throws GLib.Error {
this.last_executed = null;
yield base.redo(cancellable);
}
/**
* Notifies the stack that one or more folders were removed.
*
* This will cause any commands involving the given folder to be
* removed from the stack. It should only be called as a response
* to un-recoverable changes, e.g. when the server notifies that a
* folder has been removed.
*/
internal void folders_removed(Gee.Collection<Geary.Folder> removed) {
Gee.Iterator<Command> commands = this.undo_stack.iterator();
while (commands.next()) {
EmailCommand? email = commands.get() as EmailCommand;
if (email != null) {
if (email.folders_removed(removed) == REMOVE) {
commands.remove();
}
}
}
}
/**
* Notifies the stack that email was removed from a folder.
*
* This will cause any commands involving the given email
* identifiers to be removed from commands where they are present,
* potentially also causing the command to be removed from the
* stack. It should only be called as a response to un-recoverable
* changes, e.g. when the server notifies that an email has been
* removed as a result of some other client removing it, or the
* message being deleted completely.
*/
internal void email_removed(Geary.Folder location,
Gee.Collection<Geary.EmailIdentifier> targets) {
Gee.Iterator<Command> commands = this.undo_stack.iterator();
while (commands.next()) {
EmailCommand? email = commands.get() as EmailCommand;
if (email != null) {
if (email.email_removed(location, targets) == REMOVE) {
commands.remove();
}
}
}
}
}
/** Base class for email-related commands. */
public abstract class Application.EmailCommand : Command {
/** Specifies a command's response to external mail state changes. */
public enum StateChangePolicy {
/** The change can be ignored */
IGNORE,
/** The command is no longer valid and should be removed */
REMOVE;
}
/**
* Returns the folder where the command was initially executed.
*
* This is used by the main window to return to the folder where
* the command was first carried out.
*/
public Geary.Folder location {
get; protected set;
}
/**
* Returns the conversations which the command was initially applied to.
*
* This is used by the main window to return to the conversation where
* the command was first carried out.
*/
public Gee.Collection<Geary.App.Conversation> conversations {
get; private set;
}
/**
* Returns the email which the command was initially applied to.
*
* This is used by the main window to return to the conversation where
* the command was first carried out.
*/
public Gee.Collection<Geary.EmailIdentifier> email {
get; private set;
}
private Gee.Collection<Geary.App.Conversation> mutable_conversations;
private Gee.Collection<Geary.EmailIdentifier> mutable_email;
protected EmailCommand(Geary.Folder location,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> email) {
this.location = location;
this.conversations = conversations.read_only_view;
this.email = email.read_only_view;
this.mutable_conversations = conversations;
this.mutable_email = email;
}
public override bool equal_to(Command other) {
if (this == other) {
return true;
}
if (this.get_type() != other.get_type()) {
return false;
}
EmailCommand? other_email = other as EmailCommand;
if (other_email == null) {
return false;
}
return (
this.location == other_email.location &&
this.conversations.size == other_email.conversations.size &&
this.email.size == other_email.email.size &&
this.conversations.contains_all(other_email.conversations) &&
this.email.contains_all(other_email.email)
);
}
/**
* Determines the command's response when a folder is removed.
*
* This is called when some external means (such as another
* command, or another email client altogether) has caused a
* folder to be removed.
*
* The returned policy will determine if the command is unaffected
* by the change and hence can remain on the stack, or is no
* longer valid and hence must be removed.
*/
internal virtual StateChangePolicy folders_removed(
Gee.Collection<Geary.Folder> removed
) {
return (
this.location in removed
? StateChangePolicy.REMOVE
: StateChangePolicy.IGNORE
);
}
/**
* Determines the command's response when email is removed.
*
* This is called when some external means (such as another
* command, or another email client altogether) has caused a
* email in a folder to be removed.
*
* The returned policy will determine if the command is unaffected
* by the change and hence can remain on the stack, or is no
* longer valid and hence must be removed.
*/
internal virtual StateChangePolicy email_removed(
Geary.Folder location,
Gee.Collection<Geary.EmailIdentifier> targets
) {
StateChangePolicy ret = IGNORE;
if (this.location == location) {
// Any removed email should have already been removed from
// their conversations by the time we here, so just remove
// any conversations that don't have any messages left.
Gee.Iterator<Geary.App.Conversation> conversations =
this.mutable_conversations.iterator();
while (conversations.next()) {
var conversation = conversations.get();
if (!conversation.has_any_non_deleted_email()) {
conversations.remove();
}
}
// Update message set to remove all removed messages
this.mutable_email.remove_all(targets);
// If we have no more conversations or messages, then the
// command won't be able to do anything and should be
// removed.
if (this.mutable_conversations.is_empty ||
this.mutable_email.is_empty) {
ret = REMOVE;
}
}
return ret;
}
}
/**
* Mixin for trivial application commands.
*
* Trivial commands should not cause a notification to be shown when
* initially executed.
*/
public interface Application.TrivialCommand : Command {
}
private class Application.MarkEmailCommand : TrivialCommand, EmailCommand {
private Geary.App.EmailStore store;
private Geary.EmailFlags? to_add;
private Geary.EmailFlags? to_remove;
public MarkEmailCommand(Geary.Folder location,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> messages,
Geary.App.EmailStore store,
Geary.EmailFlags? to_add,
Geary.EmailFlags? to_remove,
string? executed_label = null,
string? undone_label = null) {
base(location, conversations, messages);
this.store = store;
this.to_add = to_add;
this.to_remove = to_remove;
this.executed_label = executed_label;
this.undone_label = undone_label;
}
public override async void execute(GLib.Cancellable? cancellable)
throws GLib.Error {
yield this.store.mark_email_async(
this.email, this.to_add, this.to_remove, cancellable
);
}
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
yield this.store.mark_email_async(
this.email, this.to_remove, this.to_add, cancellable
);
}
public override bool equal_to(Command other) {
if (!base.equal_to(other)) {
return false;
}
MarkEmailCommand other_mark = (MarkEmailCommand) other;
return (
((this.to_add == other_mark.to_add) ||
(this.to_add != null &&
other_mark.to_add != null &&
this.to_add.equal_to(other_mark.to_add))) &&
((this.to_remove == other_mark.to_remove) ||
(this.to_remove != null &&
other_mark.to_remove != null &&
this.to_remove.equal_to(other_mark.to_remove)))
);
}
}
private abstract class Application.RevokableCommand : EmailCommand {
public override bool can_undo {
get { return this.revokable != null && this.revokable.valid; }
}
private Geary.Revokable? revokable = null;
protected RevokableCommand(Geary.Folder location,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> email) {
base(location, conversations, email);
}
public override async void execute(GLib.Cancellable? cancellable)
throws GLib.Error {
set_revokable(yield execute_impl(cancellable));
if (this.revokable != null && this.revokable.valid) {
yield this.revokable.commit_async(cancellable);
}
}
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
if (this.revokable == null) {
throw new Geary.EngineError.UNSUPPORTED(
"Cannot undo command, no revokable available"
);
}
yield this.revokable.revoke_async(cancellable);
set_revokable(null);
}
protected abstract async Geary.Revokable
execute_impl(GLib.Cancellable cancellable)
throws GLib.Error;
private void set_revokable(Geary.Revokable? updated) {
if (this.revokable != null) {
this.revokable.committed.disconnect(on_revokable_committed);
}
this.revokable = updated;
if (this.revokable != null) {
this.revokable.committed.connect(on_revokable_committed);
}
}
private void on_revokable_committed(Geary.Revokable? updated) {
set_revokable(updated);
}
}
private class Application.MoveEmailCommand : RevokableCommand {
private Geary.FolderSupport.Move source;
private Geary.Folder destination;
public MoveEmailCommand(Geary.FolderSupport.Move source,
Geary.Folder destination,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> messages,
string? executed_label = null,
string? undone_label = null) {
base(source, conversations, messages);
this.source = source;
this.destination = destination;
this.executed_label = executed_label;
this.undone_label = undone_label;
}
internal override EmailCommand.StateChangePolicy folders_removed(
Gee.Collection<Geary.Folder> removed
) {
return (
this.destination in removed
? EmailCommand.StateChangePolicy.REMOVE
: base.folders_removed(removed)
);
}
internal override EmailCommand.StateChangePolicy email_removed(
Geary.Folder location,
Gee.Collection<Geary.EmailIdentifier> targets
) {
// With the current revokable mechanism we can't determine if
// specific messages removed from the destination are
// affected, so if the dest is the location, just assume they
// are for now.
return (
location == this.destination
? EmailCommand.StateChangePolicy.REMOVE
: base.email_removed(location, targets)
);
}
protected override async Geary.Revokable
execute_impl(GLib.Cancellable cancellable)
throws GLib.Error {
bool open = false;
try {
yield this.source.open_async(
Geary.Folder.OpenFlags.NO_DELAY, cancellable
);
open = true;
return yield this.source.move_email_async(
this.email,
this.destination.path,
cancellable
);
} finally {
if (open) {
try {
yield this.source.close_async(null);
} catch (GLib.Error err) {
// ignored
}
}
}
}
}
private class Application.ArchiveEmailCommand : RevokableCommand {
/** {@inheritDoc} */
public Geary.Folder command_location {
get; protected set;
}
/** {@inheritDoc} */
public Gee.Collection<Geary.EmailIdentifier> command_conversations {
get; protected set;
}
/** {@inheritDoc} */
public Gee.Collection<Geary.EmailIdentifier> command_email {
get; protected set;
}
private Geary.FolderSupport.Archive source;
public ArchiveEmailCommand(Geary.FolderSupport.Archive source,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> messages,
string? executed_label = null,
string? undone_label = null) {
base(source, conversations, messages);
this.source = source;
this.executed_label = executed_label;
this.undone_label = undone_label;
}
internal override EmailCommand.StateChangePolicy folders_removed(
Gee.Collection<Geary.Folder> removed
) {
EmailCommand.StateChangePolicy ret = base.folders_removed(removed);
if (ret == IGNORE) {
// With the current revokable mechanism we can't determine
// if specific messages removed from the destination are
// affected, so if the dest is the location, just assume
// they are for now.
foreach (var folder in removed) {
if (folder.special_folder_type == ARCHIVE) {
ret = REMOVE;
break;
}
}
}
return ret;
}
internal override EmailCommand.StateChangePolicy email_removed(
Geary.Folder location,
Gee.Collection<Geary.EmailIdentifier> targets
) {
// With the current revokable mechanism we can't determine if
// specific messages removed from the destination are
// affected, so if the dest is the location, just assume they
// are for now.
return (
location.special_folder_type == ARCHIVE
? EmailCommand.StateChangePolicy.REMOVE
: base.email_removed(location, targets)
);
}
protected override async Geary.Revokable
execute_impl(GLib.Cancellable cancellable)
throws GLib.Error {
bool open = false;
try {
yield this.source.open_async(
Geary.Folder.OpenFlags.NO_DELAY, cancellable
);
open = true;
return yield this.source.archive_email_async(
this.email, cancellable
);
} finally {
if (open) {
try {
yield this.source.close_async(null);
} catch (GLib.Error err) {
// ignored
}
}
}
}
}
private class Application.CopyEmailCommand : EmailCommand {
public override bool can_undo {
// Engine doesn't yet support it :(
get { return false; }
}
private Geary.FolderSupport.Copy source;
private Geary.Folder destination;
public CopyEmailCommand(Geary.FolderSupport.Copy source,
Geary.Folder destination,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> messages,
string? executed_label = null,
string? undone_label = null) {
base(source, conversations, messages);
this.source = source;
this.destination = destination;
this.executed_label = executed_label;
this.undone_label = undone_label;
}
public override async void execute(GLib.Cancellable? cancellable)
throws GLib.Error {
bool open = false;
try {
yield this.source.open_async(
Geary.Folder.OpenFlags.NO_DELAY, cancellable
);
open = true;
yield this.source.copy_email_async(
this.email, this.destination.path, cancellable
);
} finally {
if (open) {
try {
yield this.source.close_async(null);
} catch (GLib.Error err) {
// ignored
}
}
}
}
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
throw new Geary.EngineError.UNSUPPORTED(
"Cannot undo copy, not yet supported"
);
}
internal override EmailCommand.StateChangePolicy folders_removed(
Gee.Collection<Geary.Folder> removed
) {
return (
this.destination in removed
? EmailCommand.StateChangePolicy.REMOVE
: base.folders_removed(removed)
);
}
internal override EmailCommand.StateChangePolicy email_removed(
Geary.Folder location,
Gee.Collection<Geary.EmailIdentifier> targets
) {
// With the current revokable mechanism we can't determine if
// specific messages removed from the destination are
// affected, so if the dest is the location, just assume they
// are for now.
return (
location == this.destination
? EmailCommand.StateChangePolicy.REMOVE
: base.email_removed(location, targets)
);
}
}
private class Application.DeleteEmailCommand : EmailCommand {
public override bool can_undo {
get { return false; }
}
private Geary.FolderSupport.Remove target;
public DeleteEmailCommand(Geary.FolderSupport.Remove target,
Gee.Collection<Geary.App.Conversation> conversations,
Gee.Collection<Geary.EmailIdentifier> email) {
base(target, conversations, email);
this.target = target;
}
public override async void execute(GLib.Cancellable? cancellable)
throws GLib.Error {
bool open = false;
try {
yield this.target.open_async(
Geary.Folder.OpenFlags.NO_DELAY, cancellable
);
open = true;
yield this.target.remove_email_async(this.email, cancellable);
} finally {
if (open) {
try {
yield this.target.close_async(null);
} catch (GLib.Error err) {
// ignored
}
}
}
}
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
throw new Geary.EngineError.UNSUPPORTED(
"Cannot undo emptying a folder: %s",
this.target.path.to_string()
);
}
}
private class Application.EmptyFolderCommand : Command {
public override bool can_undo {
get { return false; }
}
private Geary.FolderSupport.Empty target;
public EmptyFolderCommand(Geary.FolderSupport.Empty target) {
this.target = target;
}
public override async void execute(GLib.Cancellable? cancellable)
throws GLib.Error {
bool open = false;
try {
yield this.target.open_async(
Geary.Folder.OpenFlags.NO_DELAY, cancellable
);
open = true;
yield this.target.empty_folder_async(cancellable);
} finally {
if (open) {
try {
yield this.target.close_async(null);
} catch (GLib.Error err) {
// ignored
}
}
}
}
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
throw new Geary.EngineError.UNSUPPORTED(
"Cannot undo emptying a folder: %s",
this.target.path.to_string()
);
}
/** Determines if this command is equal to another. */
public override bool equal_to(Command other) {
EmptyFolderCommand? other_type = other as EmptyFolderCommand;
return (other_type != null && this.target == other_type.target);
}
}
private abstract class Application.ComposerCommand : Command {
public override bool can_redo {
get { return false; }
}
protected Composer.Widget? composer { get; private set; }
protected ComposerCommand(Composer.Widget composer) {
this.composer = composer;
}
protected void clear_composer() {
this.composer = null;
}
protected void close_composer() {
// Calling close then immediately erasing the reference looks
// sketchy, but works since Controller still maintains a
// reference to the composer until it destroys itself.
this.composer.close.begin();
this.composer = null;
}
}
private class Application.SendComposerCommand : ComposerCommand {
public override bool can_undo {
get { return this.application.config.undo_send_delay > 0; }
}
private Client application;
private AccountContext context;
private Geary.Smtp.ClientService smtp;
private Geary.TimeoutManager commit_timer;
private Geary.EmailIdentifier? saved = null;
public SendComposerCommand(Client application,
AccountContext context,
Composer.Widget composer) {
base(composer);
this.application = application;
this.context = context;
this.smtp = (Geary.Smtp.ClientService) context.account.outgoing;
int send_delay = this.application.config.undo_send_delay;
this.commit_timer = new Geary.TimeoutManager.seconds(
send_delay > 0 ? send_delay : 0,
on_commit_timeout
);
}
public override async void execute(GLib.Cancellable? cancellable)
throws GLib.Error {
Geary.ComposedEmail email = yield this.composer.get_composed_email();
/// Translators: The label for an in-app notification. The
/// string substitution is a list of recipients of the email.
this.executed_label = _(
"Email to %s queued for delivery"
).printf(Util.Email.to_short_recipient_display(email));
if (this.can_undo) {
this.saved = yield this.smtp.save_email(email, cancellable);
this.commit_timer.start();
} else {
yield this.smtp.send_email(email, cancellable);
}
}
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
this.commit_timer.reset();
yield this.smtp.outbox.remove_email_async(
Geary.Collection.single(this.saved),
cancellable
);
this.saved = null;
this.composer.set_enabled(true);
this.application.controller.show_composer(this.composer, null, null);
clear_composer();
}
private void on_commit_timeout() {
this.smtp.queue_email(this.saved);
this.saved = null;
close_composer();
}
}
private class Application.SaveComposerCommand : ComposerCommand {
private const int DESTROY_TIMEOUT_SEC = 30 * 60;
public override bool can_redo {
get { return false; }
}
private Controller controller;
private Geary.TimeoutManager destroy_timer;
public SaveComposerCommand(Controller controller,
Composer.Widget composer) {
base(composer);
this.controller = controller;
this.destroy_timer = new Geary.TimeoutManager.seconds(
DESTROY_TIMEOUT_SEC,
on_destroy_timeout
);
}
public override async void execute(GLib.Cancellable? cancellable)
throws GLib.Error {
Geary.ComposedEmail email = yield this.composer.get_composed_email();
/// Translators: The label for an in-app notification. The
/// string substitution is a list of recipients of the email.
this.executed_label = _(
"Email to %s saved"
).printf(Util.Email.to_short_recipient_display(email));
this.destroy_timer.start();
}
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
if (this.composer != null) {
this.destroy_timer.reset();
this.composer.set_enabled(true);
this.controller.show_composer(this.composer, null, null);
clear_composer();
} else {
/// Translators: A label for an in-app notification.
this.undone_label = _(
"Composer could not be restored"
);
}
}
private void on_destroy_timeout() {
close_composer();
}
}
private class Application.DiscardComposerCommand : ComposerCommand {
private const int DESTROY_TIMEOUT_SEC = 30 * 60;
public override bool can_redo {
get { return false; }
}
private Controller controller;
private Geary.TimeoutManager destroy_timer;
public DiscardComposerCommand(Controller controller,
Composer.Widget composer) {
base(composer);
this.controller = controller;
this.destroy_timer = new Geary.TimeoutManager.seconds(
DESTROY_TIMEOUT_SEC,
on_destroy_timeout
);
}
public override async void execute(GLib.Cancellable? cancellable)
throws GLib.Error {
Geary.ComposedEmail email = yield this.composer.get_composed_email();
/// Translators: The label for an in-app notification. The
/// string substitution is a list of recipients of the email.
this.executed_label = _(
"Email to %s discarded"
).printf(Util.Email.to_short_recipient_display(email));
this.destroy_timer.start();
}
public override async void undo(GLib.Cancellable? cancellable)
throws GLib.Error {
if (this.composer != null) {
this.destroy_timer.reset();
this.composer.set_enabled(true);
this.controller.show_composer(this.composer, null, null);
clear_composer();
} else {
/// Translators: A label for an in-app notification.
this.undone_label = _(
"Composer could not be restored"
);
}
}
private void on_destroy_timeout() {
close_composer();
}
}