New chat view using gtkwebkit

This changes the text buffer widget to a WebKitWebView so that we can
use web technologies to control the display.

This change comes with a new dependency: libwebkit2gtk-4.0. Should
this dependency not be available on the system, we can also build the
client using libwebkit2gtk-3.0. However, the links won't be clickable.

New features:
 - Implemented delivery reports.
 - Avatars are now displayed in the chat window.
 - Links in the chat window are now clickable.

When the client is launched with the -d option, you may right click on
the chat view to open up the dev tools.

In order to improve performance, one WebKitWebView is re-used for all
of the ChatViews, since we only display one at a time.

Tuleap: #1073
Change-Id: Ic945fa6c92f92e391f0362310ddc2f0fa16641bf
[stepan.salenikovich@savoirfairelinux.com: added change_view(); start
 loading webkit on window init; destroy webkit on dispose; prevent
 warning when dispose is called more than once on ChatView]
Signed-off-by: Stepan Salenikovich <stepan.salenikovich@savoirfairelinux.com>
diff --git a/src/webkitchatcontainer.cpp b/src/webkitchatcontainer.cpp
new file mode 100644
index 0000000..46885bd
--- /dev/null
+++ b/src/webkitchatcontainer.cpp
@@ -0,0 +1,464 @@
+/*
+ *  Copyright (C) 2016 Savoir-faire Linux Inc.
+ *  Author: Alexandre Viau <alexandre.viau@savoirfairelinux.com>
+ *
+ *  This program is free software; you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation; either version 3 of the License, or
+ *  (at your option) any later version.
+ *
+ *  This program is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with this program; if not, write to the Free Software
+ *  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301 USA.
+ */
+
+#include "webkitchatcontainer.h"
+
+// GTK+ related
+#include <gtk/gtk.h>
+#include <glib/gi18n.h>
+#include <webkit2/webkit2.h>
+
+// Qt
+#include <QtCore/QJsonValue>
+#include <QtCore/QJsonObject>
+#include <QtCore/QJsonDocument>
+
+// LRC
+#include <media/textrecording.h>
+#include <globalinstances.h>
+
+// Ring Client
+#include "native/pixbufmanipulator.h"
+#include "config.h"
+
+struct _WebKitChatContainer
+{
+    GtkBox parent;
+};
+
+struct _WebKitChatContainerClass
+{
+    GtkBoxClass parent_class;
+};
+
+typedef struct _WebKitChatContainerPrivate WebKitChatContainerPrivate;
+
+struct _WebKitChatContainerPrivate
+{
+    GtkWidget* webview_chat;
+
+    bool       chatview_debug;
+
+    /* Array of javascript libraries to load. Used during initialization */
+    GList*     js_libs_to_load;
+    gboolean   js_libs_loaded;
+};
+
+G_DEFINE_TYPE_WITH_PRIVATE(WebKitChatContainer, webkit_chat_container, GTK_TYPE_BOX);
+
+#define WEBKIT_CHAT_CONTAINER_GET_PRIVATE(obj) (G_TYPE_INSTANCE_GET_PRIVATE ((obj), WEBKIT_CHAT_CONTAINER_TYPE, WebKitChatContainerPrivate))
+
+/* signals */
+enum {
+    READY,
+    LAST_SIGNAL
+};
+
+static guint webkit_chat_container_signals[LAST_SIGNAL] = { 0 };
+
+static void
+webkit_chat_container_dispose(GObject *object)
+{
+    G_OBJECT_CLASS(webkit_chat_container_parent_class)->dispose(object);
+}
+
+static void
+webkit_chat_container_init(WebKitChatContainer *view)
+{
+    gtk_widget_init_template(GTK_WIDGET(view));
+}
+
+static void
+webkit_chat_container_class_init(WebKitChatContainerClass *klass)
+{
+    G_OBJECT_CLASS(klass)->dispose = webkit_chat_container_dispose;
+
+    /* This must be called at least once before we render chatview.ui
+     * in order to allow us to use WebKitWebView in the template file. */
+    webkit_web_view_get_type();
+
+    gtk_widget_class_set_template_from_resource(GTK_WIDGET_CLASS (klass),
+                                                "/cx/ring/RingGnome/webkitchatcontainer.ui");
+
+    gtk_widget_class_bind_template_child_private(GTK_WIDGET_CLASS (klass), WebKitChatContainer, webview_chat);
+
+    /* add signals */
+    webkit_chat_container_signals[READY] = g_signal_new("ready",
+        G_TYPE_FROM_CLASS(klass),
+        (GSignalFlags) (G_SIGNAL_RUN_FIRST | G_SIGNAL_ACTION),
+        0,
+        nullptr,
+        nullptr,
+        g_cclosure_marshal_VOID__VOID,
+        G_TYPE_NONE, 0);
+}
+
+static gboolean
+webview_chat_context_menu(WebKitChatContainer *self)
+{
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(self);
+    return !priv->chatview_debug;
+}
+
+QString
+message_index_to_json_message_object(const QModelIndex &idx)
+{
+    auto message = idx.data().value<QString>();
+    auto sender = idx.data(static_cast<int>(Media::TextRecording::Role::AuthorDisplayname)).value<QString>();
+    auto timestamp = idx.data(static_cast<int>(Media::TextRecording::Role::Timestamp)).value<time_t>();
+    auto direction = idx.data(static_cast<int>(Media::TextRecording::Role::Direction)).value<Media::Media::Direction>();
+    auto message_id = idx.row();
+
+    QJsonObject message_object = QJsonObject();
+    message_object.insert("text", QJsonValue(message));
+    message_object.insert("id", QJsonValue(QString().setNum(message_id)));
+    message_object.insert("sender", QJsonValue(sender));
+    message_object.insert("timestamp", QJsonValue((int) timestamp));
+    message_object.insert("direction", QJsonValue((direction == Media::Media::Direction::IN) ? "in" : "out"));
+
+    switch(idx.data(static_cast<int>(Media::TextRecording::Role::DeliveryStatus)).value<Media::TextRecording::Status>())
+    {
+        case Media::TextRecording::Status::FAILURE:
+        {
+            message_object.insert("delivery_status", QJsonValue("failure"));
+            break;
+        }
+        case Media::TextRecording::Status::COUNT__:
+        {
+            message_object.insert("delivery_status", QJsonValue("count__"));
+            break;
+        }
+        case Media::TextRecording::Status::SENDING:
+        {
+            message_object.insert("delivery_status", QJsonValue("sending"));
+            break;
+        }
+        case Media::TextRecording::Status::UNKNOWN:
+        {
+            message_object.insert("delivery_status", QJsonValue("unknown"));
+            break;
+        }
+        case Media::TextRecording::Status::READ:
+        {
+            message_object.insert("delivery_status", QJsonValue("read"));
+            break;
+        }
+        case Media::TextRecording::Status::SENT:
+        {
+            message_object.insert("delivery_status", QJsonValue("sent"));
+            break;
+        }
+    }
+
+    return QString(QJsonDocument(message_object).toJson(QJsonDocument::Compact));
+}
+
+#if HAVE_WEBKIT2GTK4
+static gboolean
+webview_chat_decide_policy (G_GNUC_UNUSED WebKitWebView *web_view,
+                            WebKitPolicyDecision *decision,
+                            WebKitPolicyDecisionType type)
+{
+    switch (type)
+    {
+        case WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION:
+        case WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION:
+        {
+            WebKitNavigationPolicyDecision* navigation_decision = WEBKIT_NAVIGATION_POLICY_DECISION(decision);
+            WebKitNavigationAction* navigation_action = webkit_navigation_policy_decision_get_navigation_action(navigation_decision);
+            WebKitNavigationType navigation_type = webkit_navigation_action_get_navigation_type(navigation_action);
+
+            switch (navigation_type)
+            {
+                case WEBKIT_NAVIGATION_TYPE_FORM_SUBMITTED:
+                case WEBKIT_NAVIGATION_TYPE_BACK_FORWARD:
+                case WEBKIT_NAVIGATION_TYPE_RELOAD:
+                case WEBKIT_NAVIGATION_TYPE_FORM_RESUBMITTED:
+                case WEBKIT_NAVIGATION_TYPE_OTHER:
+                {
+                    /* make no decision */
+                    return FALSE;
+
+                }
+                case WEBKIT_NAVIGATION_TYPE_LINK_CLICKED:
+                {
+                    webkit_policy_decision_ignore(decision);
+
+                    WebKitURIRequest* uri_request = webkit_navigation_action_get_request(navigation_action);
+                    const gchar* uri = webkit_uri_request_get_uri(uri_request);
+
+                    gtk_show_uri(NULL, uri, GDK_CURRENT_TIME, NULL);
+                }
+            }
+
+            webkit_policy_decision_ignore(decision);
+            break;
+        }
+        case WEBKIT_POLICY_DECISION_TYPE_RESPONSE:
+        {
+            //WebKitResponsePolicyDecision *response = WEBKIT_RESPONSE_POLICY_DECISION (decision);
+            //break;
+            return FALSE;
+        }
+        default:
+        {
+            /* Making no decision results in webkit_policy_decision_use(). */
+            return FALSE;
+        }
+    }
+    return TRUE;
+}
+#endif
+
+static void
+javascript_library_loaded(WebKitWebView *webview_chat,
+                          GAsyncResult *result,
+                          WebKitChatContainer* self)
+{
+    g_return_if_fail(IS_WEBKIT_CHAT_CONTAINER(self));
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(self);
+
+    auto loaded_library = g_list_first(priv->js_libs_to_load);
+
+    GError *error = NULL;
+    WebKitJavascriptResult* js_result = webkit_web_view_run_javascript_from_gresource_finish(webview_chat, result, &error);
+    if (!js_result) {
+        g_warning("Error loading %s: %s", (const gchar*) loaded_library->data, error->message);
+        g_error_free(error);
+        g_object_unref(self);
+        /* Stop loading view, most likely resulting in a blank page */
+        return;
+    }
+    webkit_javascript_result_unref(js_result);
+
+    priv->js_libs_to_load = g_list_remove(priv->js_libs_to_load, loaded_library->data);
+
+    if(g_list_length(priv->js_libs_to_load) > 0)
+    {
+        /* keep loading... */
+        webkit_web_view_run_javascript_from_gresource(
+            webview_chat,
+            (const gchar*) g_list_first(priv->js_libs_to_load)->data,
+            NULL,
+            (GAsyncReadyCallback) javascript_library_loaded,
+            self
+        );
+    }
+    else
+    {
+         priv->js_libs_loaded = TRUE;
+         g_signal_emit(G_OBJECT(self), webkit_chat_container_signals[READY], 0);
+
+         /* The view could now be deleted without causing a crash */
+         g_object_unref(self);
+    }
+}
+
+static void
+load_javascript_libs(WebKitWebView *webview_chat,
+                     WebKitChatContainer* self)
+{
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(self);
+
+    /* Create the list of libraries to load */
+    priv->js_libs_to_load = g_list_append(priv->js_libs_to_load, (gchar*) "/cx/ring/RingGnome/jquery.js");
+    priv->js_libs_to_load = g_list_append(priv->js_libs_to_load, (gchar*) "/cx/ring/RingGnome/linkify.js");
+    priv->js_libs_to_load = g_list_append(priv->js_libs_to_load, (gchar*) "/cx/ring/RingGnome/linkify-string.js");
+    priv->js_libs_to_load = g_list_append(priv->js_libs_to_load, (gchar*) "/cx/ring/RingGnome/linkify-html.js");
+    priv->js_libs_to_load = g_list_append(priv->js_libs_to_load, (gchar*) "/cx/ring/RingGnome/linkify-jquery.js");
+
+    /* ref the chat view so that its not destroyed while we load
+     * we will unref in javascript_library_loaded
+     */
+    g_object_ref(self);
+
+   /* start loading */
+    webkit_web_view_run_javascript_from_gresource(
+        WEBKIT_WEB_VIEW(webview_chat),
+        (const gchar*) g_list_first(priv->js_libs_to_load)->data,
+        NULL,
+        (GAsyncReadyCallback) javascript_library_loaded,
+        self
+    );
+}
+
+static void
+webview_chat_load_changed(WebKitWebView  *webview_chat,
+                          WebKitLoadEvent load_event,
+                          WebKitChatContainer* self)
+{
+    switch (load_event) {
+        case WEBKIT_LOAD_REDIRECTED:
+        {
+            g_warning("webview_chat load is being redirected, this should not happen");
+        }
+        case WEBKIT_LOAD_STARTED:
+        case WEBKIT_LOAD_COMMITTED:
+        {
+            break;
+        }
+        case WEBKIT_LOAD_FINISHED:
+        {
+            load_javascript_libs(webview_chat, self);
+            //TODO: disconnect? It shouldn't happen more than once
+            break;
+        }
+    }
+}
+
+static void
+build_view(WebKitChatContainer *view)
+{
+    g_return_if_fail(IS_WEBKIT_CHAT_CONTAINER(view));
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(view);
+
+
+    priv->chatview_debug = FALSE;
+    auto ring_chatview_debug = g_getenv("RING_CHATVIEW_DEBUG");
+    if (ring_chatview_debug || g_strcmp0(ring_chatview_debug, "true") == 0)
+    {
+        priv->chatview_debug = TRUE;
+    }
+
+    WebKitSettings* webkit_settings = webkit_settings_new_with_settings(
+        "enable-javascript", TRUE,
+        "enable-developer-extras", priv->chatview_debug,
+        "enable-java", FALSE,
+        "enable-plugins", FALSE,
+        "enable-site-specific-quirks", FALSE,
+        "enable-smooth-scrolling", TRUE,
+        NULL
+    );
+    webkit_web_view_set_settings(WEBKIT_WEB_VIEW(priv->webview_chat), webkit_settings);
+
+    g_signal_connect(priv->webview_chat, "load-changed", G_CALLBACK(webview_chat_load_changed), view);
+    g_signal_connect_swapped(priv->webview_chat, "context-menu", G_CALLBACK(webview_chat_context_menu), view);
+#if HAVE_WEBKIT2GTK4
+    g_signal_connect(priv->webview_chat, "decide-policy", G_CALLBACK(webview_chat_decide_policy), view);
+#endif
+
+    GBytes* chatview_bytes = g_resources_lookup_data(
+        "/cx/ring/RingGnome/chatview.html",
+        G_RESOURCE_LOOKUP_FLAGS_NONE,
+        NULL
+    );
+
+    webkit_web_view_load_html(
+        WEBKIT_WEB_VIEW(priv->webview_chat),
+        (gchar*) g_bytes_get_data(chatview_bytes, NULL),
+        NULL
+    );
+
+    /* Now we wait for the load-changed event, before we
+     * start loading javascript libraries */
+}
+
+GtkWidget *
+webkit_chat_container_new()
+{
+    gpointer view = g_object_new(WEBKIT_CHAT_CONTAINER_TYPE, NULL);
+
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(view);
+    priv->js_libs_loaded = FALSE;
+
+    build_view(WEBKIT_CHAT_CONTAINER(view));
+
+    return (GtkWidget *)view;
+}
+
+void
+webkit_chat_container_clear(WebKitChatContainer *view)
+{
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(view);
+
+    webkit_web_view_run_javascript(
+        WEBKIT_WEB_VIEW(priv->webview_chat),
+        "ring.chatview.clearMessages()",
+        NULL,
+        NULL,
+        NULL
+    );
+
+    webkit_web_view_run_javascript(
+        WEBKIT_WEB_VIEW(priv->webview_chat),
+        "ring.chatview.clearSenderImages()",
+        NULL,
+        NULL,
+        NULL
+    );
+}
+
+void
+webkit_chat_container_print_new_message(WebKitChatContainer *view, const QModelIndex &idx)
+{
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(view);
+
+    auto message_object = message_index_to_json_message_object(idx).toUtf8().constData();
+    gchar* function_call = g_strdup_printf("ring.chatview.addMessage(%s);", message_object);
+    webkit_web_view_run_javascript(
+        WEBKIT_WEB_VIEW(priv->webview_chat),
+        function_call,
+        NULL,
+        NULL,
+        NULL
+    );
+    g_free(function_call);
+}
+
+void
+webkit_chat_container_update_message(WebKitChatContainer *view, const QModelIndex &idx)
+{
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(view);
+
+    auto message_object = message_index_to_json_message_object(idx).toUtf8().constData();
+    gchar* function_call = g_strdup_printf("ring.chatview.updateMessage(%s);", message_object);
+    webkit_web_view_run_javascript(
+        WEBKIT_WEB_VIEW(priv->webview_chat),
+        function_call,
+        NULL,
+        NULL,
+        NULL
+    );
+    g_free(function_call);
+}
+
+void
+webkit_chat_container_set_sender_image(WebKitChatContainer *view, QString sender_name, QVariant sender_image)
+{
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(view);
+
+    auto sender_image_base64 = (QString) GlobalInstances::pixmapManipulator().toByteArray(sender_image).toBase64();
+
+    QJsonObject set_sender_image_object = QJsonObject();
+    set_sender_image_object.insert("sender", QJsonValue(sender_name));
+    set_sender_image_object.insert("sender_image", QJsonValue(sender_image_base64));
+
+    auto set_sender_image_object_string = QString(QJsonDocument(set_sender_image_object).toJson(QJsonDocument::Compact)).toUtf8().constData();
+
+    gchar* function_call = g_strdup_printf("ring.chatview.setSenderImage(%s);", set_sender_image_object_string);
+    webkit_web_view_run_javascript(WEBKIT_WEB_VIEW(priv->webview_chat), function_call, NULL, NULL, NULL);
+    g_free(function_call);
+}
+
+gboolean
+webkit_chat_container_is_ready(WebKitChatContainer *view)
+{
+    WebKitChatContainerPrivate *priv = WEBKIT_CHAT_CONTAINER_GET_PRIVATE(view);
+    return priv->js_libs_loaded;
+}