geary/src/client/conversation-viewer/conversation-viewer.vala

527 lines
19 KiB
Vala

/*
* 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
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* Displays the messages in a conversation and in-window composers.
*/
[GtkTemplate (ui = "/org/gnome/Geary/conversation-viewer.ui")]
public class ConversationViewer : Gtk.Stack, Geary.BaseInterface {
/**
* The current conversation listbox, if any.
*/
public ConversationListBox? current_list {
get; private set; default = null;
}
/** Returns the currently displayed composer if any. */
public Composer.Widget? current_composer {
get; private set; default = null;
}
/**
* The most recent web view created in this viewer.
*
* Keep the last created web view around so others can share the
* same WebKitGTK WebProcess.
*/
internal ConversationWebView? previous_web_view { get; set; default = null; }
private Application.Configuration config;
private Gee.Set<Geary.App.Conversation>? selection_while_composing = null;
private GLib.Cancellable? find_cancellable = null;
// Stack pages
[GtkChild] private unowned Gtk.Spinner loading_page;
[GtkChild] private unowned Gtk.Grid no_conversations_page;
[GtkChild] private unowned Gtk.Grid conversation_page;
[GtkChild] private unowned Gtk.Grid multiple_conversations_page;
[GtkChild] private unowned Gtk.Grid empty_folder_page;
[GtkChild] private unowned Gtk.Grid empty_search_page;
[GtkChild] private unowned Gtk.Grid composer_page;
private Gtk.ScrolledWindow conversation_scroller;
[GtkChild] internal unowned Gtk.SearchBar conversation_find_bar;
[GtkChild] internal unowned Gtk.SearchEntry conversation_find_entry;
private Components.EntryUndo conversation_find_undo;
[GtkChild] private unowned Gtk.Button conversation_find_next;
[GtkChild] private unowned Gtk.Button conversation_find_prev;
/* Emitted when a new conversation list was added to this view. */
public signal void conversation_added(ConversationListBox list);
/* Emitted when a new conversation list was removed from this view. */
public signal void conversation_removed(ConversationListBox list);
static construct {
set_css_name("geary-conversation-viewer");
}
/**
* Constructs a new conversation view instance.
*/
public ConversationViewer(Application.Configuration config) {
base_ref();
this.config = config;
Components.PlaceholderPane no_conversations =
new Components.PlaceholderPane();
no_conversations.icon_name = "folder-symbolic";
// Translators: Title label for placeholder when no
// conversations have been selected.
no_conversations.title = _("No conversations selected");
// Translators: Sub-title label for placeholder when no
// conversations have been selected.
no_conversations.subtitle = _(
"Selecting a conversation from the list will display it here"
);
this.no_conversations_page.add(no_conversations);
Components.PlaceholderPane multi_conversations =
new Components.PlaceholderPane();
multi_conversations.icon_name = "folder-symbolic";
// Translators: Title label for placeholder when multiple
// conversations have been selected.
multi_conversations.title = _("Multiple conversations selected");
// Translators: Sub-title label for placeholder when multiple
// conversations have been selected.
multi_conversations.subtitle = _(
"Choosing an action will apply to all selected conversations"
);
this.multiple_conversations_page.add(multi_conversations);
Components.PlaceholderPane empty_folder =
new Components.PlaceholderPane();
empty_folder.icon_name = "folder-symbolic";
// Translators: Title label for placeholder when no
// conversations have exist in a folder.
empty_folder.title = _("No conversations found");
// Translators: Sub-title label for placeholder when no
// conversations have exist in a folder.
empty_folder.subtitle = _(
"This folder does not contain any conversations"
);
this.empty_folder_page.add(empty_folder);
Components.PlaceholderPane empty_search =
new Components.PlaceholderPane();
empty_search.icon_name = "folder-symbolic";
// Translators: Title label for placeholder when no
// conversations have been found in a search.
empty_search.title = _("No conversations found");
// Translators: Sub-title label for placeholder when no
// conversations have been found in a search.
empty_search.subtitle = _(
"Your search returned no results, try refining your search terms"
);
this.empty_search_page.add(empty_search);
this.conversation_find_undo = new Components.EntryUndo(
this.conversation_find_entry
);
// XXX GTK+ Bug 778190 workaround
new_conversation_scroller();
// XXX Do this in Glade when possible.
this.conversation_find_bar.connect_entry(this.conversation_find_entry);
}
~ConversationViewer() {
base_unref();
}
/**
* Puts the view into composer mode, showing a full-height composer.
*/
public void do_compose(Composer.Widget composer) {
var main_window = get_toplevel() as Application.MainWindow;
if (main_window != null) {
Composer.Box box = new Composer.Box(
composer, main_window.main_toolbar
);
this.current_composer = composer;
// XXX move the ConversationListView management code into
// MainWindow or somewhere more appropriate
ConversationListView conversation_list = main_window.conversation_list_view;
this.selection_while_composing = conversation_list.copy_selected();
conversation_list.get_selection().unselect_all();
box.vanished.connect(on_composer_closed);
this.composer_page.add(box);
set_visible_child(this.composer_page);
composer.update_window_title();
}
}
/**
* Puts the view into composer mode, showing an embedded composer.
*/
public void do_compose_embedded(Composer.Widget composer,
Geary.Email? referred) {
this.current_composer = composer;
Composer.Embed embed = new Composer.Embed(
referred,
composer,
this.conversation_scroller
);
embed.vanished.connect(on_composer_closed);
// We need to temporarily disable kinetic scrolling so that if
// it still has some momentum when the composer is inserted
// and scrolled to, it won't jump away again. See Bug 778027.
var kinetic = this.conversation_scroller.kinetic_scrolling;
if (kinetic) this.conversation_scroller.kinetic_scrolling = false;
if (this.current_list != null) {
this.current_list.add_embedded_composer(
embed,
composer.saved_id != null
);
composer.update_window_title();
}
if (kinetic) this.conversation_scroller.kinetic_scrolling = true;
// Set a minimal composer height
composer.set_size_request(
-1, this.conversation_scroller.get_allocated_height() / 3 * 2
);
}
/**
* Shows the loading UI.
*/
public void show_loading() {
this.loading_page.start();
set_visible_child(this.loading_page);
}
/**
* Shows the UI when no conversations have been selected
*/
public void show_none_selected() {
set_visible_child(this.no_conversations_page);
}
/**
* Shows the UI when multiple conversations have been selected
*/
public void show_multiple_selected() {
set_visible_child(this.multiple_conversations_page);
}
/**
* Shows the empty folder UI.
*/
public void show_empty_folder() {
set_visible_child(this.empty_folder_page);
}
/**
* Shows the empty search UI.
*/
public void show_empty_search() {
set_visible_child(this.empty_search_page);
}
/** Shows and focuses the find entry. */
public void enable_find() {
this.conversation_find_bar.set_search_mode(true);
this.conversation_find_entry.grab_focus();
}
/**
* Shows a conversation in the viewer.
*/
public async void load_conversation(Geary.App.Conversation conversation,
Gee.Collection<Geary.EmailIdentifier> scroll_to,
Geary.App.EmailStore store,
Application.ContactStore contacts,
bool start_mark_timer)
throws GLib.Error {
// Keep the old ScrolledWindow around long enough for its
// descendant web views to be kept so their WebProcess can be
// re-used.
var old_scroller = remove_current_list();
ConversationListBox new_list = new ConversationListBox(
conversation,
!start_mark_timer,
store,
contacts,
this.config,
this.conversation_scroller.get_vadjustment()
);
// Need to fire this signal early so the the controller
// can hook in to its signals to catch any emails added
// during loading.
this.conversation_added(new_list);
// Also set up find infrastructure early so matching emails
// are expanded and highlighted as they are added.
this.conversation_find_next.set_sensitive(false);
this.conversation_find_prev.set_sensitive(false);
new_list.search.matches_updated.connect((count) => {
bool found = count > 0;
this.conversation_find_entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.PRIMARY,
found || Geary.String.is_empty(this.conversation_find_entry.text)
? "edit-find-symbolic" : "computer-fail-symbolic"
);
this.conversation_find_next.set_sensitive(found);
this.conversation_find_prev.set_sensitive(found);
});
add_new_list(new_list);
set_visible_child(this.conversation_page);
// Highlight matching terms from find if active, otherwise
// from the search folder if that's where we are at
var query = get_find_search_query(conversation.base_folder.account);
if (query == null) {
var search_folder = conversation.base_folder as Geary.App.SearchFolder;
if (search_folder != null) {
query = search_folder.query;
}
}
yield new_list.load_conversation(scroll_to, query);
// Not strictly necessary, but keeps the compiler happy
old_scroller.destroy();
}
// Add a new conversation list to the UI
private void add_new_list(ConversationListBox list) {
this.current_list = list;
list.show();
// Manually create a Viewport rather than letting
// ScrolledWindow do it so Container.set_focus_{h,v}adjustment
// are not set on the list - it makes changing focus jumpy
// when a row or its web_view are larger than the viewport.
Gtk.Viewport viewport = new Gtk.Viewport(null, null);
viewport.show();
viewport.add(list);
this.conversation_scroller.add(viewport);
}
// Remove any existing conversation list, cancelling its loading
private Gtk.ScrolledWindow remove_current_list() {
if (this.find_cancellable != null) {
this.find_cancellable.cancel();
this.find_cancellable = null;
}
if (this.current_list != null) {
this.current_list.cancel_conversation_load();
this.conversation_removed(this.current_list);
this.current_list = null;
}
var old_scroller = this.conversation_scroller;
// XXX GTK+ Bug 778190 workaround
this.conversation_page.remove(old_scroller);
new_conversation_scroller();
return old_scroller;
}
private void new_conversation_scroller() {
// XXX Work around for GTK+ Bug 778190: Instead of replacing
// the Viewport that contains the current list, replace the
// complete ScrolledWindow. Need to remove this method and
// put the settings back into conversation-viewer.ui when we
// can rely on it being fixed again.
Gtk.ScrolledWindow scroller = new Gtk.ScrolledWindow(null, null);
scroller.get_style_context().add_class("geary-conversation-scroller");
scroller.hscrollbar_policy = Gtk.PolicyType.NEVER;
scroller.set_hexpand(true);
scroller.set_vexpand(true);
scroller.show();
scroller.scroll_event.connect(
on_conversation_scroll
);
scroller.get_vscrollbar().button_release_event.connect(
on_conversation_scroll
);
this.conversation_scroller = scroller;
this.conversation_page.add(scroller);
}
/**
* Sets the currently visible page of the stack.
*/
private new void set_visible_child(Gtk.Widget widget) {
debug("Showing: %s", widget.get_name());
Gtk.Widget current = get_visible_child();
if (current == this.conversation_page) {
if (widget != this.conversation_page) {
// By removing the current list, any load it is currently
// performing is also cancelled, which is important to
// avoid a possible crit warning when switching folders,
// etc.
remove_current_list();
}
} else if (current == this.loading_page) {
// Stop the spinner running so it doesn't trigger repaints
// and wake up Geary even when idle. See Bug 783025.
this.loading_page.stop();
}
base.set_visible_child(widget);
}
private async void update_find_results() {
ConversationListBox? list = this.current_list;
if (list != null) {
if (this.find_cancellable != null) {
this.find_cancellable.cancel();
}
GLib.Cancellable cancellable = new GLib.Cancellable();
cancellable.cancelled.connect(() => {
list.search.cancel();
});
this.find_cancellable = cancellable;
try {
var query = get_find_search_query(
list.conversation.base_folder.account
);
if (query != null) {
yield list.search.highlight_matching_email(query, true);
}
} catch (GLib.Error err) {
warning("Error updating find results: %s", err.message);
}
}
}
private Geary.SearchQuery? get_find_search_query(Geary.Account account)
throws GLib.Error {
Geary.SearchQuery? query = null;
if (this.conversation_find_bar.get_search_mode()) {
string text = this.conversation_find_entry.get_text().strip();
// Require find string of at least two chars to avoid
// opening every message in the conversation as soon as
// the user presses a key
if (text.length >= 2) {
var expr_factory = new Util.Email.SearchExpressionFactory(
this.config.get_search_strategy(),
account.information
);
query = account.new_search_query(
expr_factory.parse_query(text),
text
);
}
}
return query;
}
[GtkCallback]
private void on_find_mode_changed(Object obj, ParamSpec param) {
if (this.current_list != null) {
if (this.conversation_find_bar.get_search_mode()) {
// Find became enabled
ConversationEmail? email_view =
this.current_list.get_selection_view();
if (email_view != null) {
email_view.get_selection_for_find.begin((obj, res) => {
string text = email_view.get_selection_for_find.end(res);
if (text != null) {
this.conversation_find_entry.set_text(text);
this.conversation_find_entry.select_region(0, -1);
}
});
}
} else {
// Find became disabled, re-show search terms if any
this.current_list.search.unmark_terms();
Geary.App.SearchFolder? search_folder = (
this.current_list.conversation.base_folder
as Geary.App.SearchFolder
);
this.conversation_find_undo.reset();
if (search_folder != null) {
Geary.SearchQuery? query = search_folder.query;
if (query != null) {
this.current_list.search.highlight_matching_email.begin(
query,
true
);
}
}
}
}
}
[GtkCallback]
private void on_find_text_changed(Gtk.SearchEntry entry) {
this.conversation_find_next.set_sensitive(false);
this.conversation_find_prev.set_sensitive(false);
this.update_find_results.begin();
}
[GtkCallback]
private void on_find_next(Gtk.Widget entry) {
if (this.current_list != null) {
//this.current_list.show_prev_search_term();
}
}
[GtkCallback]
private void on_find_prev(Gtk.Widget entry) {
if (this.current_list != null) {
//this.current_list.show_next_search_term();
}
}
private bool on_conversation_scroll() {
if (this.current_list != null) {
this.current_list.mark_visible_read();
}
return Gdk.EVENT_PROPAGATE;
}
private void on_composer_closed() {
this.current_composer = null;
if (get_visible_child() == this.composer_page) {
set_visible_child(this.conversation_page);
// Restore the old selection
var main_window = get_toplevel() as Application.MainWindow;
if (main_window != null) {
main_window.update_title();
if (this.selection_while_composing != null) {
var conversation_list = main_window.conversation_list_view;
if (this.selection_while_composing.is_empty) {
conversation_list.conversations_selected(
this.selection_while_composing
);
} else {
conversation_list.select_conversations(
this.selection_while_composing
);
}
this.selection_while_composing = null;
}
}
}
}
}