geary/src/client/conversation-viewer/conversation-list-box.vala
Michael James Gratton d7af23201c Revert "Revert "Merge branch 'mjog/558-webkit-shared-process' into 'mainline'""
This reverts commit cbe6e0ba9b, which reinstates
commit e4a5b85698.

See !411 and !374
2020-10-13 00:02:02 +11:00

1525 lines
53 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.
*/
/**
* A widget for displaying conversations as a list of emails.
*
* The view displays the current selected {@link
* Geary.App.Conversation} from the conversation list. To do so, it
* listens to signals from both the list and the current conversation
* monitor, updating the email list as needed.
*
* Unlike ConversationListStore (which sorts by date received),
* ConversationListBox sorts by the {@link Geary.Email.date} field
* (the Date: header), as that's the date displayed to the user.
*/
public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
/** Fields that must be available for listing conversation email. */
public const Geary.Email.Field REQUIRED_FIELDS = (
// Sorting the conversation
Geary.Email.Field.DATE |
// Determine unread/starred, etc
Geary.Email.Field.FLAGS |
// Determine if the message is from the sender or not
Geary.Email.Field.ORIGINATORS
);
internal const string EMAIL_ACTION_GROUP_NAME = "eml";
internal const string ACTION_DELETE = "delete";
internal const string ACTION_FORWARD = "forward";
internal const string ACTION_MARK_LOAD_REMOTE = "mark-load-remote";
internal const string ACTION_MARK_READ = "mark-read";
internal const string ACTION_MARK_STARRED = "mark-starred";
internal const string ACTION_MARK_UNREAD = "mark-unread";
internal const string ACTION_MARK_UNREAD_DOWN = "mark-unread-down";
internal const string ACTION_MARK_UNSTARRED = "mark-unstarred";
internal const string ACTION_PRINT = "print";
internal const string ACTION_REPLY_ALL = "reply-all";
internal const string ACTION_REPLY_SENDER = "reply-sender";
internal const string ACTION_SAVE_ALL_ATTACHMENTS = "save-all-attachments";
internal const string ACTION_TRASH = "trash";
internal const string ACTION_VIEW_SOURCE = "view-source";
// Offset from the top of the list box which emails views will
// scrolled to, so the user can see there are additional messages
// above it. XXX This is currently approx 0.5 times the height of
// a collapsed ConversationEmail, it should probably calculated
// somehow so that differences user's font size are taken into
// account.
private const int EMAIL_TOP_OFFSET = 32;
// Amount of time to wait after the user took some action that may
// be interpreted as marking the email as read before actually
// checking
private const int MARK_READ_TIMEOUT_MSEC = 250;
// Amount of pixels that need to be shown of an email's body to
// mark it as read
private const int MARK_READ_PADDING = 50;
private const string ACTION_TARGET_TYPE = (
Geary.EmailIdentifier.BASE_VARIANT_TYPE
);
private const ActionEntry[] email_action_entries = {
{ ACTION_DELETE, on_email_delete, ACTION_TARGET_TYPE },
{ ACTION_FORWARD, on_email_forward, ACTION_TARGET_TYPE },
{ ACTION_MARK_LOAD_REMOTE, on_email_load_remote, ACTION_TARGET_TYPE },
{ ACTION_MARK_READ, on_email_mark_read, ACTION_TARGET_TYPE },
{ ACTION_MARK_STARRED, on_email_mark_starred, ACTION_TARGET_TYPE },
{ ACTION_MARK_UNREAD, on_email_mark_unread, ACTION_TARGET_TYPE },
{ ACTION_MARK_UNREAD_DOWN, on_email_mark_unread_down, ACTION_TARGET_TYPE },
{ ACTION_MARK_UNSTARRED, on_email_mark_unstarred, ACTION_TARGET_TYPE },
{ ACTION_PRINT, on_email_print, ACTION_TARGET_TYPE },
{ ACTION_REPLY_ALL, on_email_reply_all, ACTION_TARGET_TYPE },
{ ACTION_REPLY_SENDER, on_email_reply_sender, ACTION_TARGET_TYPE },
{ ACTION_SAVE_ALL_ATTACHMENTS, on_email_save_all_attachments, ACTION_TARGET_TYPE },
{ ACTION_TRASH, on_email_trash, ACTION_TARGET_TYPE },
{ ACTION_VIEW_SOURCE, on_email_view_source, ACTION_TARGET_TYPE },
};
/** Manages find/search term matching in a conversation. */
public class SearchManager : Geary.BaseObject {
// The list that owns this manager
private weak ConversationListBox list;
// Conversation being managed
private Geary.App.Conversation conversation;
// Cached search terms to apply to new messages
private Gee.Set<string>? terms = null;
// Total number of search matches found
private uint matches_found = 0;
// Cancellable used when highlighting search matches
private GLib.Cancellable highlight_cancellable = new GLib.Cancellable();
/** Fired when the number of matching emails has changed. */
public signal void matches_updated(uint matches);
internal SearchManager(ConversationListBox list,
Geary.App.Conversation conversation) {
this.list = list;
this.conversation = conversation;
}
/**
* Loads search term matches for this list's emails.
*/
public async void highlight_matching_email(Geary.SearchQuery query,
bool enable_scroll)
throws GLib.Error {
cancel();
// Keep a copy of the current cancellable so it can't get
// changed out from underneath the execution of this method
GLib.Cancellable cancellable = this.highlight_cancellable;
Geary.Account account = this.conversation.base_folder.account;
Gee.Collection<Geary.EmailIdentifier>? matching =
yield account.local_search_async(
query,
this.conversation.get_count(),
0,
null,
this.conversation.get_email_ids(),
cancellable
);
if (matching != null) {
Gee.Set<string>? terms =
yield account.get_search_matches_async(
query, matching, cancellable
);
if (cancellable.is_cancelled()) {
throw new GLib.IOError.CANCELLED(
"Search term highlighting cancelled"
);
}
if (terms != null && !terms.is_empty) {
this.terms = terms;
// Scroll to the first matching row first
EmailRow? first = null;
foreach (Geary.EmailIdentifier id in matching) {
EmailRow? row = this.list.get_email_row_by_id(id);
if (row != null &&
(first == null || row.get_index() < first.get_index())) {
first = row;
}
}
if (first != null && enable_scroll) {
this.list.scroll_to_row(first);
}
// Now expand them all
foreach (Geary.EmailIdentifier id in matching) {
EmailRow? row = this.list.get_email_row_by_id(id);
if (row != null) {
apply_terms(row, terms, cancellable);
row.expand.begin();
}
}
}
}
}
/**
* Highlights matching terms in the given email row, if any.
*/
internal void highlight_row_if_matching(EmailRow row) {
if (this.terms != null) {
apply_terms(row, this.terms, this.highlight_cancellable);
}
}
/**
* Removes search term highlighting from all messages.
*/
public void unmark_terms() {
cancel();
this.list.foreach((child) => {
EmailRow? row = child as EmailRow;
if (row != null) {
if (row.is_search_match) {
row.is_search_match = false;
foreach (ConversationMessage msg_view in row.view) {
msg_view.unmark_search_terms();
}
}
}
});
}
public void cancel() {
this.highlight_cancellable.cancel();
this.highlight_cancellable = new Cancellable();
this.terms = null;
this.matches_found = 0;
notify_matches_updated();
}
private void apply_terms(EmailRow row,
Gee.Set<string>? terms,
GLib.Cancellable cancellable) {
if (row.view.message_body_state == COMPLETED) {
this.apply_terms_impl.begin(
row, terms, cancellable, apply_terms_impl_finished
);
} else {
row.view.notify["message-body-state"].connect(() => {
this.apply_terms_impl.begin(
row, terms, cancellable, apply_terms_impl_finished
);
});
}
}
// This should only be called from apply_terms above
private async uint apply_terms_impl(EmailRow row,
Gee.Set<string>? terms,
GLib.Cancellable cancellable)
throws GLib.IOError.CANCELLED {
uint count = 0;
foreach (ConversationMessage view in row.view) {
if (cancellable.is_cancelled()) {
throw new GLib.IOError.CANCELLED(
"Applying search terms cancelled"
);
}
count += yield view.highlight_search_terms(terms, cancellable);
}
row.is_search_match = (count > 0);
return count;
}
private void apply_terms_impl_finished(GLib.Object? obj,
GLib.AsyncResult res) {
try {
this.matches_found += this.apply_terms_impl.end(res);
notify_matches_updated();
} catch (GLib.IOError.CANCELLED err) {
// All good
}
}
private inline void notify_matches_updated() {
matches_updated(this.matches_found);
}
}
// Base class for list rows in the list box
internal abstract class ConversationRow : Gtk.ListBoxRow, Geary.BaseInterface {
protected const string EXPANDED_CLASS = "geary-expanded";
// The email being displayed by this row, if any
public Geary.Email? email { get; private set; default = null; }
// Is the row showing the email's message body or just headers?
public bool is_expanded {
get {
return this._is_expanded;
}
protected set {
this._is_expanded = value;
}
}
private bool _is_expanded = false;
// We can only scroll to a specific row once it has been
// allocated space. This signal allows the viewer to hook up
// to appropriate times to try to do that scroll.
public signal void should_scroll();
// Emitted when an email is loaded for the first time
public signal void email_loaded(Geary.Email email);
protected ConversationRow(Geary.Email? email) {
base_ref();
this.email = email;
show();
}
~ConversationRow() {
base_unref();
}
// Request the row be expanded, if supported.
public virtual new async void expand()
throws GLib.Error {
// Not supported by default
}
// Request the row be collapsed, if supported.
public virtual void collapse() {
// Not supported by default
}
// Enables firing the should_scroll signal when this row is
// allocated a size
public void enable_should_scroll() {
this.size_allocate.connect(on_size_allocate);
}
protected inline void set_style_context_class(string class_name, bool value) {
if (value) {
get_style_context().add_class(class_name);
} else {
get_style_context().remove_class(class_name);
}
}
protected void on_size_allocate() {
// Disable should_scroll so we don't keep on scrolling
// later, like when the window has been resized.
this.size_allocate.disconnect(on_size_allocate);
should_scroll();
}
}
// Displays a single ConversationEmail in the list box
internal class EmailRow : ConversationRow {
private const string MATCH_CLASS = "geary-matched";
// Has the row been temporarily expanded to show search matches?
public bool is_pinned { get; private set; default = false; }
// Does the row contain an email matching the current search?
public bool is_search_match {
get { return get_style_context().has_class(MATCH_CLASS); }
set {
set_style_context_class(MATCH_CLASS, value);
this.is_pinned = value;
update_row_expansion();
}
}
// The email view for this row, if any
public ConversationEmail view { get; private set; }
public EmailRow(ConversationEmail view) {
base(view.email);
this.view = view;
add(view);
}
public override async void expand()
throws GLib.Error {
this.is_expanded = true;
update_row_expansion();
if (this.view.message_body_state == NOT_STARTED) {
yield this.view.load_body();
email_loaded(this.view.email);
}
}
public override void collapse() {
this.is_expanded = false;
this.is_pinned = false;
update_row_expansion();
}
private inline void update_row_expansion() {
if (this.is_expanded || this.is_pinned) {
get_style_context().add_class(EXPANDED_CLASS);
this.view.expand_email();
} else {
get_style_context().remove_class(EXPANDED_CLASS);
this.view.collapse_email();
}
}
}
// Displays a loading widget in the list box
internal class LoadingRow : ConversationRow {
protected const string LOADING_CLASS = "geary-loading";
public LoadingRow() {
base(null);
get_style_context().add_class(LOADING_CLASS);
Gtk.Spinner spinner = new Gtk.Spinner();
spinner.height_request = 16;
spinner.width_request = 16;
spinner.show();
spinner.start();
add(spinner);
}
}
// Displays a single embedded composer in the list box
internal class ComposerRow : ConversationRow {
// The embedded composer for this row
public Composer.Embed view { get; private set; }
public ComposerRow(Composer.Embed view) {
base(view.referred);
this.view = view;
this.is_expanded = true;
get_style_context().add_class(EXPANDED_CLASS);
add(this.view);
}
}
static construct {
// Set up custom keybindings
unowned Gtk.BindingSet bindings = Gtk.BindingSet.by_class(
(ObjectClass) typeof(ConversationListBox).class_ref()
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.space, 0, "focus-next", 0
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.KP_Space, 0, "focus-next", 0
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.space, Gdk.ModifierType.SHIFT_MASK, "focus-prev", 0
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.KP_Space, Gdk.ModifierType.SHIFT_MASK, "focus-prev", 0
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.Up, 0, "scroll", 1,
typeof(Gtk.ScrollType), Gtk.ScrollType.STEP_UP
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.Down, 0, "scroll", 1,
typeof(Gtk.ScrollType), Gtk.ScrollType.STEP_DOWN
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.Page_Up, 0, "scroll", 1,
typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_UP
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.Page_Down, 0, "scroll", 1,
typeof(Gtk.ScrollType), Gtk.ScrollType.PAGE_DOWN
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.Home, 0, "scroll", 1,
typeof(Gtk.ScrollType), Gtk.ScrollType.START
);
Gtk.BindingEntry.add_signal(
bindings, Gdk.Key.End, 0, "scroll", 1,
typeof(Gtk.ScrollType), Gtk.ScrollType.END
);
}
private static int on_sort(Gtk.ListBoxRow row1, Gtk.ListBoxRow row2) {
Geary.Email? email1 = ((ConversationRow) row1).email;
Geary.Email? email2 = ((ConversationRow) row2).email;
if (email1 == null) {
return 1;
}
if (email2 == null) {
return -1;
}
return Geary.Email.compare_sent_date_ascending(email1, email2);
}
/** Conversation being displayed. */
public Geary.App.Conversation conversation { get; private set; }
/** Search manager for highlighting search terms in this list. */
public SearchManager search { get; private set; }
/** Specifies if this list box currently has an embedded composer. */
public bool has_composer {
get { return this.current_composer != null; }
}
// Used to load messages in conversation.
private Geary.App.EmailStore email_store;
// Store from which to lookup contacts
private Application.ContactStore contacts;
// App config
private Application.Configuration config;
// Cancellable for this conversation's data loading.
private Cancellable cancellable = new Cancellable();
// Email view with selected text, if any
private ConversationEmail? body_selected_view = null;
// Maps displayed emails to their corresponding rows.
private Gee.Map<Geary.EmailIdentifier,EmailRow> email_rows =
new Gee.HashMap<Geary.EmailIdentifier,EmailRow>();
// The current composer, if any
private ComposerRow? current_composer = null;
// The id of the draft referred to by the current composer.
private Geary.EmailIdentifier? draft_id = null;
private bool suppress_mark_timer;
private Geary.TimeoutManager mark_read_timer;
private GLib.SimpleActionGroup email_actions = new GLib.SimpleActionGroup();
/** Keyboard action to scroll the conversation. */
[Signal (action=true)]
public virtual signal void scroll(Gtk.ScrollType type) {
// If there is an embedded composer, check to see if one of
// its non-web view widgets is focused and give the key press
// to that instead. If not, then standard nav
var handled = false;
var composer = this.current_composer;
if (composer != null) {
var window = get_toplevel() as Gtk.Window;
if (window != null) {
var focused = window.get_focus();
if (focused != null &&
focused.is_ancestor(composer) &&
!(focused is Composer.WebView)) {
switch (type) {
case Gtk.ScrollType.STEP_UP:
composer.focus(UP);
handled = true;
break;
case Gtk.ScrollType.STEP_DOWN:
composer.focus(DOWN);
handled = true;
break;
default:
// no-op
break;
}
}
}
}
if (!handled) {
Gtk.Adjustment adj = get_adjustment();
double value = adj.get_value();
switch (type) {
case Gtk.ScrollType.STEP_UP:
value -= adj.get_step_increment();
break;
case Gtk.ScrollType.STEP_DOWN:
value += adj.get_step_increment();
break;
case Gtk.ScrollType.PAGE_UP:
value -= adj.get_page_increment();
break;
case Gtk.ScrollType.PAGE_DOWN:
value += adj.get_page_increment();
break;
case Gtk.ScrollType.START:
value = 0.0;
break;
case Gtk.ScrollType.END:
value = adj.get_upper();
break;
default:
// no-op
break;
}
adj.set_value(value);
this.mark_read_timer.start();
}
}
/** Keyboard action to shift focus to the next message, if any. */
[Signal (action=true)]
public virtual signal void focus_next() {
this.move_cursor(Gtk.MovementStep.DISPLAY_LINES, 1);
this.mark_read_timer.start();
}
/** Keyboard action to shift focus to the prev message, if any. */
[Signal (action=true)]
public virtual signal void focus_prev() {
this.move_cursor(Gtk.MovementStep.DISPLAY_LINES, -1);
this.mark_read_timer.start();
}
/** Fired when an email is fully loaded in the list box. */
public signal void email_loaded(Geary.Email email);
/** Fired when the user clicks "reply" in the message menu. */
public signal void reply_to_sender_email(Geary.Email email, string? quote);
/** Fired when the user clicks "reply all" in the message menu. */
public signal void reply_to_all_email(Geary.Email email, string? quote);
/** Fired when the user clicks "forward" in the message menu. */
public signal void forward_email(Geary.Email email, string? quote);
/** Emitted when email message flags are to be updated. */
public signal void mark_email(Gee.Collection<Geary.EmailIdentifier> email,
Geary.NamedFlag? to_add,
Geary.NamedFlag? to_remove);
/** Fired when the user clicks "trash" in the message menu. */
public signal void trash_email(Geary.Email email);
/** Fired when the user clicks "delete" in the message menu. */
public signal void delete_email(Geary.Email email);
/**
* Constructs a new conversation list box instance.
*/
public ConversationListBox(Geary.App.Conversation conversation,
bool suppress_mark_timer,
Geary.App.EmailStore email_store,
Application.ContactStore contacts,
Application.Configuration config,
Gtk.Adjustment adjustment) {
base_ref();
this.conversation = conversation;
this.email_store = email_store;
this.contacts = contacts;
this.config = config;
this.search = new SearchManager(this, conversation);
this.suppress_mark_timer = suppress_mark_timer;
this.mark_read_timer = new Geary.TimeoutManager.milliseconds(
MARK_READ_TIMEOUT_MSEC, this.check_mark_read
);
this.selection_mode = NONE;
get_style_context().add_class("background");
get_style_context().add_class("conversation-listbox");
set_adjustment(adjustment);
set_sort_func(ConversationListBox.on_sort);
this.email_actions.add_action_entries(email_action_entries, this);
insert_action_group(EMAIL_ACTION_GROUP_NAME, this.email_actions);
this.row_activated.connect(on_row_activated);
this.conversation.appended.connect(on_conversation_appended);
this.conversation.trimmed.connect(on_conversation_trimmed);
this.conversation.email_flags_changed.connect(on_update_flags);
}
~ConversationListBox() {
base_unref();
}
public override void destroy() {
this.search.cancel();
this.cancellable.cancel();
this.email_rows.clear();
this.mark_read_timer.reset();
base.destroy();
}
public async void load_conversation(Gee.Collection<Geary.EmailIdentifier> scroll_to,
Geary.SearchQuery? query)
throws GLib.Error {
set_sort_func(null);
Gee.Collection<Geary.Email>? all_email = this.conversation.get_emails(
Geary.App.Conversation.Ordering.SENT_DATE_ASCENDING
);
// Work out what the first interesting email is, and load it
// before all of the email before and after that so we can
// load them in an optimal order.
Gee.LinkedList<Geary.Email> uninteresting =
new Gee.LinkedList<Geary.Email>();
Geary.Email? first_interesting = null;
Gee.LinkedList<Geary.Email> post_interesting =
new Gee.LinkedList<Geary.Email>();
if (!scroll_to.is_empty) {
var valid_scroll_to = Geary.traverse(scroll_to).filter(
id => this.conversation.contains_email_by_id(id)
).to_array_list();
valid_scroll_to.sort((a, b) => a.natural_sort_comparator(b));
var first_scroll = Geary.Collection.first(valid_scroll_to);
if (first_scroll != null) {
foreach (Geary.Email email in all_email) {
if (first_interesting == null) {
if (email.id == first_scroll) {
first_interesting = email;
} else {
// Inserted reversed so most recent uninteresting
// rows are added first.
uninteresting.insert(0, email);
}
} else {
post_interesting.add(email);
}
}
}
}
if (first_interesting == null) {
foreach (Geary.Email email in all_email) {
if (first_interesting == null) {
if (is_interesting(email)) {
first_interesting = email;
} else {
// Inserted reversed so most recent uninteresting
// rows are added first.
uninteresting.insert(0, email);
}
} else {
post_interesting.add(email);
}
}
}
if (first_interesting == null) {
// No interesting messages found so use the last one.
first_interesting = uninteresting.remove_at(0);
}
EmailRow interesting_row = add_email(first_interesting);
// If we have at least one uninteresting and one
// post-interesting to load afterwards, show a spinner above
// the interesting row to act as a placeholder.
if (!uninteresting.is_empty && !post_interesting.is_empty) {
insert(new LoadingRow(), 0);
}
// Load the interesting row completely up front, and load the
// remaining in the background so we can return fast.
yield interesting_row.view.load_contacts();
yield interesting_row.expand();
this.finish_loading.begin(
query, scroll_to.is_empty, uninteresting, post_interesting
);
}
/** Cancels loading the current conversation, if still in progress */
public void cancel_conversation_load() {
this.cancellable.cancel();
}
/** Scrolls to the closest message in the current conversation. */
public void scroll_to_messages(Gee.Collection<Geary.EmailIdentifier> targets) {
// Get the currently displayed email, allowing for some
// padding at the top
Gtk.ListBoxRow? current_child = get_row_at_y(32);
// Find the row currently at the top of the viewport
EmailRow? current = null;
if (current_child != null) {
int pos = current_child.get_index();
do {
current = current_child as EmailRow;
current_child = get_row_at_index(--pos);
} while (current == null && pos > 0);
}
EmailRow? best = null;
// Find the message closest to the current message, preferring
// an earlier one. If there's no current message, the list is
// empty and we don't have anything to scroll to anyway.
if (current != null) {
uint closest_distance = uint.MAX;
foreach (var id in targets) {
EmailRow? target = this.email_rows[id];
if (target != null) {
uint distance = (
current.get_index() - target.get_index()
).abs();
if (distance < closest_distance ||
(distance == closest_distance &&
Geary.Email.compare_sent_date_ascending(
target.email, best.email
) < 0)) {
closest_distance = distance;
best = target;
}
}
}
}
if (best != null) {
scroll_to_row(best);
best.expand.begin();
}
}
/**
* Returns the email view to be replied to, if any.
*
* If an email view has a visible body and selected text, that
* view will be returned. Else the last message by sort order will
* be returned, if any.
*/
public ConversationEmail? get_reply_target() {
ConversationEmail? view = get_selection_view();
if (view == null) {
EmailRow? last = null;
this.foreach((child) => {
EmailRow? row = child as EmailRow;
if (row != null) {
last = row;
}
});
if (last != null) {
view = last.view;
}
}
return view;
}
/**
* Returns the email view with a visible user selection, if any.
*
* If an email view has selected body text.
*/
public ConversationEmail? get_selection_view() {
ConversationEmail? view = this.body_selected_view;
if (view != null) {
if (view.is_collapsed) {
// A collapsed email can't be visible
view = null;
} else {
// XXX check the selected text is actually on screen
}
}
return view;
}
/**
* Adds an an embedded composer to the view.
*/
public void add_embedded_composer(Composer.Embed embed, bool is_draft) {
if (is_draft) {
this.draft_id = embed.referred.id;
EmailRow? draft = this.email_rows.get(embed.referred.id);
if (draft != null) {
remove_email(draft.email);
}
}
ComposerRow row = new ComposerRow(embed);
row.enable_should_scroll();
// Use row param rather than row var from closure to avoid a
// circular ref.
row.should_scroll.connect((row) => { scroll_to_row(row); });
add(row);
this.current_composer = row;
embed.composer.notify["saved-id"].connect(
(id) => { this.draft_id = embed.composer.saved_id; }
);
embed.vanished.connect(() => {
this.current_composer = null;
this.draft_id = null;
remove(row);
if (is_draft &&
row.email != null &&
!this.cancellable.is_cancelled()) {
load_full_email.begin(row.email.id);
}
});
}
/**
* Marks all email with a visible body read.
*/
public void mark_visible_read() {
this.mark_read_timer.start();
}
/**
* Displays an email as being read, regardless of its actual flags.
*/
public void mark_manual_read(Geary.EmailIdentifier id) {
EmailRow? row = this.email_rows.get(id);
if (row != null) {
row.view.is_manually_read = true;
}
}
/**
* Displays an email as being unread, regardless of its actual flags.
*/
public void mark_manual_unread(Geary.EmailIdentifier id) {
EmailRow? row = this.email_rows.get(id);
if (row != null) {
row.view.is_manually_read = false;
}
}
/** Adds an info bar to the given email, if any. */
public void add_email_info_bar(Geary.EmailIdentifier id,
Gtk.InfoBar info_bar) {
var row = this.email_rows.get(id);
if (row != null) {
row.view.primary_message.info_bars.add(info_bar);
}
}
/** Adds an info bar to the given email, if any. */
public void remove_email_info_bar(Geary.EmailIdentifier id,
Gtk.InfoBar info_bar) {
var row = this.email_rows.get(id);
if (row != null) {
row.view.primary_message.info_bars.remove(info_bar);
}
}
/**
* Increases the magnification level used for displaying messages.
*/
public void zoom_in() {
message_view_iterator().foreach((msg_view) => {
msg_view.zoom_in();
return true;
});
}
/**
* Decreases the magnification level used for displaying messages.
*/
public void zoom_out() {
message_view_iterator().foreach((msg_view) => {
msg_view.zoom_out();
return true;
});
}
/**
* Resets magnification level used for displaying messages to the default.
*/
public void zoom_reset() {
message_view_iterator().foreach((msg_view) => {
msg_view.zoom_reset();
return true;
});
}
/**
* Updates the displayed date for each conversation row.
*/
public void update_display() {
message_view_iterator().foreach((msg_view) => {
msg_view.update_display();
return true;
});
}
/** Returns the email row for the given id, if any. */
internal EmailRow? get_email_row_by_id(Geary.EmailIdentifier id) {
return this.email_rows.get(id);
}
private async void finish_loading(Geary.SearchQuery? query,
bool enable_query_scroll,
Gee.LinkedList<Geary.Email> to_insert,
Gee.LinkedList<Geary.Email> to_append)
throws GLib.Error {
// Add emails to append first because if the first interesting
// message was short, these will show up in the UI under it,
// filling the empty space.
foreach (Geary.Email email in to_append) {
EmailRow row = add_email(email);
yield row.view.load_contacts();
if (is_interesting(email)) {
yield row.expand();
}
yield throttle_loading();
}
// Since first rows may have extra margin, remove that from
// the height of rows when adjusting scrolling.
Gtk.ListBoxRow initial_row = get_row_at_index(0);
int loading_height = 0;
if (initial_row is LoadingRow) {
loading_height = Util.Gtk.get_border_box_height(initial_row);
remove(initial_row);
}
// None of these will be interesting, so just add them all,
// but keep the scrollbar adjusted so that the first
// interesting message remains visible.
Gtk.Adjustment listbox_adj = get_adjustment();
int i_mail_loaded = 0;
foreach (Geary.Email email in to_insert) {
EmailRow row = add_email(email, false);
// Since uninteresting rows are inserted above the
// first expanded, adjust the scrollbar as they are
// inserted so as to keep the list scrolled to the
// same place.
row.enable_should_scroll();
row.should_scroll.connect(() => {
listbox_adj.value += Util.Gtk.get_border_box_height(row);
});
// Only adjust for the loading row going away once
loading_height = 0;
yield row.view.load_contacts();
if (i_mail_loaded % 10 == 0)
yield throttle_loading();
++i_mail_loaded;
}
set_sort_func(on_sort);
if (query != null) {
// XXX this sucks for large conversations because it can take
// a long time for the load to complete and hence for
// matches to show up.
yield this.search.highlight_matching_email(
query, enable_query_scroll
);
}
}
private inline async void throttle_loading() throws GLib.IOError {
// Give GTK a moment to process newly added rows, so when
// updating the adjustment below the values are
// valid. Priority must be low otherwise other async tasks
// (like cancelling loading if another conversation is
// selected) won't get a look in until this is done.
GLib.Idle.add(
this.throttle_loading.callback, GLib.Priority.LOW
);
yield;
// Check for cancellation after resuming in case the load was
// cancelled in the mean time.
if (this.cancellable.is_cancelled()) {
throw new GLib.IOError.CANCELLED(
"Conversation load cancelled"
);
}
}
// Loads full version of an email, adds it to the listbox
private async void load_full_email(Geary.EmailIdentifier id)
throws GLib.Error {
// Even though it would save a around-trip, don't load the
// full email here so that ConverationEmail can handle it if
// the full email isn't actually available in the same way as
// any other.
Geary.Email full_email = yield this.email_store.fetch_email_async(
id,
REQUIRED_FIELDS | ConversationEmail.REQUIRED_FOR_CONSTRUCT,
Geary.Folder.ListFlags.NONE,
this.cancellable
);
if (!this.cancellable.is_cancelled()) {
EmailRow row = add_email(full_email);
yield row.view.load_contacts();
if (is_interesting(full_email)) {
yield row.expand();
}
this.search.highlight_row_if_matching(row);
}
}
// Constructs a row and view for an email, adds it to the listbox
private EmailRow add_email(Geary.Email email, bool append_row = true) {
bool is_sent = false;
Geary.Account account = this.conversation.base_folder.account;
if (email.from != null) {
foreach (Geary.RFC822.MailboxAddress from in email.from) {
if (account.information.has_sender_mailbox(from)) {
is_sent = true;
break;
}
}
}
ConversationEmail view = new ConversationEmail(
conversation,
email,
this.email_store,
this.contacts,
this.config,
is_sent,
is_draft(email),
this.cancellable
);
view.internal_link_activated.connect(on_internal_link_activated);
view.body_selection_changed.connect((email, has_selection) => {
this.body_selected_view = has_selection ? email : null;
});
view.notify["message-body-state"].connect(
on_message_body_state_notify
);
ConversationMessage conversation_message = view.primary_message;
conversation_message.body_container.button_release_event.connect_after((event) => {
// Consume all non-consumed clicks so the row is not
// inadvertently activated after clicking on the
// email body.
return true;
});
EmailRow row = new EmailRow(view);
row.email_loaded.connect((e) => { email_loaded(e); });
this.email_rows.set(email.id, row);
if (append_row) {
add(row);
} else {
insert(row, 0);
}
return row;
}
// Removes the email's row from the listbox, if any
private void remove_email(Geary.Email email) {
EmailRow? row = null;
if (this.email_rows.unset(email.id, out row)) {
remove(row);
}
}
private void scroll_to_row(ConversationRow row) {
Gtk.Allocation? alloc = null;
row.get_allocation(out alloc);
int y = 0;
if (alloc.y > EMAIL_TOP_OFFSET) {
y = alloc.y - EMAIL_TOP_OFFSET;
}
// Use set_value rather than clamp_value since we want to
// scroll to the top of the window.
get_adjustment().set_value(y);
}
private void scroll_to_anchor(EmailRow row, int anchor_y) {
Gtk.Allocation? alloc = null;
row.get_allocation(out alloc);
int x = 0, y = 0;
row.view.primary_message.web_view_translate_coordinates(row, x, anchor_y, out x, out y);
Gtk.Adjustment adj = get_adjustment();
y = alloc.y + y;
adj.set_value(y);
}
/**
* Finds any currently visible messages, marks them as being read.
*/
private void check_mark_read() {
Gee.List<Geary.EmailIdentifier> email_ids =
new Gee.LinkedList<Geary.EmailIdentifier>();
Gtk.Adjustment adj = get_adjustment();
int top_bound = (int) adj.value;
int bottom_bound = top_bound + (int) adj.page_size;
this.foreach((child) => {
// Don't bother with not-yet-loaded emails since the
// size of the body will be off, affecting the visibility
// of emails further down the conversation.
EmailRow? row = child as EmailRow;
ConversationEmail? view = (row != null) ? row.view : null;
Geary.Email? email = (view != null) ? view.email : null;
if (row != null &&
row.is_expanded &&
view.message_body_state == COMPLETED &&
!view.is_manually_read &&
email.is_unread().is_certain()) {
ConversationMessage conversation_message = view.primary_message;
int body_top = 0;
int body_left = 0;
conversation_message.web_view_translate_coordinates(
this,
0, 0,
out body_left, out body_top
);
int body_height = conversation_message.web_view_get_allocated_height();
int body_bottom = body_top + body_height;
// Only mark the email as read if it's actually visible
if (body_height > 0 &&
body_bottom > top_bound &&
body_top + MARK_READ_PADDING < bottom_bound) {
email_ids.add(view.email.id);
// Since it can take some time for the new flags
// to round-trip back to our signal handlers,
// mark as manually read here
view.is_manually_read = true;
}
}
});
if (email_ids.size > 0) {
mark_email(email_ids, null, Geary.EmailFlags.UNREAD);
}
}
/**
* Returns an new Iterable over all email views in the viewer
*/
private Gee.Iterator<ConversationEmail> email_view_iterator() {
return this.email_rows.values.map<ConversationEmail>((row) => {
return ((EmailRow) row).view;
});
}
/**
* Returns a new Iterable over all message views in the viewer
*/
private Gee.Iterator<ConversationMessage> message_view_iterator() {
return Gee.Iterator.concat<ConversationMessage>(
email_view_iterator().map<Gee.Iterator<ConversationMessage>>(
(email_view) => { return email_view.iterator(); }
)
);
}
/** Determines if an email should be expanded by default. */
private inline bool is_interesting(Geary.Email email) {
return (
email.is_unread().is_certain() ||
email.is_flagged().is_certain() ||
is_draft(email)
);
}
/** Determines if an email should be considered to be a draft. */
private inline bool is_draft(Geary.Email email) {
// XXX should be able to edit draft emails from any
// conversation. This test should be more like "is in drafts
// folder"
Geary.Folder.SpecialUse use = this.conversation.base_folder.used_as;
bool is_in_folder = this.conversation.is_in_base_folder(email.id);
return (
is_in_folder && use == DRAFTS // ||
//email.flags.is_draft()
);
}
private ConversationEmail action_target_to_view(GLib.Variant target) {
Geary.EmailIdentifier? id = null;
try {
id = this.conversation.base_folder.account.to_email_identifier(target);
} catch (Geary.EngineError err) {
debug("Failed to get email id for action target: %s", err.message);
}
EmailRow? row = (id != null) ? this.email_rows[id] : null;
return (row != null) ? row.view : null;
}
private void on_conversation_appended(Geary.App.Conversation conversation,
Geary.Email email) {
on_conversation_appended_async.begin(conversation, email);
}
private async void on_conversation_appended_async(
Geary.App.Conversation conversation, Geary.Email part_email) {
// Don't add rows that are already present, or that are
// currently being edited.
if (!this.email_rows.has_key(part_email.id) &&
part_email.id != this.draft_id) {
load_full_email.begin(part_email.id, (obj, ret) => {
try {
load_full_email.end(ret);
} catch (Error err) {
debug(
"Unable to append email to conversation: %s",
err.message
);
}
});
}
}
private void on_conversation_trimmed(Geary.Email email) {
remove_email(email);
}
private void on_update_flags(Geary.Email email) {
if (!this.email_rows.has_key(email.id)) {
return;
}
EmailRow row = this.email_rows.get(email.id);
row.view.update_flags(email);
}
private void on_message_body_state_notify(GLib.Object obj,
GLib.ParamSpec param) {
ConversationEmail? view = obj as ConversationEmail;
if (view != null && view.message_body_state == COMPLETED) {
if (!this.suppress_mark_timer) {
this.mark_read_timer.start();
}
this.suppress_mark_timer = false;
}
}
private void on_row_activated(Gtk.ListBoxRow widget) {
EmailRow? row = widget as EmailRow;
if (row != null) {
// Allow non-last rows to be expanded/collapsed, but also let
// the last row to be expanded since appended sent emails will
// be appended last. Finally, don't let rows with active
// composers be collapsed.
if (row.is_expanded) {
if (get_row_at_index(row.get_index() + 1) != null) {
row.collapse();
}
} else {
row.expand.begin();
}
}
}
private void on_internal_link_activated(ConversationEmail email, int y) {
EmailRow row = get_email_row_by_id(email.email.id);
scroll_to_anchor(row, y);
}
// Email action callbacks
private void on_email_reply_sender(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
view.get_selection_for_quoting.begin((obj, res) => {
string? quote = view.get_selection_for_quoting.end(res);
reply_to_sender_email(view.email, quote);
});
}
}
private void on_email_reply_all(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
view.get_selection_for_quoting.begin((obj, res) => {
string? quote = view.get_selection_for_quoting.end(res);
reply_to_all_email(view.email, quote);
});
}
}
private void on_email_forward(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
view.get_selection_for_quoting.begin((obj, res) => {
string? quote = view.get_selection_for_quoting.end(res);
forward_email(view.email, quote);
});
}
}
private void on_email_mark_read(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
mark_email(
Geary.Collection.single(view.email.id),
null,
Geary.EmailFlags.UNREAD
);
}
}
private void on_email_mark_unread(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
mark_email(
Geary.Collection.single(view.email.id),
Geary.EmailFlags.UNREAD,
null
);
}
}
private void on_email_mark_unread_down(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
Geary.Email email = view.email;
var ids = new Gee.LinkedList<Geary.EmailIdentifier>();
ids.add(email.id);
this.foreach((row) => {
if (row.get_visible()) {
Geary.Email other = ((EmailRow) row).view.email;
if (Geary.Email.compare_sent_date_ascending(
email, other) < 0) {
ids.add(other.id);
}
}
});
mark_email(ids, Geary.EmailFlags.UNREAD, null);
}
}
private void on_email_mark_starred(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
mark_email(
Geary.Collection.single(view.email.id),
Geary.EmailFlags.FLAGGED,
null
);
}
}
private void on_email_mark_unstarred(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
mark_email(
Geary.Collection.single(view.email.id),
null,
Geary.EmailFlags.FLAGGED
);
}
}
private void on_email_load_remote(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
mark_email(
Geary.Collection.single(view.email.id),
Geary.EmailFlags.LOAD_REMOTE_IMAGES,
null
);
}
}
private void on_email_trash(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
trash_email(view.email);
}
}
private void on_email_delete(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
delete_email(view.email);
}
}
private void on_email_save_all_attachments(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null && view.attachments_pane != null) {
view.attachments_pane.save_all();
}
}
private void on_email_print(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
view.print.begin();
}
}
private void on_email_view_source(GLib.SimpleAction action,
GLib.Variant? param) {
ConversationEmail? view = action_target_to_view(param);
if (view != null) {
view.view_source.begin();
}
}
}