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

1560 lines
56 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 an {@link Geary.RFC822.Message}.
*
* This view corresponds to {@link Geary.RFC822.Message}, displaying
* both the message's headers and body. Any attachments and sub
* messages are handled by {@link ConversationEmail}, which typically
* embeds at least one instance of this class.
*/
[GtkTemplate (ui = "/org/gnome/Geary/conversation-message.ui")]
public class ConversationMessage : Gtk.Grid, Geary.BaseInterface {
private const string FROM_CLASS = "geary-from";
private const string MATCH_CLASS = "geary-match";
private const string SPOOF_CLASS = "geary-spoofed";
private const string INTERNAL_ANCHOR_PREFIX = "geary:body#";
private const string REPLACED_CID_TEMPLATE = "replaced_%02u@geary";
private const string REPLACED_IMAGE_CLASS = "geary_replaced_inline_image";
private const string MAILTO_URI_PREFIX = "mailto:";
private const int MAX_PREVIEW_BYTES = Geary.Email.MAX_PREVIEW_BYTES;
private const int MAX_INLINE_IMAGE_MAJOR_DIM = 1024;
private const string ACTION_CONVERSATION_NEW = "conversation-new";
private const string ACTION_COPY_EMAIL = "copy-email";
private const string ACTION_COPY_LINK = "copy-link";
private const string ACTION_COPY_SELECTION = "copy-selection";
private const string ACTION_OPEN_INSPECTOR = "open-inspector";
private const string ACTION_OPEN_LINK = "open-link";
private const string ACTION_SAVE_IMAGE = "save-image";
private const string ACTION_SELECT_ALL = "select-all";
// Widget used to display sender/recipient email addresses in
// message header Gtk.FlowBox instances.
private class ContactFlowBoxChild : Gtk.FlowBoxChild {
private const string PRIMARY_CLASS = "geary-primary";
public enum Type { FROM, OTHER; }
public Type address_type { get; private set; }
public Application.Contact contact { get; private set; }
public Geary.RFC822.MailboxAddress displayed { get; private set; }
public Geary.RFC822.MailboxAddress source { get; private set; }
private string search_value;
private Gtk.Bin container;
public ContactFlowBoxChild(Application.Contact contact,
Geary.RFC822.MailboxAddress source,
Type address_type = Type.OTHER) {
this.contact = contact;
this.source = source;
this.address_type = address_type;
this.search_value = source.to_searchable_string().casefold();
// Update prelight state when mouse-overed.
Gtk.EventBox events = new Gtk.EventBox();
events.add_events(
Gdk.EventMask.ENTER_NOTIFY_MASK |
Gdk.EventMask.LEAVE_NOTIFY_MASK
);
events.set_visible_window(false);
events.enter_notify_event.connect(on_prelight_in_event);
events.leave_notify_event.connect(on_prelight_out_event);
add(events);
this.container = events;
set_halign(Gtk.Align.START);
this.contact.changed.connect(on_contact_changed);
update();
}
public override void destroy() {
this.contact.changed.disconnect(on_contact_changed);
base.destroy();
}
public bool highlight_search_term(string term) {
bool found = term in this.search_value;
if (found) {
get_style_context().add_class(MATCH_CLASS);
} else {
get_style_context().remove_class(MATCH_CLASS);
}
return found;
}
public void unmark_search_terms() {
get_style_context().remove_class(MATCH_CLASS);
}
private void update() {
// We use two GTK.Label instances here when address has
// distinct parts so we can dim the secondary part, if
// any. Ideally, it would be just one label instance in
// both cases, but we can't yet include CSS classes in
// Pango markup. See Bug 766763.
Gtk.Grid address_parts = new Gtk.Grid();
bool is_spoofed = this.source.is_spoofed();
if (is_spoofed) {
Gtk.Image spoof_img = new Gtk.Image.from_icon_name(
"dialog-warning-symbolic", Gtk.IconSize.SMALL_TOOLBAR
);
this.set_tooltip_text(
_("This email address may have been forged")
);
address_parts.add(spoof_img);
get_style_context().add_class(SPOOF_CLASS);
}
Gtk.Label primary = new Gtk.Label(null);
primary.ellipsize = Pango.EllipsizeMode.END;
primary.set_halign(Gtk.Align.START);
primary.get_style_context().add_class(PRIMARY_CLASS);
if (this.address_type == Type.FROM) {
primary.get_style_context().add_class(FROM_CLASS);
}
address_parts.add(primary);
string display_address = this.source.to_address_display("", "");
if (is_spoofed || this.contact.display_name_is_email) {
// Don't display the name to avoid duplication and/or
// reduce the chance of the user of being tricked by
// malware.
primary.set_text(display_address);
// Use the source as the displayed address so that the
// contact popover uses the spoofed mailbox and
// displays it as being spoofed.
this.displayed = this.source;
} else if (this.contact.is_trusted) {
// The contact's name can be trusted, so no need to
// display the email address
primary.set_text(this.contact.display_name);
this.displayed = new Geary.RFC822.MailboxAddress(
this.contact.display_name, this.source.address
);
this.tooltip_text = this.source.address;
} else {
// Display both the display name and the email address
// so that the user has the full information at hand
primary.set_text(this.contact.display_name);
this.displayed = new Geary.RFC822.MailboxAddress(
this.contact.display_name, this.source.address
);
Gtk.Label secondary = new Gtk.Label(null);
secondary.ellipsize = Pango.EllipsizeMode.END;
secondary.set_halign(Gtk.Align.START);
secondary.get_style_context().add_class(Gtk.STYLE_CLASS_DIM_LABEL);
secondary.set_text(display_address);
address_parts.add(secondary);
}
Gtk.Widget? existing_ui = this.container.get_child();
if (existing_ui != null) {
this.container.remove(existing_ui);
}
this.container.add(address_parts);
show_all();
}
private void on_contact_changed() {
update();
}
private bool on_prelight_in_event(Gdk.Event event) {
set_state_flags(Gtk.StateFlags.PRELIGHT, false);
return Gdk.EVENT_STOP;
}
private bool on_prelight_out_event(Gdk.Event event) {
unset_state_flags(Gtk.StateFlags.PRELIGHT);
return Gdk.EVENT_STOP;
}
}
/**
* A FlowBox that limits its contents to 12 items until a link is
* clicked to expand it. Used for to, cc, and bcc fields.
*/
public class ContactList : Gtk.FlowBox, Geary.BaseInterface {
/**
* The number of results that will be displayed when not expanded.
* Note this is actually one less than the cutoff, which is 12; we
* don't want the show more label to be visible when we could just
* put the last item.
*/
private const int SHORT_RESULTS = 11;
private Gtk.Label show_more;
private Gtk.Label show_less;
private bool expanded = false;
private int children = 0;
construct {
this.show_more = this.create_label();
this.show_more.activate_link.connect(() => {
this.set_expanded(true);
});
base.add(this.show_more);
this.show_less = this.create_label();
// Translators: Label text displayed when there are too
// many email addresses to be shown by default in an
// email's header, but they are all being shown anyway.
this.show_less.label = "<a href=''>%s</a>".printf(_("Show less"));
this.show_less.activate_link.connect(() => {
this.set_expanded(false);
});
base.add(this.show_less);
this.set_filter_func(this.filter_func);
}
public override void add(Gtk.Widget child) {
// insert before the show_more and show_less labels
int length = (int) this.get_children().length();
base.insert(child, length - 2);
this.children ++;
if (this.children >= SHORT_RESULTS && this.children <= SHORT_RESULTS + 2) {
this.invalidate_filter();
}
this.show_more.label = "<a href=''>%s</a>".printf(
// Translators: Label text displayed when there are
// too many email addresses to be shown by default in
// an email's header. The string substitution is the
// number of extra email to be shown.
_("%d more…").printf(this.children - SHORT_RESULTS)
);
}
private Gtk.Label create_label() {
var label = new Gtk.Label("");
label.visible = true;
label.use_markup = true;
label.track_visited_links = false;
label.halign = START;
return label;
}
private void set_expanded(bool expanded) {
this.expanded = expanded;
this.invalidate_filter();
}
private bool filter_func(Gtk.FlowBoxChild child) {
bool is_expandable = this.children > SHORT_RESULTS + 1;
if (child.get_child() == this.show_more) {
return !this.expanded && is_expandable;
} else if (child.get_child() == this.show_less) {
return this.expanded;
} else if (!this.expanded && is_expandable) {
return child.get_index() < SHORT_RESULTS;
} else {
return true;
}
}
}
/** Contact for the primary originator, if any. */
internal Application.Contact? primary_contact {
get; private set;
}
/** Mailbox assumed to be the primary sender. */
internal Geary.RFC822.MailboxAddress? primary_originator {
get; private set;
}
/** Box containing the preview and full header widgets. */
[GtkChild]
internal Gtk.Grid summary;
/** Box that InfoBar widgets should be added to. */
[GtkChild]
internal Components.InfoBarStack info_bars;
/**
* Emitted when web_view's content has finished loaded.
*
* See {@link Components.WebView.is_content_loaded} for details.
*/
internal bool is_content_loaded {
get {
return this.web_view != null && this.web_view.is_content_loaded;
}
}
/** HTML view that displays the message body. */
private ConversationWebView? web_view { get; private set; }
// The message headers represented by this view
private Geary.EmailHeaderSet headers;
private Application.Configuration config;
// Store from which to lookup contacts
private Application.ContactStore contacts;
private GLib.DateTime? local_date = null;
[GtkChild]
private Gtk.Image avatar;
[GtkChild]
private Gtk.Revealer compact_revealer;
[GtkChild]
private Gtk.Label compact_from;
[GtkChild]
private Gtk.Label compact_date;
[GtkChild]
private Gtk.Label compact_body;
[GtkChild]
private Gtk.Revealer header_revealer;
[GtkChild]
private Gtk.FlowBox from;
[GtkChild]
private Gtk.Label subject;
private string subject_searchable = "";
[GtkChild]
private Gtk.Label date;
[GtkChild]
private Gtk.Grid sender_header;
[GtkChild]
private Gtk.FlowBox sender_address;
[GtkChild]
private Gtk.Grid reply_to_header;
[GtkChild]
private Gtk.FlowBox reply_to_addresses;
[GtkChild]
private Gtk.Grid to_header;
[GtkChild]
private Gtk.Grid cc_header;
[GtkChild]
private Gtk.Grid bcc_header;
[GtkChild]
private Gtk.Revealer body_revealer;
[GtkChild]
public Gtk.Grid body_container;
[GtkChild]
private Gtk.ProgressBar body_progress;
private Components.InfoBar? remote_images_info_bar = null;
private Gtk.Widget? body_placeholder = null;
private string empty_from_label;
// The web_view's context menu
private Gtk.Menu? context_menu = null;
// Menu models for creating the context menu
private MenuModel context_menu_link;
private MenuModel context_menu_email;
private MenuModel context_menu_image;
private MenuModel context_menu_main;
private MenuModel? context_menu_inspector = null;
// Address fields that can be search through
private Gee.List<ContactFlowBoxChild> searchable_addresses =
new Gee.LinkedList<ContactFlowBoxChild>();
// Resource that have been loaded by the web view
private Gee.Map<string,WebKit.WebResource> resources =
new Gee.HashMap<string,WebKit.WebResource>();
// Message-specific actions
private SimpleActionGroup message_actions = new SimpleActionGroup();
private int next_replaced_buffer_number = 0;
// Is the view set to allow remote image loads?
private bool load_remote_resources;
private int remote_resources_requested = 0;
private int remote_resources_loaded = 0;
// Timeouts for showing the progress bar and hiding it when
// complete. The former is so that when loading cached images it
// doesn't pop up and then go away immediately afterwards.
private Geary.TimeoutManager show_progress_timeout = null;
private Geary.TimeoutManager hide_progress_timeout = null;
// Timer for pulsing progress bar
private Geary.TimeoutManager progress_pulse;
/** Fired when the user clicks a internal link in the email. */
public signal void internal_link_activated(int y);
/** Fired when the email should be flagged for remote image loading. */
public signal void flag_remote_images();
/** Fired when the user saves an inline displayed image. */
public signal void save_image(
string uri, string? alt_text, Geary.Memory.Buffer? buffer
);
/** Emitted when web_view has loaded a resource added to it. */
public signal void internal_resource_loaded(string name);
/** Emitted when web_view's selection has changed. */
public signal void selection_changed(bool has_selection);
/**
* Emitted when web_view's content has finished loaded.
*
* See {@link Components.WebView.is_content_loaded} for details.
*/
public signal void content_loaded();
/**
* Constructs a new view from an email's headers and body.
*
* This method sets up most of the user interface for displaying
* the message, but does not attempt any possibly long-running
* loading processes.
*/
public ConversationMessage.from_email(Geary.Email email,
bool load_remote_resources,
Application.ContactStore contacts,
Application.Configuration config) {
this(
email,
email.preview != null ? email.preview.buffer.get_valid_utf8() : null,
load_remote_resources,
contacts,
config
);
}
/**
* Constructs a new view from an RFC 822 message's headers and body.
*
* This method sets up most of the user interface for displaying
* the message, but does not attempt any possibly long-running
* loading processes.
*/
public ConversationMessage.from_message(Geary.RFC822.Message message,
bool load_remote_resources,
Application.ContactStore contacts,
Application.Configuration config) {
this(
message,
message.get_preview(),
load_remote_resources,
contacts,
config
);
}
private void trigger_internal_resource_loaded(string name) {
internal_resource_loaded(name);
}
private void trigger_content_loaded() {
content_loaded();
}
private void trigger_selection_changed(bool has_selection) {
selection_changed(has_selection);
}
private ConversationMessage(Geary.EmailHeaderSet headers,
string? preview,
bool load_remote_resources,
Application.ContactStore contacts,
Application.Configuration config) {
base_ref();
this.headers = headers;
this.load_remote_resources = load_remote_resources;
this.primary_originator = Util.Email.get_primary_originator(headers);
this.config = config;
this.contacts = contacts;
// Actions
add_action(ACTION_CONVERSATION_NEW, true, VariantType.STRING)
.activate.connect(on_link_activated);
add_action(ACTION_COPY_EMAIL, true, VariantType.STRING)
.activate.connect(on_copy_email_address);
add_action(ACTION_COPY_LINK, true, VariantType.STRING)
.activate.connect(on_copy_link);
add_action(ACTION_OPEN_LINK, true, VariantType.STRING)
.activate.connect(on_link_activated);
add_action(ACTION_SAVE_IMAGE, true, new VariantType("(sms)"))
.activate.connect(on_save_image);
insert_action_group("msg", message_actions);
// Context menu
Gtk.Builder builder = new Gtk.Builder.from_resource(
"/org/gnome/Geary/conversation-message-menus.ui"
);
context_menu_link = (MenuModel) builder.get_object("context_menu_link");
context_menu_email = (MenuModel) builder.get_object("context_menu_email");
context_menu_image = (MenuModel) builder.get_object("context_menu_image");
context_menu_main = (MenuModel) builder.get_object("context_menu_main");
if (config.enable_inspector) {
context_menu_inspector =
(MenuModel) builder.get_object("context_menu_inspector");
}
if (headers.date != null) {
this.local_date = headers.date.value.to_local();
}
update_display();
// Compact headers. These are partially done here and partially
// in load_contacts.
// Translators: This is displayed in place of the from address
// when the message has no from address.
this.empty_from_label = _("No sender");
this.compact_from.get_style_context().add_class(FROM_CLASS);
if (preview != null) {
string clean_preview = preview;
if (preview.length > MAX_PREVIEW_BYTES) {
clean_preview = Geary.String.safe_byte_substring(
preview, MAX_PREVIEW_BYTES
);
// Add an ellipsis in case the view is wider is wider than
// the text
clean_preview += "";
}
this.compact_body.set_text(clean_preview);
}
// Full headers. These are partially done here and partially
// in load_contacts.
if (headers.subject != null) {
this.subject.set_text(headers.subject.value);
this.subject.set_visible(true);
this.subject_searchable = headers.subject.value.casefold();
}
this.body_container.set_has_tooltip(true); // Used to show link URLs
this.show_progress_timeout = new Geary.TimeoutManager.milliseconds(
Util.Gtk.SHOW_PROGRESS_TIMEOUT_MSEC, this.on_show_progress_timeout
);
this.hide_progress_timeout = new Geary.TimeoutManager.milliseconds(
Util.Gtk.HIDE_PROGRESS_TIMEOUT_MSEC, this.on_hide_progress_timeout
);
this.progress_pulse = new Geary.TimeoutManager.milliseconds(
Util.Gtk.PROGRESS_PULSE_TIMEOUT_MSEC, this.body_progress.pulse
);
this.progress_pulse.repetition = FOREVER;
}
private void initialize_web_view() {
var viewer = get_ancestor(typeof(ConversationViewer)) as ConversationViewer;
// Ensure we share the same WebProcess with the last one
// constructed if possible.
if (viewer != null && viewer.previous_web_view != null) {
this.web_view = new ConversationWebView.with_related_view(
this.config,
viewer.previous_web_view
);
} else {
this.web_view = new ConversationWebView(this.config);
}
if (viewer != null) {
viewer.previous_web_view = this.web_view;
}
this.web_view.context_menu.connect(on_context_menu);
this.web_view.deceptive_link_clicked.connect(on_deceptive_link_clicked);
this.web_view.link_activated.connect((link) => {
on_link_activated(new GLib.Variant("s", link));
});
this.web_view.mouse_target_changed.connect(on_mouse_target_changed);
this.web_view.notify["is-loading"].connect(on_is_loading_notify);
this.web_view.resource_load_started.connect(on_resource_load_started);
this.web_view.remote_image_load_blocked.connect(on_remote_images_blocked);
this.web_view.selection_changed.connect(on_selection_changed);
this.web_view.internal_resource_loaded.connect(trigger_internal_resource_loaded);
this.web_view.content_loaded.connect(trigger_content_loaded);
this.web_view.selection_changed.connect(trigger_selection_changed);
this.web_view.set_hexpand(true);
this.web_view.set_vexpand(true);
this.web_view.show();
this.body_container.add(this.web_view);
add_action(ACTION_COPY_SELECTION, false).activate.connect(() => {
web_view.copy_clipboard();
});
add_action(ACTION_OPEN_INSPECTOR, config.enable_inspector).activate.connect(() => {
this.web_view.get_inspector().show();
});
add_action(ACTION_SELECT_ALL, true).activate.connect(() => {
web_view.select_all();
});
}
~ConversationMessage() {
base_unref();
}
public override void destroy() {
this.show_progress_timeout.reset();
this.hide_progress_timeout.reset();
this.progress_pulse.reset();
this.resources.clear();
this.searchable_addresses.clear();
base.destroy();
}
public async string? get_selection_for_quoting() throws Error {
if (this.web_view == null)
initialize_web_view();
return yield web_view.get_selection_for_quoting();
}
public async string? get_selection_for_find() throws Error {
if (this.web_view == null)
initialize_web_view();
return yield web_view.get_selection_for_find();
}
/**
* Adds a set of internal resources to web_view.
*
* @see Components.WebView.add_internal_resources
*/
public void add_internal_resources(Gee.Map<string,Geary.Memory.Buffer> res) {
if (this.web_view == null)
initialize_web_view();
web_view.add_internal_resources(res);
}
public WebKit.PrintOperation new_print_operation() {
if (this.web_view == null)
initialize_web_view();
return new WebKit.PrintOperation(web_view);
}
public async void run_javascript (string script, Cancellable? cancellable) throws Error {
if (this.web_view == null)
initialize_web_view();
yield web_view.run_javascript(script, cancellable);
}
public void zoom_in() {
if (this.web_view == null)
initialize_web_view();
web_view.zoom_in();
}
public void zoom_out() {
if (this.web_view == null)
initialize_web_view();
web_view.zoom_out();
}
public void zoom_reset() {
if (this.web_view == null)
initialize_web_view();
web_view.zoom_reset();
}
public void web_view_translate_coordinates(Gtk.Widget widget, int x, int anchor_y, out int x1, out int y1) {
if (this.web_view == null)
initialize_web_view();
web_view.translate_coordinates(widget, x, anchor_y, out x1, out y1);
}
public int web_view_get_allocated_height() {
if (this.web_view == null)
initialize_web_view();
return web_view.get_allocated_height();
}
/**
* Shows the complete message and hides the compact headers.
*/
public void show_message_body(bool include_transitions=true) {
if (this.web_view == null)
initialize_web_view();
set_revealer(this.compact_revealer, false, include_transitions);
set_revealer(this.header_revealer, true, include_transitions);
set_revealer(this.body_revealer, true, include_transitions);
}
/**
* Hides the complete message and shows the compact headers.
*/
public void hide_message_body() {
compact_revealer.set_reveal_child(true);
header_revealer.set_reveal_child(false);
body_revealer.set_reveal_child(false);
}
/** Shows a panel when an email is being loaded. */
public void show_loading_pane() {
Components.PlaceholderPane pane = new Components.PlaceholderPane();
pane.icon_name = "content-loading-symbolic";
pane.title = "";
pane.subtitle = "";
// Don't want to break the announced freeze for 0.13, so just
// hope the icon gets the message across for now and replace
// them with the ones below for 0.14.
// Translators: Title label for placeholder when multiple
// an error occurs loading a message for display.
//pane.title = _("A problem occurred");
// Translators: Sub-title label for placeholder when multiple
// an error occurs loading a message for display.
//pane.subtitle = _(
// "This email cannot currently be displayed"
//);
show_placeholder_pane(pane);
start_progress_pulse();
}
/** Shows an error panel when email loading failed. */
public void show_load_error_pane() {
Components.PlaceholderPane pane = new Components.PlaceholderPane();
pane.icon_name = "network-error-symbolic";
pane.title = "";
pane.subtitle = "";
// Don't want to break the announced freeze for 0.13, so just
// hope the icon gets the message across for now and replace
// them with the ones below for 0.14.
// Translators: Title label for placeholder when multiple
// an error occurs loading a message for display.
//pane.title = _("A problem occurred");
// Translators: Sub-title label for placeholder when multiple
// an error occurs loading a message for display.
//pane.subtitle = _(
// "This email cannot currently be displayed"
//);
show_placeholder_pane(pane);
stop_progress_pulse();
}
/** Shows an error panel when offline. */
public void show_offline_pane() {
show_message_body(true);
Components.PlaceholderPane pane = new Components.PlaceholderPane();
pane.icon_name = "network-offline-symbolic";
pane.title = "";
pane.subtitle = "";
// Don't want to break the announced freeze for 0.13, so just
// hope the icon gets the message across for now and replace
// them with the ones below for 0.14.
// // Translators: Title label for placeholder when loading a
// // message for display but the account is offline.
// pane.title = _("Offline");
// // Translators: Sub-title label for placeholder when loading a
// // message for display but the account is offline.
// pane.subtitle = _(
// "This email will be downloaded when reconnected to the Internet"
// );
show_placeholder_pane(pane);
stop_progress_pulse();
}
/** Shows and initialises the progress meter. */
public void start_progress_loading( ) {
this.progress_pulse.reset();
this.body_progress.fraction = 0.1;
this.show_progress_timeout.start();
this.hide_progress_timeout.reset();
}
/** Hides the progress meter. */
public void stop_progress_loading( ) {
this.body_progress.fraction = 1.0;
this.show_progress_timeout.reset();
this.hide_progress_timeout.start();
}
/** Shows and starts pulsing the progress meter. */
public void start_progress_pulse() {
this.body_progress.show();
this.progress_pulse.start();
}
/** Hides and stops pulsing the progress meter. */
public void stop_progress_pulse() {
this.body_progress.hide();
this.progress_pulse.reset();
}
/**
* Starts loading message contacts and the avatar.
*/
public async void load_contacts(GLib.Cancellable cancellable)
throws GLib.Error {
var main = this.get_toplevel() as Application.MainWindow;
if (main != null && !cancellable.is_cancelled()) {
// Load the primary contact and avatar
if (this.primary_originator != null) {
this.primary_contact = yield this.contacts.load(
this.primary_originator, cancellable
);
int window_scale = get_scale_factor();
int pixel_size =
Application.Client.AVATAR_SIZE_PIXELS * window_scale;
Gdk.Pixbuf? avatar_buf = yield this.primary_contact.load_avatar(
this.primary_originator,
pixel_size,
cancellable
);
if (avatar_buf != null) {
this.avatar.set_from_surface(
Gdk.cairo_surface_create_from_pixbuf(
avatar_buf, window_scale, get_window()
)
);
}
} else {
this.avatar.set_from_icon_name(
"avatar-default-symbolic", Gtk.IconSize.DIALOG
);
this.avatar.set_pixel_size(
Application.Client.AVATAR_SIZE_PIXELS
);
}
// Preview headers
this.compact_from.set_text(
yield format_originator_compact(cancellable)
);
// Full headers
Geary.EmailHeaderSet headers = this.headers;
yield fill_originator_addresses(
headers.from,
headers.reply_to,
headers.sender,
cancellable
);
yield fill_header_addresses(
this.to_header, headers.to, cancellable
);
yield fill_header_addresses(
this.cc_header, headers.cc, cancellable
);
yield fill_header_addresses(
this.bcc_header, headers.bcc, cancellable
);
}
}
/**
* Starts loading the message body in the HTML view.
*/
public async void load_message_body(Geary.RFC822.Message message,
GLib.Cancellable load_cancelled)
throws GLib.Error {
if (load_cancelled.is_cancelled()) {
throw new GLib.IOError.CANCELLED("Conversation load cancelled");
}
if (this.web_view == null) {
initialize_web_view();
}
bool contact_load_images = (
this.primary_contact != null &&
this.primary_contact.load_remote_resources
);
if (this.load_remote_resources || contact_load_images) {
this.web_view.allow_remote_image_loading();
}
show_placeholder_pane(null);
string? body_text = null;
try {
body_text = (message.has_html_body())
? message.get_html_body(inline_image_replacer)
: message.get_plain_body(true, inline_image_replacer);
} catch (Error err) {
debug("Could not get message text. %s", err.message);
}
load_cancelled.cancelled.connect(() => { web_view.stop_loading(); });
this.web_view.load_html(body_text ?? "");
}
/**
* Highlights user search terms in the message view.
*
* Highlighting includes both in the message headers, and the
* message body. returns the number of matching search terms.
*/
public async uint highlight_search_terms(Gee.Set<string> search_matches,
GLib.Cancellable cancellable)
throws GLib.IOError.CANCELLED {
uint headers_found = 0;
foreach(string raw_match in search_matches) {
string match = raw_match.casefold();
if (this.subject_searchable.contains(match)) {
this.subject.get_style_context().add_class(MATCH_CLASS);
++headers_found;
} else {
this.subject.get_style_context().remove_class(MATCH_CLASS);
}
foreach (ContactFlowBoxChild address in this.searchable_addresses) {
if (address.highlight_search_term(match)) {
++headers_found;
}
}
}
if (this.web_view == null)
initialize_web_view();
uint webkit_found = yield this.web_view.highlight_search_terms(
search_matches, cancellable
);
return headers_found + webkit_found;
}
/**
* Disables highlighting of any search terms in the message view.
*/
public void unmark_search_terms() {
foreach (ContactFlowBoxChild address in this.searchable_addresses) {
address.unmark_search_terms();
}
if (this.web_view != null)
this.web_view.unmark_search_terms();
}
/**
* Updates the displayed date for each conversation row.
*/
public void update_display() {
string date_text = "";
string date_tooltip = "";
if (this.local_date != null) {
date_text = Util.Date.pretty_print(
this.local_date, this.config.clock_format
);
date_tooltip = Util.Date.pretty_print_verbose(
this.local_date, this.config.clock_format
);
}
this.compact_date.set_text(date_text);
this.compact_date.set_tooltip_text(date_tooltip);
this.date.set_text(date_text);
this.date.set_tooltip_text(date_tooltip);
}
private SimpleAction add_action(string name, bool enabled, VariantType? type = null) {
SimpleAction action = new SimpleAction(name, type);
action.set_enabled(enabled);
message_actions.add_action(action);
return action;
}
private void set_action_enabled(string name, bool enabled) {
SimpleAction? action =
this.message_actions.lookup_action(name) as SimpleAction;
if (action != null) {
action.set_enabled(enabled);
}
}
private Menu set_action_param_value(MenuModel existing, Variant value) {
Menu menu = new Menu();
for (int i = 0; i < existing.get_n_items(); i++) {
MenuItem item = new MenuItem.from_model(existing, i);
Variant action = item.get_attribute_value(
Menu.ATTRIBUTE_ACTION, VariantType.STRING
);
item.set_action_and_target_value(action.get_string(), value);
menu.append_item(item);
}
return menu;
}
private async string format_originator_compact(GLib.Cancellable cancellable)
throws GLib.Error {
Geary.RFC822.MailboxAddresses? from = this.headers.from;
string text = "";
if (from != null && from.size > 0) {
int i = 0;
Gee.List<Geary.RFC822.MailboxAddress> list = from.get_all();
foreach (Geary.RFC822.MailboxAddress addr in list) {
Application.Contact originator = yield this.contacts.load(
addr, cancellable
);
text += originator.display_name;
if (++i < list.size)
// Translators: This separates multiple 'from'
// addresses in the compact header for a message.
text += _(", ");
}
} else {
text = this.empty_from_label;
}
return text;
}
private async void fill_originator_addresses(Geary.RFC822.MailboxAddresses? from,
Geary.RFC822.MailboxAddresses? reply_to,
Geary.RFC822.MailboxAddress? sender,
GLib.Cancellable? cancellable)
throws GLib.Error {
// Show any From header addresses
if (from != null && from.size > 0) {
foreach (Geary.RFC822.MailboxAddress address in from) {
ContactFlowBoxChild child = new ContactFlowBoxChild(
yield this.contacts.load(address, cancellable),
address,
ContactFlowBoxChild.Type.FROM
);
this.searchable_addresses.add(child);
this.from.add(child);
}
} else {
Gtk.Label label = new Gtk.Label(null);
label.set_text(this.empty_from_label);
Gtk.FlowBoxChild child = new Gtk.FlowBoxChild();
child.add(label);
child.set_halign(Gtk.Align.START);
child.show_all();
this.from.add(child);
}
// Show the Sender header addresses if present, but only if
// not already in the From header.
if (sender != null &&
(from == null || !from.contains_normalized(sender.address))) {
ContactFlowBoxChild child = new ContactFlowBoxChild(
yield this.contacts.load(sender, cancellable),
sender
);
this.searchable_addresses.add(child);
this.sender_header.show();
this.sender_address.add(child);
}
// Show any Reply-To header addresses if present, but only if
// each is not already in the From header.
if (reply_to != null) {
foreach (Geary.RFC822.MailboxAddress address in reply_to) {
if (from == null || !from.contains_normalized(address.address)) {
ContactFlowBoxChild child = new ContactFlowBoxChild(
yield this.contacts.load(address, cancellable),
address
);
this.searchable_addresses.add(child);
this.reply_to_addresses.add(child);
this.reply_to_header.show();
}
}
}
}
private async void fill_header_addresses(Gtk.Grid header,
Geary.RFC822.MailboxAddresses? addresses,
GLib.Cancellable? cancellable)
throws GLib.Error {
if (addresses != null && addresses.size > 0) {
ContactList box = header.get_children().nth(0).data as ContactList;
if (box != null) {
foreach (Geary.RFC822.MailboxAddress address in addresses) {
ContactFlowBoxChild child = new ContactFlowBoxChild(
yield this.contacts.load(address, cancellable),
address
);
this.searchable_addresses.add(child);
box.add(child);
}
}
header.set_visible(true);
}
}
// This delegate is called from within
// Geary.RFC822.Message.get_body while assembling the plain or
// HTML document when a non-text MIME part is encountered within a
// multipart/mixed container. If this returns null, the MIME part
// is dropped from the final returned document; otherwise, this
// returns HTML that is placed into the document in the position
// where the MIME part was found
private string? inline_image_replacer(Geary.RFC822.Part part) {
if (this.web_view == null)
initialize_web_view();
Geary.Mime.ContentType content_type = part.content_type;
if (content_type.media_type != "image" ||
!this.web_view.can_show_mime_type(content_type.to_string())) {
debug("Not displaying %s inline: unsupported Content-Type",
content_type.to_string());
return null;
}
string? id = part.content_id;
if (id == null) {
id = REPLACED_CID_TEMPLATE.printf(this.next_replaced_buffer_number++);
}
try {
this.web_view.add_internal_resource(
id,
part.write_to_buffer(Geary.RFC822.Part.EncodingConversion.UTF8)
);
} catch (Geary.RFC822.Error err) {
debug("Failed to get inline buffer: %s", err.message);
return null;
}
// Translators: This string is used as the HTML IMG ALT
// attribute value when displaying an inline image in an email
// that did not specify a file name. E.g. <IMG ALT="Image" ...
string UNKNOWN_FILENAME_ALT_TEXT = _("Image");
string clean_filename = Geary.HTML.escape_markup(
part.get_clean_filename() ?? UNKNOWN_FILENAME_ALT_TEXT
);
return "<img alt=\"%s\" class=\"%s\" src=\"%s%s\" />".printf(
clean_filename,
REPLACED_IMAGE_CLASS,
Components.WebView.CID_URL_PREFIX,
Geary.HTML.escape_markup(id)
);
}
private void show_images(bool update_email_flag) {
start_progress_loading();
if (this.remote_images_info_bar != null) {
this.info_bars.remove(this.remote_images_info_bar);
this.remote_images_info_bar = null;
}
this.load_remote_resources = true;
this.remote_resources_requested = 0;
this.remote_resources_loaded = 0;
if (this.web_view != null) {
this.web_view.load_remote_images();
}
if (update_email_flag) {
flag_remote_images();
}
}
private void show_placeholder_pane(Gtk.Widget? placeholder) {
if (this.body_placeholder != null) {
this.body_placeholder.hide();
this.body_container.remove(this.body_placeholder);
this.body_placeholder = null;
}
if (placeholder != null) {
this.body_placeholder = placeholder;
if (this.web_view != null)
this.web_view.hide();
this.body_container.add(placeholder);
show_message_body(true);
} else {
if (this.web_view != null)
this.web_view.show();
}
}
private inline void set_revealer(Gtk.Revealer revealer,
bool expand,
bool use_transition) {
Gtk.RevealerTransitionType transition = revealer.get_transition_type();
if (!use_transition) {
revealer.set_transition_type(Gtk.RevealerTransitionType.NONE);
}
revealer.set_reveal_child(expand);
revealer.set_transition_type(transition);
}
private void on_show_progress_timeout() {
if (this.body_progress.fraction < 0.99) {
this.progress_pulse.reset();
this.body_progress.show();
}
}
private void on_hide_progress_timeout() {
this.progress_pulse.reset();
this.body_progress.hide();
}
private void on_is_loading_notify() {
if (this.web_view != null) {
if (this.web_view.is_loading) {
start_progress_loading();
} else {
stop_progress_loading();
}
}
}
private void on_resource_load_started(WebKit.WebView view,
WebKit.WebResource res,
WebKit.URIRequest req) {
// Cache the resource to allow images to be saved
this.resources[res.get_uri()] = res;
// We only want to show the body loading progress meter if we
// are actually loading some images, so do it here rather than
// in on_is_loading_notify.
this.remote_resources_requested++;
res.finished.connect(() => {
this.remote_resources_loaded++;
this.body_progress.fraction = (
(float) this.remote_resources_loaded /
(float) this.remote_resources_requested
);
if (this.remote_resources_loaded ==
this.remote_resources_requested) {
stop_progress_loading();
}
});
}
[GtkCallback]
private void on_address_box_child_activated(Gtk.FlowBox box,
Gtk.FlowBoxChild child) {
ContactFlowBoxChild address_child = child as ContactFlowBoxChild;
if (address_child != null) {
address_child.set_state_flags(Gtk.StateFlags.ACTIVE, false);
Geary.RFC822.MailboxAddress address = address_child.displayed;
Gee.Map<string,GLib.Variant> values =
new Gee.HashMap<string,GLib.Variant>();
values[ACTION_COPY_EMAIL] = address.to_full_display();
Conversation.ContactPopover popover = new Conversation.ContactPopover(
address_child,
address_child.contact,
address
);
popover.load_avatar.begin();
popover.set_position(Gtk.PositionType.BOTTOM);
popover.load_remote_resources_changed.connect((enabled) => {
if (this.primary_contact.equal_to(address_child.contact) &&
enabled) {
show_images(false);
}
});
popover.closed.connect(() => {
address_child.unset_state_flags(Gtk.StateFlags.ACTIVE);
});
popover.popup();
}
}
private bool on_context_menu(WebKit.WebView view,
WebKit.ContextMenu context_menu,
Gdk.Event event,
WebKit.HitTestResult hit_test) {
if (this.context_menu != null) {
this.context_menu.detach();
}
// Build a new context menu every time the user clicks because
// at the moment under GTK+3.20 it's far easier to selectively
// build a new menu model from pieces as we do here, then to
// have a single menu model and disable the parts we don't
// need.
Menu model = new Menu();
if (hit_test.context_is_link()) {
string link_url = hit_test.get_link_uri();
MenuModel link_menu =
link_url.has_prefix(MAILTO_URI_PREFIX)
? context_menu_email
: context_menu_link;
model.append_section(
null,
set_action_param_value(
link_menu, new Variant.string(link_url)
)
);
}
if (hit_test.context_is_image()) {
string uri = hit_test.get_image_uri();
set_action_enabled(ACTION_SAVE_IMAGE, this.resources.has_key(uri));
model.append_section(
null,
set_action_param_value(
context_menu_image,
new Variant.tuple({
new Variant.string(uri),
new Variant("ms", hit_test.get_link_label()),
})
)
);
}
model.append_section(null, context_menu_main);
if (context_menu_inspector != null) {
model.append_section(null, context_menu_inspector);
}
this.context_menu = new Gtk.Menu.from_model(model);
this.context_menu.attach_to_widget(this, null);
this.context_menu.popup_at_pointer(event);
return true;
}
private void on_mouse_target_changed(WebKit.WebView web_view,
WebKit.HitTestResult hit_test,
uint modifiers) {
this.body_container.set_tooltip_text(
hit_test.context_is_link()
? Util.Gtk.shorten_url(hit_test.get_link_uri())
: null
);
this.body_container.trigger_tooltip_query();
}
// Check for possible phishing links, displays a popover if found.
// If not, lets it go through to the default handler.
private void on_deceptive_link_clicked(ConversationWebView.DeceptiveText reason,
string text,
string href,
Gdk.Rectangle location) {
string text_href = text;
if (Uri.parse_scheme(text_href) == null) {
text_href = "http://" + text_href;
}
string text_label = Soup.URI.decode(text_href);
string anchor_href = href;
if (Uri.parse_scheme(anchor_href) == null) {
anchor_href = "http://" + anchor_href;
}
string anchor_label = Soup.URI.decode(anchor_href);
Gtk.Builder builder = new Gtk.Builder.from_resource(
"/org/gnome/Geary/conversation-message-link-popover.ui"
);
var link_popover = builder.get_object("link_popover") as Gtk.Popover;
var good_link = builder.get_object("good_link_label") as Gtk.Label;
var bad_link = builder.get_object("bad_link_label") as Gtk.Label;
// Escape text and especially URLs since we got them from the
// HREF, and Gtk.Label.set_markup is a strict parser.
var main = get_toplevel() as Application.MainWindow;
good_link.set_markup(
Markup.printf_escaped("<a href=\"%s\">%s</a>", text_href, text_label)
);
good_link.activate_link.connect((label, uri) => {
link_popover.popdown();
main.application.show_uri.begin(uri);
return Gdk.EVENT_STOP;
}
);
bad_link.set_markup(
Markup.printf_escaped("<a href=\"%s\">%s</a>", anchor_href, anchor_label)
);
bad_link.activate_link.connect((label, uri) => {
link_popover.popdown();
main.application.show_uri.begin(uri);
return Gdk.EVENT_STOP;
}
);
link_popover.set_relative_to(this.web_view);
link_popover.set_pointing_to(location);
link_popover.closed.connect_after(() => { link_popover.destroy(); });
link_popover.popup();
}
private void on_selection_changed(bool has_selection) {
set_action_enabled(ACTION_COPY_SELECTION, has_selection);
}
private void on_remote_images_blocked() {
if (this.remote_images_info_bar == null) {
this.remote_images_info_bar = new Components.InfoBar(
// Translators: Info bar status message
_("Remote images not shown"),
// Translators: Info bar description
_("Only show remote images from senders you trust.")
);
var show = this.remote_images_info_bar.add_button(
// Translators: Info bar button label
_("Show"), 1
);
this.remote_images_info_bar.add_button(
// Translators: Info bar button label
_("Always show from sender"), 2
);
this.remote_images_info_bar.response.connect(on_remote_images_response);
var buttons = this.remote_images_info_bar.get_action_area() as Gtk.ButtonBox;
if (buttons != null) {
buttons.set_child_non_homogeneous(show, true);
}
this.info_bars.add(this.remote_images_info_bar);
}
}
private void on_remote_images_response(Components.InfoBar info_bar, int response_id) {
switch (response_id) {
case 1:
// Show images for the message
show_images(true);
break;
case 2:
// Show images for sender
show_images(false);
if (this.primary_contact != null) {
this.primary_contact.set_remote_resource_loading.begin(
true, null
);
}
break;
default:
this.info_bars.remove(this.remote_images_info_bar);
this.remote_images_info_bar = null;
break;
}
}
private void on_copy_link(Variant? param) {
Gtk.Clipboard clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD);
clipboard.set_text(param.get_string(), -1);
clipboard.store();
}
private void on_copy_email_address(Variant? param) {
string value = param.get_string();
if (value.has_prefix(MAILTO_URI_PREFIX)) {
value = value.substring(MAILTO_URI_PREFIX.length, -1);
}
Gtk.Clipboard clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD);
clipboard.set_text(value, -1);
clipboard.store();
}
private void on_save_image(Variant? param) {
string uri = (string) param.get_child_value(0);
string? alt_text = null;
Variant? alt_maybe = param.get_child_value(1).get_maybe();
if (alt_maybe != null) {
alt_text = (string) alt_maybe;
}
if (uri.has_prefix(Components.WebView.CID_URL_PREFIX)) {
// We can get the data directly from the attachment, so
// don't bother getting it from the web view
save_image(uri, alt_text, null);
} else {
WebKit.WebResource response = this.resources.get(uri);
response.get_data.begin(null, (obj, res) => {
try {
uint8[] data = response.get_data.end(res);
save_image(
uri,
alt_text,
new Geary.Memory.ByteBuffer(data, data.length)
);
} catch (GLib.Error err) {
debug(
"Failed to get image data from web view: %s",
err.message
);
}
});
}
}
private void on_link_activated(GLib.Variant? param) {
string link = param.get_string();
if (link.has_prefix(INTERNAL_ANCHOR_PREFIX)) {
long start = INTERNAL_ANCHOR_PREFIX.length;
long end = link.length;
string anchor_body = link.substring(start, end - start);
this.web_view.get_anchor_target_y.begin(anchor_body, (obj, res) => {
try {
int y = this.web_view.get_anchor_target_y.end(res);
if (y > 0) {
internal_link_activated(y);
} else {
debug("Failed to get anchor destination");
}
} catch (GLib.Error err) {
debug("Failed to get anchor destination");
}
});
} else {
var main = this.get_toplevel() as Application.MainWindow;
if (main != null) {
main.application.show_uri.begin(link);
}
}
}
}