Commit 1deef0b2 authored by Eric Gregory's avatar Eric Gregory

Closes #6139 Refactored conversation viewer

parent 988abcbf
......@@ -128,7 +128,7 @@ public class MainWindow : Gtk.Window {
// Message list left of message viewer.
conversations_paned.pack1(conversation_list_scrolled, false, false);
conversations_paned.pack2(conversation_viewer.content_area, true, true);
conversations_paned.pack2(conversation_viewer, true, true);
// Folder list to the left of everything.
folder_paned.pack1(status_bar_box, false, false);
......
......@@ -142,3 +142,99 @@ private void linkify_recurse(WebKit.DOM.Document document, WebKit.DOM.Node node,
}
}
// Validates a URL. Intended to be used as a RegexEvalCallback.
// Ensures the URL begins with a valid protocol specifier. (If not, we don't
// want to linkify it.)
public bool is_valid_url(MatchInfo match_info, StringBuilder result) {
try {
string? url = match_info.fetch(0);
Regex r = new Regex(PROTOCOL_REGEX, RegexCompileFlags.CASELESS);
result.append(r.match(url) ? "<a href=\"%s\">%s</a>".printf(url, url) : url);
} catch (Error e) {
debug("URL parsing error: %s\n", e.message);
}
return false; // False to continue processing.
}
// Converts plain text emails to something safe and usable in HTML.
public string linkify_and_escape_plain_text(string input) throws Error {
// Convert < and > into non-printable characters, and change & to &amp;.
string output = input.replace("<", " \01 ").replace(">", " \02 ").replace("&", "&amp;");
// Converts text links into HTML hyperlinks.
Regex r = new Regex(URL_REGEX, RegexCompileFlags.CASELESS);
output = r.replace_eval(output, -1, 0, 0, is_valid_url);
return output.replace(" \01 ", "&lt;").replace(" \02 ", "&gt;");
}
public bool node_is_child_of(WebKit.DOM.Node node, string ancestor_tag) {
WebKit.DOM.Element? ancestor = node.get_parent_element();
for (; ancestor != null; ancestor = ancestor.get_parent_element()) {
if (ancestor.get_tag_name() == ancestor_tag) {
return true;
}
}
return false;
}
public WebKit.DOM.HTMLElement? closest_ancestor(WebKit.DOM.Element element, string selector) {
try {
WebKit.DOM.Element? parent = element.get_parent_element();
while (parent != null && !parent.webkit_matches_selector(selector)) {
parent = parent.get_parent_element();
}
return parent as WebKit.DOM.HTMLElement;
} catch (Error error) {
warning("Failed to find ancestor: %s", error.message);
return null;
}
}
public bool is_image(string? uri) {
if (uri == null)
return false;
try {
Regex regex = new Regex("(?:jpe?g|gif|png)$", RegexCompileFlags.CASELESS);
return regex.match(uri);
} catch (RegexError err) {
debug("Error creating image-matching regex: %s", err.message);
return false;
}
}
public string decorate_quotes(string text) throws Error {
int level = 0;
string outtext = "";
Regex quote_leader = new Regex("^(&gt;)* ?"); // Some &gt; followed by optional space
foreach (string line in text.split("\n")) {
MatchInfo match_info;
if (quote_leader.match_all(line, 0, out match_info)) {
int start, end, new_level;
match_info.fetch_pos(0, out start, out end);
new_level = end / 4; // Cast to int removes 0.25 from space at end, if present
while (new_level > level) {
outtext += "<blockquote>";
level += 1;
}
while (new_level < level) {
outtext += "</blockquote>";
level -= 1;
}
outtext += line.substring(end);
} else {
debug("This line didn't match the quote regex: %s", line);
outtext += line;
}
}
// Close any remaining blockquotes.
while (level > 0) {
outtext += "</blockquote>";
level -= 1;
}
return outtext;
}
This diff is collapsed.
......@@ -5,11 +5,50 @@
*/
public class ConversationWebView : WebKit.WebView {
private const string[] always_loaded_prefixes = {
"http://www.gravatar.com/avatar/",
"data:"
};
private const string USER_CSS = "user-message.css";
private const string STYLE_NAME = "STYLE";
public signal void image_load_requested();
public signal void link_selected(string link);
private bool load_external_images = false;
private FileMonitor? user_style_monitor = null;
// HTML element that contains message DIVs.
public WebKit.DOM.HTMLDivElement? container { get; private set; default = null; }
public ConversationWebView() {
// Set defaults.
set_border_width(0);
WebKit.WebSettings config = new WebKit.WebSettings();
config.enable_scripts = false;
config.enable_java_applet = false;
config.enable_plugins = false;
settings = config;
// Hook up signals.
load_finished.connect(on_load_finished);
resource_request_starting.connect(on_resource_request_starting);
navigation_policy_decision_requested.connect(on_navigation_policy_decision_requested);
new_window_policy_decision_requested.connect(on_navigation_policy_decision_requested);
// Load the HTML into WebKit.
// Note: load_finished signal MUST be hooked up before this call.
string html_text = GearyApplication.instance.read_theme_file("message-viewer.html") ?? "";
load_string(html_text, "text/html", "UTF8", "");
}
public override bool query_tooltip(int x, int y, bool keyboard_tooltip, Gtk.Tooltip tooltip) {
// Disable tooltips from within WebKit itself.
return false;
}
public override bool scroll_event(Gdk.EventScroll event) {
if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0) {
if (event.direction == Gdk.ScrollDirection.UP) {
......@@ -22,4 +61,237 @@ public class ConversationWebView : WebKit.WebView {
}
return false;
}
public void hide_element_by_id(string element_id) throws Error {
get_dom_document().get_element_by_id(element_id).set_attribute("style", "display:none");
}
public void show_element_by_id(string element_id) throws Error {
get_dom_document().get_element_by_id(element_id).set_attribute("style", "display:block");
}
// Scrolls back up to the top.
public void scroll_reset() {
get_dom_document().get_default_view().scroll(0, 0);
}
private void on_resource_request_starting(WebKit.WebFrame web_frame,
WebKit.WebResource web_resource, WebKit.NetworkRequest request,
WebKit.NetworkResponse? response) {
string? uri = request.get_uri();
bool uri_is_image = is_image(uri);
if (uri_is_image && !load_external_images)
image_load_requested();
if (!is_always_loaded(uri) && !(uri_is_image && load_external_images))
request.set_uri("about:blank");
}
private bool is_always_loaded(string? uri) {
if (uri == null)
return false;
foreach (string prefix in always_loaded_prefixes) {
if (uri.has_prefix(prefix))
return true;
}
return false;
}
public void set_load_external_images(bool load_external_images) {
this.load_external_images = load_external_images;
// Refreshing the images would do nothing in this case--the resource has already been
// loaded, so no additional resource request will be sent.
if (load_external_images == false)
return;
// We can't simply set load_external_images to true before refreshing, then set it back to
// false afterwards. If one of the images' sources is redirected, an additional resource
// request will come after we reset load_external_images to false.
try {
WebKit.DOM.Document document = get_dom_document();
WebKit.DOM.NodeList nodes = document.query_selector_all("img");
for (ulong i = 0; i < nodes.length; i++) {
WebKit.DOM.Element? element = nodes.item(i) as WebKit.DOM.Element;
if (element == null)
continue;
if (!element.has_attribute("src"))
continue;
string src = element.get_attribute("src");
if (Geary.String.is_empty_or_whitespace(src) || is_always_loaded(src))
continue;
// Refresh the image source. Requests are denied when load_external_images
// is false, so we need to force webkit to send the request again.
element.set_attribute("src", src);
}
} catch (Error err) {
debug("Error refreshing images: %s", err.message);
}
}
private void on_load_finished(WebKit.WebFrame frame) {
// Load the style.
try {
WebKit.DOM.Document document = get_dom_document();
WebKit.DOM.Element style_element = document.create_element(STYLE_NAME);
string css_text = GearyApplication.instance.read_theme_file("message-viewer.css") ?? "";
WebKit.DOM.Text text_node = document.create_text_node(css_text);
style_element.append_child(text_node);
WebKit.DOM.HTMLHeadElement head_element = document.get_head();
head_element.append_child(style_element);
} catch (Error error) {
debug("Unable to load message-viewer document from files: %s", error.message);
}
load_user_style();
// Grab the HTML container.
WebKit.DOM.Element? _container = get_dom_document().get_element_by_id("message_container");
assert(_container != null);
container = _container as WebKit.DOM.HTMLDivElement;
assert(container != null);
// Load the icons.
set_icon_src("#email_template .menu .icon", "go-down");
set_icon_src("#email_template .starred .icon", "starred");
set_icon_src("#email_template .unstarred .icon", "non-starred-grey");
set_icon_src("#email_template .attachment.icon", "mail-attachment");
}
private void load_user_style() {
try {
WebKit.DOM.Document document = get_dom_document();
WebKit.DOM.Element style_element = document.create_element(STYLE_NAME);
style_element.set_attribute("id", "user_style");
WebKit.DOM.HTMLHeadElement head_element = document.get_head();
head_element.append_child(style_element);
File user_style = GearyApplication.instance.get_user_config_directory().get_child(USER_CSS);
user_style_monitor = user_style.monitor_file(FileMonitorFlags.NONE, null);
user_style_monitor.changed.connect(on_user_style_changed);
// And call it once to load the initial user style
on_user_style_changed(user_style, null, FileMonitorEvent.CREATED);
} catch (Error error) {
debug("Error setting up user style: %s", error.message);
}
}
private void on_user_style_changed(File user_style, File? other_file, FileMonitorEvent event_type) {
// Changing a file produces 1 created signal, 3 changes done hints, and 0 changed
if (event_type != FileMonitorEvent.CHANGED && event_type != FileMonitorEvent.CREATED
&& event_type != FileMonitorEvent.DELETED) {
return;
}
debug("Loading new message viewer style from %s...", user_style.get_path());
WebKit.DOM.Document document = get_dom_document();
WebKit.DOM.Element style_element = document.get_element_by_id("user_style");
ulong n = style_element.child_nodes.length;
try {
for (int i = 0; i < n; i++)
style_element.remove_child(style_element.first_child);
} catch (Error error) {
debug("Error removing old user style: %s", error.message);
}
try {
DataInputStream data_input_stream = new DataInputStream(user_style.read());
size_t length;
string user_css = data_input_stream.read_upto("\0", 1, out length);
WebKit.DOM.Text text_node = document.create_text_node(user_css);
style_element.append_child(text_node);
} catch (Error error) {
// Expected if file was deleted.
}
}
private void set_icon_src(string selector, string icon_name) {
try {
// Load the icon.
string icon_filename = IconFactory.instance.lookup_icon(icon_name, 16).get_filename();
uint8[] icon_content;
FileUtils.get_data(icon_filename, out icon_content);
// Fetch its mime type.
bool uncertain_content_type;
string icon_mimetype = ContentType.get_mime_type(ContentType.guess(icon_filename,
icon_content, out uncertain_content_type));
// Then set the source to a data url.
WebKit.DOM.HTMLImageElement img = Util.DOM.select(get_dom_document(), selector)
as WebKit.DOM.HTMLImageElement;
set_data_url(img, icon_mimetype, icon_content);
} catch (Error error) {
warning("Failed to load icon '%s': %s", icon_name, error.message);
}
}
public void set_image_src(WebKit.DOM.HTMLImageElement img, string mime_type, string filename,
int maxwidth, int maxheight = -1) {
if( maxheight == -1 ){
maxheight = maxwidth;
}
try {
// If the file is an image, use it. Otherwise get the icon for this mime_type.
uint8[] content;
string content_type = ContentType.from_mime_type(mime_type);
string icon_mime_type = mime_type;
if (mime_type.has_prefix("image/")) {
// Get a thumbnail for the image.
// TODO Generate and save the thumbnail when extracting the attachments rather than
// when showing them in the viewer.
img.get_class_list().add("thumbnail");
Gdk.Pixbuf image = new Gdk.Pixbuf.from_file_at_scale(filename, maxwidth, maxheight,
true);
image.save_to_buffer(out content, "png");
icon_mime_type = "image/png";
} else {
// Load the icon for this mime type.
ThemedIcon icon = ContentType.get_icon(content_type) as ThemedIcon;
string icon_filename = IconFactory.instance.lookup_icon(icon.names[0], maxwidth)
.get_filename();
FileUtils.get_data(icon_filename, out content);
icon_mime_type = ContentType.get_mime_type(ContentType.guess(icon_filename, content,
null));
}
// Then set the source to a data url.
set_data_url(img, icon_mime_type, content);
} catch (Error error) {
warning("Failed to load image '%s': %s", filename, error.message);
}
}
public void set_data_url(WebKit.DOM.HTMLImageElement img, string mime_type, uint8[] content)
throws Error {
img.set_attribute("src", "data:%s;base64,%s".printf(mime_type, Base64.encode(content)));
}
private bool on_navigation_policy_decision_requested(WebKit.WebFrame frame,
WebKit.NetworkRequest request, WebKit.WebNavigationAction navigation_action,
WebKit.WebPolicyDecision policy_decision) {
policy_decision.ignore();
// Other policy-decisions may be requested for various reasons. The existence of an iframe,
// for example, causes a policy-decision request with an "OTHER" reason. We don't want to
// open a webpage in the browser just because an email contains an iframe.
if (navigation_action.reason == WebKit.WebNavigationReason.LINK_CLICKED)
link_selected(request.uri);
return true;
}
public WebKit.DOM.HTMLDivElement create_div() throws Error {
return get_dom_document().create_element("div") as WebKit.DOM.HTMLDivElement;
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment