[geary/wip/728002-webkit2: 12/96] Begin the WebKit2 port in earnest.



commit 85fd9ee4801fe6a2f4f4dc745a6e635ac186bb42
Author: Michael James Gratton <mike vee net>
Date:   Thu Oct 6 23:09:21 2016 +1100

    Begin the WebKit2 port in earnest.
    
    Replace StylishWebView with ClientWebView, to act as a common base class
    for the composer, conversation and other uses of web views.
    
    Introduce a ComposerWebView that replaces WebviewEditFixer and extends
    ClientWebView, and adds (dummy for now) methods for ComposerWidget to
    call. Simiarly, make ConversationWebView extend ClientWebView, add dummy
    calls to support the conversation viewer classes. Move common code from
    both into ClientWebView.
    
    Add a web-process library, unused other than for compile-time checking,
    and move all client functions and methods involving DOM objects into util
    classes there.
    
    Bug 728002

 po/POTFILES.in                                     |    9 +-
 src/CMakeLists.txt                                 |   40 +-
 src/client/accounts/add-edit-page.vala             |   11 +-
 src/client/application/geary-application.vala      |    6 +-
 src/client/components/client-web-view.vala         |  190 +++++
 src/client/components/stylish-webview.vala         |   46 --
 src/client/composer/composer-embed.vala            |  128 ++--
 src/client/composer/composer-web-view.vala         |  164 +++++
 src/client/composer/composer-widget.vala           |  724 +++++++------------
 src/client/composer/webview-edit-fixer.vala        |  317 --------
 .../conversation-viewer/conversation-email.vala    |   33 +-
 .../conversation-viewer/conversation-message.vala  |  774 +++++---------------
 .../conversation-viewer/conversation-web-view.vala |  205 ++----
 src/client/web-process/util-composer.vala          |  488 ++++++++++++
 src/client/web-process/util-conversation.vala      |  364 +++++++++
 src/client/{util => web-process}/util-webkit.vala  |  132 +---
 src/client/web-process/web-process-extension.vala  |   11 +
 src/engine/util/util-html.vala                     |   22 +
 18 files changed, 1938 insertions(+), 1726 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 4ad8031..9a0d649 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -22,6 +22,7 @@ src/client/application/geary-config.vala
 src/client/application/geary-controller.vala
 src/client/application/main.vala
 src/client/application/secret-mediator.vala
+src/client/components/client-web-view.vala
 src/client/components/count-badge.vala
 src/client/components/empty-placeholder.vala
 src/client/components/folder-popover.vala
@@ -33,18 +34,17 @@ src/client/components/monitored-spinner.vala
 src/client/components/search-bar.vala
 src/client/components/status-bar.vala
 src/client/components/stock.vala
-src/client/components/stylish-webview.vala
 src/client/composer/composer-box.vala
 src/client/composer/composer-container.vala
 src/client/composer/composer-embed.vala
 src/client/composer/composer-headerbar.vala
+src/client/composer/composer-web-view.vala
 src/client/composer/composer-widget.vala
 src/client/composer/composer-window.vala
 src/client/composer/contact-entry-completion.vala
 src/client/composer/contact-list-store.vala
 src/client/composer/email-entry.vala
 src/client/composer/spell-check-popover.vala
-src/client/composer/webview-edit-fixer.vala
 src/client/conversation-list/conversation-list-cell-renderer.vala
 src/client/conversation-list/conversation-list-store.vala
 src/client/conversation-list/conversation-list-view.vala
@@ -87,7 +87,10 @@ src/client/util/util-gtk.vala
 src/client/util/util-international.vala
 src/client/util/util-migrate.vala
 src/client/util/util-random.vala
-src/client/util/util-webkit.vala
+src/client/web-process/web-process-extension.vala
+src/client/web-process/util-composer.vala
+src/client/web-process/util-conversation.vala
+src/client/web-process/util-webkit.vala
 src/console/main.vala
 src/engine/api/geary-abstract-local-folder.vala
 src/engine/api/geary-account-information.vala
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 29d2416..2e5a017 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -331,6 +331,7 @@ client/accounts/account-spinner-page.vala
 client/accounts/add-edit-page.vala
 client/accounts/login-dialog.vala
 
+client/components/client-web-view.vala
 client/components/count-badge.vala
 client/components/empty-placeholder.vala
 client/components/folder-popover.vala
@@ -342,19 +343,18 @@ client/components/monitored-spinner.vala
 client/components/search-bar.vala
 client/components/status-bar.vala
 client/components/stock.vala
-client/components/stylish-webview.vala
 
 client/composer/composer-box.vala
 client/composer/composer-container.vala
 client/composer/composer-embed.vala
 client/composer/composer-headerbar.vala
+client/composer/composer-web-view.vala
 client/composer/composer-widget.vala
 client/composer/composer-window.vala
 client/composer/contact-entry-completion.vala
 client/composer/contact-list-store.vala
 client/composer/email-entry.vala
 client/composer/spell-check-popover.vala
-client/composer/webview-edit-fixer.vala
 
 client/conversation-list/conversation-list-cell-renderer.vala
 client/conversation-list/conversation-list-store.vala
@@ -403,10 +403,16 @@ client/util/util-gravatar.vala
 client/util/util-gtk.vala
 client/util/util-international.vala
 client/util/util-random.vala
-client/util/util-webkit.vala
 client/util/util-migrate.vala
 )
 
+set(WEB_PROCESS_SRC
+client/web-process/web-process-extension.vala
+client/web-process/util-composer.vala
+client/web-process/util-conversation.vala
+client/web-process/util-webkit.vala
+)
+
 set(CONSOLE_SRC
 console/main.vala
 )
@@ -498,6 +504,7 @@ pkg_check_modules(DEPS REQUIRED
     gcr-3>=3.10.1
     gobject-introspection-1.0
     webkit2gtk-4.0>=2.6
+    webkit2gtk-web-extension-4.0>=2.6
     enchant>=1.6
     ${EXTRA_CLIENT_PKG_CONFIG}
 )
@@ -535,6 +542,10 @@ set(CLIENT_PACKAGES
   ${EXTRA_CLIENT_PACKAGES}
 )
 
+set(WEB_PROCESS_PACKAGES
+    gtk+-3.0 gee-0.8 webkit2gtk-web-extension-4.0
+)
+
 set(CONSOLE_PACKAGES
   geary-engine
   gtk+-3.0
@@ -665,6 +676,29 @@ add_custom_command(
 include(GSettings)
 add_schemas(geary ${GSETTINGS_DIR} ${CMAKE_INSTALL_PREFIX})
 
+# Client web process extension library
+#################################################
+vala_precompile(WEB_PROCESS_VALA_C geary-web-process
+    ${WEB_PROCESS_SRC}
+PACKAGES
+    ${WEB_PROCESS_PACKAGES}
+    ${ENGINE_PACKAGES} ## XXX REMOVE ME
+CUSTOM_VAPIS
+    "${CMAKE_BINARY_DIR}/src/geary-static.vapi"
+OPTIONS
+    ${VALAC_OPTIONS}
+)
+
+add_library(geary-web-process ${WEB_PROCESS_VALA_C})
+target_link_libraries(geary-web-process ${DEPS_LIBRARIES} gthread-2.0)
+add_custom_command(
+    TARGET
+        geary-web-process
+    POST_BUILD
+    COMMAND
+        ${CMAKE_COMMAND} -E copy geary-console ${CMAKE_BINARY_DIR}/
+)
+
 # Console app
 #################################################
 vala_precompile(CONSOLE_VALA_C geary-console
diff --git a/src/client/accounts/add-edit-page.vala b/src/client/accounts/add-edit-page.vala
index 64825a9..770ab39 100644
--- a/src/client/accounts/add-edit-page.vala
+++ b/src/client/accounts/add-edit-page.vala
@@ -178,8 +178,8 @@ public class AddEditPage : Gtk.Box {
     private Gtk.CheckButton check_use_email_signature;
     private Gtk.Stack signature_stack;
     private Gtk.TextView textview_email_signature;
-    private StylishWebView preview_webview;
-    
+    private ClientWebView preview_webview = new ClientWebView(null);
+
     private Gtk.Alignment other_info;
     
     // IMAP info widgets
@@ -271,13 +271,12 @@ public class AddEditPage : Gtk.Box {
         edit_window.set_shadow_type(Gtk.ShadowType.IN);
         textview_email_signature = new Gtk.TextView();
         edit_window.add(textview_email_signature);
-        
+
         Gtk.ScrolledWindow preview_window = new Gtk.ScrolledWindow(null, null);
         preview_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
         preview_window.set_shadow_type(Gtk.ShadowType.IN);
-        preview_webview = new StylishWebView();
         preview_window.add(preview_webview);
-        
+
         signature_stack = new Gtk.Stack();
         signature_stack.add_titled(edit_window, "edit_window", _("Edit"));
         signature_stack.child_set_property(edit_window, "icon-name", "text-editor-symbolic");
@@ -596,7 +595,7 @@ public class AddEditPage : Gtk.Box {
     
     private void on_signature_stack_changed() {
         if (signature_stack.visible_child_name == "preview_window")
-            preview_webview.load_html_string(Util.DOM.smart_escape(email_signature, true), "");
+            preview_webview.load_html(Geary.HTML.smart_escape(email_signature, true), null);
     }
 
     private uint16 get_default_smtp_port() {
diff --git a/src/client/application/geary-application.vala b/src/client/application/geary-application.vala
index 1aef984..11c1bb7 100644
--- a/src/client/application/geary-application.vala
+++ b/src/client/application/geary-application.vala
@@ -190,8 +190,10 @@ public class GearyApplication : Gtk.Application {
         
         Geary.Logging.init();
         Date.init();
-        WebKit.set_cache_model(WebKit.CacheModel.DOCUMENT_BROWSER);
-        
+
+        WebKit.WebContext context = WebKit.WebContext.get_default();
+        context.set_cache_model(WebKit.CacheModel.DOCUMENT_BROWSER);
+
         base.startup();
         
         add_action_entries(action_entries, this);
diff --git a/src/client/components/client-web-view.vala b/src/client/components/client-web-view.vala
new file mode 100644
index 0000000..68e4fe4
--- /dev/null
+++ b/src/client/components/client-web-view.vala
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public class ClientWebView : WebKit.WebView {
+
+
+    private const double ZOOM_DEFAULT = 1.0;
+    private const double ZOOM_FACTOR = 0.1;
+
+
+    public bool is_loaded { get; private set; default = false; }
+
+    private string _document_font;
+    public string document_font {
+        get {
+            return _document_font;
+        }
+        set {
+            _document_font = value;
+            Pango.FontDescription font = Pango.FontDescription.from_string(value);
+            WebKit.Settings settings = get_settings();
+            settings.default_font_family = font.get_family();
+            settings.default_font_size = font.get_size() / Pango.SCALE;
+            set_settings(settings);
+        }
+    }
+
+    private string _monospace_font;
+    public string monospace_font {
+        get {
+            return _monospace_font;
+        }
+        set {
+            _monospace_font = value;
+            Pango.FontDescription font = Pango.FontDescription.from_string(value);
+            WebKit.Settings settings = get_settings();
+            settings.monospace_font_family = font.get_family();
+            settings.default_monospace_font_size = font.get_size() / Pango.SCALE;
+            set_settings(settings);
+        }
+    }
+
+    // We need to wrap zoom_level (type float) because we cannot connect with float
+    // with double (cf https://bugzilla.gnome.org/show_bug.cgi?id=771534)
+    public double zoom_level_wrap {
+        get { return zoom_level; }
+        set { if (zoom_level != (float)value) zoom_level = (float)value; }
+    }
+
+    public string allow_prefix { get; private set; }
+
+    private Gee.Map<string,File> cid_resources = new Gee.HashMap<string,File>();
+
+
+    /** Emitted when a user clicks a link in this web view. */
+    public signal void link_activated(string uri);
+
+
+    public ClientWebView(WebKit.UserContentManager? content_manager = null) {
+        WebKit.Settings setts = new WebKit.Settings();
+        setts.enable_javascript = false;
+        setts.enable_java = false;
+        setts.enable_plugins = false;
+        setts.enable_developer_extras = Args.inspector;
+        setts.javascript_can_access_clipboard = true;
+
+        Object(user_content_manager: content_manager, settings: setts);
+
+        this.allow_prefix = random_string(10) + ":";
+
+        this.resource_load_started.connect(on_resource_load_started);
+        this.decide_policy.connect(on_decide_policy);
+        this.load_changed.connect((web_view, event) => {
+                if (event == WebKit.LoadEvent.FINISHED) {
+                    this.is_loaded = true;
+                }
+            });
+
+        GearyApplication.instance.config.bind(Configuration.CONVERSATION_VIEWER_ZOOM_KEY, this, 
"zoom_level_wrap");
+        this.notify["zoom-level"].connect(() => { zoom_level_wrap = zoom_level; });
+        this.scroll_event.connect(on_scroll_event);
+
+        Settings system_settings = GearyApplication.instance.config.gnome_interface;
+        system_settings.bind("document-font-name", this, "document-font", SettingsBindFlags.DEFAULT);
+        system_settings.bind("monospace-font-name", this, "monospace-font", SettingsBindFlags.DEFAULT);
+    }
+
+    public void add_cid_resource(string cid, File file) {
+        this.cid_resources[cid] = file;
+    }
+
+    /**
+     * Selects all content in the web view.
+     */
+    public void select_all() {
+        execute_editing_command(WebKit.EDITING_COMMAND_SELECT_ALL);
+    }
+
+    /**
+     * Sends a copy command to the web view.
+     */
+    public void copy_clipboard() {
+        execute_editing_command(WebKit.EDITING_COMMAND_CUT);
+    }
+
+    public bool can_copy_clipboard() {
+        // can_execute_editing_command.begin(
+        //     WebKit.EDITING_COMMAND_COPY,
+        //     null,
+        //     (obj, res) => {
+        //         return can_execute_editing_command.end(res);
+        //     });
+        return false;
+    }
+
+    public void reset_zoom() {
+        this.zoom_level == ZOOM_DEFAULT;
+    }
+
+    public void zoom_in() {
+        this.zoom_level += (this.zoom_level * ZOOM_FACTOR);
+    }
+
+    public void zoom_out() {
+        this.zoom_level -= (this.zoom_level * ZOOM_FACTOR);
+    }
+
+    private void on_resource_load_started(WebKit.WebView view,
+                                          WebKit.WebResource resource,
+                                          WebKit.URIRequest request) {
+        const string ABOUT_BLANK = "about:blank";
+        const string CID_PREFIX = "cid:";
+        const string DATA_PREFIX = "data:";
+
+        string? req_uri = request.get_uri();
+        string resp_uri = ABOUT_BLANK;
+        if (req_uri.has_prefix(CID_PREFIX)) {
+            File? file = this.cid_resources[req_uri.substring(CID_PREFIX.length)];
+            if (file != null) {
+                resp_uri = file.get_uri();
+            }
+        } else if (req_uri.has_prefix(this.allow_prefix)) {
+            resp_uri = req_uri.substring(this.allow_prefix.length);
+        } else if (req_uri.has_prefix(DATA_PREFIX)) {
+            resp_uri = req_uri;
+        }
+        request.set_uri(resp_uri);
+    }
+
+    private bool on_decide_policy(WebKit.WebView view,
+                                  WebKit.PolicyDecision policy,
+                                  WebKit.PolicyDecisionType type) {
+        policy.ignore();
+        if (type == WebKit.PolicyDecisionType.NAVIGATION_ACTION) {
+            WebKit.NavigationPolicyDecision nav_policy =
+                (WebKit.NavigationPolicyDecision) policy;
+            if (nav_policy.navigation_action.is_user_gesture()) {
+                link_activated(nav_policy.request.uri);
+            }
+        }
+        return true;
+    }
+
+    private bool on_scroll_event(Gdk.EventScroll event) {
+        if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0) {
+            double dir = 0;
+            if (event.direction == Gdk.ScrollDirection.UP)
+                dir = -1;
+            else if (event.direction == Gdk.ScrollDirection.DOWN)
+                dir = 1;
+            else if (event.direction == Gdk.ScrollDirection.SMOOTH)
+                dir = event.delta_y;
+
+            if (dir < 0) {
+                zoom_in();
+                return true;
+            } else if (dir > 0) {
+                zoom_out();
+                return true;
+            }
+        }
+        return false;
+    }
+
+}
+
diff --git a/src/client/composer/composer-embed.vala b/src/client/composer/composer-embed.vala
index 64b60c1..c16f99a 100644
--- a/src/client/composer/composer-embed.vala
+++ b/src/client/composer/composer-embed.vala
@@ -23,11 +23,11 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
     protected Gee.MultiMap<string, string>? old_accelerators { get; set; }
 
     private Gtk.ScrolledWindow outer_scroller;
-    private bool setting_inner_scroll;
-    private bool scrolled_to_bottom = false;
-    private double inner_scroll_adj_value;
-    private int inner_view_height;
-    private int min_height = MIN_EDITOR_HEIGHT;
+    //private bool setting_inner_scroll;
+    //private bool scrolled_to_bottom = false;
+    //private double inner_scroll_adj_value;
+    //private int inner_view_height;
+    //private int min_height = MIN_EDITOR_HEIGHT;
 
 
     public signal void vanished();
@@ -49,7 +49,14 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
         realize.connect(on_realize);
         this.composer.editor.focus_in_event.connect(on_focus_in);
         this.composer.editor.focus_out_event.connect(on_focus_out);
-        this.composer.editor.document_load_finished.connect(on_loaded);
+        this.composer.editor.load_changed.connect((web_view, event) => {
+                if (event == WebKit.LoadEvent.FINISHED) {
+                    Idle.add(() => {
+                            recalc_height();
+                            return Source.REMOVE;
+                        });
+                }
+            });
         show();
     }
 
@@ -58,20 +65,13 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
 
         this.composer.editor_scrolled.get_vscrollbar().hide();
 
-        this.composer.editor.vadjustment.value_changed.connect(on_inner_scroll);
-        this.composer.editor.vadjustment.changed.connect(on_adjust_changed);
-        this.composer.editor.user_changed_contents.connect(on_inner_size_changed);
+        //this.composer.editor.vadjustment.value_changed.connect(on_inner_scroll);
+        //this.composer.editor.vadjustment.changed.connect(on_adjust_changed);
+        //this.composer.editor.user_changed_contents.connect(on_inner_size_changed);
 
         reroute_scroll_handling(this);
     }
 
-    private void on_loaded() {
-        Idle.add(() => {
-            recalc_height();
-            return Source.REMOVE;
-        });
-    }
-
     private void reroute_scroll_handling(Gtk.Widget widget) {
         widget.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK);
         widget.scroll_event.connect(on_inner_scroll_event);
@@ -111,9 +111,9 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
         this.composer.editor.focus_in_event.disconnect(on_focus_in);
         this.composer.editor.focus_out_event.disconnect(on_focus_out);
 
-        this.composer.editor.vadjustment.value_changed.disconnect(on_inner_scroll);
-        this.composer.editor.vadjustment.changed.disconnect(on_adjust_changed);
-        this.composer.editor.user_changed_contents.disconnect(on_inner_size_changed);
+        //this.composer.editor.vadjustment.value_changed.disconnect(on_inner_scroll);
+        //this.composer.editor.vadjustment.changed.disconnect(on_adjust_changed);
+        //this.composer.editor.user_changed_contents.disconnect(on_inner_size_changed);
 
         disable_scroll_reroute(this);
         this.composer.editor_scrolled.get_vscrollbar().show();
@@ -165,57 +165,57 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
         return true;
     }
 
-    private void on_inner_scroll(Gtk.Adjustment adj) {
-        double delta = adj.value - this.inner_scroll_adj_value;
-        this.inner_scroll_adj_value = adj.value;
-        if (delta != 0 && !this.setting_inner_scroll) {
-            Gtk.Adjustment outer_adj = outer_scroller.vadjustment;
-            outer_adj.set_value(outer_adj.value + delta);
-        }
-    }
-
-    private void on_adjust_changed(Gtk.Adjustment adj) {
-        if (this.scrolled_to_bottom) {
-            this.setting_inner_scroll = true;
-            adj.set_value(adj.upper);
-            this.setting_inner_scroll = false;
-        }
-    }
-
-    private void on_inner_size_changed() {
-        this.scrolled_to_bottom = false;  // The inserted character may cause a desired scroll
-        Idle.add(recalc_height);  // So that this runs after the character has been inserted
-    }
+    // private void on_inner_scroll(Gtk.Adjustment adj) {
+    //     double delta = adj.value - this.inner_scroll_adj_value;
+    //     this.inner_scroll_adj_value = adj.value;
+    //     if (delta != 0 && !this.setting_inner_scroll) {
+    //         Gtk.Adjustment outer_adj = outer_scroller.vadjustment;
+    //         outer_adj.set_value(outer_adj.value + delta);
+    //     }
+    // }
+
+    // private void on_adjust_changed(Gtk.Adjustment adj) {
+    //     if (this.scrolled_to_bottom) {
+    //         this.setting_inner_scroll = true;
+    //         adj.set_value(adj.upper);
+    //         this.setting_inner_scroll = false;
+    //     }
+    // }
+
+    // private void on_inner_size_changed() {
+    //     this.scrolled_to_bottom = false;  // The inserted character may cause a desired scroll
+    //     Idle.add(recalc_height);  // So that this runs after the character has been inserted
+    // }
 
     private bool recalc_height() {
-        int view_height,
-            base_height = get_allocated_height() - this.composer.editor.get_allocated_height();
-        try {
-            view_height = (int) this.composer.editor.get_dom_document()
-                .query_selector("#message-body").offset_height;
-        } catch (Error error) {
-            debug("Error getting height of editor: %s", error.message);
-            return Source.REMOVE;
-        }
+        // int view_height,
+        //     base_height = get_allocated_height() - this.composer.editor.get_allocated_height();
+        // try {
+        //     view_height = (int) this.composer.editor.get_dom_document()
+        //         .query_selector("#message-body").offset_height;
+        // } catch (Error error) {
+        //     debug("Error getting height of editor: %s", error.message);
+        //     return Source.REMOVE;
+        // }
 
-        if (view_height != inner_view_height || min_height != base_height + MIN_EDITOR_HEIGHT) {
-            this.inner_view_height = view_height;
-            this.min_height = base_height + MIN_EDITOR_HEIGHT;
+        // if (view_height != inner_view_height || min_height != base_height + MIN_EDITOR_HEIGHT) {
+        //     this.inner_view_height = view_height;
+        //     this.min_height = base_height + MIN_EDITOR_HEIGHT;
 
-            // Calculate height widget should be to avoid scrolling in editor
-            int widget_height = int.max(view_height + base_height - 2, min_height); //? about 2
+        //     // Calculate height widget should be to avoid scrolling in editor
+        //     int widget_height = int.max(view_height + base_height - 2, min_height); //? about 2
 
-            // XXX Clamp the widget height to something arbitrary for
-            // the same reasons as in
-            // ConversationWebView::get_preferred_height, to avoid a
-            // crash. See Bug 765516 and Bug 728002.
-            const int MAX_HEIGHT = 5000;
-            if (widget_height > MAX_HEIGHT) {
-                widget_height = MAX_HEIGHT;
-            }
+        //     // XXX Clamp the widget height to something arbitrary for
+        //     // the same reasons as in
+        //     // ConversationWebView::get_preferred_height, to avoid a
+        //     // crash. See Bug 765516 and Bug 728002.
+        //     const int MAX_HEIGHT = 5000;
+        //     if (widget_height > MAX_HEIGHT) {
+        //         widget_height = MAX_HEIGHT;
+        //     }
 
-            set_size_request(-1, widget_height);
-        }
+        //     set_size_request(-1, widget_height);
+        // }
         return Source.REMOVE;
     }
 
diff --git a/src/client/composer/composer-web-view.vala b/src/client/composer/composer-web-view.vala
new file mode 100644
index 0000000..ddab2c8
--- /dev/null
+++ b/src/client/composer/composer-web-view.vala
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2016 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 WebView for editing messages in the composer.
+ */
+public class ComposerWebView : ClientWebView {
+
+
+    private bool is_shift_down = false;
+
+
+    public signal void text_attributes_changed(uint wk_typing_attrs);
+
+
+    public ComposerWebView() {
+        get_editor_state().notify["typing-attributes"].connect(() => {
+                text_attributes_changed(get_editor_state().typing_attributes);
+            });
+
+        // this.should_insert_text.connect(on_should_insert_text);
+        this.key_press_event.connect(on_key_press_event);
+    }
+
+    public bool can_undo() {
+        // can_execute_editing_command.begin(
+        //     WebKit.EDITING_COMMAND_UNDO,
+        //     null,
+        //     (obj, res) => {
+        //         return can_execute_editing_command.end(res);
+        //     });
+        return false;
+    }
+
+    public bool can_redo() {
+        // can_execute_editing_command.begin(
+        //     WebKit.EDITING_COMMAND_REDO,
+        //     null,
+        //     (obj, res) => {
+        //         return can_execute_editing_command.end(res);
+        //     });
+        return false;
+    }
+
+    /**
+     * Sends a cut command to the editor.
+     */
+    public void cut_clipboard() {
+        execute_editing_command(WebKit.EDITING_COMMAND_CUT);
+    }
+
+    public bool can_cut_clipboard() {
+        // can_execute_editing_command.begin(
+        //     WebKit.EDITING_COMMAND_CUT,
+        //     null,
+        //     (obj, res) => {
+        //         return can_execute_editing_command.end(res);
+        //     });
+        return false;
+    }
+
+    /**
+     * Sends a paste command to the editor.
+     */
+    public void paste_clipboard() {
+        execute_editing_command(WebKit.EDITING_COMMAND_PASTE);
+    }
+
+    public bool can_paste_clipboard() {
+        // can_execute_editing_command.begin(
+        //     WebKit.EDITING_COMMAND_PASTE,
+        //     null,
+        //     (obj, res) => {
+        //         return can_execute_editing_command.end(res);
+        //     });
+        return false;
+    }
+
+    /**
+     * Inserts some text at the current cursor location.
+     */
+    public void insert_text(string text) {
+        // XXX
+    }
+
+    /**
+     * Inserts some text at the current cursor location, quoting it.
+     */
+    public void insert_quote(string text) {
+        // XXX
+    }
+
+    /**
+     * Sets whether the editor is in rich text or plain text mode.
+     */
+    public void enable_rich_text(bool enabled) {
+        // XXX
+    }
+
+    /**
+     * ???
+     */
+    public void linkify_document() {
+        // XXX
+    }
+
+    /**
+     * ???
+     */
+    public string get_block_quote_representation() {
+        return ""; // XXX
+    }
+
+    /**
+     * ???
+     */
+    public void undo_blockquote_style() {
+        // XXX
+    }
+
+    /**
+     * Returns the editor content as an HTML string.
+     */
+    public string get_html() {
+        return ""; // XXX
+    }
+
+    /**
+     * Returns the editor content as a plain text string.
+     */
+    public string get_text() {
+        return ""; // XXX
+    }
+
+    /**
+     * ???
+     */
+    public void load_finished_and_realised() {
+        // XXX
+    }
+
+    /**
+     * ???
+     */
+    public bool handle_key_press(Gdk.EventKey event) {
+        // XXX
+        return false;
+    }
+
+    // We really want to examine
+    // Gdk.Keymap.get_default().get_modifier_state(), instead of
+    // storing whether the shift key is down at each keypress, but it
+    // isn't yet available in the Vala bindings.
+    private bool on_key_press_event (Gdk.EventKey event) {
+        is_shift_down = (event.state & Gdk.ModifierType.SHIFT_MASK) != 0;
+        return false;
+    }
+
+}
diff --git a/src/client/composer/composer-widget.vala b/src/client/composer/composer-widget.vala
index 3efab3d..6444da2 100644
--- a/src/client/composer/composer-widget.vala
+++ b/src/client/composer/composer-widget.vala
@@ -151,7 +151,6 @@ public class ComposerWidget : Gtk.EventBox {
 
     private const string URI_LIST_MIME_TYPE = "text/uri-list";
     private const string FILE_URI_PREFIX = "file://";
-    private const string BODY_ID = "message-body";
     private const string HTML_BODY = """
         <html><head><title></title>
         <style>
@@ -245,7 +244,7 @@ public class ComposerWidget : Gtk.EventBox {
         owned get { return get_html(); }
         set {
             this.body_html = value;
-            this.editor.load_string(HTML_BODY, "text/html", "UTF-8", "");
+            this.editor.load_html(HTML_BODY, null);
         }
     }
 
@@ -257,13 +256,20 @@ public class ComposerWidget : Gtk.EventBox {
 
     public bool blank {
         get {
-            return this.to_entry.empty && this.cc_entry.empty && this.bcc_entry.empty && 
this.reply_to_entry.empty &&
-                this.subject_entry.buffer.length == 0 && !this.editor.can_undo() && this.attached_files.size 
== 0;
+            return this.to_entry.empty &&
+                this.cc_entry.empty &&
+                this.bcc_entry.empty &&
+                this.reply_to_entry.empty &&
+                this.subject_entry.buffer.length == 0 &&
+                !this.editor.can_undo() &&
+                this.attached_files.size == 0;
         }
     }
 
     public ComposerHeaderbar header { get; private set; }
 
+    public ComposerWebView editor { get; private set; default = new ComposerWebView(); }
+
     public string draft_save_text { get; private set; }
 
     public bool can_delete_quote { get; private set; default = false; }
@@ -370,12 +376,6 @@ public class ComposerWidget : Gtk.EventBox {
     private uint draft_save_timeout_id = 0;
     private bool is_closing = false;
 
-    public WebKit.WebView editor = new StylishWebView();
-    // We need to keep a reference to the edit-fixer in composer-window, so it doesn't get
-    // garbage-collected.
-    private WebViewEditFixer edit_fixer;
-    private string editor_allow_prefix = "";
-
     private ComposerContainer container {
         get { return (ComposerContainer) parent; }
     }
@@ -497,9 +497,6 @@ public class ComposerWidget : Gtk.EventBox {
         else
             set_cursor();
 
-        this.edit_fixer = new WebViewEditFixer(editor);
-        this.editor_allow_prefix = random_string(10) + ":";
-
         // Add actions once every element has been initialized and added
         initialize_actions();
 
@@ -510,39 +507,33 @@ public class ComposerWidget : Gtk.EventBox {
         this.cc_entry.changed.connect(validate_send_button);
         this.bcc_entry.changed.connect(validate_send_button);
         this.reply_to_entry.changed.connect(validate_send_button);
-        this.editor.load_finished.connect(on_load_finished);
-        this.editor.hovering_over_link.connect(on_hovering_over_link);
         this.editor.context_menu.connect(on_context_menu);
-        this.editor.move_focus.connect(update_actions);
-        this.editor.copy_clipboard.connect(update_actions);
-        this.editor.cut_clipboard.connect(update_actions);
-        this.editor.paste_clipboard.connect(update_actions);
-        this.editor.undo.connect(update_actions);
-        this.editor.redo.connect(update_actions);
-        this.editor.selection_changed.connect(update_actions);
+        this.editor.link_activated.connect(on_link_activated);
+        this.editor.load_changed.connect(on_load_changed);
+        this.editor.mouse_target_changed.connect(on_mouse_target_changed);
+        this.editor.text_attributes_changed.connect(on_text_attributes_changed);
+        // this.editor.move_focus.connect(update_actions);
+        // this.editor.copy_clipboard.connect(update_actions);
+        // this.editor.cut_clipboard.connect(update_actions);
+        // this.editor.paste_clipboard.connect(update_actions);
+        // this.editor.undo.connect(update_actions);
+        // this.editor.redo.connect(update_actions);
+        // this.editor.selection_changed.connect(update_actions);
         this.editor.key_press_event.connect(on_editor_key_press);
-        this.editor.resource_request_starting.connect(on_resource_request_starting);
-        this.editor.user_changed_contents.connect(reset_draft_timer);
-        this.editor.web_inspector.inspect_web_view.connect(on_inspect_web_view);
+        //this.editor.user_changed_contents.connect(reset_draft_timer);
 
         // only do this after setting body_html
-        this.editor.load_string(HTML_BODY, "text/html", "UTF8", "");
-
-        this.editor.navigation_policy_decision_requested.connect(on_navigation_policy_decision_requested);
-        this.editor.new_window_policy_decision_requested.connect(on_navigation_policy_decision_requested);
+        this.editor.load_html(HTML_BODY, null);
 
         GearyApplication.instance.config.settings.changed[Configuration.SPELL_CHECK_KEY].connect(
             on_spell_check_changed);
 
-        WebKit.WebSettings s = this.editor.settings;
-        s.enable_spell_checking = GearyApplication.instance.config.spell_check;
-        s.spell_checking_languages = string.joinv(",",
-                                                  GearyApplication.instance.config.spell_check_languages);
-        s.enable_scripts = false;
-        s.enable_java_applet = false;
-        s.enable_plugins = false;
-        s.enable_developer_extras = Args.inspector;
-        this.editor.settings = s;
+        // WebKit.Settings s = this.editor.settings;
+        // s.enable_spell_checking = GearyApplication.instance.config.spell_check;
+        // s.spell_checking_languages = string.joinv(
+        //     ",", GearyApplication.instance.config.spell_check_languages
+        // );
+        // this.editor.settings = s;
 
         this.editor_scrolled.add(editor);
 
@@ -846,59 +837,33 @@ public class ComposerWidget : Gtk.EventBox {
         return false;
     }
 
-    private void on_load_finished(WebKit.WebFrame frame) {
-        if (get_realized())
-            on_load_finished_and_realized();
-        else
-            realize.connect(on_load_finished_and_realized);
+    private void on_load_changed(WebKit.WebView view, WebKit.LoadEvent event) {
+        if (event == WebKit.LoadEvent.FINISHED) {
+            if (get_realized())
+                on_load_finished_and_realized();
+            else
+                realize.connect(on_load_finished_and_realized);
+        }
     }
 
     private void on_load_finished_and_realized() {
         // This is safe to call even when this connection hasn't been made.
         realize.disconnect(on_load_finished_and_realized);
-        WebKit.DOM.Document document = this.editor.get_dom_document();
-        WebKit.DOM.HTMLElement? body = document.get_element_by_id(BODY_ID) as WebKit.DOM.HTMLElement;
-        assert(body != null);
 
         if (!Geary.String.is_empty(this.body_html)) {
-            try {
-                body.set_inner_html(this.body_html);
-            } catch (Error e) {
-                debug("Failed to load prefilled body: %s", e.message);
-            }
-        }
-        body.focus();  // Focus within the HTML document
-
-        // Set cursor at appropriate position
-        try {
-            WebKit.DOM.Element? cursor = document.get_element_by_id("cursormarker");
-            if (cursor != null) {
-                WebKit.DOM.Range range = document.create_range();
-                range.select_node_contents(cursor);
-                range.collapse(false);
-                WebKit.DOM.DOMSelection selection = document.default_view.get_selection();
-                selection.remove_all_ranges();
-                selection.add_range(range);
-                cursor.parent_element.remove_child(cursor);
-            }
-        } catch (Error error) {
-            debug("Error setting cursor at end of text: %s", error.message);
+            this.editor.load_finished_and_realised();
         }
 
-        protect_blockquote_styles();
-
-        set_focus();  // Focus in the GTK widget hierarchy
-
         on_spell_check_changed();
-
-        Util.DOM.bind_event(this.editor, "a", "click", (Callback) on_link_clicked, this);
         update_actions();
+
         this.actions.change_action_state(ACTION_SHOW_EXTENDED, false);
         this.actions.change_action_state(ACTION_COMPOSE_AS_HTML,
             GearyApplication.instance.config.compose_as_html);
 
-        if (can_delete_quote)
-            this.editor.selection_changed.connect(() => { this.can_delete_quote = false; });
+        // XXX
+        // if (can_delete_quote)
+        //     this.editor.selection_changed.connect(() => { this.can_delete_quote = false; });
     }
 
     private void show_attachment_overlay(bool visible) {
@@ -1002,7 +967,7 @@ public class ComposerWidget : Gtk.EventBox {
         email.inline_files.add_all(this.inline_files);
         email.cid_files.set_all(this.cid_files);
 
-        email.img_src_prefix = this.editor_allow_prefix;
+        email.img_src_prefix = this.editor.allow_prefix;
 
         if (actions.get_action_state(ACTION_COMPOSE_AS_HTML).get_boolean() || only_html)
             email.body_html = get_html();
@@ -1027,10 +992,10 @@ public class ComposerWidget : Gtk.EventBox {
         string? quote = null) {
         if (referred != null && quote != null && quote != this.last_quote) {
             this.last_quote = quote;
-            WebKit.DOM.Document document = this.editor.get_dom_document();
             // Always use reply styling, since forward styling doesn't work for inline quotes
-            document.exec_command("insertHTML", false,
-                Geary.RFC822.Utils.quote_email_for_reply(referred, quote, Geary.RFC822.TextFormat.HTML));
+            this.editor.insert_quote(
+                Geary.RFC822.Utils.quote_email_for_reply(referred, quote, Geary.RFC822.TextFormat.HTML)
+            );
 
             if (!referred_ids.contains(referred.id)) {
                 add_recipients_and_ids(new_type, referred);
@@ -1142,7 +1107,7 @@ public class ComposerWidget : Gtk.EventBox {
                     set_cursor();
                     return;
                 }
-                signature = Util.DOM.smart_escape(signature, false);
+                signature = Geary.HTML.smart_escape(signature, false);
             } catch (Error error) {
                 debug("Error reading signature file %s: %s", signature_file.get_path(), error.message);
                 set_cursor();
@@ -1154,7 +1119,7 @@ public class ComposerWidget : Gtk.EventBox {
                 set_cursor();
                 return;
             }
-            signature = Util.DOM.smart_escape(signature, true);
+            signature = Geary.HTML.smart_escape(signature, true);
         }
 
         if (this.body_html == null)
@@ -1301,12 +1266,12 @@ public class ComposerWidget : Gtk.EventBox {
     private bool email_contains_attachment_keywords() {
         // Filter out all content contained in block quotes
         string filtered = @"$subject\n";
-        filtered += Util.DOM.get_text_representation(this.editor.get_dom_document(), "blockquote");
+        filtered += this.editor.get_block_quote_representation();
         
         Regex url_regex = null;
         try {
             // Prepare to ignore urls later
-            url_regex = new Regex(URL_REGEX, RegexCompileFlags.CASELESS);
+            url_regex = new Regex(Geary.HTML.URL_REGEX, RegexCompileFlags.CASELESS);
         } catch (Error error) {
             debug("Error building regex in keyword checker: %s", error.message);
         }
@@ -1387,7 +1352,7 @@ public class ComposerWidget : Gtk.EventBox {
         this.container.vanish();
         this.is_closing = true;
         
-        Util.DOM.linkify_document(this.editor.get_dom_document());
+        this.editor.linkify_document();
         
         // Perform send.
         try {
@@ -1568,6 +1533,7 @@ public class ComposerWidget : Gtk.EventBox {
                         // attachment instead.
                         if (part.content_id != null) {
                             this.cid_files[part.content_id] = file;
+                            this.editor.add_cid_resource(part.content_id, file);
                         } else {
                             type = Geary.Mime.DispositionType.ATTACHMENT;
                         }
@@ -1726,7 +1692,7 @@ public class ComposerWidget : Gtk.EventBox {
     }
 
     private void on_justify(SimpleAction action, Variant? param) {
-        this.editor.get_dom_document().exec_command("justify" + param.get_string(), false, "");
+        this.editor.execute_editing_command("justify" + param.get_string());
     }
 
     private void on_action(SimpleAction action, Variant? param) {
@@ -1736,7 +1702,7 @@ public class ComposerWidget : Gtk.EventBox {
         // We need the unprefixed name to send as a command to the editor
         string[] prefixed_action_name = action.get_name().split(".");
         string action_name = prefixed_action_name[prefixed_action_name.length - 1];
-        this.editor.get_dom_document().exec_command(action_name, false, "");
+        this.editor.execute_editing_command(action_name);
     }
 
     private void on_cut(SimpleAction action, Variant? param) {
@@ -1755,69 +1721,13 @@ public class ComposerWidget : Gtk.EventBox {
 
     private void on_copy_link(SimpleAction action, Variant? param) {
         Gtk.Clipboard c = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD);
-        c.set_text(hover_url, -1);
+        c.set_text(this.hover_url, -1);
         c.store();
     }
 
-    private WebKit.DOM.Node? get_left_text(WebKit.DOM.Node node, long offset) {
-        WebKit.DOM.Document document = this.editor.get_dom_document();
-        string node_value = node.node_value;
-
-        // Offset is in unicode characters, but index is in bytes. We need to get the corresponding
-        // byte index for the given offset.
-        int char_count = node_value.char_count();
-        int index = offset > char_count ? node_value.length : node_value.index_of_nth_char(offset);
-
-        return offset > 0 ? document.create_text_node(node_value[0:index]) : null;
-    }
-
     private void on_clipboard_text_received(Gtk.Clipboard clipboard, string? text) {
-        if (text == null)
-            return;
-        
-        // Insert plain text from clipboard.
-        WebKit.DOM.Document document = this.editor.get_dom_document();
-        document.exec_command("inserttext", false, text);
-    
-        // The inserttext command will not scroll if needed, but we can't use the clipboard
-        // for plain text. WebKit allows us to scroll a node into view, but not an arbitrary
-        // position within a text node. So we add a placeholder node at the cursor position,
-        // scroll to that, then remove the placeholder node.
-        try {
-            WebKit.DOM.DOMSelection selection = document.default_view.get_selection();
-            WebKit.DOM.Node selection_base_node = selection.get_base_node();
-            long selection_base_offset = selection.get_base_offset();
-            
-            WebKit.DOM.NodeList selection_child_nodes = selection_base_node.get_child_nodes();
-            WebKit.DOM.Node ref_child = selection_child_nodes.item(selection_base_offset);
-        
-            WebKit.DOM.Element placeholder = document.create_element("SPAN");
-            WebKit.DOM.Text placeholder_text = document.create_text_node("placeholder");
-            placeholder.append_child(placeholder_text);
-            
-            if (selection_base_node.node_name == "#text") {
-                WebKit.DOM.Node? left = get_left_text(selection_base_node, selection_base_offset);
-                
-                WebKit.DOM.Node parent = selection_base_node.parent_node;
-                if (left != null)
-                    parent.insert_before(left, selection_base_node);
-                parent.insert_before(placeholder, selection_base_node);
-                parent.remove_child(selection_base_node);
-                
-                placeholder.scroll_into_view_if_needed(false);
-                parent.insert_before(selection_base_node, placeholder);
-                if (left != null)
-                    parent.remove_child(left);
-                parent.remove_child(placeholder);
-                selection.set_base_and_extent(selection_base_node, selection_base_offset, 
selection_base_node, selection_base_offset);
-            } else {
-                selection_base_node.insert_before(placeholder, ref_child);
-                placeholder.scroll_into_view_if_needed(false);
-                selection_base_node.remove_child(placeholder);
-            }
-            
-        } catch (Error err) {
-            debug("Error scrolling pasted text into view: %s", err.message);
+        if (text != null) {
+            this.editor.insert_text(text);
         }
     }
 
@@ -1838,11 +1748,11 @@ public class ComposerWidget : Gtk.EventBox {
     }
 
     private void on_remove_format(SimpleAction action, Variant? param) {
-        this.editor.get_dom_document().exec_command("removeformat", false, "");
-        this.editor.get_dom_document().exec_command("removeparaformat", false, "");
-        this.editor.get_dom_document().exec_command("unlink", false, "");
-        this.editor.get_dom_document().exec_command("backcolor", false, "#ffffff");
-        this.editor.get_dom_document().exec_command("forecolor", false, "#000000");
+        this.editor.execute_editing_command("removeformat");
+        this.editor.execute_editing_command("removeparaformat");
+        this.editor.execute_editing_command("unlink");
+        this.editor.execute_editing_command_with_argument("backcolor", "#ffffff");
+        this.editor.execute_editing_command_with_argument("forecolor", "#000000");
     }
 
     // Use this for toggle actions, and use the change-state signal to respond to these state changes
@@ -1860,18 +1770,8 @@ public class ComposerWidget : Gtk.EventBox {
 
         this.menu_button.menu_model = (compose_as_html) ? this.html_menu : this.plain_menu;
 
-        // style editor accordingly
-        WebKit.DOM.DOMTokenList body_classes = this.editor.get_dom_document().body.get_class_list();
-        try {
-            if (compose_as_html)
-                body_classes.remove("plain");
-            else
-                body_classes.add("plain");
-        } catch (Error error) {
-            debug("Error setting composer style: %s", error.message);
-        }
+        this.editor.enable_rich_text(compose_as_html);
 
-        // Remember preference
         GearyApplication.instance.config.compose_as_html = compose_as_html;
     }
 
@@ -1888,7 +1788,9 @@ public class ComposerWidget : Gtk.EventBox {
     }
 
     private void on_font_family(SimpleAction action, Variant? param) {
-        this.editor.get_dom_document().exec_command("fontname", false, param.get_string());
+        this.editor.execute_editing_command_with_argument(
+            "fontname", param.get_string()
+        );
         action.set_state(param.get_string());
   }
 
@@ -1901,139 +1803,112 @@ public class ComposerWidget : Gtk.EventBox {
         else // Large
             size = "7";
 
-        this.editor.get_dom_document().exec_command("fontsize", false, size);
+        this.editor.execute_editing_command_with_argument("fontsize", size);
         action.set_state(param.get_string());
     }
 
     private void on_select_color() {
         Gtk.ColorChooserDialog dialog = new Gtk.ColorChooserDialog(_("Select Color"),
             this.container.top_window);
-        if (dialog.run() == Gtk.ResponseType.OK)
-            this.editor.get_dom_document().exec_command("forecolor", false, dialog.get_rgba().to_string());
-
+        if (dialog.run() == Gtk.ResponseType.OK) {
+            this.editor.execute_editing_command_with_argument(
+                "forecolor", dialog.get_rgba().to_string()
+            );
+        }
         dialog.destroy();
     }
 
     private void on_indent(SimpleAction action, Variant? param) {
         on_action(action, param);
-
-        // Undo styling of blockquotes
-        try {
-            WebKit.DOM.NodeList node_list = this.editor.get_dom_document().query_selector_all(
-                "blockquote[style=\"margin: 0 0 0 40px; border: none; padding: 0px;\"]");
-            for (int i = 0; i < node_list.length; ++i) {
-                WebKit.DOM.Element element = (WebKit.DOM.Element) node_list.item(i);
-                element.remove_attribute("style");
-                element.set_attribute("type", "cite");
-            }
-        } catch (Error error) {
-            debug("Error removing blockquote style: %s", error.message);
-        }
-    }
-
-    private void protect_blockquote_styles() {
-        // We will search for an remove a particular styling when we quote text.  If that style
-        // exists in the quoted text, we alter it slightly so we don't mess with it later.
-        try {
-            WebKit.DOM.NodeList node_list = this.editor.get_dom_document().query_selector_all(
-                "blockquote[style=\"margin: 0 0 0 40px; border: none; padding: 0px;\"]");
-            for (int i = 0; i < node_list.length; ++i) {
-                ((WebKit.DOM.Element) node_list.item(i)).set_attribute("style", 
-                    "margin: 0 0 0 40px; padding: 0px; border:none;");
-            }
-        } catch (Error error) {
-            debug("Error protecting blockquotes: %s", error.message);
-        }
+        this.editor.undo_blockquote_style();
     }
 
     private void link_dialog(string link) {
-        Gtk.Dialog dialog = new Gtk.Dialog();
-        bool existing_link = false;
-        
-        // Save information needed to re-establish selection
-        WebKit.DOM.DOMSelection selection = this.editor.get_dom_document().get_default_view().
-            get_selection();
-        WebKit.DOM.Node anchor_node = selection.anchor_node;
-        long anchor_offset = selection.anchor_offset;
-        WebKit.DOM.Node focus_node = selection.focus_node;
-        long focus_offset = selection.focus_offset;
-        
-        // Allow user to remove link if they're editing an existing one.
-        if (focus_node != null && (focus_node is WebKit.DOM.HTMLAnchorElement ||
-            focus_node.get_parent_element() is WebKit.DOM.HTMLAnchorElement)) {
-            existing_link = true;
-            dialog.add_buttons(Stock._REMOVE, Gtk.ResponseType.REJECT);
-        }
-        
-        dialog.add_buttons(Stock._CANCEL, Gtk.ResponseType.CANCEL, Stock._OK,
-            Gtk.ResponseType.OK);
-        
-        Gtk.Entry entry = new Gtk.Entry();
-        entry.changed.connect(() => {
-            // Only allow OK when there's text in the box.
-            dialog.set_response_sensitive(Gtk.ResponseType.OK, 
-                !Geary.String.is_empty(entry.text.strip()));
-        });
-        
-        dialog.width_request = 350;
-        dialog.get_content_area().spacing = 7;
-        dialog.get_content_area().border_width = 10;
-        dialog.get_content_area().pack_start(new Gtk.Label("Link URL:"));
-        dialog.get_content_area().pack_start(entry);
-        dialog.get_widget_for_response(Gtk.ResponseType.OK).can_default = true;
-        dialog.set_default_response(Gtk.ResponseType.OK);
-        dialog.show_all();
-        
-        entry.set_text(link);
-        entry.activates_default = true;
-        entry.move_cursor(Gtk.MovementStep.BUFFER_ENDS, 0, false);
-        
-        int response = dialog.run();
-        
-        // Re-establish selection, since selecting text in the Entry will de-select all
-        // in the WebView.
-        try {
-            selection.set_base_and_extent(anchor_node, anchor_offset, focus_node, focus_offset);
-        } catch (Error e) {
-            debug("Error re-establishing selection: %s", e.message);
-        }
-        
-        if (response == Gtk.ResponseType.OK)
-            this.editor.get_dom_document().exec_command("createLink", false, entry.text);
-        else if (response == Gtk.ResponseType.REJECT)
-            this.editor.get_dom_document().exec_command("unlink", false, "");
-        
-        dialog.destroy();
-        
+        // Gtk.Dialog dialog = new Gtk.Dialog();
+        // bool existing_link = false;
+
+        // // Save information needed to re-establish selection
+        // WebKit.DOM.DOMSelection selection = this.editor.get_dom_document().get_default_view().
+        //     get_selection();
+        // WebKit.DOM.Node anchor_node = selection.anchor_node;
+        // long anchor_offset = selection.anchor_offset;
+        // WebKit.DOM.Node focus_node = selection.focus_node;
+        // long focus_offset = selection.focus_offset;
+
+        // // Allow user to remove link if they're editing an existing one.
+        // if (focus_node != null && (focus_node is WebKit.DOM.HTMLAnchorElement ||
+        //     focus_node.get_parent_element() is WebKit.DOM.HTMLAnchorElement)) {
+        //     existing_link = true;
+        //     dialog.add_buttons(Stock._REMOVE, Gtk.ResponseType.REJECT);
+        // }
+
+        // dialog.add_buttons(Stock._CANCEL, Gtk.ResponseType.CANCEL, Stock._OK,
+        //     Gtk.ResponseType.OK);
+
+        // Gtk.Entry entry = new Gtk.Entry();
+        // entry.changed.connect(() => {
+        //     // Only allow OK when there's text in the box.
+        //     dialog.set_response_sensitive(Gtk.ResponseType.OK,
+        //         !Geary.String.is_empty(entry.text.strip()));
+        // });
+
+        // dialog.width_request = 350;
+        // dialog.get_content_area().spacing = 7;
+        // dialog.get_content_area().border_width = 10;
+        // dialog.get_content_area().pack_start(new Gtk.Label("Link URL:"));
+        // dialog.get_content_area().pack_start(entry);
+        // dialog.get_widget_for_response(Gtk.ResponseType.OK).can_default = true;
+        // dialog.set_default_response(Gtk.ResponseType.OK);
+        // dialog.show_all();
+
+        // entry.set_text(link);
+        // entry.activates_default = true;
+        // entry.move_cursor(Gtk.MovementStep.BUFFER_ENDS, 0, false);
+
+        // int response = dialog.run();
+
+        // // Re-establish selection, since selecting text in the Entry will de-select all
+        // // in the WebView.
+        // try {
+        //     selection.set_base_and_extent(anchor_node, anchor_offset, focus_node, focus_offset);
+        // } catch (Error e) {
+        //     debug("Error re-establishing selection: %s", e.message);
+        // }
+
+        // if (response == Gtk.ResponseType.OK)
+        //     this.editor.execute_editing_command_with_argument("createLink", entry.text);
+        // else if (response == Gtk.ResponseType.REJECT)
+        //     this.editor.execute_editing_command("unlink");
+
+        // dialog.destroy();
+
         // Re-bind to anchor links.  This must be done every time link have changed.
-        Util.DOM.bind_event(this.editor,"a", "click", (Callback) on_link_clicked, this);
+        //Util.DOM.bind_event(this.editor,"a", "click", (Callback) on_link_clicked, this);
     }
 
     private string get_html() {
-        return ((WebKit.DOM.HTMLElement) this.editor.get_dom_document().get_element_by_id(BODY_ID))
-            .get_inner_html();
+        return this.editor.get_html();
     }
 
     private string get_text() {
-        return Util.DOM.html_to_flowed_text((WebKit.DOM.HTMLElement) this.editor.get_dom_document()
-            .get_element_by_id(BODY_ID));
+        return this.editor.get_text();
     }
 
-    private bool on_navigation_policy_decision_requested(WebKit.WebFrame frame,
-        WebKit.NetworkRequest request, WebKit.WebNavigationAction navigation_action,
-        WebKit.WebPolicyDecision policy_decision) {
-        policy_decision.ignore();
+    private void on_link_activated(ClientWebView view, string uri) {
         if (this.actions.get_action_state(ACTION_COMPOSE_AS_HTML).get_boolean())
-            link_dialog(request.uri);
-        return true;
+            link_dialog(uri);
     }
 
-    private void on_hovering_over_link(string? title, string? url) {
-        if (this.actions.get_action_state(ACTION_COMPOSE_AS_HTML).get_boolean()) {
-            message_overlay_label.label = url;
-            hover_url = url;
-            update_actions();
+
+    private void on_mouse_target_changed(WebKit.WebView web_view,
+                                         WebKit.HitTestResult hit_test,
+                                         uint modifiers) {
+        bool copy_link_enabled = false;
+        if (hit_test.context_is_link()) {
+            copy_link_enabled = true;
+            this.hover_url = hit_test.get_link_uri();
         }
+        get_action(ACTION_COPY_LINK).set_enabled(copy_link_enabled);
     }
 
     private void update_message_overlay_label_style() {
@@ -2062,8 +1937,8 @@ public class ComposerWidget : Gtk.EventBox {
     }
 
     private void on_spell_check_changed() {
-        this.editor.settings.enable_spell_checking = GearyApplication.instance.config.spell_check;
-        get_action(ACTION_SELECT_DICTIONARY).set_enabled(this.editor.settings.enable_spell_checking);
+        //this.editor.settings.enable_spell_checking = GearyApplication.instance.config.spell_check;
+        //get_action(ACTION_SELECT_DICTIONARY).set_enabled(this.editor.settings.enable_spell_checking);
     }
 
     // This overrides the keypress handling for the *widget*; the WebView editor's keypress overrides
@@ -2086,51 +1961,53 @@ public class ComposerWidget : Gtk.EventBox {
         return base.key_press_event(event);
     }
 
-    private bool on_context_menu(Gtk.Widget default_menu, WebKit.HitTestResult hit_test_result,
-        bool keyboard_triggered) {
-        Gtk.Menu context_menu = (Gtk.Menu) default_menu;
-
-        // Keep the spelling menu items
-        foreach (weak Gtk.Widget child in context_menu.get_children()) {
-            Gtk.MenuItem item = (Gtk.MenuItem) child;
-            WebKit.ContextMenuAction action = WebKit.context_menu_item_get_action(item);
-
-            const WebKit.ContextMenuAction[] spelling_actions = {
-                WebKit.ContextMenuAction.SPELLING_GUESS,
-                WebKit.ContextMenuAction.IGNORE_SPELLING,
-                WebKit.ContextMenuAction.LEARN_SPELLING
-            };
-
-            if (!(action in spelling_actions))
-                context_menu.remove(item);
-        }
-
-        // Add our own Menu (but don't add formatting actions if they are disabled).
-        context_menu.insert_action_group("cme", this.actions);
-        GtkUtil.add_g_menu_to_gtk_menu(context_menu, context_menu_model, (label, detailed_action_name) => {
-            string action_name;
-            Variant? target;
-            try {
-                Action.parse_detailed_name(detailed_action_name, out action_name, out target);
-                if ("." in action_name) // Remove possible prefixes
-                    action_name = action_name.split(".")[1];
-            } catch (GLib.Error e) {
-                debug("Couldn't parse action \"%s\" in context menu".printf(detailed_action_name));
-            }
-            return !(action_name in html_actions) || (this.actions.get_action_enabled(action_name));
-        });
-
-        if (Args.inspector) {
-            Gtk.MenuItem inspect_item = new Gtk.MenuItem.with_mnemonic(_("_Inspect"));
-            inspect_item.activate.connect(() => {
-                    this.editor.web_inspector.inspect_node(hit_test_result.inner_node);
-                });
-            context_menu.append(new Gtk.SeparatorMenuItem());
-            context_menu.append(inspect_item);
-        }
-
-        context_menu.show_all();
-        update_actions();
+    private bool on_context_menu(WebKit.WebView view,
+                                 WebKit.ContextMenu default_menu,
+                                 Gdk.Event event,
+                                 WebKit.HitTestResult hit_test_result) {
+        // Gtk.Menu context_menu = (Gtk.Menu) default_menu;
+
+        // // Keep the spelling menu items
+        // foreach (weak Gtk.Widget child in context_menu.get_children()) {
+        //     Gtk.MenuItem item = (Gtk.MenuItem) child;
+        //     WebKit.ContextMenuAction action = WebKit.context_menu_item_get_action(item);
+
+        //     const WebKit.ContextMenuAction[] spelling_actions = {
+        //         WebKit.ContextMenuAction.SPELLING_GUESS,
+        //         WebKit.ContextMenuAction.IGNORE_SPELLING,
+        //         WebKit.ContextMenuAction.LEARN_SPELLING
+        //     };
+
+        //     if (!(action in spelling_actions))
+        //         context_menu.remove(item);
+        // }
+
+        // // Add our own Menu (but don't add formatting actions if they are disabled).
+        // context_menu.insert_action_group("cme", this.actions);
+        // GtkUtil.add_g_menu_to_gtk_menu(context_menu, context_menu_model, (label, detailed_action_name) => 
{
+        //     string action_name;
+        //     Variant? target;
+        //     try {
+        //         Action.parse_detailed_name(detailed_action_name, out action_name, out target);
+        //         if ("." in action_name) // Remove possible prefixes
+        //             action_name = action_name.split(".")[1];
+        //     } catch (GLib.Error e) {
+        //         debug("Couldn't parse action \"%s\" in context menu".printf(detailed_action_name));
+        //     }
+        //     return !(action_name in html_actions) || (this.actions.get_action_enabled(action_name));
+        // });
+
+        // if (Args.inspector) {
+        //     Gtk.MenuItem inspect_item = new Gtk.MenuItem.with_mnemonic(_("_Inspect"));
+        //     inspect_item.activate.connect(() => {
+        //             this.editor.get_inspector().show();
+        //         });
+        //     context_menu.append(new Gtk.SeparatorMenuItem());
+        //     context_menu.append(inspect_item);
+        // }
+
+        // context_menu.show_all();
+        // update_actions();
         return false;
     }
 
@@ -2138,7 +2015,7 @@ public class ComposerWidget : Gtk.EventBox {
         if (this.spell_check_popover == null) {
             this.spell_check_popover = new SpellCheckPopover(select_dictionary_button);
             this.spell_check_popover.selection_changed.connect((active_langs) => {
-                    this.editor.settings.spell_checking_languages = string.joinv(",", active_langs);
+                    //this.editor.settings.spell_checking_languages = string.joinv(",", active_langs);
                     GearyApplication.instance.config.spell_check_languages = active_langs;
                 });
         }
@@ -2177,36 +2054,12 @@ public class ComposerWidget : Gtk.EventBox {
                     add_signature_and_cursor();
                 else
                     set_cursor();
-                this.editor.load_string(HTML_BODY, "text/html", "UTF8", "");
+                this.editor.load_html(HTML_BODY, null);
                 return true;
             }
         }
-        
-        WebKit.DOM.Document document = this.editor.get_dom_document();
-        if (event.keyval == Gdk.Key.Tab) {
-            document.exec_command("inserthtml", false,
-                "<span style='white-space: pre-wrap'>\t</span>");
-            return true;
-        }
-        
-        if (event.keyval == Gdk.Key.ISO_Left_Tab) {
-            // If there is no selection and the character before the cursor is tab, delete it.
-            WebKit.DOM.DOMSelection selection = document.get_default_view().get_selection();
-            if (selection.is_collapsed) {
-                selection.modify("extend", "backward", "character");
-                try {
-                    if (selection.get_range_at(0).get_text() == "\t")
-                        selection.delete_from_document();
-                    else
-                        selection.collapse_to_end();
-                } catch (Error error) {
-                    debug("Error handling Left Tab: %s", error.message);
-                }
-            }
-            return true;
-        }
-        
-        return false;
+
+        return this.editor.handle_key_press(event);
     }
 
     /**
@@ -2226,62 +2079,53 @@ public class ComposerWidget : Gtk.EventBox {
         get_action(ACTION_REDO).set_enabled(this.editor.can_redo());
         get_action(ACTION_CUT).set_enabled(this.editor.can_cut_clipboard());
         get_action(ACTION_COPY).set_enabled(this.editor.can_copy_clipboard());
-        get_action(ACTION_COPY_LINK).set_enabled(hover_url != null);
         get_action(ACTION_PASTE).set_enabled(this.editor.can_paste_clipboard());
         get_action(ACTION_PASTE_WITH_FORMATTING).set_enabled(this.editor.can_paste_clipboard()
             && get_action(ACTION_COMPOSE_AS_HTML).state.get_boolean());
 
-        // Style formatting actions.
-        WebKit.DOM.Document document = this.editor.get_dom_document();
-        WebKit.DOM.DOMWindow window = document.get_default_view();
-        WebKit.DOM.DOMSelection? selection = window.get_selection();
-        if (selection == null)
-            return;
-
-        get_action(ACTION_REMOVE_FORMAT).set_enabled(!selection.is_collapsed
-            && get_action(ACTION_COMPOSE_AS_HTML).state.get_boolean());
-
-        WebKit.DOM.Element? active = selection.focus_node as WebKit.DOM.Element;
-        if (active == null && selection.focus_node != null)
-            active = selection.focus_node.get_parent_element();
-
-        if (active != null) {
-            WebKit.DOM.CSSStyleDeclaration styles = window.get_computed_style(active, "");
-
-            this.actions.change_action_state(ACTION_BOLD, document.query_command_state("bold"));
-            this.actions.change_action_state(ACTION_ITALIC,
-                document.query_command_state("italic"));
-            this.actions.change_action_state(ACTION_UNDERLINE,
-                document.query_command_state("underline"));
-            this.actions.change_action_state(ACTION_STRIKETHROUGH,
-                document.query_command_state("strikethrough"));
-
-            // Font family.
-            string font_name = styles.get_property_value("font-family").down();
-            if (font_name.contains("sans") ||
-                font_name.contains("arial") ||
-                font_name.contains("trebuchet") ||
-                font_name.contains("helvetica"))
-                this.actions.change_action_state(ACTION_FONT_FAMILY, "sans");
-            else if (font_name.contains("serif") ||
-                font_name.contains("georgia") ||
-                font_name.contains("times"))
-                this.actions.change_action_state(ACTION_FONT_FAMILY, "serif");
-            else if (font_name.contains("monospace") ||
-                font_name.contains("courier") ||
-                font_name.contains("console"))
-                this.actions.change_action_state(ACTION_FONT_FAMILY, "monospace");
-
-            // Font size.
-            int font_size;
-            styles.get_property_value("font-size").scanf("%dpx", out font_size);
-            if (font_size < 11)
-                this.actions.change_action_state(ACTION_FONT_SIZE, "small");
-            else if (font_size > 20)
-                this.actions.change_action_state(ACTION_FONT_SIZE, "large");
-            else
-                this.actions.change_action_state(ACTION_FONT_SIZE, "medium");
-        }
+        // // Style formatting actions.
+        // WebKit.DOM.Document document = this.editor.get_dom_document();
+        // WebKit.DOM.DOMWindow window = document.get_default_view();
+        // WebKit.DOM.DOMSelection? selection = window.get_selection();
+        // if (selection == null)
+        //     return;
+
+        // get_action(ACTION_REMOVE_FORMAT).set_enabled(!selection.is_collapsed
+        //     && get_action(ACTION_COMPOSE_AS_HTML).state.get_boolean());
+
+        // WebKit.DOM.Element? active = selection.focus_node as WebKit.DOM.Element;
+        // if (active == null && selection.focus_node != null)
+        //     active = selection.focus_node.get_parent_element();
+
+        // if (active != null) {
+        //     WebKit.DOM.CSSStyleDeclaration styles = window.get_computed_style(active, "");
+
+        //     // Font family.
+        //     string font_name = styles.get_property_value("font-family").down();
+        //     if (font_name.contains("sans") ||
+        //         font_name.contains("arial") ||
+        //         font_name.contains("trebuchet") ||
+        //         font_name.contains("helvetica"))
+        //         this.actions.change_action_state(ACTION_FONT_FAMILY, "sans");
+        //     else if (font_name.contains("serif") ||
+        //         font_name.contains("georgia") ||
+        //         font_name.contains("times"))
+        //         this.actions.change_action_state(ACTION_FONT_FAMILY, "serif");
+        //     else if (font_name.contains("monospace") ||
+        //         font_name.contains("courier") ||
+        //         font_name.contains("console"))
+        //         this.actions.change_action_state(ACTION_FONT_FAMILY, "monospace");
+
+        //     // Font size.
+        //     int font_size;
+        //     styles.get_property_value("font-size").scanf("%dpx", out font_size);
+        //     if (font_size < 11)
+        //         this.actions.change_action_state(ACTION_FONT_SIZE, "small");
+        //     else if (font_size > 20)
+        //         this.actions.change_action_state(ACTION_FONT_SIZE, "large");
+        //     else
+        //         this.actions.change_action_state(ACTION_FONT_SIZE, "medium");
+        // }
     }
 
     private bool add_account_emails_to_from_list(Geary.Account other_account, bool set_active = false) {
@@ -2413,25 +2257,23 @@ public class ComposerWidget : Gtk.EventBox {
         return true;
     }
 
-    private unowned WebKit.WebView on_inspect_web_view(WebKit.WebInspector inspector, WebKit.WebView 
target_view) {
-        // XXX This was copy-pasta'ed from the conversation
-        // viewer. Both should be moved into a common superclass when
-        // ported to WebKit2 in Bug 728002.
-        Gtk.Window window = new Gtk.Window();
-        window.set_default_size(600, 600);
-        window.set_title(_("%s — Composer Inspector").printf(GearyApplication.NAME));
-        Gtk.ScrolledWindow scrolled = new Gtk.ScrolledWindow(null, null);
-        WebKit.WebView inspector_view = new WebKit.WebView();
-        scrolled.add(inspector_view);
-        window.add(scrolled);
-        window.show_all();
-        window.delete_event.connect(() => {
-            inspector.close();
-            return false;
-        });
-
-        unowned WebKit.WebView r = inspector_view;
-        return r;
+    private void on_text_attributes_changed(uint mask) {
+        this.actions.change_action_state(
+            ACTION_BOLD,
+            (mask & WebKit.EditorTypingAttributes.BOLD) == WebKit.EditorTypingAttributes.BOLD
+        );
+        this.actions.change_action_state(
+            ACTION_ITALIC,
+            (mask & WebKit.EditorTypingAttributes.ITALIC) == WebKit.EditorTypingAttributes.ITALIC
+        );
+        this.actions.change_action_state(
+            ACTION_UNDERLINE,
+            (mask & WebKit.EditorTypingAttributes.UNDERLINE) == WebKit.EditorTypingAttributes.UNDERLINE
+        );
+        this.actions.change_action_state(
+            ACTION_STRIKETHROUGH,
+            (mask & WebKit.EditorTypingAttributes.STRIKETHROUGH) == 
WebKit.EditorTypingAttributes.STRIKETHROUGH
+        );
     }
 
     private void on_add_attachment() {
@@ -2470,11 +2312,10 @@ public class ComposerWidget : Gtk.EventBox {
                     // Use insertHTML instead of insertImage here so
                     // we can specify a max width inline, preventing
                     // large images from overflowing the view port.
-                    this.editor.get_dom_document().exec_command(
+                    this.editor.execute_editing_command_with_argument(
                         "insertHTML",
-                        false,
                         "<img style=\"max-width: 100%\" src=\"%s\">".printf(
-                            this.editor_allow_prefix + file.get_uri()
+                            this.editor.allow_prefix + file.get_uri()
                         )
                     );
                 } catch (Error err) {
@@ -2490,43 +2331,14 @@ public class ComposerWidget : Gtk.EventBox {
         link_dialog("http://";);
     }
 
-    private static void on_link_clicked(WebKit.DOM.Element element, WebKit.DOM.Event event,
-        ComposerWidget composer) {
-        try {
-            composer.editor.get_dom_document().get_default_view().get_selection().
-                select_all_children(element);
-        } catch (Error e) {
-            debug("Error selecting link: %s", e.message);
-        }
-    }
-
-    private void on_resource_request_starting(WebKit.WebFrame web_frame,
-                                              WebKit.WebResource web_resource,
-                                              WebKit.NetworkRequest request,
-                                              WebKit.NetworkResponse? response) {
-        // XXX This was copy-pasta'ed from the conversation
-        // viewer. Both should be moved into a common superclass when
-        // ported to WebKit2 in Bug 728002.
-
-        if (response != null) {
-            // A request that was previously approved resulted in a redirect.
-            return;
-        }
-
-        const string CID_PREFIX = "cid:";
-        const string ABOUT_BLANK = "about:blank";
-
-        string? req_uri = request.get_uri();
-        string resp_url = ABOUT_BLANK;
-        if (req_uri.has_prefix(CID_PREFIX)) {
-            File? file = this.cid_files[req_uri.substring(CID_PREFIX.length)];
-            if (file != null) {
-                resp_url = file.get_uri();
-            }
-        } else if (req_uri.has_prefix(this.editor_allow_prefix)) {
-            resp_url = req_uri.substring(this.editor_allow_prefix.length);
-        }
-        request.set_uri(resp_url);
-    }
+    // private static void on_link_clicked(WebKit.DOM.Element element, WebKit.DOM.Event event,
+    //     ComposerWidget composer) {
+    //     try {
+    //         composer.editor.get_dom_document().get_default_view().get_selection().
+    //             select_all_children(element);
+    //     } catch (Error e) {
+    //         debug("Error selecting link: %s", e.message);
+    //     }
+    // }
 
 }
diff --git a/src/client/conversation-viewer/conversation-email.vala 
b/src/client/conversation-viewer/conversation-email.vala
index fba8f56..c122aae 100644
--- a/src/client/conversation-viewer/conversation-email.vala
+++ b/src/client/conversation-viewer/conversation-email.vala
@@ -588,8 +588,7 @@ public class ConversationEmail : Gtk.Box {
         view.web_view.notify["load-status"].connect(() => {
                 bool all_loaded = true;
                 message_view_iterator().foreach((view) => {
-                        if (view.web_view.load_status !=
-                                WebKit.LoadStatus.FINISHED) {
+                        if (!view.web_view.is_loaded) {
                             all_loaded = false;
                             return false;
                         }
@@ -599,9 +598,9 @@ public class ConversationEmail : Gtk.Box {
                     this.message_bodies_loaded = true;
                 }
             });
-        view.web_view.selection_changed.connect(() => {
-                on_message_selection_changed(view);
-            });
+        // view.web_view.selection_changed.connect(() => {
+        //         on_message_selection_changed(view);
+        //     });
     }
 
     private void update_email_state() {
@@ -696,7 +695,13 @@ public class ConversationEmail : Gtk.Box {
     private void print() {
         // XXX This isn't anywhere near good enough - headers aren't
         // being printed.
-        primary_message.web_view.get_main_frame().print();
+        WebKit.PrintOperation op = new WebKit.PrintOperation(
+            this.primary_message.web_view
+        );
+        Gtk.Window? window = get_toplevel() as Gtk.Window;
+        if (op.run_dialog(window) == WebKit.PrintOperationResponse.PRINT) {
+            op.print();
+        }
     }
 
     private void on_flag_remote_images(ConversationMessage view) {
@@ -727,17 +732,11 @@ public class ConversationEmail : Gtk.Box {
         contact_store.mark_contacts_async.begin(contact_list, flags, null);
     }
 
-    private void on_message_selection_changed(ConversationMessage view) {
-        bool has_selection = false;
-        if (view.web_view.has_selection()) {
-            WebKit.DOM.Document document = view.web_view.get_dom_document();
-            has_selection = !document.default_view.get_selection().is_collapsed;
-            this.body_selection_message = view;
-        } else {
-            this.body_selection_message = null;
-        }
-        body_selection_changed(has_selection);
-    }
+    // private void on_message_selection_changed(ConversationMessage view) {
+    //     bool has_selection = view.web_view.has_selection();
+    //     this.body_selection_message = has_selection ? view : null;
+    //     body_selection_changed(has_selection);
+    // }
 
     [GtkCallback]
     private void on_attachments_child_activated(Gtk.FlowBox view,
diff --git a/src/client/conversation-viewer/conversation-message.vala 
b/src/client/conversation-viewer/conversation-message.vala
index 96fc7ff..c933e44 100644
--- a/src/client/conversation-viewer/conversation-message.vala
+++ b/src/client/conversation-viewer/conversation-message.vala
@@ -19,6 +19,9 @@ public class ConversationMessage : Gtk.Grid {
 
 
     private const string FROM_CLASS = "geary-from";
+    private const string DATA_IMAGE_CLASS = "geary_data_inline_image";
+    private const string REPLACED_IMAGE_CLASS = "geary_replaced_inline_image";
+
     private const int MAX_PREVIEW_BYTES = Geary.Email.MAX_PREVIEW_BYTES;
 
 
@@ -148,14 +151,8 @@ public class ConversationMessage : Gtk.Grid {
         "image/x-xbitmap",
         "image/x-xbm"
     };
-    private const string QUOTE_CONTAINER_CLASS = "geary_quote_container";
-    private const string QUOTE_CONTROLLABLE_CLASS = "controllable";
-    private const string QUOTE_HIDE_CLASS = "hide";
-    private const string SIGNATURE_CONTAINER_CLASS = "geary_signature";
-    private const string REPLACED_IMAGE_CLASS = "geary_replaced_inline_image";
-    private const string DATA_IMAGE_CLASS = "geary_data_inline_image";
+
     private const int MAX_INLINE_IMAGE_MAJOR_DIM = 1024;
-    private const float QUOTE_SIZE_THRESHOLD = 2.0f;
 
     private const string ACTION_COPY_EMAIL = "copy_email";
     private const string ACTION_COPY_LINK = "copy_link";
@@ -226,10 +223,10 @@ public class ConversationMessage : Gtk.Grid {
 
     [GtkChild]
     private Gtk.Popover link_popover;
-    [GtkChild]
-    private Gtk.Label good_link_label;
-    [GtkChild]
-    private Gtk.Label bad_link_label;
+    //[GtkChild]
+    //private Gtk.Label good_link_label;
+    //[GtkChild]
+    //private Gtk.Label bad_link_label;
 
     [GtkChild]
     private Gtk.InfoBar remote_images_infobar;
@@ -245,12 +242,6 @@ public class ConversationMessage : Gtk.Grid {
     private MenuModel context_menu_contact;
     private MenuModel? context_menu_inspector = null;
 
-    // Last known DOM element under the context menu
-    private WebKit.DOM.HTMLElement? context_menu_element = null;
-
-    // Contains the current mouse-over'ed link URL, if any
-    private string? hover_url = null;
-
     // The contacts for the message's account
     private Geary.ContactStore contact_store;
 
@@ -318,8 +309,7 @@ public class ConversationMessage : Gtk.Grid {
                 web_view.copy_clipboard();
             });
         add_action(ACTION_OPEN_INSPECTOR, Args.inspector).activate.connect(() => {
-                web_view.web_inspector.inspect_node(this.context_menu_element);
-                this.context_menu_element = null;
+                this.web_view.get_inspector().show();
             });
         add_action(ACTION_OPEN_LINK, true, VariantType.STRING)
             .activate.connect((param) => {
@@ -404,12 +394,12 @@ public class ConversationMessage : Gtk.Grid {
 
         this.web_view = new ConversationWebView();
         // Suppress default context menu.
-        this.web_view.context_menu.connect(() => { return true; });
-        this.web_view.hovering_over_link.connect(on_hovering_over_link);
-        this.web_view.link_selected.connect((link) => {
+        this.web_view.context_menu.connect(on_context_menu);
+        this.web_view.mouse_target_changed.connect(on_mouse_target_changed);
+        this.web_view.link_activated.connect((link) => {
                 link_activated(link);
             });
-        this.web_view.selection_changed.connect(on_selection_changed);
+        //this.web_view.selection_changed.connect(on_selection_changed);
         this.web_view.show();
 
         this.body.set_has_tooltip(true); // Used to show link URLs
@@ -417,7 +407,6 @@ public class ConversationMessage : Gtk.Grid {
     }
 
     public override void destroy() {
-        this.context_menu_element = null;
         this.searchable_addresses.clear();
         base.destroy();
     }
@@ -480,24 +469,6 @@ public class ConversationMessage : Gtk.Grid {
             debug("Could not get message text. %s", err.message);
         }
 
-        bool load_images = false;
-        body_text = clean_html_markup(
-            body_text ?? "", this.message, out load_images
-        );
-
-        if (load_images) {
-            bool contact_load = false;
-            Geary.Contact contact = this.contact_store.get_by_rfc822(
-                message.get_primary_originator()
-            );
-            if (contact != null)
-                contact_load = contact.always_load_remote_images();
-            if (!contact_load && !this.always_load_remote_images) {
-                remote_images_infobar.show();
-                load_images = false;
-            }
-        }
-
         load_cancelled.cancelled.connect(() => { web_view.stop_loading(); });
         // XXX Hook up unset_controllable_quotes() to size_allocate
         // and check is_height_valid since we need to accurately know
@@ -510,48 +481,35 @@ public class ConversationMessage : Gtk.Grid {
         // user could collapse the quote again the space wouldn't be
         // reclaimed, which is worse than this.
         this.web_view.size_allocate.connect(() => {
-                if (this.web_view.load_status == WebKit.LoadStatus.FINISHED &&
+                if (this.web_view.is_loaded &&
                     this.web_view.is_height_valid) {
-                    WebKit.DOM.HTMLElement html = (
-                        this.web_view.get_dom_document().document_element as
-                        WebKit.DOM.HTMLElement
-                    );
-                    if (html != null) {
-                        try {
-                            unset_controllable_quotes(html);
-                        } catch (Error error) {
-                            warning("Error unsetting controllable_quotes: %s",
-                                    error.message);
-                        }
-                    }
+                    this.web_view.unset_controllable_quotes();
                 }
             });
-        this.web_view.notify["load-status"].connect((source, param) => {
-                if (this.web_view.load_status == WebKit.LoadStatus.FINISHED) {
-                    if (load_images) {
-                        show_images(false);
+
+        bool load_images = this.web_view.clean_and_load(body_text ?? "");
+        if (load_images) {
+            bool contact_load = false;
+            Geary.Contact contact = this.contact_store.get_by_rfc822(
+                message.get_primary_originator()
+            );
+            if (contact != null)
+                contact_load = contact.always_load_remote_images();
+            if (!contact_load && !this.always_load_remote_images) {
+                remote_images_infobar.show();
+                load_images = false;
+            }
+
+            // XXX racy
+            this.web_view.notify["load-status"].connect((source, param) => {
+                    if (this.web_view.is_loaded) {
+                        if (load_images) {
+                            show_images(false);
+                        }
                     }
-                    Util.DOM.bind_event(
-                        this.web_view, "html", "contextmenu",
-                        (Callback) on_context_menu, this
-                    );
-                    Util.DOM.bind_event(
-                        this.web_view, "body a", "click",
-                        (Callback) on_link_clicked, this
-                    );
-                    Util.DOM.bind_event(
-                        this.web_view, ".%s > .shower".printf(QUOTE_CONTAINER_CLASS),
-                        "click",
-                        (Callback) on_show_quote_clicked, this);
-                    Util.DOM.bind_event(
-                        this.web_view, ".%s > .hider".printf(QUOTE_CONTAINER_CLASS),
-                        "click",
-                        (Callback) on_hide_quote_clicked, this);
-                }
-            });
+                });
+        }
 
-        // Only load it after we've hooked up the signals above
-        this.web_view.load_string(body_text, "text/html", "UTF-8", "");
     }
 
     /**
@@ -561,7 +519,7 @@ public class ConversationMessage : Gtk.Grid {
      */
     public uint highlight_search_terms(Gee.Set<string> search_matches) {
         // Remove existing highlights
-        this.web_view.unmark_text_matches();
+        this.web_view.get_find_controller().search_finish();
 
         uint headers_found = 0;
         uint webkit_found = 0;
@@ -576,11 +534,12 @@ public class ConversationMessage : Gtk.Grid {
                 }
             }
 
-            webkit_found += this.web_view.mark_text_matches(raw_match, false, 0);
-        }
-
-        if (webkit_found > 0) {
-            this.web_view.set_highlight_text_matches(true);
+            //webkit_found += this.web_view.mark_text_matches(raw_match, false, 0);
+            this.web_view.get_find_controller().search(
+                raw_match,
+                WebKit.FindOptions.CASE_INSENSITIVE,
+                1024
+            );
         }
 
         return headers_found + webkit_found;
@@ -593,72 +552,18 @@ public class ConversationMessage : Gtk.Grid {
         foreach (AddressFlowBoxChild address in this.searchable_addresses) {
             address.unmark_search_terms();
         }
-        web_view.set_highlight_text_matches(false);
-        web_view.unmark_text_matches();
+        web_view.get_find_controller().search_finish();
     }
 
     internal string? get_selection_for_quoting() {
-        string? quote = null;
-        WebKit.DOM.Document document = this.web_view.get_dom_document();
-        WebKit.DOM.DOMSelection selection = document.default_view.get_selection();
-        if (!selection.is_collapsed) {
-            try {
-                WebKit.DOM.Range range = selection.get_range_at(0);
-                WebKit.DOM.HTMLElement dummy =
-                    (WebKit.DOM.HTMLElement) document.create_element("div");
-                bool include_dummy = false;
-                WebKit.DOM.Node ancestor_node = range.get_common_ancestor_container();
-                WebKit.DOM.Element? ancestor = ancestor_node as WebKit.DOM.Element;
-                if (ancestor == null)
-                    ancestor = ancestor_node.get_parent_element();
-                // If the selection is part of a plain text message,
-                // we have to stick it in an appropriately styled div,
-                // so that new lines are preserved.
-                if (Util.DOM.is_descendant_of(ancestor, ".plaintext")) {
-                    dummy.get_class_list().add("plaintext");
-                    dummy.set_attribute("style", "white-space: pre-wrap;");
-                    include_dummy = true;
-                }
-                dummy.append_child(range.clone_contents());
-
-                // Remove the chrome we put around quotes, leaving
-                // only the blockquote element.
-                WebKit.DOM.NodeList quotes =
-                    dummy.query_selector_all("." + QUOTE_CONTAINER_CLASS);
-                for (int i = 0; i < quotes.length; i++) {
-                    WebKit.DOM.Element div = (WebKit.DOM.Element) quotes.item(i);
-                    WebKit.DOM.Element blockquote = div.query_selector("blockquote");
-                    div.get_parent_element().replace_child(blockquote, div);
-                }
-
-                quote = include_dummy ? dummy.get_outer_html() : dummy.get_inner_html();
-            } catch (Error error) {
-                debug("Problem getting selected text: %s", error.message);
-            }
-        }
-        return quote;
+        return this.web_view.get_selection_for_quoting();
     }
 
     /**
      * Returns the current selection as a string, suitable for find.
      */
     internal string? get_selection_for_find() {
-        string? value = null;
-        WebKit.DOM.Document document = web_view.get_dom_document();
-        WebKit.DOM.DOMWindow window = document.get_default_view();
-        WebKit.DOM.DOMSelection selection = window.get_selection();
-
-        if (selection.get_range_count() > 0) {
-            try {
-                WebKit.DOM.Range range = selection.get_range_at(0);
-                value = range.get_text().strip();
-                if (value.length <= 0)
-                    value = null;
-            } catch (Error e) {
-                warning("Could not get selected text from web view: %s", e.message);
-            }
-        }
-        return value;
+        return this.web_view.get_selection_for_find();
     }
 
     private SimpleAction add_action(string name, bool enabled, VariantType? type = null) {
@@ -890,11 +795,26 @@ public class ConversationMessage : Gtk.Grid {
         return "<img alt=\"%s\" class=\"%s %s\" src=\"%s\" replaced-id=\"%s\" %s />".printf(
             Geary.HTML.escape_markup(filename),
             DATA_IMAGE_CLASS, REPLACED_IMAGE_CLASS,
-            Util.DOM.assemble_data_uri(mime_type, rotated_image),
+            assemble_data_uri(mime_type, rotated_image),
             Geary.HTML.escape_markup(replaced_image.id),
             escaped_content_id != null ? @"cid=\"$escaped_content_id\"" : "");
     }
-    
+
+    // Returns a URI suitable for an IMG SRC attribute (or elsewhere, potentially) that is the
+    // memory buffer unpacked into a Base-64 encoded data: URI
+    private string assemble_data_uri(string mimetype, Geary.Memory.Buffer buffer) {
+        // attempt to use UnownedBytesBuffer to avoid memcpying a potentially huge buffer only to
+        // free it when the encoding operation is completed
+        string base64;
+        Geary.Memory.UnownedBytesBuffer? unowned_bytes = buffer as Geary.Memory.UnownedBytesBuffer;
+        if (unowned_bytes != null)
+            base64 = Base64.encode(unowned_bytes.to_unowned_uint8_array());
+        else
+            base64 = Base64.encode(buffer.get_uint8_array());
+
+        return "data:%s;base64,%s".printf(mimetype, base64);
+    }
+
     // Called by Gdk.PixbufLoader when the image's size has been determined but not loaded yet ...
     // this allows us to load the image scaled down, for better performance when manipulating and
     // writing the data URI for WebKit
@@ -925,266 +845,8 @@ public class ConversationMessage : Gtk.Grid {
         loader.set_size(adj_width, adj_height);
     }
 
-    private string clean_html_markup(string text, Geary.RFC822.Message message, out bool remote_images) {
-        remote_images = false;
-        try {
-            WebKit.DOM.HTMLElement html = (WebKit.DOM.HTMLElement)
-                this.web_view.get_dom_document().document_element;
-
-            // If the message has a HTML element, get its inner
-            // markup. We can't just set this on a temp container div
-            // (the old approach) using set_inner_html() will refuse
-            // to parse any HTML, HEAD and BODY elements that are out
-            // of place in the structure. We can't use
-            // set_outer_html() on the document element since it
-            // throws an error.
-            GLib.Regex html_regex = new GLib.Regex("<html([^>]*)>(.*)</html>",
-                GLib.RegexCompileFlags.DOTALL);
-            GLib.MatchInfo matches;
-            if (html_regex.match(text, 0, out matches)) {
-                // Set the existing HTML element's content. Here, HEAD
-                // and BODY elements will be parsed fine.
-                html.set_inner_html(matches.fetch(2));
-                // Copy email HTML element attrs across to the
-                // existing HTML element
-                string attrs = matches.fetch(1);
-                if (attrs != "") {
-                    WebKit.DOM.HTMLElement container =
-                        this.web_view.create("div");
-                    container.set_inner_html(@"<div$attrs></div>");
-                    WebKit.DOM.HTMLElement? attr_element =
-                        Util.DOM.select(container, "div");
-                    WebKit.DOM.NamedNodeMap html_attrs =
-                        attr_element.get_attributes();
-                    for (int i = 0; i < html_attrs.get_length(); i++) {
-                        WebKit.DOM.Node attr = html_attrs.item(i);
-                        html.set_attribute(attr.node_name, attr.text_content);
-                    }
-                }
-            } else {
-                html.set_inner_html(text);
-            }
-
-            // Set dir="auto" if not already set possibly get a
-            // slightly better RTL experience.
-            string? dir = html.get_dir();
-            if (dir == null || dir.length == 0) {
-                html.set_dir("auto");
-            }
-
-            // Add application CSS to the document
-            WebKit.DOM.HTMLElement? head = Util.DOM.select(html, "head");
-            if (head == null) {
-                head = this.web_view.create("head");
-                html.insert_before(head, html.get_first_child());
-            }
-            WebKit.DOM.HTMLElement style_element = this.web_view.create("style");
-            string css_text = GearyApplication.instance.read_resource("conversation-web-view.css");
-            WebKit.DOM.Text text_node = this.web_view.get_dom_document().create_text_node(css_text);
-            style_element.append_child(text_node);
-            head.insert_before(style_element, head.get_first_child());
-
-            // Get all the top level block quotes and stick them into a hide/show controller.
-            WebKit.DOM.NodeList blockquote_list = html.query_selector_all("blockquote");
-            for (int i = 0; i < blockquote_list.length; ++i) {
-                // Get the nodes we need.
-                WebKit.DOM.Node blockquote_node = blockquote_list.item(i);
-                WebKit.DOM.Node? next_sibling = blockquote_node.get_next_sibling();
-                WebKit.DOM.Node parent = blockquote_node.get_parent_node();
-
-                // Make sure this is a top level blockquote.
-                if (Util.DOM.node_is_child_of(blockquote_node, "BLOCKQUOTE")) {
-                    continue;
-                }
-
-                WebKit.DOM.Element quote_container = create_quote_container();
-                Util.DOM.select(quote_container, ".quote").append_child(blockquote_node);
-                if (next_sibling == null) {
-                    parent.append_child(quote_container);
-                } else {
-                    parent.insert_before(quote_container, next_sibling);
-                }
-            }
-
-            // Now look for the signature.
-            wrap_html_signature(ref html);
-
-            // Then look for all <img> tags. Inline images are replaced with
-            // data URLs.
-            WebKit.DOM.NodeList inline_list = html.query_selector_all("img");
-            Gee.HashSet<string> inlined_content_ids = new Gee.HashSet<string>();
-            for (ulong i = 0; i < inline_list.length; ++i) {
-                // Get the MIME content for the image.
-                WebKit.DOM.HTMLImageElement img = (WebKit.DOM.HTMLImageElement) inline_list.item(i);
-                string? src = img.get_attribute("src");
-                if (Geary.String.is_empty(src))
-                    continue;
-                
-                // if no Content-ID, then leave as-is, but note if a non-data: URI is being used for
-                // purposes of detecting remote images
-                string? content_id = src.has_prefix("cid:") ? src.substring(4) : null;
-                if (Geary.String.is_empty(content_id)) {
-                    remote_images = remote_images || !src.has_prefix("data:");
-                    
-                    continue;
-                }
-                
-                // if image has a Content-ID and it's already been replaced by the image replacer,
-                // drop this tag, otherwise fix up this one with the Base-64 data URI of the image
-                if (!replaced_content_ids.contains(content_id)) {
-                    string? filename = message.get_content_filename_by_mime_id(content_id);
-                    Geary.Memory.Buffer image_content = message.get_content_by_mime_id(content_id);
-                    if (image_content.size > 0) {
-                        Geary.Memory.UnownedBytesBuffer? unowned_buffer =
-                            image_content as Geary.Memory.UnownedBytesBuffer;
-
-                        // Get the content type.
-                        string guess;
-                        if (unowned_buffer != null)
-                            guess = ContentType.guess(null, unowned_buffer.to_unowned_uint8_array(), null);
-                        else
-                            guess = ContentType.guess(null, image_content.get_uint8_array(), null);
-
-                        string mimetype = ContentType.get_mime_type(guess);
-
-                        // Replace the SRC to a data URI, the class to a known label for the popup menu,
-                        // and the ALT to its filename, if supplied
-                        img.remove_attribute("src");  // Work around a WebKitGTK+ crash. Bug 764152
-                        img.set_attribute("src", Util.DOM.assemble_data_uri(mimetype, image_content));
-                        img.class_list.add(DATA_IMAGE_CLASS);
-                        if (!Geary.String.is_empty(filename))
-                            img.set_attribute("alt", filename);
-
-                        // stash here so inlined image isn't listed as attachment (esp. if it has no
-                        // Content-Disposition)
-                        inlined_content_ids.add(content_id);
-                        attachment_displayed_inline(content_id);
-                    }
-                } else {
-                    // replaced by data: URI, remove this tag and let the inserted one shine through
-                    img.parent_element.remove_child(img);
-                }
-            }
-            
-            // Remove any inline images that were referenced through Content-ID
-            foreach (string cid in inlined_content_ids) {
-                try {
-                    string escaped_cid = Geary.HTML.escape_markup(cid);
-                    WebKit.DOM.Element? img = html.query_selector(@"[cid='$escaped_cid']");
-                    if (img != null)
-                        img.parent_element.remove_child(img);
-                } catch (Error error) {
-                    debug("Error removing inlined image: %s", error.message);
-                }
-            }
-            
-            // Now return the whole message.
-            return html.get_outer_html();
-        } catch (Error e) {
-            debug("Error modifying HTML message: %s", e.message);
-            return text;
-        }
-    }
-
-    private WebKit.DOM.HTMLElement create_quote_container() throws Error {
-        WebKit.DOM.HTMLElement quote_container = web_view.create("div");
-        quote_container.class_list.add(QUOTE_CONTAINER_CLASS);
-        quote_container.class_list.add(QUOTE_CONTROLLABLE_CLASS);
-        quote_container.class_list.add(QUOTE_HIDE_CLASS);
-        // New lines are preserved within blockquotes, so this string
-        // needs to be new-line free.
-        quote_container.set_inner_html("""<div class="shower"><input type="button" value="▼        ▼        
▼" /></div><div class="hider"><input type="button" value="▲        ▲        ▲" /></div><div 
class="quote"></div>""");
-        return quote_container;
-    }
-
-    private void wrap_html_signature(ref WebKit.DOM.HTMLElement container) throws Error {
-        // Most HTML signatures fall into one of these designs which are handled by this method:
-        //
-        // 1. GMail:            <div>-- </div>$SIGNATURE
-        // 2. GMail Alternate:  <div><span>-- </span></div>$SIGNATURE
-        // 3. Thunderbird:      <div>-- <br>$SIGNATURE</div>
-        //
-        WebKit.DOM.NodeList div_list = container.query_selector_all("div,span,p");
-        int i = 0;
-        Regex sig_regex = new Regex("^--\\s*$");
-        Regex alternate_sig_regex = new Regex("^--\\s*(?:<br|\\R)");
-        for (; i < div_list.length; ++i) {
-            // Get the div and check that it starts a signature block and is not inside a quote.
-            WebKit.DOM.HTMLElement div = div_list.item(i) as WebKit.DOM.HTMLElement;
-            string inner_html = div.get_inner_html();
-            if ((sig_regex.match(inner_html) || alternate_sig_regex.match(inner_html)) &&
-                !Util.DOM.node_is_child_of(div, "BLOCKQUOTE")) {
-                break;
-            }
-        }
-
-        // If we have a signature, move it and all of its following siblings that are not quotes
-        // inside a signature div.
-        if (i == div_list.length) {
-            return;
-        }
-        WebKit.DOM.Node elem = div_list.item(i) as WebKit.DOM.Node;
-        WebKit.DOM.Element parent = elem.get_parent_element();
-        WebKit.DOM.HTMLElement signature_container = web_view.create("div");
-        signature_container.class_list.add(SIGNATURE_CONTAINER_CLASS);
-        do {
-            // Get its sibling _before_ we move it into the signature div.
-            WebKit.DOM.Node? sibling = elem.get_next_sibling();
-            signature_container.append_child(elem);
-            elem = sibling;
-        } while (elem != null);
-        parent.append_child(signature_container);
-    }
-
-    private void unset_controllable_quotes(WebKit.DOM.HTMLElement element) throws GLib.Error {
-        WebKit.DOM.NodeList quote_list = element.query_selector_all(
-            ".%s.%s".printf(QUOTE_CONTAINER_CLASS, QUOTE_CONTROLLABLE_CLASS)
-        );
-        for (int i = 0; i < quote_list.length; ++i) {
-            WebKit.DOM.Element quote_container = quote_list.item(i) as WebKit.DOM.Element;
-            long outer_client_height = quote_container.client_height;
-            long scroll_height = quote_container.query_selector(".quote").scroll_height;
-            // If the message is hidden, scroll_height will be
-            // 0. Otherwise, unhide the full quote if there is not a
-            // substantial amount hidden.
-            if (scroll_height > 0 &&
-                scroll_height <= outer_client_height * QUOTE_SIZE_THRESHOLD) {
-                quote_container.class_list.remove(QUOTE_CONTROLLABLE_CLASS);
-                quote_container.class_list.remove(QUOTE_HIDE_CLASS);
-            }
-        }
-    }
-
     private void show_images(bool remember) {
-        try {
-            WebKit.DOM.Element body = Util.DOM.select(
-                web_view.get_dom_document(), "body"
-            );
-            if (body == null) {
-                warning("Could not find message body");
-            } else {
-                WebKit.DOM.NodeList nodes = body.get_elements_by_tag_name("img");
-                for (ulong i = 0; i < nodes.length; i++) {
-                    WebKit.DOM.Element? element = nodes.item(i) as WebKit.DOM.Element;
-                    if (element == null || !element.has_attribute("src"))
-                        continue;
-
-                    string src = element.get_attribute("src");
-                    // Don't prefix empty src strings since it will
-                    // cause e.g. 0px images (commonly found in
-                    // commercial mailouts) to be rendered as broken
-                    // images instead of empty elements.
-                    if (src.length > 0 && !web_view.is_always_loaded(src)) {
-                        // Workaround a WebKitGTK+ 2.4.10 crash. See Bug 763933
-                        element.remove_attribute("src");
-                        element.set_attribute("src", web_view.allow_prefix + src);
-                    }
-                }
-            }
-        } catch (Error error) {
-            warning("Error showing images: %s", error.message);
-        }
-
+        this.web_view.show_images();
         if (remember) {
             flag_remote_images();
         }
@@ -1208,79 +870,79 @@ public class ConversationMessage : Gtk.Grid {
      * 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;
+    // 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;
-        }
+    //     // 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];
-        }
-    }
+    //     // 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 ReplacedImage? get_replaced_image() {
         ReplacedImage? image = null;
-        string? replaced_id = this.context_menu_element.get_attribute(
-            "replaced-id"
-        );
-        this.context_menu_element = null;
-        if (!Geary.String.is_empty(replaced_id)) {
-            image = replaced_images.get(replaced_id);
-        }
+        // string? replaced_id = this.context_menu_element.get_attribute(
+        //     "replaced-id"
+        // );
+        // this.context_menu_element = null;
+        // if (!Geary.String.is_empty(replaced_id)) {
+        //     image = replaced_images.get(replaced_id);
+        // }
         return image;
     }
 
@@ -1325,43 +987,12 @@ public class ConversationMessage : Gtk.Grid {
         }
     }
 
-    private static void on_show_quote_clicked(WebKit.DOM.Element element,
-                                              WebKit.DOM.Event event) {
-        try {
-            ((WebKit.DOM.HTMLElement) element.parent_node).class_list.remove(
-                QUOTE_HIDE_CLASS
-            );
-        } catch (Error error) {
-            warning("Error showing quote: %s", error.message);
-        }
-    }
-
-    private static void on_hide_quote_clicked(WebKit.DOM.Element element,
-                                              WebKit.DOM.Event event,
-                                              ConversationMessage message) {
-        try {
-            ((WebKit.DOM.HTMLElement) element.parent_node).class_list.add(
-                QUOTE_HIDE_CLASS
-            );
-            message.web_view.queue_resize();
-        } catch (Error error) {
-            warning("Error toggling quote: %s", error.message);
-        }
-    }
-
-    private static void on_context_menu(WebKit.DOM.Element element,
-                                        WebKit.DOM.Event event,
-                                        ConversationMessage message) {
-        message.on_context_menu_self(element, event);
-        event.prevent_default();
-    }
-
-    private void on_context_menu_self(WebKit.DOM.Element element,
-                                      WebKit.DOM.Event event) {
-        this.context_menu_element =
-             event.get_target() as WebKit.DOM.HTMLElement;
-        if (context_menu != null) {
-            context_menu.detach();
+    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
@@ -1370,83 +1001,82 @@ public class ConversationMessage : Gtk.Grid {
         // have a single menu model and disable the parts we don't
         // need.
         Menu model = new Menu();
-        if (this.hover_url != null) {
+
+        if (hit_test.context_is_link()) {
+            string link_url = hit_test.get_link_uri();
             MenuModel link_menu =
-                this.hover_url.has_prefix(Geary.ComposedEmail.MAILTO_SCHEME)
+                link_url.has_prefix(Geary.ComposedEmail.MAILTO_SCHEME)
                 ? context_menu_email
                 : context_menu_link;
             model.append_section(
-                null, set_action_param_string(link_menu, this.hover_url)
+                null, set_action_param_string(link_menu, link_url)
             );
         }
-        if (this.context_menu_element.local_name.down() == "img") {
+
+        if (hit_test.context_is_image()) {
             ReplacedImage image = get_replaced_image();
             set_action_enabled(ACTION_SAVE_IMAGE, image != null);
             model.append_section(null, context_menu_image);
         }
+
         model.append_section(null, context_menu_main);
+
         if (context_menu_inspector != null) {
             model.append_section(null, context_menu_inspector);
         }
 
-        context_menu = new Gtk.Menu.from_model(model);
-        context_menu.attach_to_widget(this, null);
-        context_menu.popup(null, null, null, 0, Gtk.get_current_event_time());
-    }
+        this.context_menu = new Gtk.Menu.from_model(model);
+        this.context_menu.attach_to_widget(this, null);
+        this.context_menu.popup(null, null, null, 0, event.get_time());
 
-    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();
-        }
+        return true;
     }
 
-    // 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);
-        WebKit.DOM.Element? offset_parent = element;
-        while (offset_parent != null) {
-            link_rect.x += (int) offset_parent.offset_left;
-            link_rect.y += (int) offset_parent.offset_top;
-            offset_parent = offset_parent.offset_parent;
+    private void on_mouse_target_changed(WebKit.WebView web_view,
+                                         WebKit.HitTestResult hit_test,
+                                         uint modifiers) {
+        if (hit_test.context_is_link()) {
+            this.body.set_tooltip_text(hit_test.get_link_uri());
+            this.body.trigger_tooltip_query();
         }
-        link_rect.width = (int) element.offset_width;
-        link_rect.height = (int) element.offset_height;
-        link_popover.set_pointing_to(link_rect);
-
-        link_popover.show();
-        return true;
     }
 
-    private void on_hovering_over_link(string? title, string? url) {
-        this.hover_url = (url != null) ? Uri.unescape_string(url) : null;
-
-        // Use tooltip on the containing box since the web_view
-        // doesn't want to pay ball.
-        this.body.set_tooltip_text(this.hover_url);
-        this.body.trigger_tooltip_query();
-    }
+    // // 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() {
+    //     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);
+    //     WebKit.DOM.Element? offset_parent = element;
+    //     while (offset_parent != null) {
+    //         link_rect.x += (int) offset_parent.offset_left;
+    //         link_rect.y += (int) offset_parent.offset_top;
+    //         offset_parent = offset_parent.offset_parent;
+    //     }
+    //     link_rect.width = (int) element.offset_width;
+    //     link_rect.height = (int) element.offset_height;
+    //     link_popover.set_pointing_to(link_rect);
+
+    //     link_popover.show();
+    //     return true;
+    // }
 
     [GtkCallback]
     private bool on_link_popover_activated() {
@@ -1454,14 +1084,14 @@ public class ConversationMessage : Gtk.Grid {
         return Gdk.EVENT_PROPAGATE;
     }
 
-    private void on_selection_changed() {
-        bool has_selection = false;
-        if (web_view.has_selection()) {
-            WebKit.DOM.Document document = web_view.get_dom_document();
-            has_selection = !document.default_view.get_selection().is_collapsed;
-        }
-        set_action_enabled(ACTION_COPY_SELECTION, has_selection);
-    }
+    // private void on_selection_changed() {
+    //     bool has_selection = false;
+    //     if (web_view.has_selection()) {
+    //         WebKit.DOM.Document document = web_view.get_dom_document();
+    //         has_selection = !document.default_view.get_selection().is_collapsed;
+    //     }
+    //     set_action_enabled(ACTION_COPY_SELECTION, has_selection);
+    // }
 
     [GtkCallback]
     private void on_remote_images_response(Gtk.InfoBar info_bar, int response_id) {
diff --git a/src/client/conversation-viewer/conversation-web-view.vala 
b/src/client/conversation-viewer/conversation-web-view.vala
index 8a72eaa..4402c2b 100644
--- a/src/client/conversation-viewer/conversation-web-view.vala
+++ b/src/client/conversation-viewer/conversation-web-view.vala
@@ -6,32 +6,15 @@
  * (version 2.1 or later).  See the COPYING file in this distribution.
  */
 
-public class ConversationWebView : StylishWebView {
+public class ConversationWebView : ClientWebView {
 
-    private const string[] always_loaded_prefixes = {
-        "https://secure.gravatar.com/avatar/";,
-        "data:"
-    };
     private const string USER_CSS = "user-message.css";
 
-    public string allow_prefix { get; private set; default = ""; }
-
-    // We need to wrap zoom_level (type float) because we cannot connect with float
-    // with double (cf https://bugzilla.gnome.org/show_bug.cgi?id=771534)
-    public double zoom_level_wrap {
-        get { return zoom_level; }
-        set { if (zoom_level != (float)value) zoom_level = (float)value; }
-    }
 
     public bool is_height_valid { get; private set; default = false; }
 
-    public signal void link_selected(string link);
 
     public ConversationWebView() {
-        // Set defaults.
-        set_border_width(0);
-        allow_prefix = random_string(10) + ":";
-
         File user_css = GearyApplication.instance.get_user_config_directory().get_child(USER_CSS);
         // Print out a debug line here if the user CSS file exists, so
         // we get warning about it when debugging visual issues.
@@ -49,23 +32,60 @@ public class ConversationWebView : StylishWebView {
                 }
             });
 
-        WebKit.WebSettings config = settings;
-        config.enable_scripts = false;
-        config.enable_java_applet = false;
-        config.enable_plugins = false;
-        config.enable_developer_extras = Args.inspector;
-        config.user_stylesheet_uri = user_css.get_uri();
-        settings = config;
-
-        // Hook up signals.
-        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);
-        web_inspector.inspect_web_view.connect(activate_inspector);
-        scroll_event.connect(on_scroll_event);
-
-        GearyApplication.instance.config.bind(Configuration.CONVERSATION_VIEWER_ZOOM_KEY, this, 
"zoom_level_wrap");
-        notify["zoom-level"].connect(() => { zoom_level_wrap = zoom_level; });
+        WebKit.UserStyleSheet user_style = new WebKit.UserStyleSheet(
+            user_css.get_uri(),
+            WebKit.UserContentInjectedFrames.ALL_FRAMES,
+            WebKit.UserStyleLevel.USER,
+            null,
+            null
+        );
+
+        WebKit.UserContentManager content = new WebKit.UserContentManager();
+        content.add_style_sheet(user_style);
+
+        base(content);
+
+        // Set defaults.
+        set_border_width(0);
+    }
+
+    public bool clean_and_load(string html) {
+        // XXX clean me
+        load_html(html, null);
+        return false; // XXX Work this thes hit out
+    }
+
+    public bool has_selection() {
+        bool has_selection = false; // XXX set me
+        return has_selection;
+    }
+
+    /**
+     * Returns the current selection, for prefill as find text.
+     */
+    public string get_selection_for_find() {
+        return ""; // XXX
+    }
+
+    /**
+     * Returns the current selection, for quoting in a message.
+     */
+    public string get_selection_for_quoting() {
+        return ""; // XXX
+    }
+
+    /**
+     * XXX
+     */
+    public void unset_controllable_quotes() {
+        // XXX
+    }
+
+    /**
+     * XXX
+     */
+    public void show_images() {
+        // XXX
     }
 
     // Overridden since WebKitGTK+ 2.4.10 at least doesn't want to
@@ -79,31 +99,13 @@ public class ConversationWebView : StylishWebView {
         // warning in GTK 3.20-ish.
         base.get_preferred_height(out minimum_height, out natural_height);
 
-        WebKit.DOM.Element html = get_dom_document().get_document_element();
-        long offset_height = html.offset_height;
-        long offset_width = html.offset_width;
-        long px = offset_width * offset_height;
-
-        const long MAX_LEN = 15 * 1000;
-        const long MAX_PX = 10 * 1000 * 1000;
-
-        // If the offset_width is very small, the offset_height will
-        // likely be bogus, so just pretend we have no height for the
-        // moment. WebKitGTK seems to report an offset width of 1 in
-        // these cases.
-        if (offset_width > 1) {
-            if (offset_height > MAX_LEN || px > MAX_PX) {
-                long new_height = long.min(MAX_LEN, MAX_PX / offset_width);
-                debug("Clamping window height to: %lu, current size: %lux%lu (%lupx)",
-                      new_height, offset_width, offset_height, px);
-                offset_height = new_height;
-            }
+        long offset_height = 0; // XXX set me
+
+        if (offset_height > 0) {
             // Avoid multiple notify signals?
             if (!this.is_height_valid) {
                 this.is_height_valid = true;
             }
-        } else {
-            offset_height = 0;
         }
 
         minimum_height = natural_height = (int) offset_height;
@@ -120,95 +122,4 @@ public class ConversationWebView : StylishWebView {
         minimum_height = natural_height = 0;
     }
 
-    public WebKit.DOM.HTMLElement create(string name) throws Error {
-        return get_dom_document().create_element(name) as WebKit.DOM.HTMLElement;
-    }
-
-    public bool is_always_loaded(string uri) {
-        foreach (string prefix in always_loaded_prefixes) {
-            if (uri.has_prefix(prefix))
-                return true;
-        }
-        
-        return false;
-    }
-    
-    private void on_resource_request_starting(WebKit.WebFrame web_frame,
-        WebKit.WebResource web_resource, WebKit.NetworkRequest request,
-        WebKit.NetworkResponse? response) {
-        if (response != null) {
-            // A request that was previously approved resulted in a redirect.
-            return;
-        }
-
-        string? uri = request.get_uri();
-        if (uri != null && !is_always_loaded(uri)) {
-            if (uri.has_prefix(allow_prefix)) {
-                // webkit_network_request_set_uri() will crash with
-                // "assertion 'soupURI' failed" if the string passed
-                // in is not a validi-sh URI, so check first
-                string allowed_uri = uri.substring(this.allow_prefix.length);
-                if (new Soup.URI(allowed_uri) != null) {
-                    request.set_uri(allowed_uri);
-                }
-            } else {
-                request.set_uri("about:blank");
-            }
-        }
-    }
-
-    private bool on_scroll_event(Gdk.EventScroll event) {
-        if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0) {
-            double dir = 0;
-            if (event.direction == Gdk.ScrollDirection.UP)
-                dir = -1;
-            else if (event.direction == Gdk.ScrollDirection.DOWN)
-                dir = 1;
-            else if (event.direction == Gdk.ScrollDirection.SMOOTH)
-                dir = event.delta_y;
-            
-            if (dir < 0) {
-                zoom_in();
-                return true;
-            } else if (dir > 0) {
-                zoom_out();
-                return true;
-            }
-        }
-        return false;
-    }
-
-    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;
-    }
-    
-    private unowned WebKit.WebView activate_inspector(WebKit.WebInspector inspector, WebKit.WebView 
target_view) {
-        Gtk.Window window = new Gtk.Window();
-        window.set_default_size(600, 600);
-        window.set_title(_("%s — Conversation Inspector").printf(GearyApplication.NAME));
-        Gtk.ScrolledWindow scrolled = new Gtk.ScrolledWindow(null, null);
-        WebKit.WebView inspector_view = new WebKit.WebView();
-        scrolled.add(inspector_view);
-        window.add(scrolled);
-        window.show_all();
-        window.delete_event.connect(() => {
-            inspector.close();
-            return false;
-        });
-        
-        unowned WebKit.WebView r = inspector_view;
-        return r;
-    }
-    
 }
-
diff --git a/src/client/web-process/util-composer.vala b/src/client/web-process/util-composer.vala
new file mode 100644
index 0000000..0f83dc8
--- /dev/null
+++ b/src/client/web-process/util-composer.vala
@@ -0,0 +1,488 @@
+/* Copyright 2016 Software Freedom Conservancy Inc.
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Util.Composer {
+
+    private const string BODY_ID = "message-body";
+
+    // HTML node names
+    private const string BLOCKQUOTE_NAME = "BLOCKQUOTE";
+    private const string BODY_NAME = "BODY";
+    private const string BR_NAME = "BR";
+    private const string DIV_NAME = "DIV";
+    private const string DOCUMENT_NAME = "#document";
+    private const string SPAN_NAME = "SPAN";
+    private const string TEXT_NAME = "#text";
+
+    // WebKit-specific node ids
+    private const string EDITING_DELETE_CONTAINER_ID = "WebKit-Editing-Delete-Container";
+
+
+    public void on_load_finished_and_realized(WebKit.WebPage page, string body_html) {
+        WebKit.DOM.Document document = page.get_dom_document();
+        WebKit.DOM.HTMLElement? body = document.get_element_by_id(BODY_ID) as WebKit.DOM.HTMLElement;
+        assert(body != null);
+
+        try {
+            body.set_inner_html(body_html);
+        } catch (Error e) {
+            debug("Failed to load prefilled body: %s", e.message);
+        }
+
+        protect_blockquote_styles(page);
+
+        // Focus within the HTML document
+        body.focus();
+
+        // Set cursor at appropriate position
+        try {
+            WebKit.DOM.Element? cursor = document.get_element_by_id("cursormarker");
+            if (cursor != null) {
+                WebKit.DOM.Range range = document.create_range();
+                range.select_node_contents(cursor);
+                range.collapse(false);
+                // WebKit.DOM.DOMSelection selection = document.default_page.get_selection();
+                // selection.remove_all_ranges();
+                // selection.add_range(range);
+                // cursor.parent_element.remove_child(cursor);
+            }
+        } catch (Error error) {
+            debug("Error setting cursor at end of text: %s", error.message);
+        }
+
+        //Util.DOM.bind_event(view, "a", "click", (Callback) on_link_clicked, this);
+    }
+
+    private void protect_blockquote_styles(WebKit.WebPage page) {
+        // We will search for an remove a particular styling when we quote text.  If that style
+        // exists in the quoted text, we alter it slightly so we don't mess with it later.
+        try {
+            WebKit.DOM.NodeList node_list = page.get_dom_document().query_selector_all(
+                "blockquote[style=\"margin: 0 0 0 40px; border: none; padding: 0px;\"]");
+            for (int i = 0; i < node_list.length; ++i) {
+                ((WebKit.DOM.Element) node_list.item(i)).set_attribute("style",
+                    "margin: 0 0 0 40px; padding: 0px; border:none;");
+            }
+        } catch (Error error) {
+            debug("Error protecting blockquotes: %s", error.message);
+        }
+    }
+
+    public void insert_quote(WebKit.WebPage page, string quote) {
+        WebKit.DOM.Document document = page.get_dom_document();
+        document.exec_command("insertHTML", false, quote);
+    }
+
+    public string get_block_quote_representation(WebKit.WebPage page) {
+        return Util.DOM.get_text_representation(page.get_dom_document(), "blockquote");
+    }
+
+    public void linkify_document(WebKit.WebPage page) {
+        Util.DOM.linkify_document(page.get_dom_document());
+    }
+
+    public void insert_clipboard_text(WebKit.WebPage page, string text) {
+        // Insert plain text from clipboard.
+        WebKit.DOM.Document document = page.get_dom_document();
+        document.exec_command("inserttext", false, text);
+
+        // The inserttext command will not scroll if needed, but we
+        // can't use the clipboard for plain text. WebKit allows us to
+        // scroll a node into view, but not an arbitrary position
+        // within a text node. So we add a placeholder node at the
+        // cursor position, scroll to that, then remove the
+        // placeholder node.
+        // try {
+        //     WebKit.DOM.DOMSelection selection = document.default_view.get_selection();
+        //     WebKit.DOM.Node selection_base_node = selection.get_base_node();
+        //     long selection_base_offset = selection.get_base_offset();
+
+        //     WebKit.DOM.NodeList selection_child_nodes = selection_base_node.get_child_nodes();
+        //     WebKit.DOM.Node ref_child = selection_child_nodes.item(selection_base_offset);
+
+        //     WebKit.DOM.Element placeholder = document.create_element("SPAN");
+        //     WebKit.DOM.Text placeholder_text = document.create_text_node("placeholder");
+        //     placeholder.append_child(placeholder_text);
+
+        //     if (selection_base_node.node_name == "#text") {
+        //         WebKit.DOM.Node? left = get_left_text(selection_base_node, selection_base_offset);
+
+        //         WebKit.DOM.Node parent = selection_base_node.parent_node;
+        //         if (left != null)
+        //             parent.insert_before(left, selection_base_node);
+        //         parent.insert_before(placeholder, selection_base_node);
+        //         parent.remove_child(selection_base_node);
+
+        //         placeholder.scroll_into_view_if_needed(false);
+        //         parent.insert_before(selection_base_node, placeholder);
+        //         if (left != null)
+        //             parent.remove_child(left);
+        //         parent.remove_child(placeholder);
+        //         selection.set_base_and_extent(selection_base_node, selection_base_offset, 
selection_base_node, selection_base_offset);
+        //     } else {
+        //         selection_base_node.insert_before(placeholder, ref_child);
+        //         placeholder.scroll_into_view_if_needed(false);
+        //         selection_base_node.remove_child(placeholder);
+        //     }
+        // } catch (Error err) {
+        //     debug("Error scrolling pasted text into view: %s", err.message);
+        // }
+    }
+
+    // private WebKit.DOM.Node? get_left_text(WebKit.WebPage page, WebKit.DOM.Node node, long offset) {
+    //     WebKit.DOM.Document document = page.get_dom_document();
+    //     string node_value = node.node_value;
+
+    //     // Offset is in unicode characters, but index is in bytes. We need to get the corresponding
+    //     // byte index for the given offset.
+    //     int char_count = node_value.char_count();
+    //     int index = offset > char_count ? node_value.length : node_value.index_of_nth_char(offset);
+
+    //     return offset > 0 ? document.create_text_node(node_value[0:index]) : null;
+    // }
+
+    public void enable_rich_text(WebKit.WebPage page, bool is_enabled) {
+        // WebKit.DOM.DOMTokenList body_classes = this.editor.get_dom_document().body.get_class_list();
+        // try {
+        //     if (is_enabled)
+        //         body_classes.remove("plain");
+        //     else
+        //         body_classes.add("plain");
+        // } catch (Error error) {
+        //     debug("Error setting composer style: %s", error.message);
+        // }
+    }
+
+    public void undo_blockquote_style(WebKit.WebPage page) {
+        try {
+            WebKit.DOM.NodeList node_list = page.get_dom_document().query_selector_all(
+                "blockquote[style=\"margin: 0 0 0 40px; border: none; padding: 0px;\"]");
+            for (int i = 0; i < node_list.length; ++i) {
+                WebKit.DOM.Element element = (WebKit.DOM.Element) node_list.item(i);
+                element.remove_attribute("style");
+                element.set_attribute("type", "cite");
+            }
+        } catch (Error error) {
+            debug("Error removing blockquote style: %s", error.message);
+        }
+    }
+
+    public string get_html(WebKit.WebPage page) {
+        return (
+            (WebKit.DOM.HTMLElement) page.get_dom_document().get_element_by_id(BODY_ID)
+         ).get_inner_html();
+    }
+
+    public string get_text(WebKit.WebPage page) {
+        return Util.DOM.html_to_flowed_text(
+            (WebKit.DOM.HTMLElement) page.get_dom_document().get_element_by_id(BODY_ID)
+        );
+    }
+
+    public bool handle_key_press(WebKit.WebPage page, Gdk.EventKey event) {
+        WebKit.DOM.Document document = page.get_dom_document();
+        if (event.keyval == Gdk.Key.Tab) {
+            document.exec_command("inserthtml", false,
+                "<span style='white-space: pre-wrap'>\t</span>");
+            return true;
+        }
+
+        if (event.keyval == Gdk.Key.ISO_Left_Tab) {
+            // If there is no selection and the character before the cursor is tab, delete it.
+            // WebKit.DOM.DOMSelection selection = document.get_default_view().get_selection();
+            // if (selection.is_collapsed) {
+            //     selection.modify("extend", "backward", "character");
+            //     try {
+            //         if (selection.get_range_at(0).get_text() == "\t")
+            //             selection.delete_from_document();
+            //         else
+            //             selection.collapse_to_end();
+            //     } catch (Error error) {
+            //         debug("Error handling Left Tab: %s", error.message);
+            //     }
+            // }
+            return true;
+        }
+
+        return false;
+    }
+
+
+    /////////////////////// From WebEditorFixer ///////////////////////
+
+    public bool on_should_insert_text(WebKit.WebPage page,
+                                      string text_to_insert,
+                                      WebKit.DOM.Range selected_range,
+                                      bool is_shift_down) {
+        // We only want to intercept this event when inserting a newline.
+        if (text_to_insert != "\n")
+            return true;
+
+        try {
+            WebKit.DOM.Node start_container = selected_range.get_start_container();
+            // If we are not inside a blockquote, the default behavior is fine.
+            if (!has_blockquote_in_ancestry(start_container))
+                return true;
+
+            selected_range.delete_contents();
+
+            // If the user is holding down shift, we simply insert a linebreak without splitting-
+            // up the DOM (recursively or otherwise).
+            long start_offset = selected_range.get_start_offset();
+            if (is_shift_down)
+                insert_linebreak_at_current_level(start_container, start_offset);
+            else
+                insert_linebreak_at_highest_level(start_container, start_offset);
+
+            return false;
+        } catch (Error err) {
+            debug("Error in on_should_insert_text: '%s'", err.message);
+            return false;
+        }
+    }
+
+    // Checks whether node is a blockquote or has one among its ancestors.
+    private bool has_blockquote_in_ancestry(WebKit.DOM.Node node) {
+        WebKit.DOM.Node? current = node;
+        while (current != null && current.node_name != DOCUMENT_NAME) {
+            if (current.node_name == BLOCKQUOTE_NAME)
+                return true;
+
+            current = current.parent_node;
+        }
+
+        return false;
+    }
+
+    // Insert a linebreak without splitting up the DOM (recursively or otherwise). This method is
+    // used instead of do_split when the user is holding down shift.
+    private void insert_linebreak_at_current_level(WebKit.DOM.Node node, long offset) {
+        try {
+            WebKit.DOM.Element br = node.owner_document.create_element(BR_NAME);
+            WebKit.DOM.Node parent = node.parent_node;
+
+            if (node.node_name == TEXT_NAME) {
+                WebKit.DOM.Node? left, right;
+                get_split_text(node, offset, out left, out right);
+                if (left != null)
+                    parent.insert_before(left, node);
+                parent.insert_before(br, node);
+                if (right != null)
+                    parent.insert_before(right, node);
+                parent.remove_child(node);
+
+                set_focus(right ?? br, right == null ? 1 : 0);
+            } else {
+                WebKit.DOM.NodeList children = node.child_nodes;
+                if (offset < children.length)
+                    node.insert_before(br, children.item(offset));
+                else
+                    node.append_child(br);
+
+                set_focus(br, 1);
+            }
+        } catch (Error err) {
+            debug("Error in insert_linebreak_at_current_level: '%s'", err.message);
+        }
+    }
+
+    // Splits a text node into two halfs, with the left half containing the next before offset,
+    // and the right node containing the text after offset. If either node is empty, it will be
+    // null.
+    private void get_split_text(WebKit.DOM.Node node, long offset, out WebKit.DOM.Node? left,
+        out WebKit.DOM.Node? right) {
+        WebKit.DOM.Document document = node.owner_document;
+        string node_value = node.node_value;
+
+        // Offset is in unicode characters, but index is in bytes. We need to get the corresponding
+        // byte index for the given offset.
+        int char_count = node_value.char_count();
+        int index = offset > char_count ? node_value.length : node_value.index_of_nth_char(offset);
+
+        left = offset > 0 ? document.create_text_node(node_value[0:index]) : null;
+        right = offset < char_count ? document.create_text_node(node_value[index:node_value.length]) : null;
+    }
+
+    // Focuses the cursor at the specified position.
+    private void set_focus(WebKit.DOM.Node focus_node, long focus_offset = 0) {
+        // try {
+        //     WebKit.DOM.DOMSelection selection = get_document().default_view.get_selection();
+        //     selection.set_position(focus_node, focus_offset);
+        // } catch (Error err) {
+        //     debug("Error in set_focus: '%s'", err.message);
+        // }
+
+        // scroll_into_view_if_needed(focus_node);
+    }
+
+    public void scroll_into_view_if_needed(WebKit.DOM.Node node) {
+        if (node.node_value.length > 0 && node is WebKit.DOM.Element) {
+            ((WebKit.DOM.Element)node).scroll_into_view_if_needed(false);
+            return;
+        }
+
+        WebKit.DOM.Node? parent = node.parent_node;
+        if (parent == null)
+            return;
+
+        // WebKit.DOM.Element.scroll_into_view_if_needed does not work if the element has no
+        // visual component. So we create a placeholder element, scroll to that, then remove
+        // the placeholder.
+        try {
+            WebKit.DOM.Document document = node.owner_document;
+            WebKit.DOM.Element placeholder = document.create_element(SPAN_NAME);
+            WebKit.DOM.Text placeholder_text = document.create_text_node("placeholder");
+            placeholder.append_child(placeholder_text);
+            parent.insert_before(placeholder, node);
+            placeholder.scroll_into_view_if_needed(false);
+            parent.remove_child(placeholder);
+        } catch (Error err) {
+            debug("Error in scroll_into_view_if_needed: '%s'", err.message);
+        }
+    }
+
+    // Recursively splits node, with 'offset' as the divider between the two halfs. Continue
+    // climbing up the DOM tree, splitting as we go, until there are no more blockquotes in our
+    // ancestry. When we finish recursing, insert a BR between the split nodes at the highest level
+    // of the tree, and focus the BR.
+    private void insert_linebreak_at_highest_level(WebKit.DOM.Node node, long offset)
+    throws Error {
+        WebKit.DOM.Node parent = node.parent_node;
+        WebKit.DOM.Node? left, right;
+        get_split(node, offset, out left, out right);
+
+        try {
+            if (has_blockquote_in_ancestry(parent)) {
+                // Recursive case. Don't insert line break, and don't set focus.
+                long split_offset = get_offset(parent, node);
+                if (left != null) {
+                    parent.insert_before(left, node);
+                    split_offset++;
+                }
+                if (right != null)
+                    parent.insert_before(right, node);
+                parent.remove_child(node);
+
+                insert_linebreak_at_highest_level(parent, split_offset);
+            } else {
+                // Base case. Insert a line break in the middle, and set the cursor focus.
+                if (left != null)
+                    parent.insert_before(left, node);
+                WebKit.DOM.Element br = node.owner_document.create_element(BR_NAME);
+                parent.insert_before(br, node);
+                if (right != null)
+                    parent.insert_before(right, node);
+                parent.remove_child(node);
+
+                // Set the cursor focus.
+                set_focus(br);
+            }
+        } catch (Error err) {
+            debug("Error in do_split: '%s'", err.message);
+        }
+    }
+
+    // Splits node into two halfs, one containing the children to the left of offset, the other
+    // containing the children to the right of offset. If either of the split nodes has no children
+    // that are neither whitespace nor a line break, null is returned for that split node.
+    private void get_split(WebKit.DOM.Node node, long offset, out WebKit.DOM.Node? left,
+        out WebKit.DOM.Node? right)
+    throws Error {
+        string node_name = node.node_name;
+
+        if (node_name == TEXT_NAME) {
+            get_split_text(node, offset, out left, out right);
+            return;
+        }
+
+        left = node.clone_node(false);
+        right = node.clone_node(false);
+
+        // Move the first $offset children to the left node.
+        for (long i = 0; i < offset; i++)
+            move_first_child(node, left);
+
+        // Move the remaining children to the right node.
+        while (node.child_nodes.length > 0) {
+            // If anything goes wrong, break out of the loop.
+             if (!move_first_child(node, right))
+                break;
+        }
+
+        if (!is_substantial(left))
+            left = null;
+        if (!is_substantial(right))
+            right = null;
+    }
+
+    // Gets the number of children before child in parent's children. Child must be among parent's
+    // children.
+    private long get_offset(WebKit.DOM.Node parent, WebKit.DOM.Node child) {
+        long offset = 0;
+        WebKit.DOM.Node current = parent.child_nodes.item(offset);
+        while (offset < parent.child_nodes.length) {
+            if (current == null)
+                break;
+            if (current.is_same_node(child))
+                return offset;
+
+            offset++;
+            current = parent.child_nodes.item(offset);
+        }
+
+        // Hopefully, this should never happen. But if it does, better to split in a wrong location
+        // than to crash.
+        return 0;
+    }
+
+    // Removes the first child of source and appends it to destination.
+    private bool move_first_child(WebKit.DOM.Node source, WebKit.DOM.Node destination) {
+        try {
+            WebKit.DOM.Node? temp = source.child_nodes.item(0);
+            if (is_editing_delete_container(temp)) {
+                source.remove_child(temp);
+            } else {
+                // This will remove temp from source
+                destination.append_child(temp);
+            }
+        } catch (Error err) {
+            debug("Error in move_first_child: '%s'", err.message);
+            return false;
+        }
+
+        return true;
+    }
+
+    // There is a special node that webkit attaches to the BLOCKQUOTE in focus. We want to ignore
+    // this node, as it is transient.
+    private bool is_editing_delete_container(WebKit.DOM.Node? node) {
+        WebKit.DOM.Element? element = node as WebKit.DOM.Element;
+        return (
+            element != null &&
+            element.get_attribute("id") == EDITING_DELETE_CONTAINER_ID
+        );
+    }
+
+    // True if node has at least one child that is not a BR, a #text consisting entirely of
+    // whitespace, or an unsubstantial div.
+    private bool is_substantial(WebKit.DOM.Node node) {
+        WebKit.DOM.Node child;
+        for (ulong i = 0; i < node.child_nodes.length; i++) {
+            child = node.child_nodes.item(i);
+            if (child.node_name == BR_NAME)
+                continue;
+            if (child.node_name == TEXT_NAME && Geary.String.is_empty_or_whitespace(child.node_value))
+                continue;
+            if (child.node_name == DIV_NAME && !is_substantial(child))
+                continue;
+
+            return true;
+        }
+
+        return false;
+    }
+
+}
diff --git a/src/client/web-process/util-conversation.vala b/src/client/web-process/util-conversation.vala
new file mode 100644
index 0000000..578bf99
--- /dev/null
+++ b/src/client/web-process/util-conversation.vala
@@ -0,0 +1,364 @@
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2016 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.
+ */
+
+namespace Util.Conversation {
+
+    private const string SIGNATURE_CONTAINER_CLASS = "geary_signature";
+
+    private const string QUOTE_CONTAINER_CLASS = "geary_quote_container";
+    private const string QUOTE_CONTROLLABLE_CLASS = "controllable";
+    private const string QUOTE_HIDE_CLASS = "hide";
+    private const float QUOTE_SIZE_THRESHOLD = 2.0f;
+
+
+    public double get_preferred_height(WebKit.WebPage page) {
+        WebKit.DOM.Element html = page.get_dom_document().get_document_element();
+        double offset_height = html.offset_height;
+        double offset_width = html.offset_width;
+        double px = offset_width * offset_height;
+
+        const double MAX_LEN = 15.0 * 1000;
+        const double MAX_PX = 10.0 * 1000 * 1000;
+
+        // If the offset_width is very small, the offset_height will
+        // likely be bogus, so just pretend we have no height for the
+        // moment. WebKitGTK seems to report an offset width of 1 in
+        // these cases.
+        if (offset_width > 1) {
+            if (offset_height > MAX_LEN || px > MAX_PX) {
+                double new_height = double.min(MAX_LEN, MAX_PX / offset_width);
+                debug("Clamping window height to: %f, current size: %fx%f (%fpx)",
+                      new_height, offset_width, offset_height, px);
+                offset_height = new_height;
+            }
+        } else {
+            offset_height = 0;
+        }
+
+        return offset_height;
+    }
+
+    public string clean_html_markup(WebKit.WebPage page, string text, Geary.RFC822.Message message, out bool 
remote_images) {
+        remote_images = false;
+        try {
+            WebKit.DOM.HTMLElement html = (WebKit.DOM.HTMLElement)
+                page.get_dom_document().document_element;
+
+            // If the message has a HTML element, get its inner
+            // markup. We can't just set this on a temp container div
+            // (the old approach) using set_inner_html() will refuse
+            // to parse any HTML, HEAD and BODY elements that are out
+            // of place in the structure. We can't use
+            // set_outer_html() on the document element since it
+            // throws an error.
+            GLib.Regex html_regex = new GLib.Regex("<html([^>]*)>(.*)</html>",
+                GLib.RegexCompileFlags.DOTALL);
+            GLib.MatchInfo matches;
+            if (html_regex.match(text, 0, out matches)) {
+                // Set the existing HTML element's content. Here, HEAD
+                // and BODY elements will be parsed fine.
+                html.set_inner_html(matches.fetch(2));
+                // Copy email HTML element attrs across to the
+                // existing HTML element
+                string attrs = matches.fetch(1);
+                if (attrs != "") {
+                    WebKit.DOM.HTMLElement container = create(page, "div");
+                    container.set_inner_html(@"<div$attrs></div>");
+                    WebKit.DOM.HTMLElement? attr_element =
+                        Util.DOM.select(container, "div");
+                    WebKit.DOM.NamedNodeMap html_attrs =
+                        attr_element.get_attributes();
+                    for (int i = 0; i < html_attrs.get_length(); i++) {
+                        WebKit.DOM.Node attr = html_attrs.item(i);
+                        html.set_attribute(attr.node_name, attr.text_content);
+                    }
+                }
+            } else {
+                html.set_inner_html(text);
+            }
+
+            // Set dir="auto" if not already set possibly get a
+            // slightly better RTL experience.
+            string? dir = html.get_dir();
+            if (dir == null || dir.length == 0) {
+                html.set_dir("auto");
+            }
+
+            // Add application CSS to the document
+            WebKit.DOM.HTMLElement? head = Util.DOM.select(html, "head");
+            if (head == null) {
+                head = create(page, "head");
+                html.insert_before(head, html.get_first_child());
+            }
+            WebKit.DOM.HTMLElement style_element = create(page, "style");
+            string css_text = ""; // XXX 
GearyApplication.instance.read_resource("conversation-web-view.css");
+            WebKit.DOM.Text text_node = page.get_dom_document().create_text_node(css_text);
+            style_element.append_child(text_node);
+            head.insert_before(style_element, head.get_first_child());
+
+            // Get all the top level block quotes and stick them into a hide/show controller.
+            WebKit.DOM.NodeList blockquote_list = html.query_selector_all("blockquote");
+            for (int i = 0; i < blockquote_list.length; ++i) {
+                // Get the nodes we need.
+                WebKit.DOM.Node blockquote_node = blockquote_list.item(i);
+                WebKit.DOM.Node? next_sibling = blockquote_node.get_next_sibling();
+                WebKit.DOM.Node parent = blockquote_node.get_parent_node();
+
+                // Make sure this is a top level blockquote.
+                if (Util.DOM.node_is_child_of(blockquote_node, "BLOCKQUOTE")) {
+                    continue;
+                }
+
+                WebKit.DOM.Element quote_container = create_quote_container(page);
+                Util.DOM.select(quote_container, ".quote").append_child(blockquote_node);
+                if (next_sibling == null) {
+                    parent.append_child(quote_container);
+                } else {
+                    parent.insert_before(quote_container, next_sibling);
+                }
+            }
+
+            // Now look for the signature.
+            wrap_html_signature(page, ref html);
+
+            // Then look for all <img> tags. Inline images are replaced with
+            // data URLs.
+            WebKit.DOM.NodeList inline_list = html.query_selector_all("img");
+            Gee.HashSet<string> inlined_content_ids = new Gee.HashSet<string>();
+            for (ulong i = 0; i < inline_list.length; ++i) {
+                // Get the MIME content for the image.
+                WebKit.DOM.HTMLImageElement img = (WebKit.DOM.HTMLImageElement) inline_list.item(i);
+                string? src = img.get_attribute("src");
+                if (Geary.String.is_empty(src))
+                    continue;
+
+                // if no Content-ID, then leave as-is, but note if a non-data: URI is being used for
+                // purposes of detecting remote images
+                string? content_id = src.has_prefix("cid:") ? src.substring(4) : null;
+                if (Geary.String.is_empty(content_id)) {
+                    remote_images = remote_images || !src.has_prefix("data:");
+
+                    continue;
+                }
+
+                // if image has a Content-ID and it's already been replaced by the image replacer,
+                // drop this tag, otherwise fix up this one with the Base-64 data URI of the image
+                // if (!replaced_content_ids.contains(content_id)) {
+                //     string? filename = message.get_content_filename_by_mime_id(content_id);
+                //     Geary.Memory.Buffer image_content = message.get_content_by_mime_id(content_id);
+                //     Geary.Memory.UnownedBytesBuffer? unowned_buffer =
+                //         image_content as Geary.Memory.UnownedBytesBuffer;
+
+                //     // Get the content type.
+                //     string guess;
+                //     if (unowned_buffer != null)
+                //         guess = ContentType.guess(null, unowned_buffer.to_unowned_uint8_array(), null);
+                //     else
+                //         guess = ContentType.guess(null, image_content.get_uint8_array(), null);
+
+                //     string mimetype = ContentType.get_mime_type(guess);
+
+                //     // Replace the SRC to a data URI, the class to a known label for the popup menu,
+                //     // and the ALT to its filename, if supplied
+                //     img.remove_attribute("src");  // Work around a WebKitGTK+ crash. Bug 764152
+                //     img.set_attribute("src", Util.DOM.assemble_data_uri(mimetype, image_content));
+                //     //img.class_list.add(DATA_IMAGE_CLASS);
+                //     if (!Geary.String.is_empty(filename))
+                //         img.set_attribute("alt", filename);
+
+                //     // stash here so inlined image isn't listed as attachment (esp. if it has no
+                //     // Content-Disposition)
+                //     inlined_content_ids.add(content_id);
+                //     attachment_displayed_inline(content_id);
+                // } else {
+                //     // replaced by data: URI, remove this tag and let the inserted one shine through
+                //     img.parent_element.remove_child(img);
+                // }
+            }
+
+            // Remove any inline images that were referenced through Content-ID
+            foreach (string cid in inlined_content_ids) {
+                try {
+                    string escaped_cid = Geary.HTML.escape_markup(cid);
+                    WebKit.DOM.Element? img = html.query_selector(@"[cid='$escaped_cid']");
+                    if (img != null)
+                        img.parent_element.remove_child(img);
+                } catch (Error error) {
+                    debug("Error removing inlined image: %s", error.message);
+                }
+            }
+
+            // Now return the whole message.
+            return html.get_outer_html();
+        } catch (Error e) {
+            debug("Error modifying HTML message: %s", e.message);
+            return text;
+        }
+    }
+
+    public void unset_controllable_quotes(WebKit.WebPage page)
+    throws Error {
+        WebKit.DOM.HTMLElement html =
+            page.get_dom_document().document_element as WebKit.DOM.HTMLElement;
+        if (html != null) {
+            WebKit.DOM.NodeList quote_list = html.query_selector_all(
+                ".%s.%s".printf(QUOTE_CONTAINER_CLASS, QUOTE_CONTROLLABLE_CLASS)
+            );
+            for (int i = 0; i < quote_list.length; ++i) {
+                WebKit.DOM.Element quote_container = quote_list.item(i) as WebKit.DOM.Element;
+                double outer_client_height = quote_container.client_height;
+                long scroll_height = quote_container.query_selector(".quote").scroll_height;
+                // If the message is hidden, scroll_height will be
+                // 0. Otherwise, unhide the full quote if there is not a
+                // substantial amount hidden.
+                if (scroll_height > 0 &&
+                    scroll_height <= outer_client_height * QUOTE_SIZE_THRESHOLD) {
+                    //quote_container.class_list.remove(QUOTE_CONTROLLABLE_CLASS);
+                    //quote_container.class_list.remove(QUOTE_HIDE_CLASS);
+                }
+            }
+        }
+    }
+
+    public void show_images(WebKit.WebPage page)
+    throws Error {
+        WebKit.DOM.NodeList nodes =
+            page.get_dom_document().body.get_elements_by_tag_name("img");
+        for (ulong i = 0; i < nodes.length; i++) {
+            WebKit.DOM.Element? element = nodes.item(i) as WebKit.DOM.Element;
+            if (element == null || !element.has_attribute("src"))
+                continue;
+
+            // string src = element.get_attribute("src");
+            // Don't prefix empty src strings since it will cause
+            // e.g. 0px images (commonly found in commercial mailouts)
+            // to be rendered as broken images instead of empty
+            // elements.
+            // if (src.length > 0 && !page.is_always_loaded(src)) {
+            //     element.set_attribute("src", page.allow_prefix + src);
+            // }
+        }
+    }
+
+    public string? get_selection_for_quoting(WebKit.WebPage page) {
+        string? quote = null;
+        // WebKit.DOM.Document document = page.get_dom_document();
+        // WebKit.DOM.DOMSelection selection = document.default_view.get_selection();
+        // if (!selection.is_collapsed) {
+        //     try {
+        //         WebKit.DOM.Range range = selection.get_range_at(0);
+        //         WebKit.DOM.HTMLElement dummy =
+        //             (WebKit.DOM.HTMLElement) document.create_element("div");
+        //         bool include_dummy = false;
+        //         WebKit.DOM.Node ancestor_node = range.get_common_ancestor_container();
+        //         WebKit.DOM.Element? ancestor = ancestor_node as WebKit.DOM.Element;
+        //         if (ancestor == null)
+        //             ancestor = ancestor_node.get_parent_element();
+        //         // If the selection is part of a plain text message,
+        //         // we have to stick it in an appropriately styled div,
+        //         // so that new lines are preserved.
+        //         if (Util.DOM.is_descendant_of(ancestor, ".plaintext")) {
+        //             dummy.get_class_list().add("plaintext");
+        //             dummy.set_attribute("style", "white-space: pre-wrap;");
+        //             include_dummy = true;
+        //         }
+        //         dummy.append_child(range.clone_contents());
+
+        //         // Remove the chrome we put around quotes, leaving
+        //         // only the blockquote element.
+        //         WebKit.DOM.NodeList quotes =
+        //             dummy.query_selector_all("." + QUOTE_CONTAINER_CLASS);
+        //         for (int i = 0; i < quotes.length; i++) {
+        //             WebKit.DOM.Element div = (WebKit.DOM.Element) quotes.item(i);
+        //             WebKit.DOM.Element blockquote = div.query_selector("blockquote");
+        //             div.get_parent_element().replace_child(blockquote, div);
+        //         }
+
+        //         quote = include_dummy ? dummy.get_outer_html() : dummy.get_inner_html();
+        //     } catch (Error error) {
+        //         debug("Problem getting selected text: %s", error.message);
+        //     }
+        // }
+        return quote;
+    }
+
+    public string? get_selection_for_find(WebKit.WebPage page) {
+        string? value = null;
+        // WebKit.DOM.Document document = page.get_dom_document();
+        // WebKit.DOM.DOMWindow window = document.get_default_view();
+        // WebKit.DOM.DOMSelection selection = window.get_selection();
+
+        // if (selection.get_range_count() > 0) {
+        //     try {
+        //         WebKit.DOM.Range range = selection.get_range_at(0);
+        //         value = range.get_text().strip();
+        //         if (value.length <= 0)
+        //             value = null;
+        //     } catch (Error e) {
+        //         warning("Could not get selected text from web view: %s", e.message);
+        //     }
+        // }
+        return value;
+    }
+
+    private WebKit.DOM.HTMLElement create(WebKit.WebPage page, string name)
+    throws Error {
+        return page.get_dom_document().create_element(name) as WebKit.DOM.HTMLElement;
+    }
+
+    private WebKit.DOM.HTMLElement create_quote_container(WebKit.WebPage page) throws Error {
+        WebKit.DOM.HTMLElement quote_container = create(page, "div");
+        // quote_container.class_list.add(QUOTE_CONTAINER_CLASS);
+        // quote_container.class_list.add(QUOTE_CONTROLLABLE_CLASS);
+        // quote_container.class_list.add(QUOTE_HIDE_CLASS);
+        // New lines are preserved within blockquotes, so this string
+        // needs to be new-line free.
+        quote_container.set_inner_html("""<div class="shower"><input type="button" value="▼        ▼        
▼" /></div><div class="hider"><input type="button" value="▲        ▲        ▲" /></div><div 
class="quote"></div>""");
+        return quote_container;
+    }
+
+    private void wrap_html_signature(WebKit.WebPage page, ref WebKit.DOM.HTMLElement container) throws Error 
{
+        // Most HTML signatures fall into one of these designs which are handled by this method:
+        //
+        // 1. GMail:            <div>-- </div>$SIGNATURE
+        // 2. GMail Alternate:  <div><span>-- </span></div>$SIGNATURE
+        // 3. Thunderbird:      <div>-- <br>$SIGNATURE</div>
+        //
+        WebKit.DOM.NodeList div_list = container.query_selector_all("div,span,p");
+        int i = 0;
+        Regex sig_regex = new Regex("^--\\s*$");
+        Regex alternate_sig_regex = new Regex("^--\\s*(?:<br|\\R)");
+        for (; i < div_list.length; ++i) {
+            // Get the div and check that it starts a signature block and is not inside a quote.
+            WebKit.DOM.HTMLElement div = div_list.item(i) as WebKit.DOM.HTMLElement;
+            string inner_html = div.get_inner_html();
+            if ((sig_regex.match(inner_html) || alternate_sig_regex.match(inner_html)) &&
+                !Util.DOM.node_is_child_of(div, "BLOCKQUOTE")) {
+                break;
+            }
+        }
+
+        // If we have a signature, move it and all of its following siblings that are not quotes
+        // inside a signature div.
+        if (i == div_list.length) {
+            return;
+        }
+        WebKit.DOM.Node elem = div_list.item(i) as WebKit.DOM.Node;
+        WebKit.DOM.Element parent = elem.get_parent_element();
+        WebKit.DOM.HTMLElement signature_container = create(page, "div");
+        //signature_container.class_list.add(SIGNATURE_CONTAINER_CLASS);
+        do {
+            // Get its sibling _before_ we move it into the signature div.
+            WebKit.DOM.Node? sibling = elem.get_next_sibling();
+            signature_container.append_child(elem);
+            elem = sibling;
+        } while (elem != null);
+        parent.append_child(signature_container);
+    }
+
+}
diff --git a/src/client/util/util-webkit.vala b/src/client/web-process/util-webkit.vala
similarity index 79%
rename from src/client/util/util-webkit.vala
rename to src/client/web-process/util-webkit.vala
index 5a6989f..b8bb3a1 100644
--- a/src/client/util/util-webkit.vala
+++ b/src/client/web-process/util-webkit.vala
@@ -4,10 +4,6 @@
  * (version 2.1 or later).  See the COPYING file in this distribution.
  */
 
-// Regex to detect URLs.
-// Originally from here: http://daringfireball.net/2010/07/improved_regex_for_matching_urls
-public const string URL_REGEX = 
"(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))";
-
 // Regex to determine if a URL has a known protocol.
 public const string PROTOCOL_REGEX = 
"^(aim|apt|bitcoin|cvs|ed2k|ftp|file|finger|git|gtalk|http|https|irc|ircs|irc6|lastfm|ldap|ldaps|magnet|news|nntp|rsync|sftp|skype|smb|sms|svn|telnet|tftp|ssh|webcal|xmpp):";
 
@@ -30,7 +26,13 @@ namespace Util.DOM {
     }
 
     public WebKit.DOM.HTMLElement? clone_node(WebKit.DOM.Node node, bool deep = true) {
-        return node.clone_node(deep) as WebKit.DOM.HTMLElement;
+        WebKit.DOM.HTMLElement? clone = null;
+        try {
+            clone = node.clone_node(deep) as WebKit.DOM.HTMLElement;
+        } catch (Error err) {
+            debug("Error selecting cloning node: %s", err.message);
+        }
+        return clone;
     }
 
     public WebKit.DOM.HTMLElement? clone_select(WebKit.DOM.Node node, string selector,
@@ -38,13 +40,13 @@ namespace Util.DOM {
         return clone_node(select(node, selector), deep);
     }
 
-    public void toggle_class(WebKit.DOM.DOMTokenList class_list, string clas, bool add) throws Error {
-        if (add) {
-            class_list.add(clas);
-        } else {
-            class_list.remove(clas);
-        }
-    }
+    //public void toggle_class(WebKit.DOM.DOMTokenList class_list, string clas, bool add) throws Error {
+    //     if (add) {
+    //         class_list.add(clas);
+    //     } else {
+    //         class_list.remove(clas);
+    //     }
+    //}
 
     // Returns the text contained in the DOM document, after ignoring tags of type "exclude"
     // and padding newlines where appropriate. Used to scan for attachment keywords.
@@ -109,19 +111,19 @@ namespace Util.DOM {
         return copy.get_inner_text();
     }
 
-    public void bind_event(WebKit.WebView view, string selector, string event, Callback callback,
-        Object? extra = null) {
-        try {
-            WebKit.DOM.NodeList node_list = view.get_dom_document().query_selector_all(selector);
-            for (int i = 0; i < node_list.length; ++i) {
-                WebKit.DOM.EventTarget node = node_list.item(i) as WebKit.DOM.EventTarget;
-                node.remove_event_listener(event, callback, false);
-                node.add_event_listener(event, callback, false, extra);
-            }
-        } catch (Error error) {
-            warning("Error setting up click handlers: %s", error.message);
-        }
-    }
+    // public void bind_event(WebKit.WebView view, string selector, string event, Callback callback,
+    //     Object? extra = null) {
+    //     try {
+    //         WebKit.DOM.NodeList node_list = view.get_dom_document().query_selector_all(selector);
+    //         for (int i = 0; i < node_list.length; ++i) {
+    //             WebKit.DOM.EventTarget node = node_list.item(i) as WebKit.DOM.EventTarget;
+    //             node.remove_event_listener(event, callback, false);
+    //             node.add_event_listener(event, callback, false, extra);
+    //         }
+    //     } catch (Error error) {
+    //         warning("Error setting up click handlers: %s", error.message);
+    //     }
+    // }
 
     // Linkifies plain text links in an HTML document.
     public void linkify_document(WebKit.DOM.Document document) {
@@ -157,7 +159,7 @@ namespace Util.DOM {
         string input = node.get_node_value();
         if (!in_link && !Geary.String.is_empty(input)) {
             try {
-                Regex r = new Regex(URL_REGEX, RegexCompileFlags.CASELESS);
+                Regex r = new Regex(Geary.HTML.URL_REGEX, RegexCompileFlags.CASELESS);
                 string output = r.replace_eval(input, -1, 0, 0, pre_split_urls);
                 if (input != output) {
                     // We got one!  Now split the text and swap out the node.
@@ -228,7 +230,7 @@ namespace Util.DOM {
         string output = input.replace("<", " \01 ").replace(">", " \02 ").replace("&", "&amp;");
 
         // Converts text links into HTML hyperlinks.
-        Regex r = new Regex(URL_REGEX, RegexCompileFlags.CASELESS);
+        Regex r = new Regex(Geary.HTML.URL_REGEX, RegexCompileFlags.CASELESS);
 
         output = r.replace_eval(output, -1, 0, 0, is_valid_url);
         return output.replace(" \01 ", "&lt;").replace(" \02 ", "&gt;");
@@ -244,25 +246,18 @@ namespace Util.DOM {
         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_descendant_of(WebKit.DOM.Element? element, string selector) {
         try {
-            while (element != null) {
-                if (element.webkit_matches_selector(selector))
-                    return true;
-                element = element.get_parent_element();
+            WebKit.DOM.NodeList matching = element.owner_document.query_selector_all(selector);
+            for (int i = 0; i < matching.length; i++) {
+                WebKit.DOM.Node parent = matching.item(i);
+                WebKit.DOM.Node child = element;
+                while (child != null) {
+                    if (child.parent_node == parent) {
+                        return true;
+                    }
+                    child = child.parent_node;
+                }
             }
         } catch (Error error) {
             warning("Problem traversing DOM: %s", error.message);
@@ -440,54 +435,5 @@ namespace Util.DOM {
         return "data:%s;base64,%s".printf(mimetype, base64);
     }
 
-    // Turns the data: URI created by assemble_data_uri() back into its components.  The returned
-    // buffer is decoded.
-    //
-    // TODO: Return mimetype
-    public bool disassemble_data_uri(string uri, out Geary.Memory.Buffer? buffer) {
-        buffer = null;
-
-        if (!uri.has_prefix("data:"))
-            return false;
-
-        // count from semicolon past encoding type specifier
-        int start_index = uri.index_of(";");
-        if (start_index <= 0)
-            return false;
-
-        // watch for string termination to avoid overflow
-        int base64_len = "base64,".length;
-        for (int ctr = 0; ctr < base64_len; ctr++) {
-            if (uri[start_index++] == Geary.String.EOS)
-                return false;
-        }
-
-        // avoid a memory copy of the substring by manually calculating the start address
-        uint8[] bytes = Base64.decode((string) (((char *) uri) + start_index));
-
-        // transfer ownership of the byte array directly to the Buffer; this prevents an
-        // unnecessary copy ... save length before transferring ownership (which frees the array)
-        int bytes_length = bytes.length;
-        buffer = new Geary.Memory.ByteBuffer.take((owned) bytes, bytes_length);
-
-        return true;
-    }
-
-    // Escape reserved HTML entities if the string does not have HTML tags.  If there are no tags,
-    // or if preserve_whitespace_in_html is true, wrap the string a div to preserve whitespace.
-    public string smart_escape(string? text, bool preserve_whitespace_in_html) {
-        if (text == null)
-            return text;
-
-        string res = text;
-        if (!Regex.match_simple("<([A-Z]*)(?: [^>]*)?>.*</(\\1)>|<[A-Z]*(?: [^>]*)?/>", res,
-            RegexCompileFlags.CASELESS)) {
-            res = Geary.HTML.escape_markup(res);
-            preserve_whitespace_in_html = true;
-        }
-        if (preserve_whitespace_in_html)
-            res = @"<div style='white-space: pre;'>$res</div>";
-        return res;
-    }
 }
 
diff --git a/src/client/web-process/web-process-extension.vala 
b/src/client/web-process/web-process-extension.vala
new file mode 100644
index 0000000..b063878
--- /dev/null
+++ b/src/client/web-process/web-process-extension.vala
@@ -0,0 +1,11 @@
+/*
+ * Copyright 2016 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.
+ */
+
+
+public static void webkit_web_extension_initialize (WebKit.WebExtension extension) {
+    // noop for now
+}
diff --git a/src/engine/util/util-html.vala b/src/engine/util/util-html.vala
index fdc3d1e..8114f03 100644
--- a/src/engine/util/util-html.vala
+++ b/src/engine/util/util-html.vala
@@ -6,6 +6,10 @@
 
 namespace Geary.HTML {
 
+// Regex to detect URLs.
+// Originally from here: http://daringfireball.net/2010/07/improved_regex_for_matching_urls
+public const string URL_REGEX = 
"(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))";
+
 private int init_count = 0;
 private Gee.HashSet<string>? breaking_elements;
 private Gee.HashSet<string>? spacing_elements;
@@ -164,4 +168,22 @@ private void recurse_html_nodes_for_text(Xml.Node? node,
     }
 }
 
+// Escape reserved HTML entities if the string does not have HTML
+// tags.  If there are no tags, or if preserve_whitespace_in_html is
+// true, wrap the string a div to preserve whitespace.
+public string smart_escape(string? text, bool preserve_whitespace_in_html) {
+    if (text == null)
+        return text;
+
+    string res = text;
+    if (!Regex.match_simple("<([A-Z]*)(?: [^>]*)?>.*</(\\1)>|<[A-Z]*(?: [^>]*)?/>", res,
+                            RegexCompileFlags.CASELESS)) {
+        res = Geary.HTML.escape_markup(res);
+        preserve_whitespace_in_html = true;
+    }
+    if (preserve_whitespace_in_html)
+        res = @"<div style='white-space: pre;'>$res</div>";
+    return res;
+}
+
 }


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