[geary/wip/765516-gtk-widget-conversation-viewer: 10/78] Renable and update code for clicking on links in messages.



commit 044ae0f6f43785e51c53168e240b0865315f6a99
Author: Michael James Gratton <mike vee net>
Date:   Sun Apr 10 10:46:49 2016 +1000

    Renable and update code for clicking on links in messages.
    
    Requires GTK+ 3.12.
    
    * src/client/conversation-viewer/conversation-message.vala
      (ConversationMessage::link_selected): Added to pass through successful
      link clicks to the message_viewer, hook it up to the web_view.
      (ConversationMessage::load_message_body): Hook up WebKit event handler
      for when links are clicked, so phishing links can be intercepted.
      (ConversationMessage::on_link_clicked_self): Use a popover to display
      phishing warning, recursively check link's offset parent's when
      calculating box position, escape link text/href before using it as
      markup.
    
    * src/client/conversation-viewer/conversation-viewer.vala
      (ConversationViewer::add_message): Ensure unhandled mouse clicks o the
      web_view are not used to activate the message's ListBoxRow.
    
    * ui/conversation-message.ui: Add popover (GTK+ 3.12) for phishing links.

 .../conversation-viewer/conversation-message.vala  |  263 ++++++++++---------
 .../conversation-viewer/conversation-viewer.vala   |   11 +-
 ui/conversation-message.ui                         |   72 ++++++-
 3 files changed, 219 insertions(+), 127 deletions(-)
---
diff --git a/src/client/conversation-viewer/conversation-message.vala 
b/src/client/conversation-viewer/conversation-message.vala
index 6aee5c4..8851206 100644
--- a/src/client/conversation-viewer/conversation-message.vala
+++ b/src/client/conversation-viewer/conversation-message.vala
@@ -93,7 +93,14 @@ public class ConversationMessage : Gtk.Box {
     [GtkChild]
     private Gtk.Box body_box;
 
+    [GtkChild]
+    private Gtk.Popover link_popover;
 
+    [GtkChild]
+    private Gtk.Label good_link_label;
+
+    [GtkChild]
+    private Gtk.Label bad_link_label;
 
     // The folder containing the message
     private Geary.Folder containing_folder = null; // XXX weak??
@@ -110,6 +117,11 @@ public class ConversationMessage : Gtk.Box {
     private Gee.HashMap<string, ReplacedImage> replaced_images = new Gee.HashMap<string, ReplacedImage>();
     private Gee.HashSet<string> replaced_content_ids = new Gee.HashSet<string>();
 
+
+    // Fired when the user clicks a link.
+    public signal void link_selected(string link);
+
+
     public ConversationMessage(Geary.Email email, Geary.Folder containing_folder) {
         this.email = email;
         this.containing_folder = containing_folder;
@@ -179,7 +191,7 @@ public class ConversationMessage : Gtk.Box {
         web_view.realize.connect(() => { debug("web_view: realised"); });
         web_view.size_allocate.connect(() => { debug("web_view: allocated"); });
 
-        // web_view.link_selected.connect((link) => { link_selected(link); });
+        web_view.link_selected.connect((link) => { link_selected(link); });
         
         // if (email.from != null && email.from.contains_normalized(current_account_information.email)) {
         //  // XXX set a RO property?
@@ -418,8 +430,15 @@ public class ConversationMessage : Gtk.Box {
             debug("Could not get message text. %s", err.message);
         }
 
-        body_text = clean_html_markup(body_text, message, out remote_images);
-        web_view.load_string(body_text ?? "", "text/html", "UTF8", "");
+        body_text = clean_html_markup(body_text ?? "", message, out remote_images);
+
+        web_view.notify["load-status"].connect((source, param) => {
+                if (web_view.load_status == WebKit.LoadStatus.FINISHED) {
+                    bind_event(web_view, "body a", "click",
+                               (Callback) on_link_clicked, this);
+                }
+            });
+        web_view.load_string(body_text, "text/html", "UTF8", "");
 
         // XXX The following will probably need to happen after the
         // message has been loaded.
@@ -725,128 +744,6 @@ public class ConversationMessage : Gtk.Box {
     //     }
     // }
     
-    // private bool on_link_clicked_self(WebKit.DOM.Element element) {
-    //     if (!Geary.String.is_empty(element.get_attribute("warning"))) {
-    //         // A warning is open, so ignore clicks.
-    //         return true;
-    //     }
-        
-    //     string? href = element.get_attribute("href");
-    //     if (Geary.String.is_empty(href))
-    //         return false;
-    //     string text = ((WebKit.DOM.HTMLElement) element).get_inner_text();
-    //     string href_short, text_short;
-    //     if (!deceptive_text(href, ref text, out href_short, out text_short))
-    //         return false;
-        
-    //     WebKit.DOM.HTMLElement div = Util.DOM.clone_select(web_view.get_dom_document(),
-    //         "#link_warning_template");
-    //     try {
-    //         div.set_inner_html("""%s %s <span><a href="%s">%s</a></span> %s
-    //             <span><a href="%s">%s</a></span>""".printf(div.get_inner_html(),
-    //             _("This link appears to go to"), text, text_short,
-    //             _("but actually goes to"), href, href_short));
-    //         div.remove_attribute("id");
-    //         element.parent_node.insert_before(div, element);
-    //         element.set_attribute("warning", "open");
-            
-    //         long overhang = div.get_offset_left() + div.get_offset_width() -
-    //             web_view.get_dom_document().get_body().get_offset_width();
-    //         if (overhang > 0)
-    //             div.set_attribute("style", @"margin-left: -$(overhang)px;");
-    //     } catch (Error error) {
-    //         warning("Error showing link warning dialog: %s", error.message);
-    //     }
-    //     bind_event(web_view, ".link_warning .close_link_warning, .link_warning a", "click",
-    //         (Callback) on_close_link_warning, this);
-    //     return true;
-    // }
-    
-    // private void on_close_link_warning(WebKit.DOM.Element element, WebKit.DOM.Event event,
-    //     ConversationMessage conversation_message) {
-    //     try {
-    //         WebKit.DOM.Element warning_div = closest_ancestor(element, ".link_warning");
-    //         WebKit.DOM.Element link = (WebKit.DOM.Element) warning_div.get_next_sibling();
-    //         link.remove_attribute("warning");
-    //         warning_div.parent_node.remove_child(warning_div);
-    //     } catch (Error error) {
-    //         warning("Error removing link warning dialog: %s", error.message);
-    //     }
-    // }
-
-    // private void on_draft_edit_menu() {
-    //     get_viewer().edit_draft(email);
-    // }
-    
-    // /*
-    //  * Test whether text looks like a URI that leads somewhere other than href.  The text
-    //  * will have a scheme prepended if it doesn't already have one, and the short versions
-    //  * have the scheme skipped and long paths truncated.
-    //  */
-    // private bool deceptive_text(string href, ref string text, out string href_short,
-    //     out string text_short) {
-    //     href_short = "";
-    //     text_short = "";
-    //     // mailto URLs have a different form, and the worst they can do is pop up a composer,
-    //     // so we don't trigger on them.
-    //     if (href.has_prefix("mailto:";))
-    //         return false;
-        
-    //     // First, does text look like a URI?  Right now, just test whether it has
-    //     // <string>.<string> in it.  More sophisticated tests are possible.
-    //     GLib.MatchInfo text_match, href_match;
-    //     try {
-    //         GLib.Regex domain = new GLib.Regex(
-    //             "([a-z]*://)?"                  // Optional scheme
-    //             + "([^\\s:/]+\\.[^\\s:/\\.]+)"  // Domain
-    //             + "(/[^\\s]*)?"                 // Optional path
-    //             );
-    //         if (!domain.match(text, 0, out text_match))
-    //             return false;
-    //         if (!domain.match(href, 0, out href_match)) {
-    //             // If href doesn't look like a URL, something is fishy, so warn the user
-    //             href_short = href + _(" (Invalid?)");
-    //             text_short = text;
-    //             return true;
-    //         }
-    //     } catch (Error error) {
-    //         warning("Error in Regex text for deceptive urls: %s", error.message);
-    //         return false;
-    //     }
-        
-    //     // Second, do the top levels of the two domains match?  We compare the top n levels,
-    //     // where n is the minimum of the number of levels of the two domains.
-    //     string[] href_parts = href_match.fetch_all();
-    //     string[] text_parts = text_match.fetch_all();
-    //     string[] text_domain = text_parts[2].down().reverse().split(".");
-    //     string[] href_domain = href_parts[2].down().reverse().split(".");
-    //     for (int i = 0; i < text_domain.length && i < href_domain.length; i++) {
-    //         if (text_domain[i] != href_domain[i]) {
-    //             if (href_parts[1] == "")
-    //                 href_parts[1] = "http://";;
-    //             if (text_parts[1] == "")
-    //                 text_parts[1] = href_parts[1];
-    //             string temp;
-    //             assemble_uris(href_parts, out temp, out href_short);
-    //             assemble_uris(text_parts, out text, out text_short);
-    //             return true;
-    //         }
-    //     }
-    //     return false;
-    // }
-    
-    // private void assemble_uris(string[] parts, out string full, out string short_) {
-    //     full = parts[1] + parts[2];
-    //     short_ = parts[2];
-    //     if (parts.length == 4 && parts[3] != "/") {
-    //         full += parts[3];
-    //         if (parts[3].length > 20)
-    //             short_ += parts[3].substring(0, 20) + "…";
-    //         else
-    //             short_ += parts[3];
-    //     }
-    // }
-    
     // private void on_attachment_clicked(string attachment_id) {
     //     Geary.Attachment? attachment = null;
     //     try {
@@ -1268,6 +1165,122 @@ public class ConversationMessage : Gtk.Box {
         return false;
     }
     
+    /*
+     * Test whether text looks like a URI that leads somewhere other than href.  The text
+     * will have a scheme prepended if it doesn't already have one, and the short versions
+     * have the scheme skipped and long paths truncated.
+     */
+    private bool deceptive_text(string href, ref string text, out string href_short,
+        out string text_short) {
+        href_short = "";
+        text_short = "";
+        // mailto URLs have a different form, and the worst they can do is pop up a composer,
+        // so we don't trigger on them.
+        if (href.has_prefix("mailto:";))
+            return false;
+        
+        // First, does text look like a URI?  Right now, just test whether it has
+        // <string>.<string> in it.  More sophisticated tests are possible.
+        GLib.MatchInfo text_match, href_match;
+        try {
+            GLib.Regex domain = new GLib.Regex(
+                "([a-z]*://)?"                  // Optional scheme
+                + "([^\\s:/]+\\.[^\\s:/\\.]+)"  // Domain
+                + "(/[^\\s]*)?"                 // Optional path
+                );
+            if (!domain.match(text, 0, out text_match))
+                return false;
+            if (!domain.match(href, 0, out href_match)) {
+                // If href doesn't look like a URL, something is fishy, so warn the user
+                href_short = href + _(" (Invalid?)");
+                text_short = text;
+                return true;
+            }
+        } catch (Error error) {
+            warning("Error in Regex text for deceptive urls: %s", error.message);
+            return false;
+        }
+        
+        // Second, do the top levels of the two domains match?  We compare the top n levels,
+        // where n is the minimum of the number of levels of the two domains.
+        string[] href_parts = href_match.fetch_all();
+        string[] text_parts = text_match.fetch_all();
+        string[] text_domain = text_parts[2].down().reverse().split(".");
+        string[] href_domain = href_parts[2].down().reverse().split(".");
+        for (int i = 0; i < text_domain.length && i < href_domain.length; i++) {
+            if (text_domain[i] != href_domain[i]) {
+                if (href_parts[1] == "")
+                    href_parts[1] = "http://";;
+                if (text_parts[1] == "")
+                    text_parts[1] = href_parts[1];
+                string temp;
+                assemble_uris(href_parts, out temp, out href_short);
+                assemble_uris(text_parts, out text, out text_short);
+                return true;
+            }
+        }
+        return false;
+    }
+    
+    private void assemble_uris(string[] parts, out string full, out string short_) {
+        full = parts[1] + parts[2];
+        short_ = parts[2];
+        if (parts.length == 4 && parts[3] != "/") {
+            full += parts[3];
+            if (parts[3].length > 20)
+                short_ += parts[3].substring(0, 20) + "…";
+            else
+                short_ += parts[3];
+        }
+    }
+
+    private static void on_link_clicked(WebKit.DOM.Element element,
+                                        WebKit.DOM.Event event,
+                                        ConversationMessage message) {
+        if (message.on_link_clicked_self(element)) {
+            event.prevent_default();
+        }
+    }
+
+    // Check for possible phishing links, displays a popover if found.
+    // If not, lets it go through to the default handler.
+    private bool on_link_clicked_self(WebKit.DOM.Element element) {
+        string? href = element.get_attribute("href");
+        if (Geary.String.is_empty(href))
+            return false;
+        string text = ((WebKit.DOM.HTMLElement) element).get_inner_text();
+        string href_short, text_short;
+        if (!deceptive_text(href, ref text, out href_short, out text_short))
+            return false;
+
+        // Escape text and especially URLs since we got them from the
+        // HREF, and Gtk.Label.set_markup is a strict parser.
+        good_link_label.set_markup(
+            Markup.printf_escaped("<a href=\"%s\">%s</a>", text, text_short)
+        );
+        bad_link_label.set_markup(
+            Markup.printf_escaped("<a href=\"%s\">%s</a>", href, href_short)
+        );
+
+        // Work out the link's position, update the popover.
+        Gdk.Rectangle link_rect = Gdk.Rectangle();
+        web_view.get_allocation(out link_rect);
+        link_rect.x += (int) element.get_offset_left();
+        link_rect.y += (int) element.get_offset_top();
+        WebKit.DOM.Element? offset_parent = element.get_offset_parent();
+        while (offset_parent != null) {
+            link_rect.x += (int) offset_parent.get_offset_left();
+            link_rect.y += (int) offset_parent.get_offset_top();
+            offset_parent = offset_parent.get_offset_parent();
+        }
+        link_rect.width = (int) element.get_offset_width();
+        link_rect.height = (int) element.get_offset_height();
+        link_popover.set_pointing_to(link_rect);
+
+        link_popover.show();
+        return true;
+    }
+    
     // private void on_hovering_over_link(string? title, string? url) {
     //     // Copy the link the user is hovering over.  Note that when the user mouses-out, 
     //     // this signal is called again with null for both parameters.
diff --git a/src/client/conversation-viewer/conversation-viewer.vala 
b/src/client/conversation-viewer/conversation-viewer.vala
index c45cf90..16b3c29 100644
--- a/src/client/conversation-viewer/conversation-viewer.vala
+++ b/src/client/conversation-viewer/conversation-viewer.vala
@@ -524,10 +524,19 @@ public class ConversationViewer : Gtk.Stack {
         if (messages.contains(email))
             return;
 
+        ConversationMessage message = new ConversationMessage(email, current_folder);
+        message.link_selected.connect((link) => { link_selected(link); });
+        message.web_view.button_release_event.connect_after((event) => {
+                // Consume all non-consumed clicks so the row is not
+                // inadvertently activated after clicking on the
+                // message body.
+                return true;
+            });
+
         Gtk.ListBoxRow row = new Gtk.ListBoxRow();
         row.get_style_context().add_class("frame");
         row.show();
-        row.add(new ConversationMessage(email, current_folder));
+        row.add(message);
         conversation_listbox.add(row);
 
         messages.add(email);
diff --git a/ui/conversation-message.ui b/ui/conversation-message.ui
index 0b1eb16..4aff05c 100644
--- a/ui/conversation-message.ui
+++ b/ui/conversation-message.ui
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!-- Generated with glade 3.20.0 -->
 <interface>
-  <requires lib="gtk+" version="3.10"/>
+  <requires lib="gtk+" version="3.12"/>
   <template class="ConversationMessage" parent="GtkBox">
     <property name="name">ConversationMessage</property>
     <property name="visible">True</property>
@@ -436,4 +436,74 @@
       <widget name="date_label"/>
     </widgets>
   </object>
+  <object class="GtkPopover" id="link_popover">
+    <property name="can_focus">False</property>
+    <property name="relative_to">body_box</property>
+    <property name="position">bottom</property>
+    <child>
+      <object class="GtkBox">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_left">6</property>
+        <property name="margin_right">6</property>
+        <property name="margin_top">6</property>
+        <property name="margin_bottom">6</property>
+        <property name="orientation">vertical</property>
+        <property name="spacing">6</property>
+        <child>
+          <object class="GtkLabel">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label" translatable="yes">This link appears to go to:</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="good_link_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label">&lt;a 
href="http://goodlink.com"&gt;http://goodlink.com&lt;/a&gt;</property>
+            <property name="use_markup">True</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label" translatable="yes">But actually goes to:</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">2</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="bad_link_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label">&lt;a href="http://badlink.com"&gt;http://badlink.com&lt;/a&gt;</property>
+            <property name="use_markup">True</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">3</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+    <style>
+      <class name="tooltip"/>
+    </style>
+  </object>
 </interface>


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]