diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1676d5dc28c509b12e1842c40395fc9aaccb3c11..797c6ed702a1b0f4bc8d1160c66f91bfe5c4f8d2 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -344,6 +344,19 @@ SET(libringclient_api_LIB_HDRS
   src/api/newvideo.h
 )
 
+SET(libringclient_WEB_chatview
+   src/web-chatview/.eslintrc.json
+   src/web-chatview/chatview-gnome.css
+   src/web-chatview/chatview.css
+   src/web-chatview/chatview.html
+   src/web-chatview/chatview.js
+   src/web-chatview/jed.js
+   src/web-chatview/linkify-html.js
+   src/web-chatview/linkify-string.js
+   src/web-chatview/linkify.js
+   src/web-chatview/qwebchannel.js
+   src/web-chatview/web.gresource.xml
+)
 
 SET(libringclient_video_LIB_HDRS
   src/video/renderer.h
@@ -580,6 +593,11 @@ INSTALL( FILES ${libringclient_video_LIB_HDRS}
   COMPONENT Devel
 )
 
+INSTALL( FILES ${libringclient_WEB_chatview}
+  DESTINATION ${INCLUDE_INSTALL_DIR}/libringclient/web-chatview
+  COMPONENT Devel
+)
+
 INSTALL( FILES ${libringclient_extensions_LIB_HDRS}
   DESTINATION ${INCLUDE_INSTALL_DIR}/libringclient/extensions
   COMPONENT Devel
diff --git a/src/web-chatview/.eslintrc.json b/src/web-chatview/.eslintrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..746c54a40a2dda347f83854dd478ca9d217ac13b
--- /dev/null
+++ b/src/web-chatview/.eslintrc.json
@@ -0,0 +1,31 @@
+{
+    "env": {
+        "browser": true
+    },
+    "plugins": ["html"],
+    "extends": "eslint:recommended",
+    "parserOptions": {
+        "ecmaVersion": 6
+    },
+    "rules": {
+        "indent": [
+            "error",
+            4
+        ],
+        "linebreak-style": [
+            "error",
+            "unix"
+        ],
+        "quotes": [
+            "error",
+            "double"
+        ],
+        "semi": [
+            "error",
+            "never"
+        ],
+        "no-inner-declarations": [
+            0
+        ]
+    }
+}
diff --git a/src/web-chatview/README b/src/web-chatview/README
new file mode 100644
index 0000000000000000000000000000000000000000..75b6bf9fe20b27b42a99b5e2416e0e0381d68219
--- /dev/null
+++ b/src/web-chatview/README
@@ -0,0 +1,33 @@
+# README - chatview
+
+The chatview runs under a WebKit GTK view. It is written using web technologies
+(HTML5/CSS3/JS) and is responsible for displaying everything that deals with the
+navbar, the messages, and the message bar.
+
+## Contributing - syntax
+
+We have a set of ESLint rules that define clear syntax rules (web/.eslintrc.json).
+
+You will need the following tools:
+
+- ESLint (The pluggable linting utility for JavaScript and JSX)
+  https://eslint.org/
+- ESLint HTML plugin (eslint-plugin-html)
+  https://www.npmjs.com/package/eslint-plugin-html
+
+Before pushing a patch, make sure that it passes ESLint:
+$ eslint chatview.html
+
+Most trivial issues can be fixed using
+$ eslint chatview.js --fix
+
+We will not accept patches introducing non-ESLint-compliant code.
+
+## WebKit GTK
+
+Everything runs under WebKit GTK, that is if you need to write browser specific
+code, you will only need to support WebKit (CSS -webkit- prefix).
+
+Do not use querySelector if getElementById or getElementByClassName can be used
+instead. querySelector doesn't always make the code easier and has very bad
+performances.
diff --git a/src/web-chatview/chatview-gnome.css b/src/web-chatview/chatview-gnome.css
new file mode 100644
index 0000000000000000000000000000000000000000..bc336eed23569ce3ff7c2b2d7901e24a505f9f76
--- /dev/null
+++ b/src/web-chatview/chatview-gnome.css
@@ -0,0 +1,90 @@
+.internal_mes_wrapper {
+    margin: 3px 0 0 0;
+}
+
+.message_wrapper {
+    padding: 1em;
+}
+
+.sender_image {
+    margin: 10px;
+}
+
+.message_out + .message_out .message_wrapper {
+    border-top-right-radius: 10px;
+}
+
+.message_in + .message_in .message_wrapper {
+    border-top-left-radius: 10px;
+}
+
+.message_in + .message_in .sender_image {
+    visibility: hidden;
+}
+
+.message_out + .message_out .sender_image {
+    visibility: hidden;
+}
+
+.message_text {
+    hyphens: auto;
+    word-break: break-word;
+    word-wrap: break-word;
+}
+
+.nav-right {
+  align-self: flex-end;
+}
+
+.nav-left {
+  align-self: flex-start;
+}
+
+#nav-contactid {
+  margin: 0px;
+  margin-left: 5px;
+  padding: 0px;
+  height: 100%;
+  font-family: emoji;
+
+  /* enable selection (it is globally disabled in the body) */
+  -webkit-user-select: auto;
+
+  /* contactid field should take as much place as possible, but it should
+     also be the first one to shrink if necesary. */
+  flex-grow: 2;
+  flex-shrink: 2;
+  min-width: 0; /* necessary for child to be able to shrink correctly */
+
+  /* center vertically */
+  display: flex;
+  align-items: center;
+}
+
+#nav-contactid-wrapper {
+  width: 100%;
+}
+
+#nav-contactid-alias {
+  font-size: 14px;
+  font-weight: bold;
+  margin-bottom: 3px;
+
+  width: 100%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+#nav-contactid-bestId {
+  font-size: 11px;
+
+  width: 100%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.oneEntry #nav-contactid-bestId {
+  display: none;
+}
\ No newline at end of file
diff --git a/src/web-chatview/chatview-windows.css b/src/web-chatview/chatview-windows.css
new file mode 100644
index 0000000000000000000000000000000000000000..566ca0d2883557371439478168e910d90b5d4f58
--- /dev/null
+++ b/src/web-chatview/chatview-windows.css
@@ -0,0 +1,198 @@
+:root {
+    /* color definitions */
+    --jami-light-blue: rgba(59, 193, 211, 0.3);
+    --jami-dark-blue: #004e86;
+    --jami-green: #219d55;
+    --jami-green-hover: #1f8b4c;
+    --jami-red: #dc2719;
+    --jami-red-hover: #b02e2c;
+    /* main properties */
+    --bg-color: #ffffff;
+    /* navbar properties */
+    --navbar-height: 40px;
+    --navbar-padding-top: 8px;
+    --navbar-padding-bottom: var(--navbar-padding-top);
+    /* message bar properties */
+    --textarea-max-height: 150px;
+    --placeholder-text-color: #d3d3d3;
+    /* button properties */
+    --action-icon-color: #00;
+    --deactivated-icon-color: #bebebe;
+    --action-icon-hover-color: #ededed;
+    --action-critical-icon-hover-color: rgba(211, 77, 59, 0.3); /* complementary color of jami light blue */
+    --action-critical-icon-press-color: rgba(211, 77, 59, 0.5);
+    --action-critical-icon-color: #4E1300;
+    --non-action-icon-color: #212121;
+    --action-icon-press-color: rgba(212, 212, 212, 1.0);
+    --invite-hover-color: white;
+    /* hairline properties */
+    --hairline-color: #f0f0f0;
+    --hairline-thickness: 2px;
+}
+
+#invite_contact_name {
+    font-weight: 500;
+}
+
+
+.internal_mes_wrapper {
+    margin: 0;
+}
+
+.message_wrapper {
+    padding: 0.5em 1em 0.5em 1em;
+}
+
+.sender_image {
+    margin: 0px 10px 0px 10px;
+}
+
+.message_in .message_wrapper {
+    background-color: #cfebf5;
+}
+
+.message_in .sender_image,
+.message_out .sender_image {
+    visibility: hidden;
+}
+
+.message_in.last_of_sequence .sender_image,
+.message_in.single_message .sender_image {
+    visibility: visible;
+}
+
+.message_in.last_of_sequence .sender_image {
+    margin-top: 2px;
+}
+
+.message_in.middle_of_sequence .sender_image {
+    margin-top: 0px;
+}
+
+
+.generated_message.message_in .message_wrapper,
+.generated_message.message_out .message_wrapper {
+    background-color: transparent !important;
+    border-radius: 0px !important;
+}
+
+.single_message.message_in .message_wrapper,
+.single_message.message_out .message_wrapper {
+    border-radius: 20px 20px 20px 20px !important;
+}
+
+.last_of_sequence.message_in .message_wrapper {
+    border-radius: 4px 20px 20px 20px;
+}
+
+.first_of_sequence.message_in .message_wrapper {
+    border-radius: 20px 20px 20px 4px;
+}
+
+.middle_of_sequence.message_in .message_wrapper {
+    border-radius: 4px 20px 20px 5px;
+}
+
+.last_of_sequence.message_out .message_wrapper {
+    border-radius: 20px 4px 20px 20px;
+}
+
+.first_of_sequence.message_out .message_wrapper {
+    border-radius: 20px 20px 4px 20px;
+}
+
+.middle_of_sequence.message_out .message_wrapper {
+    border-radius: 20px 5px 4px 20px;
+}
+
+.middle_of_sequence .internal_mes_wrapper,
+.last_of_sequence .internal_mes_wrapper,
+.last_message .internal_mes_wrapper {
+    margin: 0px 0 0 0 !important;
+}
+
+.message_out .sender_image {
+    margin: 8px;
+}
+
+.first_of_sequence.message_out .internal_mes_wrapper,
+.single_message.message_out .internal_mes_wrapper {
+    margin: 0px 0 0 0 !important;
+}
+
+.sender_image_cell {
+    vertical-align: bottom;
+    min-width: 16px;
+}
+
+.message_in .sender_image_cell {
+    min-width: 56px;
+}
+
+.dummy_cell {
+    padding: 0;
+}
+
+.timestamp_cell {
+    padding: 0;
+    max-width: 0px;
+    overflow: visible;
+    white-space: nowrap;
+}
+
+.timestamp_cell_out {
+    padding: 0;
+    text-align: right;
+    direction: rtl;
+    max-width: 0px;
+    overflow: visible;
+    white-space: nowrap;
+}
+
+table {
+    border-collapse: collapse;
+    border-spacing: 0 0px;
+    margin: 0;
+    padding: 0;
+}
+
+.message_text {
+    word-break: break-all;
+    word-wrap: hyphenate;
+}
+
+pre {
+    white-space: pre-wrap;
+}
+
+.message:hover:not(.message_type_contact) .menu_interaction {
+    display: block;
+    opacity: 1;
+}
+
+.menuoption {
+    user-select: none;
+    cursor: pointer;
+}
+
+.nav-button {
+    width: 30px;
+    height: 30px;
+    margin: 8px;
+    padding: 2px;
+    display: flex;
+    cursor: pointer;
+    align-self: center;
+    border-radius: 16px;
+}
+
+.nav-button.deactivated {
+    width: 30px;
+    height: 30px;
+    margin: 8px;
+    padding: 2px;
+    align-self: center;
+    display: flex;
+    border-radius: 16px;
+    cursor: auto;
+}
\ No newline at end of file
diff --git a/src/web-chatview/chatview.css b/src/web-chatview/chatview.css
new file mode 100644
index 0000000000000000000000000000000000000000..9466a5ee16f4c709375343b9bc5c82c78a91c948
--- /dev/null
+++ b/src/web-chatview/chatview.css
@@ -0,0 +1,1106 @@
+/** Variable and font definitions */
+
+:root {
+  /* color definitions */
+  --jami-light-blue: rgba(59, 193, 211, 0.3);
+  --jami-dark-blue: #003b4e;
+  --jami-green: #219d55;
+  --jami-green-hover: #1f8b4c;
+  --jami-red: #dc2719;
+  --jami-red-hover: #b02e2c;
+
+  /* main properties */
+  --bg-color: #f2f2f2; /* same as macOS client */
+
+  /* navbar properties */
+  --navbar-height: 40px;
+  --navbar-padding-top: 8px;
+  --navbar-padding-bottom: var(--navbar-padding-top);
+
+  /* message bar properties */
+  --textarea-max-height: 150px;
+  --placeholder-text-color: #d3d3d3;
+
+  /* button properties */
+  --action-icon-color: var(--jami-dark-blue);
+  --deactivated-icon-color: #BEBEBE;
+  --action-icon-hover-color: var(--jami-light-blue);
+  --action-critical-icon-hover-color: rgba(211, 77, 59, 0.3); /* complementary color of ring light blue */
+  --action-critical-icon-press-color: rgba(211, 77, 59, 0.5);
+  --action-critical-icon-color: #4E1300;
+  --non-action-icon-color: #212121;
+  --action-icon-press-color: rgba(59, 193, 211, 0.5);
+  --invite-hover-color: white;
+
+  /* hairline properties */
+  --hairline-color: #d9d9d9;
+  --hairline-thickness: 0.2px;
+}
+
+@font-face {
+    font-family: emoji;
+
+    /* Fonts for text outside emoji blocks */
+    src: local('Open sans'),
+         local('Helvetica'),
+         local('Segoe UI'),
+         local('sans-serif');
+}
+
+@font-face {
+    font-family: emoji;
+
+    src: local('Noto Color Emoji'),
+         local('Android Emoji'),
+         local('Twitter Color Emoji');
+
+    /* Emoji unicode blocks */
+    unicode-range: U+1F300-1F5FF, U+1F600-1F64F, U+1F680-1F6FF, U+2600-26FF;
+}
+
+/** Body */
+
+body {
+    --messagebar-size: 47px;
+    margin: 0;
+    overflow: hidden;
+    background-color: var(--bg-color);
+    padding-bottom: var(--messagebar-size);
+    /* disable selection highlight because it looks very bad */
+    -webkit-user-select: text;
+}
+
+::-webkit-scrollbar-track {
+    background-color: var(--bg-color);
+}
+
+::-webkit-scrollbar {
+    width: 8px;
+    background-color: var(--bg-color);
+}
+
+::-webkit-scrollbar-thumb {
+    background-color: var(--bg-color);
+ }
+
+
+/** Navbar */
+
+.navbar-wrapper {
+  /* on top, over everything and full width */
+  position: fixed;
+  left:0; right:0;
+  z-index: 500;
+  top: 0;
+}
+
+#navbar {
+  background-color: var(--bg-color);
+  padding-right: 8px;
+  padding-left: 8px;
+  padding-top: var(--navbar-padding-top);
+  padding-bottom: var(--navbar-padding-bottom);
+  height: var(--navbar-height);
+  overflow: hidden;
+  align-items: center;
+
+  /* takes whole width */
+  left:0; right:0;
+
+  /* hairline */
+  border-bottom: var(--hairline-thickness) solid var(--hairline-color);
+
+  display: flex;
+}
+
+.hiddenState {
+  /* Used to hide navbar and message bar */
+  display: none !important;
+}
+
+.svgicon {
+  display: block;
+  margin: auto;
+  height: 70%;
+}
+
+.nav-button {
+  width: 40px;
+  height: 40px;
+  display: flex;
+  cursor: pointer;
+  align-self: center;
+  border-radius: 50%;
+}
+
+.nav-button.deactivated {
+  width: 40px;
+  height: 40px;
+  align-self: center;
+  display: flex;
+  border-radius: 50%;
+  cursor: auto;
+}
+
+.action-button svg {
+  fill: var(--action-icon-color);
+}
+
+.action-button.deactivated svg {
+  fill: var(--deactivated-icon-color);
+}
+
+.non-action-button svg {
+  fill: var(--non-action-icon-color);
+}
+
+.non-action-button:hover, .action-button:hover {
+  background: var(--action-icon-hover-color);
+}
+
+.non-action-button:active, .action-button:active {
+  background: var(--action-icon-press-color);
+}
+
+.action-button.deactivated:hover, .action-button.deactivated:active {
+  background: none;
+}
+
+.action-critical-button svg {
+  fill: var(--action-critical-icon-color);
+}
+
+.action-critical-button:hover {
+  background: var(--action-critical-icon-hover-color);
+}
+
+.action-critical-button:active {
+  background: var(--action-critical-icon-press-color);
+}
+
+#callButtons {
+  display: flex;
+}
+
+#navbar #unbanButton, #navbar #addToConversationsButton {
+  display: none;
+}
+
+#navbar.onBannedState #addToConvButton, #navbar.onBannedState #callButtons, #navbar.onBannedState #addToConversationsButton {
+  display: none;
+}
+
+#navbar.onBannedState #unbanButton {
+  display: flex;
+}
+
+/** Invitation bar */
+
+#invitation {
+  visibility: hidden;
+  background: var(--bg-color);
+  position: absolute;
+  width: 100%;
+  padding-bottom: 10px;
+
+  /* hairline */
+  border-bottom: var(--hairline-thickness) solid var(--hairline-color);
+}
+
+#invitation #invite_header {
+  margin: 10px;
+  list-style: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  /* enable selection (it is globally disabled in the body) */
+  -webkit-user-select: auto;
+}
+
+#invitation .sender_image {
+  width: 50px;
+  height: 50px;
+}
+
+#invitation #actions {
+  margin-right: auto;
+  margin-left: auto;
+  list-style: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  /* enable selection (it is globally disabled in the body) */
+  -webkit-user-select: auto;
+}
+
+#invitation #actions div {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 5px;
+    margin-left: 5px;
+}
+
+#invitation #text {
+  text-align: center;
+  font-size: 0.8em;
+
+  /* enable selection (it is globally disabled in the body) */
+  -webkit-user-select: auto;
+}
+
+
+/** Messaging bar */
+
+#sendMessage {
+    background-color: var(--bg-color);
+    display: flex;
+    overflow: hidden;
+    padding: 4px;
+    align-items: center;
+    position: fixed;
+    left: 0;
+    right: 0;
+    z-index: 500;
+    bottom: 0;
+    /* hairline */
+    border-top: var(--hairline-thickness) solid var(--hairline-color);
+}
+
+#message {
+  font-family: emoji;
+  flex: 1;
+  background-color: var(--bg-color);
+  border: 0;
+  overflow-y: scroll;
+  color: black;
+  max-height: var(--textarea-max-height);
+  margin-right: 10px;
+  resize: none;
+
+  /* enable selection (it is globally disabled in the body) */
+  -webkit-user-select: auto;
+}
+
+#message:focus,
+#message.focus {
+  outline: none;
+}
+
+#container[disabled] {
+  background-color: #ccc;
+}
+
+input[placeholder], [placeholder], *[placeholder] {
+    color: var(--placeholder-text-color);
+}
+
+/** Main chat view */
+
+#lazyloading-icon {
+    margin: auto;
+    margin-bottom: 10px;
+    margin-top: 5px;
+    vertical-align: center;
+    width: 30px;
+    display: flex;
+}
+
+#container {
+    position: relative;
+    height: 100%;
+    width: 100%;
+    margin: 0;
+    padding: 0;
+
+    display: flex;
+    flex-direction: column;
+
+    /* When there are not enough messages to occupy full height of the
+       container, make sure they are displayed at the bottom, not at the
+       top. */
+    justify-content: flex-end;
+}
+
+a:link {
+  text-decoration: none;
+  color: #0e649b;
+  transition: all 0.2s ease-in-out;
+  border-bottom: dotted 1px;
+}
+
+a:hover {
+  border: 0;
+}
+
+#file_image_send_container {
+    visibility: hidden;
+    position: fixed;
+    bottom: var(--messagebar-size);
+    z-index: 1;
+    display: flex;
+    justify-content: flex-start;
+    left: 0;
+    right: 0;
+    height: 8em;
+    /*border-top-left-radius: 25px;
+    border-top-right-radius: 25px;*/
+    border: 2px solid lightgray;
+    padding: 20px;
+    border-bottom: none;
+    background-color: #cfdbdd;
+    overflow-x: overlay;
+}
+
+#file_image_send_container::-webkit-scrollbar {
+    height: 10px;
+}
+
+#file_image_send_container::after {
+    /*Create the margins with pseudo-elements*/
+    /*to solve overflow:scroll and The Right Padding Problem*/
+    content: ' ';
+    min-width: 20px;
+}
+
+.img_wrapper {
+    position: relative;
+    max-width: 65px;
+    min-width: 65px;
+    max-height: 80px;
+    border: 3px solid rgba(255,255,255,0);
+    padding: 30px;
+    border-radius: 20px;
+    background-color: #cfdbdd;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    margin: 5px;
+}
+
+/* Make the image responsive */
+.img_wrapper img {
+    height: 118px;
+    min-width: 131px;
+    object-fit: cover;
+    border-radius: 20px;
+}
+
+/* Style the button and place it at the top right corner of the image */
+.img_wrapper .btn {
+    position: absolute;
+    color: #fff;
+    border: 1px solid #AEAEAE;
+    border-radius: 40%;
+    background: rgba(96,95,97,0.5);
+    font-size: 10px;
+    font-weight: bold;
+    top: -5%;
+    right: -5%;
+}
+
+.img_wrapper .btn:hover {
+    background-color: lightgray;
+}
+
+.img_wrapper .btn:focus {
+    outline: none;
+    color: black;
+}
+
+.file_wrapper {
+    position: relative;
+    max-width: 65px;
+    min-width: 65px;
+    max-height: 80px;
+    border: 3px solid rgba(255,255,255,0);
+    padding: 30px;
+    border-radius: 20px;
+    background-color: white;
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    margin: 5px;
+    font-family: sans-serif;
+}
+
+/* Style the button and place it at the top right corner of the image */
+.file_wrapper .btn {
+    position: absolute;
+    color: #fff;
+    border: 1px solid #AEAEAE;
+    border-radius: 40%;
+    background: rgba(96,95,97,0.5);
+    font-size: 10px;
+    font-weight: bold;
+    top: -5%;
+    right: -5%;
+}
+
+.file_wrapper .btn:hover {
+    background-color: lightgray;
+}
+
+.file_wrapper .btn:focus {
+    outline: none;
+    color: black;
+}
+
+.file_wrapper .svg-icon {
+    position: absolute;
+    max-width: 2em;
+    max-height: 25px;
+    margin-right: 2em;
+    top: 8%;
+    left: 1px;
+}
+
+.file_wrapper .svg-icon path,
+.file_wrapper .svg-icon polygon,
+.file_wrapper .svg-icon rect {
+    fill: #000000;
+}
+
+.file_wrapper .svg-icon circle {
+    stroke: #4691f6;
+    stroke-width: 1;
+}
+
+.file_wrapper .fileinfo {
+    position: absolute;
+    top: 30%;
+    left: 7%;
+}
+
+#back_to_bottom_button_container {
+    position: absolute;
+    z-index: 1;
+    display: flex;
+    justify-content: center;
+    width: 100%;
+    height: 4em;
+    pointer-events: none;
+}
+
+#back_to_bottom_button {
+    visibility: hidden;
+    margin: auto;
+    font: 0.875em emoji;
+    text-align: center;
+    width: 10em;
+    border-radius: 2em;
+    background-color: var(--jami-dark-blue);
+    color: #fff;
+    padding: 0.5em;
+    box-shadow: 2px 2px 4px black;
+    opacity: 1;
+    overflow: hidden;
+    white-space: nowrap;
+    transition: opacity .5s ease, width .2s ease, color .1s ease .2s;
+    pointer-events: all;
+}
+
+#back_to_bottom_button:hover {
+    cursor: pointer;
+}
+
+#back_to_bottom_button.fade {
+    opacity: 0;
+    width: 1em;
+    color: transparent;
+    transition: .2s opacity ease .1s, .2s width ease .1s, color .1s ease;
+}
+
+#back_to_bottom_button.fade:hover {
+    cursor: context-menu;
+}
+
+#messages {
+    position: relative;
+    z-index: 0;
+    width: 100%;
+    overflow: hidden;
+    height: auto;
+    padding-top: 0.5em;
+    opacity: 1;
+    transition: 0.5s opacity;
+}
+
+#messages.fade {
+    opacity: 0;
+    transition: none;
+}
+
+#messages:hover {
+    overflow-y: overlay;
+}
+
+.last_message {
+    /* The last message gets a bigger bottom padding so that it is not
+     "glued" to the message bar. */
+    padding-bottom: 1.5em !important;
+    margin-top: 4px;
+}
+
+
+/* General messages */
+
+.internal_mes_wrapper {
+    max-width: 70%;
+
+    display: flex;
+    flex-direction: column;
+
+    /* If a message is smaller (in width) than the timestamp, do not fill
+       full width and pack message at the left. */
+    align-items: flex-start;
+    align-content: flex-start;
+}
+
+.message_out > .internal_mes_wrapper {
+    /* If message is in the outgoing direction, pack at the right. */
+    align-items: flex-end;
+    align-content: flex-end;
+}
+
+.message_wrapper {
+    max-width: calc(100% - 2em);
+    border-radius: 10px;
+    position: relative;
+    display: flex;
+    flex-direction: row;
+}
+
+.message_type_data_transfer .message_wrapper {
+    display: flex;
+    flex-direction: column;
+    padding: 0;
+    width: 450px;
+    max-width: none;
+}
+
+.transfer_info_wrapper {
+    display: flex;
+    flex-direction: row;
+    padding-bottom: .8em;
+    padding-top: .8em;
+}
+
+.message {
+    font: 0.875em emoji;
+    margin: 0;
+    display: flex;
+    justify-content: flex-start;
+    align-items: top;
+    overflow: hidden;
+
+  /* enable selection (it is globally disabled in the body) */
+  -webkit-user-select: auto;
+}
+
+.message_in {
+    padding-left: 25%;
+}
+
+.message_out {
+    padding-right: 25%;
+
+    /* Message sent by the user should be displayed at the right side of
+       the screen. */
+    flex-direction: row-reverse;
+}
+
+.message_delivery_status {
+    margin: 10px 10px;
+    color: #A0A0A0;
+}
+
+.message_sender {
+    display: none;
+}
+
+.sender_image {
+    border-radius: 50%;
+    width: 35px;
+    height: 35px;
+}
+
+div.last_message > span {
+    margin: 0px 10px 0px 10px;
+}
+
+.message_out .message_wrapper {
+    border-top-right-radius: 0;
+    transform-origin: top right;
+}
+
+.message_in .message_wrapper {
+    border-top-left-radius: 0;
+    transform-origin: top left;
+}
+
+.message_out .message_wrapper {
+    background-color: #cfd8dc;
+}
+.message_in .message_wrapper {
+    background-color: #fdfdfd;
+}
+
+@-webkit-keyframes fade-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+}
+
+.timestamp {
+    display: inline-flex;
+    justify-content: flex-start;
+    align-self: stretch;
+    color: #333;
+    font-size: 10px;
+    padding: 5px;
+}
+
+.timestamp_out {
+    flex-direction: row-reverse;
+}
+
+.timestamp_action {
+    margin: auto;
+    padding: 0;
+    vertical-align: center;
+    opacity: 0;
+    transition: visibility 0.3s linear, opacity 0.3s linear;
+}
+
+.message_type_contact .message_wrapper:hover .timestamp_action,
+.message_type_call .message_wrapper:hover .timestamp_action {
+    opacity: 1;
+}
+
+/* Ellipsis - dropdown menu */
+
+input[type=checkbox] {
+    display: none;
+}
+
+.menu_interaction
+{
+    margin: 5px;
+    padding: 10px;
+    padding-top: 0;
+    opacity: 0;
+    height: 20px;
+    transition: visibility 0.3s linear, opacity 0.3s linear;
+}
+
+.message_type_call .menu_interaction
+.message_type_contact .menu_interaction
+{
+    margin: auto;
+    padding: 0;
+    vertical-align: center;
+}
+
+.message_type_call .menu_interaction .dropdown
+.message_type_contact .menu_interaction .dropdown
+{
+    margin-top: -17px;
+}
+
+.message:hover .menu_interaction
+{
+    display: block;
+    opacity: 1;
+}
+
+.dropdown {
+    display: none;
+    z-index: 1;
+    position: absolute;
+    background-color: #fff;
+    padding-top: 3px;
+    padding-bottom: 3px;
+}
+
+.dropdown div
+{
+    color: #111;
+    padding: 10px;
+}
+
+.dropdown div:hover
+{
+    background-color: #ddd;
+}
+
+.showmenu:checked ~ .dropdown{
+    display: block;
+}
+
+/* Buttons */
+
+.flat-button {
+  border: 0;
+  border-radius: 5px;
+  transition: all 0.3s ease;
+  color: #f9f9f9;
+  padding: 10px 20px 10px 20px;
+  vertical-align: middle;
+  cursor: pointer;
+  flex: 1;
+  padding: 0;
+}
+
+.left_buttons {
+    align-self: center;
+    max-width: 90px;
+    padding-left: 1em;
+}
+
+/* Status */
+
+.status_circle {
+    fill: #A0A0A0;
+    -webkit-animation: circle-dance;
+    -webkit-animation-duration: 0.8s;
+    -webkit-animation-iteration-count: infinite;
+    -webkit-animation-direction: alternate;
+    -webkit-animation-timing-function: ease-in-out;
+}
+
+.anim-first {
+    -webkit-animation-delay: 0.7s;
+}
+
+.anim-second {
+    -webkit-animation-delay: 0.9s;
+}
+
+.anim-third {
+    -webkit-animation-delay: 1.1s;
+}
+
+@-webkit-keyframes circle-dance {
+  0%,50% {
+    -webkit-transform: translateY(0);
+    fill: #A0A0A0;
+  }
+  100% {
+    -webkit-transform: translateY(-8px);
+    fill: #000;
+  }
+}
+
+.status-x {
+    stroke-dasharray: 12;
+}
+
+/* Contact + Call interactions */
+.message_type_contact .message_wrapper,
+.message_type_call .message_wrapper {
+    width: auto;
+    margin: auto;
+    display: flex;
+    flex-wrap: wrap;
+    background-color: var(--bg-color);
+    padding: 0;
+}
+
+.message_type_contact .message_wrapper:before,
+.message_type_call .message_wrapper:before {
+    display: none;
+}
+
+.message_type_contact .text,
+.message_type_call .text {
+    align-self: center;
+    font-size: 1.2em;
+    padding: 1em;
+}
+
+/* file interactions */
+
+.message_type_data_transfer .internal_mes_wrapper {
+    padding: 0.1em;
+    display: flex;
+    flex-wrap: wrap;
+}
+
+.invite-btn-red {
+    transition: background-color 0.5s ease;
+}
+
+.invite-btn-red:hover {
+    background: var(--jami-red);
+}
+
+.invite-btn-red svg {
+    fill: var(--jami-red);
+    transition: fill 0.5s ease;
+}
+
+.invite-btn-red:hover svg {
+    fill: var(--invite-hover-color);
+}
+
+.invite-btn-green {
+    transition: background-color 0.5s ease;
+}
+
+.invite-btn-green:hover {
+    background: var(--jami-green);
+}
+
+.invite-btn-green svg {
+    fill: var(--jami-green);
+    transition: fill 0.5s ease;
+}
+
+.invite-btn-green:hover svg {
+    fill: var(--invite-hover-color);
+}
+
+.accept, .refuse {
+    border-radius: 50%;
+    cursor: pointer;
+}
+
+.accept svg,
+.refuse svg {
+    padding: 8px;
+    width: 24px;
+    height: 24px;
+}
+
+.accept {
+    fill: var(--jami-green);
+}
+
+.accept:hover {
+    fill: var(--invite-hover-color);
+    background: var(--jami-green-hover);
+}
+
+.refuse {
+    fill: var(--jami-red);
+}
+
+.refuse:hover {
+    fill: var(--invite-hover-color);
+    background: var(--jami-red-hover);
+}
+
+.message_type_data_transfer .text {
+    text-align: left;
+    align-self: left;
+    padding: 0.3em;
+}
+
+.truncate-ellipsis {
+    display: table;
+    table-layout: fixed;
+    width: 100%;
+    white-space: nowrap;
+}
+
+.truncate-ellipsis > * {
+    display: table-cell;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.message_type_data_transfer .filename {
+    cursor: pointer;
+    font-size: 1.1em;
+}
+
+.message_type_data_transfer .informations {
+    color: #555;
+    font-size: 0.8em;
+}
+
+.message_progress_bar {
+    width: 100%;
+    height: 1em;
+    position: relative;
+    overflow: hidden;
+    background-color: #eee;
+    border-radius: 0;
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25) inset;
+}
+
+.message_progress_bar > span {
+    display: inline;
+    height: 100%;
+    background-color: #01a2b8;
+    position: absolute;
+    overflow: hidden;
+}
+
+/* text interactions */
+
+.message_type_text .internal_mes_wrapper {
+    padding: 0.1em;
+}
+
+.message_text {
+    max-width: 100%;
+}
+
+.message_text pre {
+    display: inline;
+}
+
+pre {
+    font : inherit;
+    font-family : inherit;
+    font-size : inherit;
+    font-style : inherit;
+    font-variant : inherit;
+    font-weight : inherit;
+    margin: 0;
+    padding: 0;
+}
+
+/* Media interactions */
+.media_wrapper img {
+    max-width: 800px;
+    max-height: 700px;
+    margin: 2px 0 2px 0;
+    border-radius: 10px;
+}
+
+.playVideo {
+    background-color: rgba(0, 0, 0, 0.6);
+    height: 50px;
+    width: 50px;
+    border-radius: 5px;
+    float: right;
+    position: absolute;
+    top: calc(50% - 25px);
+    left: calc(50% - 25px);
+    z-index: 3;
+}
+
+.containerVideo {
+    width: 100%;
+    position: relative;
+}
+
+.playVideo svg {
+    height: 40px;
+    width: 40px;
+    margin: 5px;
+}
+
+/* Text interaction */
+.failure,
+.sending {
+    margin: 10px 10px;
+    color: #A0A0A0;
+}
+
+.no-audio-overlay {
+    overflow: visible;
+}
+
+audio {
+    align-self: center;
+}
+
+/* classic screens */
+@media screen and (max-width: 1920px), screen and (max-height: 1080px) {
+  .message_in {
+      padding-left: 15%;
+  }
+
+  .message_out {
+      padding-right: 15%;
+  }
+
+  .message_type_contact,
+  .message_type_call {
+    padding: 0;
+  }
+
+
+  .internal_mes_wrapper {
+      max-width: 60%;
+  }
+
+  .media_wrapper img {
+      /* It is perfectly fine to specify max-widths in px when the
+         wrapper's max-width is in %, as long as the max-width in px
+         doesn't exceed the one in %. */
+      max-width: 450px;
+      max-height: 450px;
+  }
+
+  .menu_interaction
+  {
+      margin: 5px;
+      padding: 2px;
+      height: 10px;
+      font-size: 0.7em;
+      transition: visibility 0.3s linear,opacity 0.3s linear;
+  }
+}
+
+/* lower resolutions */
+@media screen and (max-width: 1000px), screen and (max-height: 480px) {
+  .message_in {
+      padding-left: 0;
+  }
+
+  .message_out {
+      padding-right: 0;
+  }
+
+  .message_type_contact,
+  .message_type_call {
+      max-width: 100%;
+  }
+
+  .internal_mes_wrapper {
+      max-width: 90%;
+  }
+
+  /* Media interactions */
+  .media_wrapper img {
+      max-width: 200px;
+      max-height: 200px;
+  }
+}
+
+@media screen and (max-width: 550px) {
+  .message_type_data_transfer .message_wrapper {
+      width: 250px;
+  }
+}
+
+/* Special case */
+@media screen and (max-width: 350px) {
+  .sender_image {
+      display: none;
+  }
+
+  /* File interactions */
+  .message_type_data_transfer .left_buttons {
+      max-width: 100%;
+  }
+
+  .message_type_data_transfer .text {
+      max-width: 100%;
+      padding-left: 0;
+  }
+
+  .message_type_data_transfer .message_wrapper {
+      width: 200px;
+  }
+}
diff --git a/src/web-chatview/chatview.html b/src/web-chatview/chatview.html
new file mode 100644
index 0000000000000000000000000000000000000000..3b8f16fad7aedd1b04dd8683cf09bc1b8b9c7369
--- /dev/null
+++ b/src/web-chatview/chatview.html
@@ -0,0 +1,111 @@
+<html>
+<!-- Empty head might be needed for setSenderImage -->
+
+<head>
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta charset="utf-8">
+</head>
+
+<body>
+    <div id="wrapperOfNavbar" class="navbar-wrapper">
+        <div id="navbar">
+            <div id="backButton" class="nav-button non-action-button nav-left" onmouseover="addBackButtonHoverProperty()"
+                onclick="backToWelcomeView()">
+                <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
+                    <path d="M0 0h24v24H0z" fill="none" />
+                    <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
+                </svg>
+            </div>
+            <div id="nav-contactid" class="nav-left">
+                <div id="nav-contactid-wrapper">
+                    <div id="nav-contactid-alias"></div>
+                    <div id="nav-contactid-bestId"></div>
+                </div>
+            </div>
+            <div id="optionsButton" style="display:none" class="deactivated nav-button action-button nav-right" onclick="moreOptions()">
+                <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
+                    <path d="M0 0h24v24H0z" fill="none" />
+                    <path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
+                </svg>
+            </div>
+            <div id="callButtons">
+                <!-- callButtons block allows more efficient hiding of placeCallButton and placeAudioCallButton -->
+                <div id="placeCallButton" class="nav-button action-button nav-right" onclick="placeCall()">
+                    <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+                        <path d="M0 0h24v24H0z" fill="none" />
+                        <path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
+                    </svg>
+                </div>
+                <div id="placeAudioCallButton" class="nav-button action-button nav-right" onclick="placeAudioCall()">
+                    <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+                        <path fill="none" d="M0 0h24v24H0z" />
+                        <path d="M20.01 15.38c-1.23 0-2.42-.2-3.53-.56-.35-.12-.74-.03-1.01.24l-1.57 1.97c-2.83-1.35-5.48-3.9-6.89-6.83l1.95-1.66c.27-.28.35-.67.24-1.02-.37-1.11-.56-2.3-.56-3.53 0-.54-.45-.99-.99-.99H4.19C3.65 3 3 3.24 3 3.99 3 13.28 10.73 21 20.01 21c.71 0 .99-.63.99-1.18v-3.45c0-.54-.45-.99-.99-.99z" />
+                    </svg>
+                </div>
+            </div>
+            <div id="addToConversationsButton" class="nav-button action-button nav-right" onclick="addToConversations()">
+                <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+                    <path d="M0 0h24v24H0z" fill="none" />
+                    <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
+                </svg>
+            </div>
+            <div id="unbanButton" class="nav-button action-critical-button nav-right" onclick="addBannedContact()">
+                <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+                    <path fill="none" d="M0 0h24v24H0V0z" />
+                    <circle cx="15" cy="8" r="4" />
+                    <path d="M23 20v-2c0-2.3-4.1-3.7-6.9-3.9l6 5.9h.9zm-11.6-5.5C9.2 15.1 7 16.3 7 18v2h9.9l4 4 1.3-1.3-21-20.9L0 3.1l4 4V10H1v2h3v3h2v-3h2.9l2.5 2.5zM6 10v-.9l.9.9H6z" />
+                </svg>
+            </div>
+        </div>
+        <div id="invitation">
+            <div id="invite_header">
+                <span id="invite_image"></span>
+                <div id="text"></div>
+            </div>
+            <div id="actions">
+                <div id="acceptButton" class="nav-button action-button invite-btn-green" onclick="acceptInvitation()">
+                    <svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+                        <path d="M0 0h24v24H0z" fill="none" />
+                        <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
+                    </svg>
+                </div>
+                <div id="refuseButton" class="nav-button action-button invite-btn-red" onclick="refuseInvitation()">
+                    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+                        <path fill="none" d="M0 0h24v24H0V0z" />
+                        <path d="M18.3 5.71c-.39-.39-1.02-.39-1.41 0L12 10.59 7.11 5.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L10.59 12 5.7 16.89c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L12 13.41l4.89 4.89c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4z" />
+                    </svg>
+                </div>
+                <div id="blockButton" class="nav-button action-button invite-btn-red" onclick="blockConversation()">
+                    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+                        <path d="M0 0h24v24H0z" fill="none" />
+                        <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z" />
+                    </svg>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div id="container">
+        <div id="messages" onscroll="onScrolled()"></div>
+        <div id="back_to_bottom_button_container">
+            <div id="back_to_bottom_button" onclick="back_to_bottom()">Jump to latest &#9660;</div>
+        </div>
+        <div id="file_image_send_container"></div>
+        <div id="sendMessage">
+            <div id="sendFileButton" class="nav-button action-button" onclick="selectFileToSend()" title="select file to send">
+                <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+                    <path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
+                    <path d="M0 0h24v24H0z" fill="none" />
+                </svg>
+            </div>
+            <textarea id="message" autofocus placeholder="Type a message" onkeyup="grow_text_area()" onkeydown="process_messagebar_keydown()"
+                rows="1"></textarea>
+            <div id="sendButton" class="nav-button action-button" onclick="sendMessage(); grow_text_area()">
+                <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+                    <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
+                    <path d="M0 0h24v24H0z" fill="none" />
+                </svg>
+            </div>
+        </div>
+    </div>
+</body>
+</html>
diff --git a/src/web-chatview/chatview.js b/src/web-chatview/chatview.js
new file mode 100644
index 0000000000000000000000000000000000000000..0519905b7ae09efe059716ec17314156bbfa8c77
--- /dev/null
+++ b/src/web-chatview/chatview.js
@@ -0,0 +1,2251 @@
+"use strict"
+
+/* Constants used at several places */
+// scrollDetectionThresh represents the number of pixels a user can scroll
+// without disabling the automatic go-back-to-bottom when a new message is
+// received
+const scrollDetectionThresh = 200
+// printHistoryPart loads blocks of messages. Each block contains
+// scrollBuffer messages
+const scrollBuffer = 20
+// The first time a conversation is loaded, the lazy loading system makes
+// sure at least initialScrollBufferFactor screens of messages are loaded
+const initialScrollBufferFactor = 3
+// Some signal like the onscrolled signals are debounced so that the their
+// assigned function isn't fired too often
+const debounceTime = 200
+
+/* Buffers */
+// current index in the history buffer
+var historyBufferIndex = 0
+// buffer containing the conversation's messages
+var historyBuffer = []
+
+// If we use qt
+var use_qt = false
+
+if (navigator.userAgent == "jami-windows") {
+    use_qt = true
+}
+
+/* We retrieve refs to the most used navbar and message bar elements for efficiency purposes */
+/* NOTE: use getElementById when possible (more readable and efficient) */
+const addToConversationsButton = document.getElementById("addToConversationsButton")
+const placeAudioCallButton = document.getElementById("placeAudioCallButton")
+const backButton = document.getElementById("backButton")
+const placeCallButton = document.getElementById("placeCallButton")
+const unbanButton = document.getElementById("unbanButton")
+const acceptButton = document.getElementById("acceptButton")
+const refuseButton = document.getElementById("refuseButton")
+const blockButton = document.getElementById("blockButton")
+const callButtons = document.getElementById("callButtons")
+const sendButton = document.getElementById("sendButton")
+const optionsButton = document.getElementById("optionsButton")
+const backToBottomBtn = document.getElementById("back_to_bottom_button")
+const backToBottomBtnContainer = document.getElementById("back_to_bottom_button_container")
+const sendFileButton = document.getElementById("sendFileButton")
+const aliasField = document.getElementById("nav-contactid-alias")
+const bestIdField = document.getElementById("nav-contactid-bestId")
+const idField = document.getElementById("nav-contactid")
+const messageBar = document.getElementById("sendMessage")
+const messageBarInput = document.getElementById("message")
+const addToConvButton = document.getElementById("addToConversationsButton")
+const invitation = document.getElementById("invitation")
+const inviteImage = document.getElementById("invite_image")
+const navbar = document.getElementById("navbar")
+const invitationText = document.getElementById("text")
+var   messages = document.getElementById("messages")
+var   sendContainer = document.getElementById("file_image_send_container")
+var   wrapperOfNavbar = document.getElementById("wrapperOfNavbar")
+
+/* States: allows us to avoid re-doing something if it isn't meaningful */
+var displayLinksEnabled = true
+var hoverBackButtonAllowed = true
+var hasInvitation = false
+var isTemporary = false
+var isBanned = false
+var isAccountEnabled = true
+var isInitialLoading = false
+var imagesLoadingCounter = 0
+var canLazyLoad = false
+
+/* Set the default target to _self and handle with QWebEnginePage::acceptNavigationRequest */
+var linkifyOptions = {}
+if (use_qt) {
+    messageBarInput.onpaste = pasteKeyDetected;
+    wrapperOfNavbar.outerHTML = ""
+    linkifyOptions = {
+        attributes: null,
+        className: "linkified",
+        defaultProtocol: "http",
+        events: null,
+        format: function (value, type) {
+            return value
+        },
+        formatHref: function (href, type) {
+            return href
+        },
+        ignoreTags: [],
+        nl2br: false,
+        tagName: "a",
+        target: {
+            url: "_self"
+        },
+        validate: true
+    }
+    new QWebChannel(qt.webChannelTransport, function (channel) {
+        window.jsbridge = channel.objects.jsbridge
+    })
+} else {
+    sendContainer.outerHTML = ""
+}
+
+/* i18n manager */
+var i18n = null
+
+/* exported init_i18n */
+function init_i18n(data) {
+    if (data === undefined) {
+        i18n = new Jed({ locale_data: { "messages": { "": {} } } }) // eslint-disable-line no-undef
+    } else {
+        i18n = new Jed(data) // eslint-disable-line no-undef
+    }
+
+    reset_message_bar_input()
+    set_titles()
+}
+
+function set_titles() {
+    if (use_qt) {
+        backButton.title = "Hide chat view"
+        placeCallButton.title = "Place video call"
+        placeAudioCallButton.title = "Place audio call"
+        addToConversationsButton.title = "Add to conversations"
+        unbanButton.title = "Unban contact"
+        sendButton.title = "Send"
+        optionsButton.title = "Options"
+        backToBottomBtn.innerHTML = "'Jump to latest' &#9660;"
+        sendFileButton.title = "Send File"
+        acceptButton.title = "Accept"
+        refuseButton.title = "Refuse"
+        blockButton.title = "Block"
+    } else {
+        backButton.title = i18n.gettext("Hide chat view")
+        placeCallButton.title = i18n.gettext("Place video call")
+        placeAudioCallButton.title = i18n.gettext("Place audio call")
+        addToConversationsButton.title = i18n.gettext("Add to conversations")
+        unbanButton.title = i18n.gettext("Unban contact")
+        sendButton.title = i18n.gettext("Send")
+        optionsButton.title = i18n.gettext("Options")
+        backToBottomBtn.innerHTML = `${i18n.gettext("Jump to latest")} &#9660;`
+        sendFileButton.title = i18n.gettext("Send File")
+        acceptButton.title = i18n.gettext("Accept")
+        refuseButton.title = i18n.gettext("Refuse")
+        blockButton.title = i18n.gettext("Block")
+    }
+}
+
+function reset_message_bar_input() {
+    if (use_qt) {
+        messageBarInput.placeholder = "Type a message"
+    }
+    else {
+        messageBarInput.placeholder = i18n.gettext("Type a message")
+    }
+
+}
+
+function onScrolled_() {
+    if (!canLazyLoad)
+        return
+    // back to bottom button
+    if(messages.scrollTop >= messages.scrollHeight - messages.clientHeight - scrollDetectionThresh) {
+        // fade out
+        if (!backToBottomBtn.classList.contains("fade")) {
+            backToBottomBtn.classList.add("fade")
+            backToBottomBtn.removeAttribute("onclick")
+        }
+    } else {
+        // fade in
+        if (backToBottomBtn.classList.contains("fade")) {
+            backToBottomBtn.style.visibility = "visible"
+            backToBottomBtnContainer.style.visibility = "visible"
+            backToBottomBtn.classList.remove("fade")
+            backToBottomBtn.onclick = back_to_bottom
+        }
+    }
+    if (messages.scrollTop == 0 && historyBufferIndex != historyBuffer.length) {
+        /* At the top and there's something to print */
+        printHistoryPart(messages, messages.scrollHeight)
+    }
+}
+
+const debounce = (fn, time) => {
+    let timeout
+
+    return function () {
+        const functionCall = () => fn.apply(this, arguments)
+
+        clearTimeout(timeout)
+        timeout = setTimeout(functionCall, time)
+    }
+}
+
+/* exported onScrolled */
+var onScrolled = debounce(onScrolled_, debounceTime)
+
+/**
+ * Generic wrapper. Execute passed function keeping scroll position identical.
+ *
+ * @param func function to execute
+ * @param args parameters as array
+ */
+function exec_keeping_scroll_position(func, args) {
+    var atEnd = messages.scrollTop >= messages.scrollHeight - messages.clientHeight - scrollDetectionThresh
+    func(...args)
+    if (atEnd) {
+        messages.scrollTop = messages.scrollHeight
+    }
+}
+
+/**
+ * Reset scrollbar at a given position.
+ * @param scroll position at which the scrollbar should be set.
+ *               Here position means the number of pixels scrolled,
+ *               i.e. scroll = 0 resets the scrollbar at the bottom.
+ */
+function back_to_scroll(scroll) {
+    messages.scrollTop = messages.scrollHeight - scroll
+}
+
+/**
+ * Reset scrollbar at bottom.
+ */
+function back_to_bottom() {
+    back_to_scroll(0)
+}
+
+/**
+ * Update common frame between conversations.
+ *
+ * Whenever the current conversation is switched, information from the navbar
+ * and message bar have to be updated to match new contact. This function
+ * handles most of the required work (except the showing/hiding the invitation,
+ * which is handled by showInvitation()).
+ *
+ * @param accountEnabled 	whether account is enabled or not
+ * @param banned 	whether contact is banned or not
+ * @param temporary	whether contact is temporary or not
+ * @param alias
+ * @param bestId
+ */
+/* exported update_chatview_frame */
+function update_chatview_frame(accountEnabled, banned, temporary, alias, bestid) {
+    /* This function updates lots of things in the navbar and we don't want to
+       trigger that many DOM updates. Instead set display to none so DOM is
+       updated only once. */
+    navbar.style.display = "none"
+
+    hoverBackButtonAllowed = true
+
+    aliasField.innerHTML = (alias ? alias : bestid)
+
+    if (alias) {
+        bestIdField.innerHTML = bestid
+        idField.classList.remove("oneEntry")
+    } else {
+        idField.classList.add("oneEntry")
+    }
+
+    if (isAccountEnabled !== accountEnabled) {
+        isAccountEnabled = accountEnabled
+        hideMessageBar(!accountEnabled)
+        hideControls(accountEnabled)
+    }
+
+    if (isBanned !== banned) {
+        isBanned = banned
+        hideMessageBar(banned)
+
+        if (banned) {
+            // contact is banned. update navbar and states
+            navbar.classList.add("onBannedState")
+        } else {
+            navbar.classList.remove("onBannedState")
+        }
+    } else if (isTemporary !== temporary) {
+        isTemporary = temporary
+        if (temporary) {
+            addToConvButton.style.display = "flex"
+            if (use_qt) {
+                messageBarInput.placeholder = "Note: an interaction will create a new contact."
+            }
+            else {
+                messageBarInput.placeholder = i18n.gettext("Note: an interaction will create a new contact.")
+            }
+        } else {
+            addToConvButton.style.display = ""
+            reset_message_bar_input()
+        }
+    }
+
+    navbar.style.display = ""
+}
+
+/**
+ * Hide or show invitation.
+ *
+ * Invitation is hidden if no contactAlias/invalid alias is passed.
+ * Otherwise, invitation div is updated.
+ *
+ * @param contactAlias
+ * @param contactId
+ */
+/* exported showInvitation */
+function showInvitation(contactAlias, contactId) {
+    if (!contactAlias) {
+        if (hasInvitation) {
+            hasInvitation = false
+            invitation.style.visibility = ""
+        }
+    } else {
+        if (!inviteImage.classList.contains("sender_image")) {
+            inviteImage.classList.add("sender_image")
+        }
+        if (use_qt) {
+            if (!inviteImage.classList.contains(`sender_image_${contactId}`)) {
+                inviteImage.classList.add(`sender_image_${contactId}`)
+            }
+            invitationText.innerHTML = "<span id='invite_contact_name'>" + contactAlias + "</span> is not in your contacts<br/>"
+                + "Note: you can automatically accept this invitation by sending a message."
+            invitation.style.visibility = "visible"
+        } else {
+            const className = `sender_image_${contactId}`.replace(/@/g, "_").replace(/\./g, "_")
+            if (!inviteImage.classList.contains(className)) {
+                inviteImage.classList.add(className);
+            }
+            invitationText.innerHTML = "<b>"
+                + i18n.sprintf(i18n.gettext("%s is not in your contacts"), contactAlias)
+                + "</b><br/>"
+                + i18n.gettext("Note: you can automatically accept this invitation by sending a message.")
+        }
+        hasInvitation = true
+        invitation.style.visibility = "visible"
+    }
+}
+
+/**
+ * Hide or show navbar, and update body top padding accordingly.
+ *
+ * @param isVisible whether navbar should be displayed or not
+ */
+/* exported displayNavbar */
+function displayNavbar(isVisible) {
+    if (isVisible) {
+        navbar.classList.remove("hiddenState")
+        document.body.style.setProperty("--navbar-size", undefined)
+    } else {
+        navbar.classList.add("hiddenState")
+        document.body.style.setProperty("--navbar-size", "0")
+    }
+}
+
+/**
+ * Hide or show message bar, and update body bottom padding accordingly.
+ *
+ * @param isHidden whether message bar should be displayed or not
+ */
+/* exported hideMessageBar */
+function hideMessageBar(isHidden) {
+    if (isHidden) {
+        messageBar.classList.add("hiddenState")
+        document.body.style.setProperty("--messagebar-size", "0")
+    } else {
+        messageBar.classList.remove("hiddenState")
+        document.body.style.removeProperty("--messagebar-size")
+    }
+}
+
+/* exported setDisplayLinks */
+function setDisplayLinks(display) {
+    displayLinksEnabled = display
+}
+
+/**
+ * This event handler dynamically resizes the message bar depending on the amount of
+ * text entered, while adjusting the body paddings so that that the message bar doesn't
+ * overlap messages when it grows.
+ */
+/* exported grow_text_area */
+function grow_text_area() {
+    exec_keeping_scroll_position(function () {
+        var old_height = window.getComputedStyle(messageBar).height
+        messageBarInput.style.height = "auto" /* <-- necessary, no clue why */
+        messageBarInput.style.height = messageBarInput.scrollHeight + "px"
+        var new_height = window.getComputedStyle(messageBar).height
+
+        var msgbar_size = window.getComputedStyle(document.body).getPropertyValue("--messagebar-size")
+        var total_size = parseInt(msgbar_size) + parseInt(new_height) - parseInt(old_height)
+
+        document.body.style.setProperty("--messagebar-size", total_size.toString() + "px")
+    }, [])
+}
+
+/**
+ * This event handler processes keydown events from the message bar. When pressed key is
+ * the enter key, send the message unless shift or control was pressed too.
+ *
+ * @param key the pressed key
+ */
+/* exported process_messagebar_keydown */
+function process_messagebar_keydown(key) {
+    key = key || event
+    var map = {}
+    map[key.keyCode] = key.type == "keydown"
+    if (key.ctrlKey && map[13]) {
+        messageBarInput.value += "\n"
+    }
+    if (key.ctrlKey || key.shiftKey) {
+        return true
+    }
+    if (map[13]) {
+        sendMessage()
+        key.preventDefault()
+    }
+    return true
+}
+
+/* exported clearSenderImages */
+function clearSenderImages() {
+    var styles = document.head.querySelectorAll("style"),
+        i = styles.length
+
+    while (i--) {
+        document.head.removeChild(styles[i])
+    }
+}
+
+/**
+ * This event handler adds the hover property back to the "back to welcome view"
+ * button.
+ *
+ * This is a hack. It needs some explanations.
+ *
+ * Problem: Whenever the "back to welcome view" button is clicked, the webview
+ * freezes and the GTK ring welcome view is displayed. While the freeze
+ * itself is perfectly fine (probably necessary for good performances), this
+ * is a big problem for us when the user opens a chatview again: Since the
+ * chatview was freezed, the back button has «remembered» the hover state and
+ * still displays the blue background for a small instant. This is a very bad
+ * looking artefact.
+ *
+ * In order to counter this problem, we introduced the following evil mechanism:
+ * Whenever a user clicks on the "back to welcome view" button, the hover
+ * property is disabled. The hover property stays disabled until the user calls
+ * this event handler by hover-ing the button.
+ */
+/* exported addBackButtonHoverProperty */
+function addBackButtonHoverProperty() {
+    if (hoverBackButtonAllowed) {
+        backButton.classList.add("non-action-button")
+    }
+}
+
+/**
+ * Disable or enable textarea.
+ *
+ * @param isDisabled whether message bar should be enabled or disabled
+ */
+/* exported disableSendMessage */
+function disableSendMessage(isDisabled) {
+    messageBarInput.disabled = isDisabled
+}
+
+/*
+ * Update timestamps messages.
+ */
+function updateView() {
+    updateTimestamps(messages)
+}
+
+setInterval(updateView, 60000)
+
+/* exported addBannedContact */
+function addBannedContact() {
+    window.prompt("UNBLOCK")
+}
+
+/* exported addToConversations */
+function addToConversations() {
+    window.prompt("ADD_TO_CONVERSATIONS")
+}
+
+/* exported placeCall */
+function placeCall() {
+    window.prompt("PLACE_CALL")
+}
+
+/* exported placeAudioCall */
+function placeAudioCall() {
+    window.prompt("PLACE_AUDIO_CALL")
+}
+
+/* exported backToWelcomeView */
+function backToWelcomeView() {
+    backButton.classList.remove("non-action-button")
+    hoverBackButtonAllowed = false
+    window.prompt("CLOSE_CHATVIEW")
+}
+
+/**
+ * Transform a date to a string group like "1 hour ago".
+ *
+ * @param date
+ */
+function formatDate(date) {
+    const seconds = Math.floor((new Date() - date) / 1000)
+    var interval = Math.floor(seconds / (3600 * 24))
+
+
+    if (use_qt) {
+        if (interval > 5) {
+            return date.toLocaleDateString()
+        }
+        if (interval > 1) {
+            return interval + "\u200E days ago"
+        }
+        if (interval === 1) {
+            return interval + "\u200E day ago"
+        }
+        interval = Math.floor(seconds / 3600)
+        if (interval > 1) {
+            return interval + "\u200E hours ago"
+        }
+        if (interval === 1) {
+            return interval + "\u200E hour ago"
+        }
+        interval = Math.floor(seconds / 60)
+        if (interval > 1) {
+            return interval + "\u200E minutes ago"
+        }
+        return "just now"
+
+    } else {
+        if (interval > 5) {
+            return date.toLocaleDateString()
+        }
+
+        if (interval > 1) {
+            return i18n.sprintf(i18n.gettext("%d days ago"), interval)
+        }
+        if (interval === 1) {
+            return i18n.gettext("one day ago") // what about "yesterday"?
+        }
+
+        interval = Math.floor(seconds / 3600)
+        if (interval > 1) {
+            return i18n.sprintf(i18n.gettext("%d hours ago"), interval)
+        }
+        if (interval === 1) {
+            return i18n.gettext("one hour ago")
+        }
+
+        interval = Math.floor(seconds / 60)
+        if (interval > 1) {
+            return i18n.sprintf(i18n.gettext("%d minutes ago"), interval)
+        }
+
+        return i18n.gettext("just now")
+    }
+}
+
+/**
+ * Send content of message bar
+ */
+function sendMessage() {
+    if (use_qt) {
+        //send image in sendContainer
+        var data_to_send = sendContainer.innerHTML
+        var imgSrcExtract = new RegExp("<img src=\"(.*?)\">", "g")
+        var fileSrcExtract = new RegExp("<div class=\"file_wrapper\" data-path=\"(.*?)\">", "g")
+
+        var img_src
+        while ((img_src = imgSrcExtract.exec(data_to_send)) !== null) {
+            window.jsbridge.sendImage(img_src[1])
+        }
+
+        var file_src
+        while ((file_src = fileSrcExtract.exec(data_to_send)) !== null) {
+            window.jsbridge.sendFile(file_src[1])
+        }
+
+        sendContainer.innerHTML = ""
+        sendContainer.style.visibility = "hidden"
+        reduce_send_container();
+    }
+
+    var message = messageBarInput.value
+    if (message.length > 0) {
+        messageBarInput.value = ""
+        if (use_qt) {
+            window.jsbridge.sendMessage(message)
+        } else {
+            window.prompt("SEND:" + message)
+        }
+    }
+}
+
+/* exported acceptInvitation */
+function acceptInvitation() {
+    if (use_qt) {
+        window.jsbridge.acceptInvitation()
+    } else {
+        window.prompt("ACCEPT")
+    }
+}
+/* exported refuseInvitation */
+function refuseInvitation() {
+    if (use_qt) {
+        window.jsbridge.refuseInvitation()
+    } else {
+        window.prompt("REFUSE")
+    }
+}
+/* exported blockConversation */
+function blockConversation() {
+    if (use_qt) {
+        window.jsbridge.blockConversation()
+    } else {
+        window.prompt("BLOCK")
+    }
+}
+
+/* exported sendFile */
+function selectFileToSend() {
+    if (use_qt) {
+        window.jsbridge.selectFile()
+    } else {
+        window.prompt("SEND_FILE")
+    }
+}
+
+/**
+ * Clear all messages.
+ */
+/* exported clearMessages */
+function clearMessages() {
+    canLazyLoad = false
+    while (messages.firstChild) {
+        messages.removeChild(messages.firstChild)
+    }
+
+    if (use_qt) {
+        backToBottomBtn.style.visibility="hidden"
+        window.jsbridge.emitMessagesCleared()
+    } else {
+        window.prompt("MESSAGE_CLEARED")
+    }
+}
+
+/**
+ * Convert text to HTML.
+ */
+function escapeHtml(html) {
+    var text = document.createTextNode(html)
+    var div = document.createElement("div")
+    div.appendChild(text)
+    return div.innerHTML
+}
+
+/**
+ * Get the youtube video id from a URL.
+ * @param url
+ */
+function youtube_id(url) {
+    const regExp = /^.*(youtu\.be\/|v\/|\/u\/w|embed\/|watch\?v=|&v=)([^#&?]*).*/
+    const match = url.match(regExp)
+    return (match && match[2].length == 11) ? match[2] : null
+}
+
+/**
+ * Returns HTML message from the message text, cleaned and linkified.
+ * @param message_text
+ */
+function getMessageHtml(message_text) {
+    const escaped_message = escapeHtml(message_text)
+
+    var linkified_message = linkifyHtml(escaped_message, linkifyOptions)  // eslint-disable-line no-undef
+
+    const textPart = document.createElement("pre")
+    textPart.innerHTML = linkified_message
+
+    return textPart.outerHTML
+}
+
+/**
+ * Returns the message status, formatted for display
+ * @param message_delivery_status
+ */
+/* exported getMessageDeliveryStatusText */
+function getMessageDeliveryStatusText(message_delivery_status) {
+    var formatted_delivery_status = message_delivery_status
+
+    if (use_qt) {
+        switch (message_delivery_status) {
+        case "sending":
+        case "ongoing":
+            formatted_delivery_status = "Sending<svg overflow='visible' viewBox='0 -2 16 14' height='16px' width='16px'><circle class='status_circle anim-first' cx='4' cy='12' r='1'/><circle class='status_circle anim-second' cx='8' cy='12' r='1'/><circle class='status_circle anim-third' cx='12' cy='12' r='1'/></svg>"
+            break
+        case "failure":
+            formatted_delivery_status = "Failure <svg overflow='visible' viewBox='0 -2 16 14' height='16px' width='16px'><path class='status-x x-first' stroke='#AA0000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' fill='none' d='M4,4 L12,12'/><path class='status-x x-second' stroke='#AA0000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' fill='none' d='M12,4 L4,12'/></svg>"
+            break
+        case "sent":
+        case "finished":
+        case "unknown":
+        case "read":
+            formatted_delivery_status = ""
+            break
+        default:
+            break
+        }
+    } else {
+        switch (message_delivery_status) {
+        case "sending":
+        case "ongoing":
+            formatted_delivery_status = i18n.gettext("Sending") + "<svg overflow='visible' viewBox='0 -2 16 14' height='16px' width='16px'><circle class='status_circle anim-first' cx='4' cy='12' r='1'/><circle class='status_circle anim-second' cx='8' cy='12' r='1'/><circle class='status_circle anim-third' cx='12' cy='12' r='1'/></svg>"
+            break
+        case "failure":
+            formatted_delivery_status = i18n.gettext("Failure") + "<svg overflow='visible' viewBox='0 -2 16 14' height='16px' width='16px'><path class='status-x x-first' stroke='#AA0000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' fill='none' d='M4,4 L12,12'/><path class='status-x x-second' stroke='#AA0000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' fill='none' d='M12,4 L4,12'/></svg>"
+            break
+        case "sent":
+        case "finished":
+        case "unknown":
+        case "read":
+            formatted_delivery_status = ""
+            break
+        default:
+            break
+        }
+    }
+
+    return formatted_delivery_status
+}
+
+/**
+ * Returns the message date, formatted for display
+ */
+function getMessageTimestampText(message_timestamp, custom_format) {
+    const date = new Date(1000 * message_timestamp)
+    if (custom_format) {
+        return formatDate(date)
+    } else {
+        return date.toLocaleString()
+    }
+}
+
+/**
+ * Update timestamps.
+ * @param message_div
+ */
+function updateTimestamps(messages_div) {
+    const timestamps = messages_div.getElementsByClassName("timestamp")
+    for (var c = timestamps.length - 1; c >= 0; --c) {
+        var timestamp = timestamps.item(c)
+        timestamp.innerHTML = getMessageTimestampText(timestamp.getAttribute("message_timestamp"), true)
+    }
+}
+
+/**
+ * Convert a value in filesize
+ */
+function humanFileSize(bytes) {
+    var thresh = 1024
+    if (Math.abs(bytes) < thresh) {
+        return bytes + " B"
+    }
+    var units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
+    var u = -1
+    do {
+        bytes /= thresh
+        ++u
+    } while (Math.abs(bytes) >= thresh && u < units.length - 1)
+    return bytes.toFixed(1) + " " + units[u]
+}
+
+/**
+ * Hide or show add to conversations/calls whether the account is enabled
+ * @param accountEnabled true if account is enabled
+ */
+function hideControls(accountEnabled) {
+    if (!accountEnabled) {
+        callButtons.style.display = "none"
+    } else {
+        callButtons.style.display = ""
+    }
+}
+
+/**
+ * Change the value of the progress bar.
+ *
+ * @param progress_bar
+ * @param message_object
+ */
+function updateProgressBar(progress_bar, message_object) {
+    var delivery_status = message_object["delivery_status"]
+    if ("progress" in message_object && !isErrorStatus(delivery_status) && message_object["progress"] !== 100) {
+        var progress_percent = (100 * message_object["progress"] / message_object["totalSize"])
+        if (progress_percent !== 100)
+            progress_bar.childNodes[0].setAttribute("style", "width: " + progress_percent + "%")
+        else
+            progress_bar.setAttribute("style", "display: none")
+    } else
+        progress_bar.setAttribute("style", "display: none")
+}
+
+/**
+ * Check if a status is an error status
+ * @param
+ */
+function isErrorStatus(status) {
+    return (status === "failure"
+         || status === "awaiting peer timeout"
+         || status === "canceled"
+         || status === "unjoinable peer")
+}
+
+/**
+ * Build a new file interaction
+ * @param message_id
+ */
+function fileInteraction(message_id, message_direction) {
+    var message_wrapper = document.createElement("div")
+    message_wrapper.setAttribute("class", "message_wrapper")
+
+    var transfer_info_wrapper = document.createElement("div")
+    transfer_info_wrapper.setAttribute("class", "transfer_info_wrapper")
+    message_wrapper.appendChild(transfer_info_wrapper)
+
+    /* Buttons at the left for status information or accept/refuse actions.
+       The text is bold and clickable. */
+    var left_buttons = document.createElement("div")
+    left_buttons.setAttribute("class", "left_buttons")
+    transfer_info_wrapper.appendChild(left_buttons)
+
+    var full_div = document.createElement("div")
+    full_div.setAttribute("class", "full")
+    full_div.style.visibility = "hidden"
+    full_div.style.display = "none"
+
+    var filename_wrapper = document.createElement("div")
+    filename_wrapper.setAttribute("class", "truncate-ellipsis")
+
+    var message_text = document.createElement("span")
+    message_text.setAttribute("class", "filename")
+    filename_wrapper.appendChild(message_text)
+
+    // And information like size or error message.
+    var informations_div = document.createElement("div")
+    informations_div.setAttribute("class", "informations")
+
+    var text_div = document.createElement("div")
+    text_div.setAttribute("class", "text")
+    text_div.addEventListener("click", function () {
+        // ask ring to open the file
+        const filename = document.querySelector("#message_" + message_id + " .full").innerText
+        if (use_qt) {
+            window.jsbridge.openFile(filename)
+        } else {
+            window.prompt(`OPEN_FILE:${filename}`)
+        }
+    })
+
+    text_div.appendChild(filename_wrapper)
+    text_div.appendChild(full_div)
+    text_div.appendChild(informations_div)
+    transfer_info_wrapper.appendChild(text_div)
+
+    // And finally, a progress bar
+    var message_transfer_progress_bar = document.createElement("span")
+    message_transfer_progress_bar.setAttribute("class", "message_progress_bar")
+
+    var message_transfer_progress_completion = document.createElement("span")
+    message_transfer_progress_bar.appendChild(message_transfer_progress_completion)
+    message_wrapper.appendChild(message_transfer_progress_bar)
+
+    const internal_mes_wrapper = document.createElement("div")
+    internal_mes_wrapper.setAttribute("class", "internal_mes_wrapper")
+    if(use_qt) {
+        var tbl = buildMsgTable(message_direction);
+        var msg_cell = tbl.querySelector(".msg_cell");
+        msg_cell.appendChild(message_wrapper);
+        internal_mes_wrapper.appendChild(tbl);
+    } else {
+        internal_mes_wrapper.appendChild(message_wrapper)
+    }
+
+
+    return internal_mes_wrapper
+}
+
+function buildMsgTable(message_direction) {
+    var tbl = document.createElement("table");
+
+    var row0 = document.createElement("tr");
+    var sender_image_cell = document.createElement("td");
+    sender_image_cell.setAttribute("class", "sender_image_cell")
+    var msg_cell = document.createElement("td");
+    msg_cell.setAttribute("class", "msg_cell")
+
+    row0.appendChild((message_direction === "in") ? sender_image_cell : msg_cell);
+    row0.appendChild((message_direction === "in") ? msg_cell : sender_image_cell);
+
+    tbl.appendChild(row0);
+
+    var row1 = document.createElement("tr");
+    var dummy_cell = document.createElement("td");
+    dummy_cell.setAttribute("class", "dummy_cell")
+    var timestamp_cell = document.createElement("td");
+    timestamp_cell.setAttribute("class", "timestamp_cell")
+
+    row1.appendChild((message_direction === "in") ? dummy_cell : timestamp_cell);
+    row1.appendChild((message_direction === "in") ? timestamp_cell : dummy_cell);
+
+    tbl.appendChild(row1);
+
+    return tbl;
+}
+
+/**
+ * Build information text for passed file interaction message object
+ *
+ * @param message_object message object containing file interaction info
+ */
+function buildFileInformationText(message_object) {
+    var informations_txt = getMessageTimestampText(message_object["timestamp"], true)
+    if (message_object["totalSize"] && message_object["progress"]) {
+        if (message_object["delivery_status"] === "finished") {
+            informations_txt += " - " + humanFileSize(message_object["totalSize"])
+        } else {
+            informations_txt += " - " + humanFileSize(message_object["progress"])
+                         + " / " + humanFileSize(message_object["totalSize"])
+        }
+    }
+
+    return informations_txt + " - " + message_object["delivery_status"]
+}
+
+/**
+ * Update a file interaction (icons + filename + status + progress bar)
+ *
+ * @param message_div the message to update
+ * @param message_object new informations
+ * @param forceTypeToFile
+ */
+function updateFileInteraction(message_div, message_object, forceTypeToFile = false) {
+    if (!message_div.querySelector(".informations")) return // media
+
+    var acceptSvg = "<svg height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z\"/></svg>",
+        refuseSvg = "<svg height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></svg>",
+        fileSvg = "<svg fill=\"#000000\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></svg>",
+        warningSvg = "<svg fill=\"#000000\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z\"/></svg>"
+    var message_delivery_status = message_object["delivery_status"]
+    var message_direction = message_object["direction"]
+    var message_id = message_object["id"]
+    var message_text = message_object["text"]
+
+    if (isImage(message_text) && message_delivery_status === "finished" && displayLinksEnabled && !forceTypeToFile) {
+        // Replace the old wrapper by the downloaded image
+        var old_wrapper = message_div.querySelector(".internal_mes_wrapper")
+        if (old_wrapper) {
+            old_wrapper.parentNode.removeChild(old_wrapper)
+        }
+
+        var errorHandler = function () {
+            var wrapper = message_div.querySelector(".internal_mes_wrapper")
+            var message_wrapper = message_div.querySelector(".message_wrapper")
+            if (message_wrapper) {
+                message_wrapper.parentNode.removeChild(message_wrapper)
+            }
+
+            var media_wrapper = message_div.querySelector(".media_wrapper")
+            if (media_wrapper) {
+                media_wrapper.parentNode.removeChild(media_wrapper)
+            }
+
+            var new_interaction = fileInteraction(message_id, message_direction)
+            var new_message_wrapper = new_interaction.querySelector(".message_wrapper")
+            wrapper.prepend(new_message_wrapper)
+            updateFileInteraction(message_div, message_object, true)
+        }
+
+        var new_wrapper = mediaInteraction(message_id, message_direction, message_text, null, errorHandler)
+        message_div.insertBefore(new_wrapper, message_div.querySelector(".menu_interaction"))
+        message_div.querySelector("img").id = message_id
+        message_div.querySelector("img").msg_obj = message_object
+        if (use_qt) {
+            addSenderImage(message_div, message_object["type"], message_object["sender_contact_method"])
+        }
+        return
+    }
+
+
+    if (isAudio(message_text) && message_delivery_status === "finished" && displayLinksEnabled && !forceTypeToFile) {
+        // Replace the old wrapper by the downloaded audio
+        var old_wrapper = message_div.querySelector(".internal_mes_wrapper")
+        if (old_wrapper) {
+            old_wrapper.parentNode.removeChild(old_wrapper)
+        }
+
+        var errorHandler = function () {
+            var wrapper = message_div.querySelector(".internal_mes_wrapper")
+            wrapper.parentNode.classList.remove("no-audio-overlay")
+            var message_wrapper = message_div.querySelector(".message_wrapper")
+            if (message_wrapper) {
+                message_wrapper.parentNode.removeChild(message_wrapper)
+            }
+
+            var media_wrapper = message_div.querySelector(".audio")
+            if (media_wrapper) {
+                media_wrapper.parentNode.removeChild(media_wrapper)
+            }
+
+            var new_interaction = fileInteraction(message_id)
+            var new_message_wrapper = new_interaction.querySelector(".message_wrapper")
+            wrapper.prepend(new_message_wrapper)
+            updateFileInteraction(message_div, message_object, true)
+        }
+
+        const new_wrapper = document.createElement("audio")
+        new_wrapper.onerror = errorHandler
+        new_wrapper.setAttribute("src", "file://" + message_text)
+        new_wrapper.setAttribute("controls", "controls")
+        var audio_type = "audio/mpeg"
+        if (message_text.toLowerCase().match(/\.(ogg)$/)) {
+            audio_type = "audio/ogg"
+        } else if (message_text.toLowerCase().match(/\.(flac)$/)) {
+            audio_type = "audio/flac"
+        } else if (message_text.toLowerCase().match(/\.(wav)$/)) {
+            audio_type = "audio/wav"
+        }
+        new_wrapper.setAttribute("type", audio_type)
+        new_wrapper.setAttribute("class", "audio")
+        const internal_mes_wrapper = document.createElement("div")
+        internal_mes_wrapper.setAttribute("class", "internal_mes_wrapper")
+        internal_mes_wrapper.appendChild(new_wrapper)
+        message_div.insertBefore(internal_mes_wrapper, message_div.querySelector(".menu_interaction"))
+        internal_mes_wrapper.parentNode.classList.add("no-audio-overlay")
+
+        return
+    }
+
+    // Set informations text
+    var informations_div = message_div.querySelector(".informations")
+    informations_div.innerText = buildFileInformationText(message_object)
+
+    // Update flat buttons
+    var left_buttons = message_div.querySelector(".left_buttons")
+    left_buttons.innerHTML = ""
+    if (message_delivery_status === "awaiting peer" ||
+        message_delivery_status === "awaiting host" ||
+        message_delivery_status.indexOf("ongoing") === 0) {
+
+        if (message_direction === "in" && message_delivery_status.indexOf("ongoing") !== 0) {
+            // add buttons to accept or refuse a call.
+            var accept_button = document.createElement("div")
+            accept_button.innerHTML = acceptSvg
+            if (use_qt) {
+                accept_button.setAttribute("title", "Accept")
+            } else {
+                accept_button.setAttribute("title", i18n.gettext("Accept"))
+            }
+            accept_button.setAttribute("class", "flat-button accept")
+            accept_button.onclick = function () {
+                if (use_qt) {
+                    window.jsbridge.acceptFile(message_id)
+                } else {
+                    window.prompt(`ACCEPT_FILE:${message_id}`)
+                }
+            }
+            left_buttons.appendChild(accept_button)
+        }
+
+        var refuse_button = document.createElement("div")
+        refuse_button.innerHTML = refuseSvg
+        if (use_qt) {
+            refuse_button.setAttribute("title", "Refuse")
+        } else {
+            refuse_button.setAttribute("title", i18n.gettext("Refuse"))
+        }
+        refuse_button.setAttribute("class", "flat-button refuse")
+        refuse_button.onclick = function () {
+            if (use_qt) {
+                window.jsbridge.refuseFile(message_id)
+            } else {
+                window.prompt(`REFUSE_FILE:${message_id}`)
+            }
+        }
+        left_buttons.appendChild(refuse_button)
+    } else {
+        var status_button = document.createElement("div")
+        var statusFile = fileSvg
+        if (isErrorStatus(message_delivery_status))
+            statusFile = warningSvg
+        status_button.innerHTML = statusFile
+        status_button.setAttribute("class", "flat-button")
+        left_buttons.appendChild(status_button)
+    }
+
+    message_div.querySelector(".full").innerText = message_text
+    message_div.querySelector(".filename").innerText = message_text.split("/").pop()
+    updateProgressBar(message_div.querySelector(".message_progress_bar"), message_object)
+}
+
+/**
+ * Return if a file is an image
+ * @param file
+ */
+function isImage(file) {
+    return file.toLowerCase().match(/\.(jpeg|jpg|gif|png)$/) !== null
+}
+
+/**
+ * Return if a file is an image
+ * @param file
+ */
+function isAudio(file) {
+    return file.toLowerCase().match(/\.(mp3|mpeg|ogg|flac|wav)$/) !== null
+}
+
+/**
+ * Return if a file is a youtube video
+ * @param file
+ */
+function isVideo(file) {
+    const availableProtocols = ["http:", "https:"]
+    const videoHostname = ["youtube.com", "www.youtube.com", "youtu.be"]
+    const urlParser = document.createElement("a")
+    urlParser.href = file
+    return (availableProtocols.includes(urlParser.protocol) && videoHostname.includes(urlParser.hostname))
+}
+
+/**
+ * Build a container for passed video thumbnail
+ * @param linkElt video thumbnail div
+ */
+function buildVideoContainer(linkElt) {
+    const containerElt = document.createElement("div")
+    containerElt.setAttribute("class", "containerVideo")
+    const playDiv = document.createElement("div")
+    playDiv.setAttribute("class", "playVideo")
+    playDiv.innerHTML = "<svg fill=\"#ffffff\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\
+        <path d=\"M8 5v14l11-7z\"/>\
+        <path d=\"M0 0h24v24H0z\" fill=\"none\"/>\
+    </svg>"
+    linkElt.appendChild(playDiv)
+    containerElt.appendChild(linkElt)
+
+    return containerElt
+}
+
+/**
+ * Try to show an image or a video link (youtube for now)
+ * @param message_id
+ * @param link to show
+ * @param ytid if it's a youtube video
+ * @param errorHandler the new media's onerror field will be set to this function
+ */
+function mediaInteraction(message_id, message_direction, link, ytid, errorHandler) {
+    /* TODO promise?
+     Try to display images. */
+    const media_wrapper = document.createElement("div")
+    media_wrapper.setAttribute("class", "media_wrapper")
+    const linkElt = document.createElement("a")
+    linkElt.href = link
+    linkElt.style.textDecoration = "none"
+    linkElt.style.border = "none"
+    const imageElt = document.createElement("img")
+
+    imageElt.src = ytid ? `http://img.youtube.com/vi/${ytid}/0.jpg` : link
+
+    /* Note, here, we don't check the size of the image.
+     in the future, we can check the content-type and content-length with a request
+     and maybe disable svg */
+
+    if (isInitialLoading) {
+        /* During initial load, make sure the scrollbar stays at the bottom.
+           Also, the final scrollHeight is only known after the last image was
+           loaded. We want to display a specific number of messages screens so
+           we have to set up a callback (on_image_load_finished) which will
+           check on that and reschedule a new display batch if not enough
+           messages have been loaded in the DOM. */
+        imagesLoadingCounter++
+        imageElt.onload = function () {
+            back_to_bottom()
+            on_image_load_finished()
+        }
+
+        if (errorHandler) {
+            imageElt.onerror = function () {
+                errorHandler()
+                back_to_bottom()
+                on_image_load_finished()
+            }
+        }
+    } else if (messages.scrollTop >= messages.scrollHeight - messages.clientHeight - scrollDetectionThresh) {
+        /* Keep the scrollbar at the bottom. Images are loaded asynchronously and
+           the scrollbar position is changed each time an image is loaded and displayed.
+           In order to make sure the scrollbar stays at the bottom, reset scrollbar
+           position each time an image was loaded. */
+        imageElt.onload = back_to_bottom
+
+        if (errorHandler) {
+            imageElt.onerror = function () {
+                errorHandler()
+                back_to_bottom()
+            }
+        }
+    } else if (errorHandler) {
+        imageElt.onerror = errorHandler
+    }
+
+    linkElt.appendChild(imageElt)
+
+    if (ytid) {
+        media_wrapper.appendChild(buildVideoContainer(linkElt))
+    } else {
+        media_wrapper.appendChild(linkElt)
+    }
+
+    const internal_mes_wrapper = document.createElement("div")
+    internal_mes_wrapper.setAttribute("class", "internal_mes_wrapper")
+    if(use_qt) {
+        var tbl = buildMsgTable(message_direction)
+        var msg_cell = tbl.querySelector(".msg_cell")
+        msg_cell.appendChild(media_wrapper)
+        internal_mes_wrapper.appendChild(tbl)
+    } else {
+        internal_mes_wrapper.appendChild(media_wrapper)
+    }
+
+    return internal_mes_wrapper
+}
+
+/**
+ * Build a new text interaction
+ * @param message_id
+ * @param htmlText the DOM to show
+ */
+function textInteraction(message_id, message_direction, htmlText) {
+    const message_wrapper = document.createElement("div")
+    message_wrapper.setAttribute("class", "message_wrapper")
+    var message_text = document.createElement("div")
+    message_text.setAttribute("class", "message_text")
+    message_text.innerHTML = htmlText
+    message_wrapper.appendChild(message_text)
+    // TODO STATUS
+
+    const internal_mes_wrapper = document.createElement("div")
+    internal_mes_wrapper.setAttribute("class", "internal_mes_wrapper")
+    if(use_qt) {
+        var tbl = buildMsgTable(message_direction);
+        var msg_cell = tbl.querySelector(".msg_cell");
+        msg_cell.appendChild(message_wrapper);
+        internal_mes_wrapper.appendChild(tbl);
+    } else {
+        internal_mes_wrapper.appendChild(message_wrapper)
+    }
+
+    return internal_mes_wrapper
+}
+
+/**
+ * Update a text interaction (text)
+ * @param message_div the message to update
+ * @param delivery_status the status of the message
+ */
+function updateTextInteraction(message_div, delivery_status) {
+    if (!message_div.querySelector(".message_text")) return // media
+    var sending = message_div.querySelector(".sending")
+    switch(delivery_status)
+    {
+    case "ongoing":
+    case "sending":
+        if (!sending) {
+            sending = document.createElement("div")
+            sending.setAttribute("class", "sending")
+            sending.innerHTML = "<svg overflow=\"hidden\" viewBox=\"0 -2 16 14\" height=\"16px\" width=\"16px\"><circle class=\"status_circle anim-first\" cx=\"4\" cy=\"12\" r=\"1\"/><circle class=\"status_circle anim-second\" cx=\"8\" cy=\"12\" r=\"1\"/><circle class=\"status_circle anim-third\" cx=\"12\" cy=\"12\" r=\"1\"/></svg>"
+            // add sending animation to message
+            message_div.insertBefore(sending, message_div.querySelector(".menu_interaction"))
+        }
+        message_div.querySelector(".message_text").style.color = "#888"
+        break
+    case "failure":
+        // change text color to red
+        message_div.querySelector(".message_text").color = "#000"
+        var failure_div = message_div.querySelector(".failure")
+        if (!failure_div) {
+            failure_div = document.createElement("div")
+            failure_div.setAttribute("class", "failure")
+            failure_div.innerHTML = "<svg overflow=\"visible\" viewBox=\"0 -2 16 14\" height=\"16px\" width=\"16px\"><path class=\"status-x x-first\" stroke=\"#AA0000\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" fill=\"none\" d=\"M4,4 L12,12\"/><path class=\"status-x x-second\" stroke=\"#AA0000\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" fill=\"none\" d=\"M12,4 L4,12\"/></svg>"
+            // add failure animation to message
+            message_div.insertBefore(failure_div, message_div.querySelector(".menu_interaction"))
+        }
+        message_div.querySelector(".message_text").style.color = "#000"
+        if (sending) sending.style.display = "none"
+        break
+    case "sent":
+    case "finished":
+    case "unknown":
+    case "read":
+        // change text color to black
+        message_div.querySelector(".message_text").style.color = "#000"
+        if (sending) sending.style.display = "none"
+        break
+    default:
+        break
+    }
+}
+
+/**
+ * Build a new interaction (call or contact)
+ */
+function actionInteraction() {
+    var message_wrapper = document.createElement("div")
+    message_wrapper.setAttribute("class", "message_wrapper")
+
+    // A file interaction contains buttons at the left of the interaction
+    // for the status or accept/refuse buttons
+    var left_buttons = document.createElement("div")
+    left_buttons.setAttribute("class", "left_buttons")
+    message_wrapper.appendChild(left_buttons)
+    // Also contains a bold clickable text
+    var text_div = document.createElement("div")
+    text_div.setAttribute("class", "text")
+    message_wrapper.appendChild(text_div)
+    return message_wrapper
+}
+
+/**
+ * Update a call interaction (icon + text)
+ * @param message_div the message to update
+ * @param message_object new informations
+ */
+function updateCallInteraction(message_div, message_object) {
+    const outgoingCall = "<svg fill=\"#219d55\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5z\"/></svg>"
+    const callMissed = "<svg fill=\"#dc2719\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M19.59 7L12 14.59 6.41 9H11V7H3v8h2v-4.59l7 7 9-9z\"/></svg>"
+    const outgoingMissed = "<svg fill=\"#dc2719\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"><defs><path d=\"M24 24H0V0h24v24z\" id=\"a\"/></defs><clipPath id=\"b\"><use overflow=\"visible\" xlink:href=\"#a\"/></clipPath><path clip-path=\"url(#b)\" d=\"M3 8.41l9 9 7-7V15h2V7h-8v2h4.59L12 14.59 4.41 7 3 8.41z\"/></svg>"
+    const callReceived = "<svg fill=\"#219d55\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M20 5.41L18.59 4 7 15.59V9H5v10h10v-2H8.41z\"/></svg>"
+
+    const message_text = message_object["text"]
+    const message_direction = (message_text.toLowerCase().indexOf("incoming") !== -1) ? "in" : "out"
+    const missed = message_text.indexOf("Missed") !== -1
+
+    message_div.querySelector(".text").innerText = message_text.substring(2)
+
+    var left_buttons = message_div.querySelector(".left_buttons")
+    left_buttons.innerHTML = ""
+    var status_button = document.createElement("div")
+    var statusFile = ""
+    if (missed)
+        statusFile = (message_direction === "in") ? callMissed : outgoingMissed
+    else
+        statusFile = (message_direction === "in") ? callReceived : outgoingCall
+    status_button.innerHTML = statusFile
+    status_button.setAttribute("class", "flat-button")
+    left_buttons.appendChild(status_button)
+}
+
+/**
+ * Update a contact interaction (icon + text)
+ * @param message_div the message to update
+ * @param message_object new informations
+ */
+function updateContactInteraction(message_div, message_object) {
+    const message_text = message_object["text"]
+
+    message_div.querySelector(".text").innerText = message_text
+
+    var left_buttons = message_div.querySelector(".left_buttons")
+    left_buttons.innerHTML = ""
+    var status_button = document.createElement("div")
+    status_button.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\">\
+<path d=\"M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z\"/>\
+<path d=\"M0 0h24v24H0z\" fill=\"none\"/></svg>"
+    status_button.setAttribute("class", "flat-button")
+    left_buttons.appendChild(status_button)
+}
+
+/**
+ * Remove an interaction from the conversation
+ * @param interaction_id
+ */
+/* exported removeInteraction */
+function removeInteraction(interaction_id) {
+    var interaction = document.getElementById(`message_${interaction_id}`)
+    if (!interaction) {
+        return
+    }
+
+    if (interaction.previousSibling) {
+        /* if element was the most recently received message, make sure the
+           last_message property is given away to the previous sibling */
+        if (interaction.classList.contains("last_message")) {
+            interaction.previousSibling.classList.add("last_message")
+        }
+
+        /* same for timestamp */
+        var timestamp = interaction.querySelector(".timestamp")
+        var previousTimeStamp = interaction.previousSibling.querySelector(".timestamp")
+        if (timestamp && !previousTimeStamp) {
+            if (use_qt) {
+                interaction.previousSibling.querySelector(".timestamp_cell").appendChild(timestamp)
+            } else {
+                interaction.previousSibling.querySelector(".internal_mes_wrapper").appendChild(timestamp)
+            }
+        }
+    }
+
+    var firstMessage = getPreviousInteraction(interaction)
+    var secondMessage = getNextInteraction(interaction)
+
+    updateSequencing(firstMessage, secondMessage)
+
+    interaction.parentNode.removeChild(interaction)
+}
+
+/**
+ * Build message dropdown
+ * @return a message dropdown for passed message id
+ */
+function buildMessageDropdown(message_id) {
+    const menu_element = document.createElement("div")
+    menu_element.setAttribute("class", "menu_interaction")
+    menu_element.innerHTML =
+    `<input type="checkbox" id="showmenu${message_id}" class="showmenu">
+     <label for="showmenu${message_id}">
+       <svg fill="#888888" height="12" viewBox="0 0 24 24" width="12" xmlns="http://www.w3.org/2000/svg">
+         <path d="M0 0h24v24H0z" fill="none"/>
+         <path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
+       </svg>
+     </label>`
+    menu_element.onclick = function () {
+        const button = this.querySelector(".showmenu")
+        button.checked = !button.checked
+    }
+    menu_element.onmouseleave = function () {
+        const button = this.querySelector(".showmenu")
+        button.checked = false
+    }
+    const dropdown = document.createElement("div")
+    const dropdown_classes = [
+        "dropdown",
+        `dropdown_${message_id}`
+    ]
+    dropdown.setAttribute("class", dropdown_classes.join(" "))
+
+    const remove = document.createElement("div")
+    remove.setAttribute("class", "menuoption")
+    if (use_qt) {
+        remove.innerHTML = "Delete"
+    } else {
+        remove.innerHTML = i18n.gettext("Delete")
+    }
+    remove.msg_id = message_id
+    remove.onclick = function () {
+        if (use_qt) {
+            window.jsbridge.deleteInteraction(`${this.msg_id}`)
+        } else {
+            window.prompt(`DELETE_INTERACTION:${this.msg_id}`)
+        }
+    }
+    dropdown.appendChild(remove)
+    menu_element.appendChild(dropdown)
+
+    return menu_element
+}
+
+/**
+ * Build a message div for passed message object
+ * @param message_object to treat
+ */
+function buildNewMessage(message_object) {
+    const message_id = message_object["id"]
+    const message_type = message_object["type"]
+    const message_text = message_object["text"]
+    const message_direction = message_object["direction"]
+    const delivery_status = message_object["delivery_status"]
+    const message_sender_contact_method = message_object["sender_contact_method"]
+
+    var classes = [
+        "message",
+        `message_${message_direction}`,
+        `message_type_${message_type}`
+    ]
+
+    var type = ""
+    var message_div = document.createElement("div")
+    message_div.setAttribute("id", `message_${message_id}`)
+    message_div.setAttribute("class", classes.join(" "))
+
+    // Build message for each types.
+    if(!use_qt) {
+        // Add sender images if necessary (like if the interaction doesn't take the whole width)
+        const need_sender = (message_type === "data_transfer" || message_type === "text")
+        if (need_sender) {
+            var message_sender_image = document.createElement("span")
+            message_sender_image.setAttribute("class", `sender_image sender_image_${message_sender_contact_method}`)
+            message_div.appendChild(message_sender_image)
+        }
+    }
+
+
+    // Build main content
+    if (message_type === "data_transfer") {
+        if (isImage(message_text) && delivery_status === "finished" && displayLinksEnabled) {
+            var errorHandler = function () {
+                var wrapper = message_div.querySelector(".internal_mes_wrapper")
+                var message_wrapper = message_div.querySelector(".message_wrapper")
+                if (message_wrapper) {
+                    message_wrapper.parentNode.removeChild(message_wrapper)
+                }
+
+                var media_wrapper = message_div.querySelector(".media_wrapper")
+                if (media_wrapper) {
+                    media_wrapper.parentNode.removeChild(media_wrapper)
+                }
+
+                var new_interaction = fileInteraction(message_id, message_direction)
+                var new_message_wrapper = new_interaction.querySelector(".message_wrapper")
+                wrapper.prepend(new_message_wrapper)
+                updateFileInteraction(message_div, message_object, true)
+            }
+            message_div.append(mediaInteraction(message_id, message_direction, message_text, null, errorHandler))
+            message_div.querySelector("img").id = message_id
+            message_div.querySelector("img").msg_obj = message_object
+        } else {
+            message_div.append(fileInteraction(message_id, message_direction))
+        }
+    } else if (message_type === "text") {
+        // TODO add the possibility to update messages (remove and rebuild)
+        const htmlText = getMessageHtml(message_text)
+        if (displayLinksEnabled) {
+            const parser = new DOMParser()
+            const DOMMsg = parser.parseFromString(htmlText, "text/xml")
+            const links = DOMMsg.querySelectorAll("a")
+            if (DOMMsg.childNodes.length && links.length) {
+                var isTextToShow = (DOMMsg.childNodes[0].childNodes.length > 1)
+                const ytid = (isVideo(message_text)) ? youtube_id(message_text) : ""
+                if (!isTextToShow && (ytid || isImage(message_text))) {
+                    type = "media"
+                    message_div.append(mediaInteraction(message_id, message_direction, message_text, ytid))
+                }
+            }
+        }
+        if (type !== "media") {
+            type = "text"
+            message_div.append(textInteraction(message_id, message_direction, htmlText))
+        }
+    } else if (message_type === "call" || message_type === "contact") {
+        message_div.append(actionInteraction())
+    } else {
+        const temp = document.createElement("div")
+        temp.innerText = message_type
+        message_div.appendChild(temp)
+    }
+
+    var message_dropdown = buildMessageDropdown(message_id)
+    if (message_type !== "call" && message_type !== "contact") {
+        message_div.appendChild(message_dropdown)
+    } else {
+        var wrapper = message_div.querySelector(".message_wrapper")
+        wrapper.insertBefore(message_dropdown, wrapper.firstChild)
+    }
+
+    if(use_qt) {
+        // Add sender images if necessary (like if the interaction doesn't take the whole width)
+        addSenderImage(message_div, message_type, message_sender_contact_method)
+    }
+
+    return message_div
+}
+
+function addSenderImage(message_div, message_type, message_sender_contact_method) {
+    const need_sender = (message_type === "data_transfer" || message_type === "text")
+    if (need_sender) {
+        var sender_image_cell = message_div.querySelector(".sender_image_cell")
+        if (sender_image_cell) {
+            var message_sender_image = document.createElement("span")
+            var cssSafeStr = message_sender_contact_method.replace(/@/g, "_").replace(/\./g, "_");
+            message_sender_image.setAttribute("class", `sender_image sender_image_${cssSafeStr}`)
+            sender_image_cell.appendChild(message_sender_image)
+        } else {
+            console.warn("can't find sender_image_cell");
+        }
+    }
+}
+
+
+/**
+ * Build a timestamp for passed message object
+ * @param message_object to treat
+ */
+function buildNewTimestamp(message_object) {
+    const message_type = message_object["type"]
+    const message_direction = message_object["direction"]
+    const message_timestamp = message_object["timestamp"]
+
+    const formattedTimestamp = getMessageTimestampText(message_timestamp, true)
+    const date_elt = document.createElement("div")
+
+    date_elt.innerText = formattedTimestamp
+    var typeIsCallOrContact = (message_type === "call" || message_type === "contact")
+    var timestamp_div_classes = ["timestamp", typeIsCallOrContact ? "timestamp_action" : `timestamp_${message_direction}`]
+    date_elt.setAttribute("class", timestamp_div_classes.join(" "))
+    date_elt.setAttribute("message_timestamp", message_timestamp)
+
+    return date_elt
+}
+
+/**
+ * Add a message to the conversation.
+ * @param message_object to treat
+ * @param new_message if it's a new message or if we need to update
+ * @param insert_after if we want the message at the end or the top of the conversation
+ * @param messages_div
+ */
+function addOrUpdateMessage(message_object, new_message, insert_after = true, messages_div) {
+    const message_id = message_object["id"]
+    const message_type = message_object["type"]
+    const message_direction = message_object["direction"]
+    const delivery_status = message_object["delivery_status"]
+
+    var message_div = messages_div.querySelector("#message_" + message_id)
+    if (new_message) {
+        message_div = buildNewMessage(message_object)
+
+        /* Show timestamp if either:
+           - message has type call or contact
+           - or most recently added timestamp in this set is different
+           - or message is the first message in this set */
+
+        var date_elt = buildNewTimestamp(message_object)
+        var timestamp = messages_div.querySelector(".timestamp")
+
+        if (message_type === "call" || message_type === "contact") {
+            message_div.querySelector(".message_wrapper").appendChild(date_elt)
+        } else if (insert_after || !timestamp || timestamp.className !== date_elt.className
+                                || timestamp.innerHTML !== date_elt.innerHTML) {
+            if (use_qt) {
+                message_div.querySelector(".timestamp_cell").appendChild(date_elt)
+                if (message_direction === "out")
+                    message_div.querySelector(".timestamp_cell").setAttribute("class", "timestamp_cell_out")
+            } else {
+                message_div.querySelector(".internal_mes_wrapper").appendChild(date_elt)
+            }
+        }
+
+        var isGenerated = message_type === "call" || message_type === "contact"
+        if (isGenerated) {
+            message_div.classList.add("generated_message")
+        }
+
+        if (insert_after) {
+            var previousMessage = messages_div.lastChild
+            messages_div.appendChild(message_div)
+            computeSequencing(previousMessage, message_div, null, insert_after)
+            if (previousMessage) {
+                previousMessage.classList.remove("last_message")
+            }
+            message_div.classList.add("last_message")
+
+            /* When inserting at the bottom we should also check that the
+               previously sent message does not have the same timestamp.
+               If it's the case, remove it.*/
+            if (message_div.previousSibling) {
+                var previous_timestamp = message_div.previousSibling.querySelector(".timestamp")
+                if (previous_timestamp &&
+                    previous_timestamp.className === date_elt.className &&
+                    previous_timestamp.innerHTML === date_elt.innerHTML &&
+                    !message_div.previousSibling.classList.contains("last_of_sequence")) {
+                    previous_timestamp.parentNode.removeChild(previous_timestamp)
+                }
+            }
+        } else {
+            var nextMessage = messages_div.firstChild
+            messages_div.prepend(message_div)
+            computeSequencing(message_div, nextMessage, null, insert_after)
+        }
+    }
+
+    if (isErrorStatus(delivery_status) && message_direction === "out") {
+        const dropdown = messages_div.querySelector(`.dropdown_${message_id}`)
+        if (!dropdown.querySelector(".retry")) {
+            const retry = document.createElement("div")
+            retry.setAttribute("class", "retry")
+            if (use_qt) {
+                retry.innerHTML = "Retry"
+            } else {
+                retry.innerHTML = i18n.gettext("Retry")
+            }
+            retry.msg_id = message_id
+            retry.onclick = function () {
+                if (use_qt) {
+                    window.jsbridge.retryInteraction(`${this.msg_id}`)
+                } else {
+                    window.prompt(`RETRY_INTERACTION:${this.msg_id}`)
+                }
+            }
+            dropdown.insertBefore(retry, message_div.querySelector(".delete"))
+        }
+    }
+
+    // Update informations if needed
+    if (message_type === "data_transfer")
+        updateFileInteraction(message_div, message_object)
+    if (message_type === "text" && message_direction === "out")
+    // Modify sent status if necessary
+        updateTextInteraction(message_div, delivery_status)
+    if (message_type === "call")
+        updateCallInteraction(message_div, message_object)
+    if (message_type === "contact")
+        updateContactInteraction(message_div, message_object)
+
+    // Clean timestamps
+    updateTimestamps(messages_div)
+}
+
+function getNextInteraction(interaction, includeLazyLoadedBlock = true) {
+    var nextInteraction = interaction.nextSibling
+    if (!nextInteraction && includeLazyLoadedBlock) {
+        var nextBlock = interaction.parentNode.nextElementSibling
+        if (nextBlock) {
+            nextInteraction = nextBlock.firstElementChild
+        }
+    }
+    return nextInteraction
+}
+
+function getPreviousInteraction(interaction, includeLazyLoadedBlock = true) {
+    var previousInteraction = interaction.previousSibling
+    if (!previousInteraction && includeLazyLoadedBlock) {
+        var previousBlock = interaction.parentNode.previousElementSibling
+        if (previousBlock) {
+            previousInteraction = previousBlock.lastElementChild
+        }
+    }
+    return previousInteraction
+}
+
+function isSequenceBreak(firstMessage, secondMessage, insert_after = true) {
+    if (!firstMessage || !secondMessage) {
+        return false
+    }
+    var first_message_direction = firstMessage.classList.contains("message_out") ? "out" : "in"
+    var second_message_direction = secondMessage.classList.contains("message_out") ? "out" : "in"
+    if (second_message_direction != first_message_direction) {
+        return true
+    }
+    var firstMessageIsGenerated = firstMessage.classList.contains("generated_message")
+    var secondMessageIsGenerated = secondMessage.classList.contains("generated_message")
+    if (firstMessageIsGenerated != secondMessageIsGenerated) {
+        return true
+    }
+    if (insert_after) {
+        const internal_message_wrapper = firstMessage.querySelector(".internal_mes_wrapper")
+        if (internal_message_wrapper) {
+            const firstTimestamp = internal_message_wrapper.querySelector(".timestamp")
+            return !!(firstTimestamp) && firstTimestamp.innerHTML !== "just now"
+        }
+        return false
+    } else {
+        const internal_message_wrapper = firstMessage.querySelector(".internal_mes_wrapper")
+        if (internal_message_wrapper) {
+            return !!(internal_message_wrapper.querySelector(".timestamp"))
+        }
+        return false
+    }
+}
+
+function updateSequencing(firstMessage, secondMessage) {
+    if (firstMessage) {
+        if (secondMessage) {
+            var sequence_break = isSequenceBreak(firstMessage, secondMessage, false)
+            if (sequence_break) {
+                if (firstMessage.classList.contains("middle_of_sequence")) {
+                    firstMessage.classList.remove("middle_of_sequence")
+                    firstMessage.classList.add("last_of_sequence")
+                } else if (firstMessage.classList.contains("first_of_sequence")) {
+                    firstMessage.classList.remove("first_of_sequence")
+                    firstMessage.classList.add("single_message")
+                }
+                if (secondMessage.classList.contains("middle_of_sequence")) {
+                    secondMessage.classList.remove("middle_of_sequence")
+                    secondMessage.classList.add("first_of_sequence")
+                } else if (secondMessage.classList.contains("last_of_sequence")) {
+                    secondMessage.classList.remove("last_of_sequence")
+                    secondMessage.classList.add("single_message")
+                }
+            } else {
+                if (firstMessage.classList.contains("last_of_sequence")) {
+                    firstMessage.classList.remove("last_of_sequence")
+                    firstMessage.classList.add("middle_of_sequence")
+                } else if (firstMessage.classList.contains("single_message")) {
+                    firstMessage.classList.remove("single_message")
+                    firstMessage.classList.add("first_of_sequence")
+                }
+                if (secondMessage.classList.contains("first_of_sequence")) {
+                    secondMessage.classList.remove("first_of_sequence")
+                    secondMessage.classList.add("middle_of_sequence")
+                } else if (secondMessage.classList.contains("single_message")) {
+                    secondMessage.classList.remove("single_message")
+                    secondMessage.classList.add("last_of_sequence")
+                }
+            }
+        } else {
+            // this is the last interaction of the conversation
+            if (firstMessage.classList.contains("first_of_sequence")) {
+                firstMessage.classList.remove("first_of_sequence")
+                firstMessage.classList.add("single_message")
+            } else if (firstMessage.classList.contains("middle_of_sequence")) {
+                firstMessage.classList.remove("middle_of_sequence")
+                firstMessage.classList.add("last_of_sequence")
+            }
+        }
+    } else if (secondMessage) {
+        // this is the first interaction of the conversation
+        if (secondMessage.classList.contains("middle_of_sequence")) {
+            secondMessage.classList.remove("middle_of_sequence")
+            secondMessage.classList.add("first_of_sequence")
+        } else if (secondMessage.classList.contains("last_of_sequence")) {
+            secondMessage.classList.remove("last_of_sequence")
+            secondMessage.classList.add("single_message")
+        }
+    }
+}
+
+function computeSequencing(firstMessage, secondMessage, lazyLoadingBlock, insert_after = true) {
+    if (insert_after) {
+        if (secondMessage) {
+            var secondMessageIsGenerated = secondMessage.classList.contains("generated_message")
+            if (firstMessage && !secondMessageIsGenerated) {
+                var firstMessageIsGenerated = firstMessage.classList.contains("generated_message")
+                var sequence_break = isSequenceBreak(firstMessage, secondMessage)
+                if (sequence_break) {
+                    secondMessage.classList.add("single_message")
+                } else {
+                    if (firstMessage.classList.contains("single_message")) {
+                        firstMessage.classList.remove("single_message")
+                        firstMessage.classList.add("first_of_sequence")
+                    } else if (firstMessage.classList.contains("last_of_sequence")) {
+                        firstMessage.classList.remove("last_of_sequence")
+                        firstMessage.classList.add("middle_of_sequence")
+                    }
+                    if (firstMessageIsGenerated) {
+                        secondMessage.classList.add("single_message")
+                    } else {
+                        secondMessage.classList.add("last_of_sequence")
+                    }
+                }
+            } else if (!secondMessageIsGenerated) {
+                secondMessage.classList.add("single_message")
+            }
+        }
+    } else if (firstMessage) {
+        var firstMessageIsGenerated = firstMessage.classList.contains("generated_message")
+        if (secondMessage && !firstMessageIsGenerated) {
+            var secondMessageIsGenerated = secondMessage.classList.contains("generated_message")
+            var sequence_break = isSequenceBreak(firstMessage, secondMessage, false)
+            if (sequence_break) {
+                firstMessage.classList.add("single_message")
+            } else {
+                if (secondMessage.classList.contains("single_message")) {
+                    secondMessage.classList.remove("single_message")
+                    secondMessage.classList.add("last_of_sequence")
+                } else if (secondMessage.classList.contains("first_of_sequence")) {
+                    secondMessage.classList.remove("first_of_sequence")
+                    secondMessage.classList.add("middle_of_sequence")
+                }
+                if (secondMessageIsGenerated) {
+                    firstMessage.classList.add("single_message")
+                } else {
+                    firstMessage.classList.add("first_of_sequence")
+                }
+            }
+        } else if (!firstMessageIsGenerated) {
+            firstMessage.classList.add("single_message")
+        }
+    }
+}
+
+/**
+ * Wrapper for addOrUpdateMessage.
+ *
+ * Add or update a message and make sure the scrollbar position
+ * is refreshed correctly
+ *
+ * @param message_object message to be added
+ */
+/* exported addMessage */
+function addMessage(message_object) {
+    if (!messages.lastChild) {
+        var block_wrapper = document.createElement("div")
+        messages.append(block_wrapper)
+    }
+
+    exec_keeping_scroll_position(addOrUpdateMessage, [message_object, true, undefined, messages.lastChild])
+}
+
+/**
+ * Update a message that was previously added with addMessage and
+ * make sure the scrollbar position is refreshed correctly
+ *
+ * @param message_object message to be updated
+ */
+/* exported updateMessage */
+function updateMessage(message_object) {
+    var message_div = messages.querySelector("#message_" + message_object["id"])
+    exec_keeping_scroll_position(addOrUpdateMessage, [message_object, false, undefined, message_div.parentNode])
+}
+
+/**
+ * Called whenever an image has finished loading. Check lazy loading status
+ * once all images have finished loading.
+ */
+function on_image_load_finished() {
+    imagesLoadingCounter--
+
+    if (!imagesLoadingCounter) {
+        /* This code is executed once all images have been loaded. */
+        check_lazy_loading()
+    }
+}
+
+/**
+ * Make sure at least initialScrollBufferFactor screens of messages are
+ * available in the DOM.
+ */
+function check_lazy_loading() {
+    if (!canLazyLoad)
+        return
+    if (messages.scrollHeight < initialScrollBufferFactor * messages.clientHeight
+        && historyBufferIndex !== historyBuffer.length) {
+        /* Not enough messages loaded, print a new batch. Enable isInitialLoading
+           as reloading a single batch might not be sufficient to fulfill our
+           criteria (we want to be called back again to check on that) */
+        isInitialLoading = true
+        printHistoryPart(messages, 0)
+        isInitialLoading = false
+    }
+}
+
+/**
+ * Display 'scrollBuffer' messages from history in passed div (reverse order).
+ *
+ * @param messages_div that should be modified
+ * @param setMessages if enabled, #messages will be set to the resulting messages
+ *                    div after being modified. If #messages already exists it will
+ *                    be removed and replaced by the new div.
+ * @param fixedAt if setMessages is enabled, maintain scrollbar at the specified
+ *                position (otherwise modifying #messages would result in
+ *                changing the position of the scrollbar)
+ */
+function printHistoryPart(messages_div, fixedAt) {
+    if (historyBufferIndex === historyBuffer.length) {
+        return
+    }
+    /* If first element is a spinner, remove it */
+    if (messages_div.firstChild && messages_div.firstChild.id === "lazyloading-icon") {
+        messages_div.removeChild(messages_div.firstChild)
+    }
+
+    /* Elements are appended to a wrapper div. This div has no style
+       properties, it allows us to add all messages at once to the main
+       messages div. */
+    var block_wrapper = document.createElement("div")
+
+    messages_div.prepend(block_wrapper)
+
+    for (var i = 0; i < scrollBuffer && historyBufferIndex < historyBuffer.length; ++historyBufferIndex && ++i) {
+        // TODO on-screen messages should be removed from the buffer
+        addOrUpdateMessage(historyBuffer[historyBuffer.length - 1 - historyBufferIndex], true, false, block_wrapper)
+    }
+
+    var lastMessage = block_wrapper.lastChild
+
+    updateSequencing(lastMessage, getNextInteraction(lastMessage))
+
+    var absoluteLastMessage = messages_div.lastChild.lastChild
+    if (absoluteLastMessage) {
+        absoluteLastMessage.classList.add("last_message")
+    }
+
+    /* Add ellipsis (...) at the top if there are still messages to load */
+    if (historyBufferIndex < historyBuffer.length) {
+        var llicon = document.createElement("span")
+        llicon.id = "lazyloading-icon"
+        llicon.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"#888888\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z\"/></svg>"
+        messages_div.prepend(llicon)
+    }
+
+    if (fixedAt !== undefined) {
+        /* update scrollbar position to take text-message -related
+           scrollHeight changes in account (not necessary to wait
+           for DOM redisplay in this case). Changes due to image
+           messages are handled in their onLoad callbacks. */
+        back_to_scroll(fixedAt)
+        /* schedule a scrollbar position update for changes which
+           are neither handled by the previous call nor by onLoad
+           callbacks. This call is necessary but not sufficient,
+           dropping the previous call would result in visual
+           glitches during initial load. */
+        setTimeout(function () {back_to_scroll(fixedAt)}, 0)
+    }
+
+    if (!imagesLoadingCounter) {
+        setTimeout(check_lazy_loading, 0)
+    }
+}
+
+function hideMessagesDiv()
+{
+    if (!messages.classList.contains("fade")) {
+        messages.classList.add("fade")
+    }
+}
+
+function showMessagesDiv()
+{
+    if (messages.classList.contains("fade")) {
+        messages.classList.remove("fade")
+    }
+}
+
+/**
+ * Set history buffer, initialize messages div and display a first batch
+ * of messages.
+ *
+ * Make sure that enough messages are displayed to fill initialScrollBufferFactor
+ * screens of messages (if enough messages are present in the conversation)
+ *
+ * @param messages_array should contain history to be printed
+ */
+/* exported printHistory */
+function printHistory(messages_array)
+{
+    historyBuffer = messages_array
+    historyBufferIndex = 0
+
+    isInitialLoading = true
+    printHistoryPart(messages, 0)
+    isInitialLoading = false
+
+    canLazyLoad = true
+
+    if (use_qt) {
+        window.jsbridge.emitMessagesLoaded()
+    } else {
+        window.prompt("MESSAGES_LOADED")
+    }
+}
+
+/**
+ * Set the image for a given sender
+ * set_sender_image object should contain the following keys:
+ * - sender: the name of the sender
+ * - sender_image: base64 png encoding of the sender image
+ *
+ * @param set_sender_image_object sender image object as previously described
+ */
+/* exported setSenderImage */
+function setSenderImage(set_sender_image_object)
+{
+    if (use_qt) {
+        var sender_contact_method = set_sender_image_object["sender_contact_method"].replace(/@/g, "_").replace(/\./g, "_"),
+            sender_image = set_sender_image_object["sender_image"],
+            sender_image_id = "sender_image_" + sender_contact_method,
+            invite_sender_image_id = "invite_sender_image_" + sender_contact_method,
+            currentSenderImage = document.getElementById(sender_image_id), // Remove the currently set sender image
+            style, invite_style
+
+    } else {
+        var sender_contact_method = set_sender_image_object["sender_contact_method"],
+            sender_image = set_sender_image_object["sender_image"],
+            sender_image_id = "sender_image_" + sender_contact_method,
+            invite_sender_image_id = "invite_sender_image_" + sender_contact_method,
+            currentSenderImage = document.getElementById(sender_image_id), // Remove the currently set sender image
+            style, invite_style
+    }
+
+    if (currentSenderImage) {
+        currentSenderImage.parentNode.removeChild(currentSenderImage)
+    }
+
+    // Create a new style element
+    style = document.createElement("style")
+
+    style.type = "text/css"
+    style.id = sender_image_id
+    style.innerHTML = "." + sender_image_id + " {content: url(data:image/png;base64," + sender_image + ");height: 2.25em;width: 2.25em;}"
+    document.head.appendChild(style)
+
+    invite_style = document.createElement("style")
+
+    invite_style.type = "text/css"
+    invite_style.id = invite_sender_image_id
+    invite_style.innerHTML = "." + invite_sender_image_id + " {content: url(data:image/png;base64," + sender_image + ");height: 48px;width: 48px;}"
+    document.head.appendChild(invite_style)
+}
+
+/**
+ * Copy Mouse Selected Text and return it
+ */
+function copy_text_selected() {
+    var selObj = document.getSelection()
+    var selectedText = selObj.toString()
+    return selectedText
+}
+
+/**
+ * Check if text is selected by mouse
+ */
+function isTextSelected() {
+
+    var selObj = document.getSelection()
+    var selectedText = selObj.toString()
+
+    if (selectedText.length != 0) {
+        return true
+    }
+    return false
+}
+
+/**
+ * add file (local file) to message area
+ */
+function addFile_path(path, name, size) {
+    var html = "<div class='file_wrapper' data-path='" + path + "'>" +
+        "<svg class='svg-icon' viewBox='0 0 20 20'>" +
+        "<path fill = 'none' d = 'M17.222,5.041l-4.443-4.414c-0.152-0.151-0.356-0.235-0.571-0.235h-8.86c-0.444,0-0.807,0.361-0.807,0.808v17.602c0,0.448,0.363,0.808,0.807,0.808h13.303c0.448,0,0.808-0.36,0.808-0.808V5.615C17.459,5.399,17.373,5.192,17.222,5.041zM15.843,17.993H4.157V2.007h7.72l3.966,3.942V17.993z' ></path>" +
+        "<path fill='none' d='M5.112,7.3c0,0.446,0.363,0.808,0.808,0.808h8.077c0.445,0,0.808-0.361,0.808-0.808c0-0.447-0.363-0.808-0.808-0.808H5.92C5.475,6.492,5.112,6.853,5.112,7.3z'></path>" +
+        "<path fill='none' d='M5.92,5.331h4.342c0.445,0,0.808-0.361,0.808-0.808c0-0.446-0.363-0.808-0.808-0.808H5.92c-0.444,0-0.808,0.361-0.808,0.808C5.112,4.97,5.475,5.331,5.92,5.331z'></path>" +
+        "<path fill='none' d='M13.997,9.218H5.92c-0.444,0-0.808,0.361-0.808,0.808c0,0.446,0.363,0.808,0.808,0.808h8.077c0.445,0,0.808-0.361,0.808-0.808C14.805,9.58,14.442,9.218,13.997,9.218z'></path>" +
+        "<path fill='none' d='M13.997,11.944H5.92c-0.444,0-0.808,0.361-0.808,0.808c0,0.446,0.363,0.808,0.808,0.808h8.077c0.445,0,0.808-0.361,0.808-0.808C14.805,12.306,14.442,11.944,13.997,11.944z'></path>" +
+        "<path fill='none' d='M13.997,14.67H5.92c-0.444,0-0.808,0.361-0.808,0.808c0,0.447,0.363,0.808,0.808,0.808h8.077c0.445,0,0.808-0.361,0.808-0.808C14.805,15.032,14.442,14.67,13.997,14.67z'></path>" +
+        "</svg >" +
+        "<div class='fileinfo'>" +
+        "<p>" + name + "</p>" +
+        "<p>" + size + "</p>" +
+        "</div >" +
+        "<button class='btn' onclick='remove(this)'>X</button>" +
+        "</div >"
+    // At first, visiblity can empty
+    if (sendContainer.style.visibility.length == 0 || sendContainer.style.visibility == "hidden") {
+        grow_send_container()
+        sendContainer.style.visibility = "visible"
+    }
+    //add html here since display is set to flex, image will change accordingly
+    sendContainer.innerHTML += html
+}
+
+/**
+ * add image (base64 array) to message area
+ */
+function addImage_base64(base64) {
+
+    var html = "<div class='img_wrapper'>" +
+        "<img src='data:image/png;base64," + base64 + "'/>" +
+        "<button class='btn' onclick='remove(this)'>X</button>" +
+        "</div >"
+    // At first, visiblity can empty
+    if (sendContainer.style.visibility.length == 0 || sendContainer.style.visibility == "hidden") {
+        grow_send_container()
+        sendContainer.style.visibility = "visible"
+    }
+    //add html here since display is set to flex, image will change accordingly
+    sendContainer.innerHTML += html
+}
+
+/**
+ * add image (image path) to message area
+ */
+function addImage_path(path) {
+
+    var html = "<div class='img_wrapper'>" +
+        "<img src='" + path +"'/>" +
+        "<button class='btn' onclick='remove(this)'>X</button>" +
+        "</div >"
+    // At first, visiblity can empty
+    if (sendContainer.style.visibility.length == 0 || sendContainer.style.visibility == "hidden") {
+        grow_send_container()
+        sendContainer.style.visibility = "visible"
+    }
+    //add html here since display is set to flex, image will change accordingly
+    sendContainer.innerHTML += html
+}
+
+/**
+ * This function adjusts the body paddings so that that the file_image_send_container doesn't
+ * overlap messages when it grows.
+ */
+/* exported grow_send_container */
+function grow_send_container() {
+    exec_keeping_scroll_position(function () {
+        var msgbar_size = window.getComputedStyle(document.body).getPropertyValue("--messagebar-size");
+        document.body.style.paddingBottom = (parseInt(msgbar_size) + 158).toString() + "px";
+        //6em
+    }, [])
+}
+
+/**
+ * This function adjusts the body paddings so that that the file_image_send_container will hide
+ * and recover padding bottom
+ */
+/* exported grow_send_container */
+function reduce_send_container() {
+    exec_keeping_scroll_position(function () {
+        document.body.style.paddingBottom = (parseInt(document.body.style.paddingBottom) - 158).toString() + "px";
+        //6em
+    }, [])
+}
+
+// Remove current cancel button division  and hide the sendContainer
+function remove(e) {
+    e.parentNode.parentNode.removeChild(e.parentNode)
+    if (sendContainer.innerHTML.length == 0) {
+        reduce_send_container()
+        sendContainer.style.visibility = "hidden"
+    }
+}
+
+
+// It's called in qt qwebengine
+function pasteKeyDetected(e) {
+    e.preventDefault()
+    window.jsbridge.emitPasteKeyDetected()
+}
+
+// Set the curser to a target position
+function setCaretPosition(elem, caretPos) {
+    var range
+
+    if (elem.createTextRange) {
+        range = elem.createTextRange()
+        range.move("character", caretPos)
+        range.select()
+    } else {
+        elem.focus()
+        if (elem.selectionStart !== undefined) {
+            elem.setSelectionRange(caretPos, caretPos)
+        }
+    }
+}
+
+function replaceText(text) {
+    var input = messageBarInput
+    var currentContent = input.value
+    var start = input.selectionStart
+    var end = input.selectionEnd
+    var output = [currentContent.slice(0, start), text, currentContent.slice(end)].join("")
+    input.value = output
+    setCaretPosition(input, start + text.length)
+}
diff --git a/src/web-chatview/jed.js b/src/web-chatview/jed.js
new file mode 100644
index 0000000000000000000000000000000000000000..bda163beff3232c68093b675eed9f18ffe15f265
--- /dev/null
+++ b/src/web-chatview/jed.js
@@ -0,0 +1,1033 @@
+/**
+ * @preserve jed.js https://github.com/SlexAxton/Jed
+ */
+/*
+-----------
+A gettext compatible i18n library for modern JavaScript Applications
+
+by Alex Sexton - AlexSexton [at] gmail - @SlexAxton
+
+MIT License
+
+A jQuery Foundation project - requires CLA to contribute -
+https://contribute.jquery.org/CLA/
+
+
+
+Jed offers the entire applicable GNU gettext spec'd set of
+functions, but also offers some nicer wrappers around them.
+The api for gettext was written for a language with no function
+overloading, so Jed allows a little more of that.
+
+Many thanks to Joshua I. Miller - unrtst@cpan.org - who wrote
+gettext.js back in 2008. I was able to vet a lot of my ideas
+against his. I also made sure Jed passed against his tests
+in order to offer easy upgrades -- jsgettext.berlios.de
+*/
+(function (root, undef) {
+
+  // Set up some underscore-style functions, if you already have
+  // underscore, feel free to delete this section, and use it
+  // directly, however, the amount of functions used doesn't
+  // warrant having underscore as a full dependency.
+  // Underscore 1.3.0 was used to port and is licensed
+  // under the MIT License by Jeremy Ashkenas.
+  var ArrayProto    = Array.prototype,
+      ObjProto      = Object.prototype,
+      slice         = ArrayProto.slice,
+      hasOwnProp    = ObjProto.hasOwnProperty,
+      nativeForEach = ArrayProto.forEach,
+      breaker       = {};
+
+  // We're not using the OOP style _ so we don't need the
+  // extra level of indirection. This still means that you
+  // sub out for real `_` though.
+  var _ = {
+    forEach : function( obj, iterator, context ) {
+      var i, l, key;
+      if ( obj === null ) {
+        return;
+      }
+
+      if ( nativeForEach && obj.forEach === nativeForEach ) {
+        obj.forEach( iterator, context );
+      }
+      else if ( obj.length === +obj.length ) {
+        for ( i = 0, l = obj.length; i < l; i++ ) {
+          if ( i in obj && iterator.call( context, obj[i], i, obj ) === breaker ) {
+            return;
+          }
+        }
+      }
+      else {
+        for ( key in obj) {
+          if ( hasOwnProp.call( obj, key ) ) {
+            if ( iterator.call (context, obj[key], key, obj ) === breaker ) {
+              return;
+            }
+          }
+        }
+      }
+    },
+    extend : function( obj ) {
+      this.forEach( slice.call( arguments, 1 ), function ( source ) {
+        for ( var prop in source ) {
+          obj[prop] = source[prop];
+        }
+      });
+      return obj;
+    }
+  };
+  // END Miniature underscore impl
+
+  // Jed is a constructor function
+  var Jed = function ( options ) {
+    // Some minimal defaults
+    this.defaults = {
+      "locale_data" : {
+        "messages" : {
+          "" : {
+            "domain"       : "messages",
+            "lang"         : "en",
+            "plural_forms" : "nplurals=2; plural=(n != 1);"
+          }
+          // There are no default keys, though
+        }
+      },
+      // The default domain if one is missing
+      "domain" : "messages",
+      // enable debug mode to log untranslated strings to the console
+      "debug" : false
+    };
+
+    // Mix in the sent options with the default options
+    this.options = _.extend( {}, this.defaults, options );
+    this.textdomain( this.options.domain );
+
+    if ( options.domain && ! this.options.locale_data[ this.options.domain ] ) {
+      throw new Error('Text domain set to non-existent domain: `' + options.domain + '`');
+    }
+  };
+
+  // The gettext spec sets this character as the default
+  // delimiter for context lookups.
+  // e.g.: context\u0004key
+  // If your translation company uses something different,
+  // just change this at any time and it will use that instead.
+  Jed.context_delimiter = String.fromCharCode( 4 );
+
+  function getPluralFormFunc ( plural_form_string ) {
+    return Jed.PF.compile( plural_form_string || "nplurals=2; plural=(n != 1);");
+  }
+
+  function Chain( key, i18n ){
+    this._key = key;
+    this._i18n = i18n;
+  }
+
+  // Create a chainable api for adding args prettily
+  _.extend( Chain.prototype, {
+    onDomain : function ( domain ) {
+      this._domain = domain;
+      return this;
+    },
+    withContext : function ( context ) {
+      this._context = context;
+      return this;
+    },
+    ifPlural : function ( num, pkey ) {
+      this._val = num;
+      this._pkey = pkey;
+      return this;
+    },
+    fetch : function ( sArr ) {
+      if ( {}.toString.call( sArr ) != '[object Array]' ) {
+        sArr = [].slice.call(arguments, 0);
+      }
+      return ( sArr && sArr.length ? Jed.sprintf : function(x){ return x; } )(
+        this._i18n.dcnpgettext(this._domain, this._context, this._key, this._pkey, this._val),
+        sArr
+      );
+    }
+  });
+
+  // Add functions to the Jed prototype.
+  // These will be the functions on the object that's returned
+  // from creating a `new Jed()`
+  // These seem redundant, but they gzip pretty well.
+  _.extend( Jed.prototype, {
+    // The sexier api start point
+    translate : function ( key ) {
+      return new Chain( key, this );
+    },
+
+    textdomain : function ( domain ) {
+      if ( ! domain ) {
+        return this._textdomain;
+      }
+      this._textdomain = domain;
+    },
+
+    gettext : function ( key ) {
+      return this.dcnpgettext.call( this, undef, undef, key );
+    },
+
+    dgettext : function ( domain, key ) {
+     return this.dcnpgettext.call( this, domain, undef, key );
+    },
+
+    dcgettext : function ( domain , key /*, category */ ) {
+      // Ignores the category anyways
+      return this.dcnpgettext.call( this, domain, undef, key );
+    },
+
+    ngettext : function ( skey, pkey, val ) {
+      return this.dcnpgettext.call( this, undef, undef, skey, pkey, val );
+    },
+
+    dngettext : function ( domain, skey, pkey, val ) {
+      return this.dcnpgettext.call( this, domain, undef, skey, pkey, val );
+    },
+
+    dcngettext : function ( domain, skey, pkey, val/*, category */) {
+      return this.dcnpgettext.call( this, domain, undef, skey, pkey, val );
+    },
+
+    pgettext : function ( context, key ) {
+      return this.dcnpgettext.call( this, undef, context, key );
+    },
+
+    dpgettext : function ( domain, context, key ) {
+      return this.dcnpgettext.call( this, domain, context, key );
+    },
+
+    dcpgettext : function ( domain, context, key/*, category */) {
+      return this.dcnpgettext.call( this, domain, context, key );
+    },
+
+    npgettext : function ( context, skey, pkey, val ) {
+      return this.dcnpgettext.call( this, undef, context, skey, pkey, val );
+    },
+
+    dnpgettext : function ( domain, context, skey, pkey, val ) {
+      return this.dcnpgettext.call( this, domain, context, skey, pkey, val );
+    },
+
+    // The most fully qualified gettext function. It has every option.
+    // Since it has every option, we can use it from every other method.
+    // This is the bread and butter.
+    // Technically there should be one more argument in this function for 'Category',
+    // but since we never use it, we might as well not waste the bytes to define it.
+    dcnpgettext : function ( domain, context, singular_key, plural_key, val ) {
+      // Set some defaults
+
+      plural_key = plural_key || singular_key;
+
+      // Use the global domain default if one
+      // isn't explicitly passed in
+      domain = domain || this._textdomain;
+
+      var fallback;
+
+      // Handle special cases
+
+      // No options found
+      if ( ! this.options ) {
+        // There's likely something wrong, but we'll return the correct key for english
+        // We do this by instantiating a brand new Jed instance with the default set
+        // for everything that could be broken.
+        fallback = new Jed();
+        return fallback.dcnpgettext.call( fallback, undefined, undefined, singular_key, plural_key, val );
+      }
+
+      // No translation data provided
+      if ( ! this.options.locale_data ) {
+        throw new Error('No locale data provided.');
+      }
+
+      if ( ! this.options.locale_data[ domain ] ) {
+        throw new Error('Domain `' + domain + '` was not found.');
+      }
+
+      if ( ! this.options.locale_data[ domain ][ "" ] ) {
+        throw new Error('No locale meta information provided.');
+      }
+
+      // Make sure we have a truthy key. Otherwise we might start looking
+      // into the empty string key, which is the options for the locale
+      // data.
+      if ( ! singular_key ) {
+        throw new Error('No translation key found.');
+      }
+
+      var key  = context ? context + Jed.context_delimiter + singular_key : singular_key,
+          locale_data = this.options.locale_data,
+          dict = locale_data[ domain ],
+          defaultConf = (locale_data.messages || this.defaults.locale_data.messages)[""],
+          pluralForms = dict[""].plural_forms || dict[""]["Plural-Forms"] || dict[""]["plural-forms"] || defaultConf.plural_forms || defaultConf["Plural-Forms"] || defaultConf["plural-forms"],
+          val_list,
+          res;
+
+      var val_idx;
+      if (val === undefined) {
+        // No value passed in; assume singular key lookup.
+        val_idx = 0;
+
+      } else {
+        // Value has been passed in; use plural-forms calculations.
+
+        // Handle invalid numbers, but try casting strings for good measure
+        if ( typeof val != 'number' ) {
+          val = parseInt( val, 10 );
+
+          if ( isNaN( val ) ) {
+            throw new Error('The number that was passed in is not a number.');
+          }
+        }
+
+        val_idx = getPluralFormFunc(pluralForms)(val);
+      }
+
+      // Throw an error if a domain isn't found
+      if ( ! dict ) {
+        throw new Error('No domain named `' + domain + '` could be found.');
+      }
+
+      val_list = dict[ key ];
+
+      // If there is no match, then revert back to
+      // english style singular/plural with the keys passed in.
+      if ( ! val_list || val_idx > val_list.length ) {
+        if (this.options.missing_key_callback) {
+          this.options.missing_key_callback(key, domain);
+        }
+        res = [ singular_key, plural_key ];
+
+        // collect untranslated strings
+        if (this.options.debug===true) {
+          console.log(res[ getPluralFormFunc(pluralForms)( val ) ]);
+        }
+        return res[ getPluralFormFunc()( val ) ];
+      }
+
+      res = val_list[ val_idx ];
+
+      // This includes empty strings on purpose
+      if ( ! res  ) {
+        res = [ singular_key, plural_key ];
+        return res[ getPluralFormFunc()( val ) ];
+      }
+      return res;
+    }
+  });
+
+
+  // We add in sprintf capabilities for post translation value interolation
+  // This is not internally used, so you can remove it if you have this
+  // available somewhere else, or want to use a different system.
+
+  // We _slightly_ modify the normal sprintf behavior to more gracefully handle
+  // undefined values.
+
+  /**
+   sprintf() for JavaScript 0.7-beta1
+   http://www.diveintojavascript.com/projects/javascript-sprintf
+
+   Copyright (c) Alexandru Marasteanu <alexaholic [at) gmail (dot] com>
+   All rights reserved.
+
+   Redistribution and use in source and binary forms, with or without
+   modification, are permitted provided that the following conditions are met:
+       * Redistributions of source code must retain the above copyright
+         notice, this list of conditions and the following disclaimer.
+       * Redistributions in binary form must reproduce the above copyright
+         notice, this list of conditions and the following disclaimer in the
+         documentation and/or other materials provided with the distribution.
+       * Neither the name of sprintf() for JavaScript nor the
+         names of its contributors may be used to endorse or promote products
+         derived from this software without specific prior written permission.
+
+   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+   ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+   WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+   DISCLAIMED. IN NO EVENT SHALL Alexandru Marasteanu BE LIABLE FOR ANY
+   DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+   (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+   LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+   ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+   (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+   SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+  */
+  var sprintf = (function() {
+    function get_type(variable) {
+      return Object.prototype.toString.call(variable).slice(8, -1).toLowerCase();
+    }
+    function str_repeat(input, multiplier) {
+      for (var output = []; multiplier > 0; output[--multiplier] = input) {/* do nothing */}
+      return output.join('');
+    }
+
+    var str_format = function() {
+      if (!str_format.cache.hasOwnProperty(arguments[0])) {
+        str_format.cache[arguments[0]] = str_format.parse(arguments[0]);
+      }
+      return str_format.format.call(null, str_format.cache[arguments[0]], arguments);
+    };
+
+    str_format.format = function(parse_tree, argv) {
+      var cursor = 1, tree_length = parse_tree.length, node_type = '', arg, output = [], i, k, match, pad, pad_character, pad_length;
+      for (i = 0; i < tree_length; i++) {
+        node_type = get_type(parse_tree[i]);
+        if (node_type === 'string') {
+          output.push(parse_tree[i]);
+        }
+        else if (node_type === 'array') {
+          match = parse_tree[i]; // convenience purposes only
+          if (match[2]) { // keyword argument
+            arg = argv[cursor];
+            for (k = 0; k < match[2].length; k++) {
+              if (!arg.hasOwnProperty(match[2][k])) {
+                throw(sprintf('[sprintf] property "%s" does not exist', match[2][k]));
+              }
+              arg = arg[match[2][k]];
+            }
+          }
+          else if (match[1]) { // positional argument (explicit)
+            arg = argv[match[1]];
+          }
+          else { // positional argument (implicit)
+            arg = argv[cursor++];
+          }
+
+          if (/[^s]/.test(match[8]) && (get_type(arg) != 'number')) {
+            throw(sprintf('[sprintf] expecting number but found %s', get_type(arg)));
+          }
+
+          // Jed EDIT
+          if ( typeof arg == 'undefined' || arg === null ) {
+            arg = '';
+          }
+          // Jed EDIT
+
+          switch (match[8]) {
+            case 'b': arg = arg.toString(2); break;
+            case 'c': arg = String.fromCharCode(arg); break;
+            case 'd': arg = parseInt(arg, 10); break;
+            case 'e': arg = match[7] ? arg.toExponential(match[7]) : arg.toExponential(); break;
+            case 'f': arg = match[7] ? parseFloat(arg).toFixed(match[7]) : parseFloat(arg); break;
+            case 'o': arg = arg.toString(8); break;
+            case 's': arg = ((arg = String(arg)) && match[7] ? arg.substring(0, match[7]) : arg); break;
+            case 'u': arg = Math.abs(arg); break;
+            case 'x': arg = arg.toString(16); break;
+            case 'X': arg = arg.toString(16).toUpperCase(); break;
+          }
+          arg = (/[def]/.test(match[8]) && match[3] && arg >= 0 ? '+'+ arg : arg);
+          pad_character = match[4] ? match[4] == '0' ? '0' : match[4].charAt(1) : ' ';
+          pad_length = match[6] - String(arg).length;
+          pad = match[6] ? str_repeat(pad_character, pad_length) : '';
+          output.push(match[5] ? arg + pad : pad + arg);
+        }
+      }
+      return output.join('');
+    };
+
+    str_format.cache = {};
+
+    str_format.parse = function(fmt) {
+      var _fmt = fmt, match = [], parse_tree = [], arg_names = 0;
+      while (_fmt) {
+        if ((match = /^[^\x25]+/.exec(_fmt)) !== null) {
+          parse_tree.push(match[0]);
+        }
+        else if ((match = /^\x25{2}/.exec(_fmt)) !== null) {
+          parse_tree.push('%');
+        }
+        else if ((match = /^\x25(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosuxX])/.exec(_fmt)) !== null) {
+          if (match[2]) {
+            arg_names |= 1;
+            var field_list = [], replacement_field = match[2], field_match = [];
+            if ((field_match = /^([a-z_][a-z_\d]*)/i.exec(replacement_field)) !== null) {
+              field_list.push(field_match[1]);
+              while ((replacement_field = replacement_field.substring(field_match[0].length)) !== '') {
+                if ((field_match = /^\.([a-z_][a-z_\d]*)/i.exec(replacement_field)) !== null) {
+                  field_list.push(field_match[1]);
+                }
+                else if ((field_match = /^\[(\d+)\]/.exec(replacement_field)) !== null) {
+                  field_list.push(field_match[1]);
+                }
+                else {
+                  throw('[sprintf] huh?');
+                }
+              }
+            }
+            else {
+              throw('[sprintf] huh?');
+            }
+            match[2] = field_list;
+          }
+          else {
+            arg_names |= 2;
+          }
+          if (arg_names === 3) {
+            throw('[sprintf] mixing positional and named placeholders is not (yet) supported');
+          }
+          parse_tree.push(match);
+        }
+        else {
+          throw('[sprintf] huh?');
+        }
+        _fmt = _fmt.substring(match[0].length);
+      }
+      return parse_tree;
+    };
+
+    return str_format;
+  })();
+
+  var vsprintf = function(fmt, argv) {
+    argv.unshift(fmt);
+    return sprintf.apply(null, argv);
+  };
+
+  Jed.parse_plural = function ( plural_forms, n ) {
+    plural_forms = plural_forms.replace(/n/g, n);
+    return Jed.parse_expression(plural_forms);
+  };
+
+  Jed.sprintf = function ( fmt, args ) {
+    if ( {}.toString.call( args ) == '[object Array]' ) {
+      return vsprintf( fmt, [].slice.call(args) );
+    }
+    return sprintf.apply(this, [].slice.call(arguments) );
+  };
+
+  Jed.prototype.sprintf = function () {
+    return Jed.sprintf.apply(this, arguments);
+  };
+  // END sprintf Implementation
+
+  // Start the Plural forms section
+  // This is a full plural form expression parser. It is used to avoid
+  // running 'eval' or 'new Function' directly against the plural
+  // forms.
+  //
+  // This can be important if you get translations done through a 3rd
+  // party vendor. I encourage you to use this instead, however, I
+  // also will provide a 'precompiler' that you can use at build time
+  // to output valid/safe function representations of the plural form
+  // expressions. This means you can build this code out for the most
+  // part.
+  Jed.PF = {};
+
+  Jed.PF.parse = function ( p ) {
+    var plural_str = Jed.PF.extractPluralExpr( p );
+    return Jed.PF.parser.parse.call(Jed.PF.parser, plural_str);
+  };
+
+  Jed.PF.compile = function ( p ) {
+    // Handle trues and falses as 0 and 1
+    function imply( val ) {
+      return (val === true ? 1 : val ? val : 0);
+    }
+
+    var ast = Jed.PF.parse( p );
+    return function ( n ) {
+      return imply( Jed.PF.interpreter( ast )( n ) );
+    };
+  };
+
+  Jed.PF.interpreter = function ( ast ) {
+    return function ( n ) {
+      var res;
+      switch ( ast.type ) {
+        case 'GROUP':
+          return Jed.PF.interpreter( ast.expr )( n );
+        case 'TERNARY':
+          if ( Jed.PF.interpreter( ast.expr )( n ) ) {
+            return Jed.PF.interpreter( ast.truthy )( n );
+          }
+          return Jed.PF.interpreter( ast.falsey )( n );
+        case 'OR':
+          return Jed.PF.interpreter( ast.left )( n ) || Jed.PF.interpreter( ast.right )( n );
+        case 'AND':
+          return Jed.PF.interpreter( ast.left )( n ) && Jed.PF.interpreter( ast.right )( n );
+        case 'LT':
+          return Jed.PF.interpreter( ast.left )( n ) < Jed.PF.interpreter( ast.right )( n );
+        case 'GT':
+          return Jed.PF.interpreter( ast.left )( n ) > Jed.PF.interpreter( ast.right )( n );
+        case 'LTE':
+          return Jed.PF.interpreter( ast.left )( n ) <= Jed.PF.interpreter( ast.right )( n );
+        case 'GTE':
+          return Jed.PF.interpreter( ast.left )( n ) >= Jed.PF.interpreter( ast.right )( n );
+        case 'EQ':
+          return Jed.PF.interpreter( ast.left )( n ) == Jed.PF.interpreter( ast.right )( n );
+        case 'NEQ':
+          return Jed.PF.interpreter( ast.left )( n ) != Jed.PF.interpreter( ast.right )( n );
+        case 'MOD':
+          return Jed.PF.interpreter( ast.left )( n ) % Jed.PF.interpreter( ast.right )( n );
+        case 'VAR':
+          return n;
+        case 'NUM':
+          return ast.val;
+        default:
+          throw new Error("Invalid Token found.");
+      }
+    };
+  };
+
+  Jed.PF.regexps = {
+    TRIM_BEG: /^\s\s*/,
+    TRIM_END: /\s\s*$/,
+    HAS_SEMICOLON: /;\s*$/,
+    NPLURALS: /nplurals\=(\d+);/,
+    PLURAL: /plural\=(.*);/
+  };
+
+  Jed.PF.extractPluralExpr = function ( p ) {
+    // trim first
+    p = p.replace(Jed.PF.regexps.TRIM_BEG, '').replace(Jed.PF.regexps.TRIM_END, '');
+
+    if (! Jed.PF.regexps.HAS_SEMICOLON.test(p)) {
+      p = p.concat(';');
+    }
+
+    var nplurals_matches = p.match( Jed.PF.regexps.NPLURALS ),
+        res = {},
+        plural_matches;
+
+    // Find the nplurals number
+    if ( nplurals_matches.length > 1 ) {
+      res.nplurals = nplurals_matches[1];
+    }
+    else {
+      throw new Error('nplurals not found in plural_forms string: ' + p );
+    }
+
+    // remove that data to get to the formula
+    p = p.replace( Jed.PF.regexps.NPLURALS, "" );
+    plural_matches = p.match( Jed.PF.regexps.PLURAL );
+
+    if (!( plural_matches && plural_matches.length > 1 ) ) {
+      throw new Error('`plural` expression not found: ' + p);
+    }
+    return plural_matches[ 1 ];
+  };
+
+  /* Jison generated parser */
+  Jed.PF.parser = (function(){
+
+var parser = {trace: function trace() { },
+yy: {},
+symbols_: {"error":2,"expressions":3,"e":4,"EOF":5,"?":6,":":7,"||":8,"&&":9,"<":10,"<=":11,">":12,">=":13,"!=":14,"==":15,"%":16,"(":17,")":18,"n":19,"NUMBER":20,"$accept":0,"$end":1},
+terminals_: {2:"error",5:"EOF",6:"?",7:":",8:"||",9:"&&",10:"<",11:"<=",12:">",13:">=",14:"!=",15:"==",16:"%",17:"(",18:")",19:"n",20:"NUMBER"},
+productions_: [0,[3,2],[4,5],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,1],[4,1]],
+performAction: function anonymous(yytext,yyleng,yylineno,yy,yystate,$$,_$) {
+
+var $0 = $$.length - 1;
+switch (yystate) {
+case 1: return { type : 'GROUP', expr: $$[$0-1] };
+break;
+case 2:this.$ = { type: 'TERNARY', expr: $$[$0-4], truthy : $$[$0-2], falsey: $$[$0] };
+break;
+case 3:this.$ = { type: "OR", left: $$[$0-2], right: $$[$0] };
+break;
+case 4:this.$ = { type: "AND", left: $$[$0-2], right: $$[$0] };
+break;
+case 5:this.$ = { type: 'LT', left: $$[$0-2], right: $$[$0] };
+break;
+case 6:this.$ = { type: 'LTE', left: $$[$0-2], right: $$[$0] };
+break;
+case 7:this.$ = { type: 'GT', left: $$[$0-2], right: $$[$0] };
+break;
+case 8:this.$ = { type: 'GTE', left: $$[$0-2], right: $$[$0] };
+break;
+case 9:this.$ = { type: 'NEQ', left: $$[$0-2], right: $$[$0] };
+break;
+case 10:this.$ = { type: 'EQ', left: $$[$0-2], right: $$[$0] };
+break;
+case 11:this.$ = { type: 'MOD', left: $$[$0-2], right: $$[$0] };
+break;
+case 12:this.$ = { type: 'GROUP', expr: $$[$0-1] };
+break;
+case 13:this.$ = { type: 'VAR' };
+break;
+case 14:this.$ = { type: 'NUM', val: Number(yytext) };
+break;
+}
+},
+table: [{3:1,4:2,17:[1,3],19:[1,4],20:[1,5]},{1:[3]},{5:[1,6],6:[1,7],8:[1,8],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16]},{4:17,17:[1,3],19:[1,4],20:[1,5]},{5:[2,13],6:[2,13],7:[2,13],8:[2,13],9:[2,13],10:[2,13],11:[2,13],12:[2,13],13:[2,13],14:[2,13],15:[2,13],16:[2,13],18:[2,13]},{5:[2,14],6:[2,14],7:[2,14],8:[2,14],9:[2,14],10:[2,14],11:[2,14],12:[2,14],13:[2,14],14:[2,14],15:[2,14],16:[2,14],18:[2,14]},{1:[2,1]},{4:18,17:[1,3],19:[1,4],20:[1,5]},{4:19,17:[1,3],19:[1,4],20:[1,5]},{4:20,17:[1,3],19:[1,4],20:[1,5]},{4:21,17:[1,3],19:[1,4],20:[1,5]},{4:22,17:[1,3],19:[1,4],20:[1,5]},{4:23,17:[1,3],19:[1,4],20:[1,5]},{4:24,17:[1,3],19:[1,4],20:[1,5]},{4:25,17:[1,3],19:[1,4],20:[1,5]},{4:26,17:[1,3],19:[1,4],20:[1,5]},{4:27,17:[1,3],19:[1,4],20:[1,5]},{6:[1,7],8:[1,8],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16],18:[1,28]},{6:[1,7],7:[1,29],8:[1,8],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16]},{5:[2,3],6:[2,3],7:[2,3],8:[2,3],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16],18:[2,3]},{5:[2,4],6:[2,4],7:[2,4],8:[2,4],9:[2,4],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16],18:[2,4]},{5:[2,5],6:[2,5],7:[2,5],8:[2,5],9:[2,5],10:[2,5],11:[2,5],12:[2,5],13:[2,5],14:[2,5],15:[2,5],16:[1,16],18:[2,5]},{5:[2,6],6:[2,6],7:[2,6],8:[2,6],9:[2,6],10:[2,6],11:[2,6],12:[2,6],13:[2,6],14:[2,6],15:[2,6],16:[1,16],18:[2,6]},{5:[2,7],6:[2,7],7:[2,7],8:[2,7],9:[2,7],10:[2,7],11:[2,7],12:[2,7],13:[2,7],14:[2,7],15:[2,7],16:[1,16],18:[2,7]},{5:[2,8],6:[2,8],7:[2,8],8:[2,8],9:[2,8],10:[2,8],11:[2,8],12:[2,8],13:[2,8],14:[2,8],15:[2,8],16:[1,16],18:[2,8]},{5:[2,9],6:[2,9],7:[2,9],8:[2,9],9:[2,9],10:[2,9],11:[2,9],12:[2,9],13:[2,9],14:[2,9],15:[2,9],16:[1,16],18:[2,9]},{5:[2,10],6:[2,10],7:[2,10],8:[2,10],9:[2,10],10:[2,10],11:[2,10],12:[2,10],13:[2,10],14:[2,10],15:[2,10],16:[1,16],18:[2,10]},{5:[2,11],6:[2,11],7:[2,11],8:[2,11],9:[2,11],10:[2,11],11:[2,11],12:[2,11],13:[2,11],14:[2,11],15:[2,11],16:[2,11],18:[2,11]},{5:[2,12],6:[2,12],7:[2,12],8:[2,12],9:[2,12],10:[2,12],11:[2,12],12:[2,12],13:[2,12],14:[2,12],15:[2,12],16:[2,12],18:[2,12]},{4:30,17:[1,3],19:[1,4],20:[1,5]},{5:[2,2],6:[1,7],7:[2,2],8:[1,8],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16],18:[2,2]}],
+defaultActions: {6:[2,1]},
+parseError: function parseError(str, hash) {
+    throw new Error(str);
+},
+parse: function parse(input) {
+    var self = this,
+        stack = [0],
+        vstack = [null], // semantic value stack
+        lstack = [], // location stack
+        table = this.table,
+        yytext = '',
+        yylineno = 0,
+        yyleng = 0,
+        recovering = 0,
+        TERROR = 2,
+        EOF = 1;
+
+    //this.reductionCount = this.shiftCount = 0;
+
+    this.lexer.setInput(input);
+    this.lexer.yy = this.yy;
+    this.yy.lexer = this.lexer;
+    if (typeof this.lexer.yylloc == 'undefined')
+        this.lexer.yylloc = {};
+    var yyloc = this.lexer.yylloc;
+    lstack.push(yyloc);
+
+    if (typeof this.yy.parseError === 'function')
+        this.parseError = this.yy.parseError;
+
+    function popStack (n) {
+        stack.length = stack.length - 2*n;
+        vstack.length = vstack.length - n;
+        lstack.length = lstack.length - n;
+    }
+
+    function lex() {
+        var token;
+        token = self.lexer.lex() || 1; // $end = 1
+        // if token isn't its numeric value, convert
+        if (typeof token !== 'number') {
+            token = self.symbols_[token] || token;
+        }
+        return token;
+    }
+
+    var symbol, preErrorSymbol, state, action, a, r, yyval={},p,len,newState, expected;
+    while (true) {
+        // retreive state number from top of stack
+        state = stack[stack.length-1];
+
+        // use default actions if available
+        if (this.defaultActions[state]) {
+            action = this.defaultActions[state];
+        } else {
+            if (symbol == null)
+                symbol = lex();
+            // read action for current state and first input
+            action = table[state] && table[state][symbol];
+        }
+
+        // handle parse error
+        _handle_error:
+        if (typeof action === 'undefined' || !action.length || !action[0]) {
+
+            if (!recovering) {
+                // Report error
+                expected = [];
+                for (p in table[state]) if (this.terminals_[p] && p > 2) {
+                    expected.push("'"+this.terminals_[p]+"'");
+                }
+                var errStr = '';
+                if (this.lexer.showPosition) {
+                    errStr = 'Parse error on line '+(yylineno+1)+":\n"+this.lexer.showPosition()+"\nExpecting "+expected.join(', ') + ", got '" + this.terminals_[symbol]+ "'";
+                } else {
+                    errStr = 'Parse error on line '+(yylineno+1)+": Unexpected " +
+                                  (symbol == 1 /*EOF*/ ? "end of input" :
+                                              ("'"+(this.terminals_[symbol] || symbol)+"'"));
+                }
+                this.parseError(errStr,
+                    {text: this.lexer.match, token: this.terminals_[symbol] || symbol, line: this.lexer.yylineno, loc: yyloc, expected: expected});
+            }
+
+            // just recovered from another error
+            if (recovering == 3) {
+                if (symbol == EOF) {
+                    throw new Error(errStr || 'Parsing halted.');
+                }
+
+                // discard current lookahead and grab another
+                yyleng = this.lexer.yyleng;
+                yytext = this.lexer.yytext;
+                yylineno = this.lexer.yylineno;
+                yyloc = this.lexer.yylloc;
+                symbol = lex();
+            }
+
+            // try to recover from error
+            while (1) {
+                // check for error recovery rule in this state
+                if ((TERROR.toString()) in table[state]) {
+                    break;
+                }
+                if (state == 0) {
+                    throw new Error(errStr || 'Parsing halted.');
+                }
+                popStack(1);
+                state = stack[stack.length-1];
+            }
+
+            preErrorSymbol = symbol; // save the lookahead token
+            symbol = TERROR;         // insert generic error symbol as new lookahead
+            state = stack[stack.length-1];
+            action = table[state] && table[state][TERROR];
+            recovering = 3; // allow 3 real symbols to be shifted before reporting a new error
+        }
+
+        // this shouldn't happen, unless resolve defaults are off
+        if (action[0] instanceof Array && action.length > 1) {
+            throw new Error('Parse Error: multiple actions possible at state: '+state+', token: '+symbol);
+        }
+
+        switch (action[0]) {
+
+            case 1: // shift
+                //this.shiftCount++;
+
+                stack.push(symbol);
+                vstack.push(this.lexer.yytext);
+                lstack.push(this.lexer.yylloc);
+                stack.push(action[1]); // push state
+                symbol = null;
+                if (!preErrorSymbol) { // normal execution/no error
+                    yyleng = this.lexer.yyleng;
+                    yytext = this.lexer.yytext;
+                    yylineno = this.lexer.yylineno;
+                    yyloc = this.lexer.yylloc;
+                    if (recovering > 0)
+                        recovering--;
+                } else { // error just occurred, resume old lookahead f/ before error
+                    symbol = preErrorSymbol;
+                    preErrorSymbol = null;
+                }
+                break;
+
+            case 2: // reduce
+                //this.reductionCount++;
+
+                len = this.productions_[action[1]][1];
+
+                // perform semantic action
+                yyval.$ = vstack[vstack.length-len]; // default to $$ = $1
+                // default location, uses first token for firsts, last for lasts
+                yyval._$ = {
+                    first_line: lstack[lstack.length-(len||1)].first_line,
+                    last_line: lstack[lstack.length-1].last_line,
+                    first_column: lstack[lstack.length-(len||1)].first_column,
+                    last_column: lstack[lstack.length-1].last_column
+                };
+                r = this.performAction.call(yyval, yytext, yyleng, yylineno, this.yy, action[1], vstack, lstack);
+
+                if (typeof r !== 'undefined') {
+                    return r;
+                }
+
+                // pop off stack
+                if (len) {
+                    stack = stack.slice(0,-1*len*2);
+                    vstack = vstack.slice(0, -1*len);
+                    lstack = lstack.slice(0, -1*len);
+                }
+
+                stack.push(this.productions_[action[1]][0]);    // push nonterminal (reduce)
+                vstack.push(yyval.$);
+                lstack.push(yyval._$);
+                // goto new state = table[STATE][NONTERMINAL]
+                newState = table[stack[stack.length-2]][stack[stack.length-1]];
+                stack.push(newState);
+                break;
+
+            case 3: // accept
+                return true;
+        }
+
+    }
+
+    return true;
+}};/* Jison generated lexer */
+var lexer = (function(){
+
+var lexer = ({EOF:1,
+parseError:function parseError(str, hash) {
+        if (this.yy.parseError) {
+            this.yy.parseError(str, hash);
+        } else {
+            throw new Error(str);
+        }
+    },
+setInput:function (input) {
+        this._input = input;
+        this._more = this._less = this.done = false;
+        this.yylineno = this.yyleng = 0;
+        this.yytext = this.matched = this.match = '';
+        this.conditionStack = ['INITIAL'];
+        this.yylloc = {first_line:1,first_column:0,last_line:1,last_column:0};
+        return this;
+    },
+input:function () {
+        var ch = this._input[0];
+        this.yytext+=ch;
+        this.yyleng++;
+        this.match+=ch;
+        this.matched+=ch;
+        var lines = ch.match(/\n/);
+        if (lines) this.yylineno++;
+        this._input = this._input.slice(1);
+        return ch;
+    },
+unput:function (ch) {
+        this._input = ch + this._input;
+        return this;
+    },
+more:function () {
+        this._more = true;
+        return this;
+    },
+pastInput:function () {
+        var past = this.matched.substr(0, this.matched.length - this.match.length);
+        return (past.length > 20 ? '...':'') + past.substr(-20).replace(/\n/g, "");
+    },
+upcomingInput:function () {
+        var next = this.match;
+        if (next.length < 20) {
+            next += this._input.substr(0, 20-next.length);
+        }
+        return (next.substr(0,20)+(next.length > 20 ? '...':'')).replace(/\n/g, "");
+    },
+showPosition:function () {
+        var pre = this.pastInput();
+        var c = new Array(pre.length + 1).join("-");
+        return pre + this.upcomingInput() + "\n" + c+"^";
+    },
+next:function () {
+        if (this.done) {
+            return this.EOF;
+        }
+        if (!this._input) this.done = true;
+
+        var token,
+            match,
+            col,
+            lines;
+        if (!this._more) {
+            this.yytext = '';
+            this.match = '';
+        }
+        var rules = this._currentRules();
+        for (var i=0;i < rules.length; i++) {
+            match = this._input.match(this.rules[rules[i]]);
+            if (match) {
+                lines = match[0].match(/\n.*/g);
+                if (lines) this.yylineno += lines.length;
+                this.yylloc = {first_line: this.yylloc.last_line,
+                               last_line: this.yylineno+1,
+                               first_column: this.yylloc.last_column,
+                               last_column: lines ? lines[lines.length-1].length-1 : this.yylloc.last_column + match[0].length}
+                this.yytext += match[0];
+                this.match += match[0];
+                this.matches = match;
+                this.yyleng = this.yytext.length;
+                this._more = false;
+                this._input = this._input.slice(match[0].length);
+                this.matched += match[0];
+                token = this.performAction.call(this, this.yy, this, rules[i],this.conditionStack[this.conditionStack.length-1]);
+                if (token) return token;
+                else return;
+            }
+        }
+        if (this._input === "") {
+            return this.EOF;
+        } else {
+            this.parseError('Lexical error on line '+(this.yylineno+1)+'. Unrecognized text.\n'+this.showPosition(),
+                    {text: "", token: null, line: this.yylineno});
+        }
+    },
+lex:function lex() {
+        var r = this.next();
+        if (typeof r !== 'undefined') {
+            return r;
+        } else {
+            return this.lex();
+        }
+    },
+begin:function begin(condition) {
+        this.conditionStack.push(condition);
+    },
+popState:function popState() {
+        return this.conditionStack.pop();
+    },
+_currentRules:function _currentRules() {
+        return this.conditions[this.conditionStack[this.conditionStack.length-1]].rules;
+    },
+topState:function () {
+        return this.conditionStack[this.conditionStack.length-2];
+    },
+pushState:function begin(condition) {
+        this.begin(condition);
+    }});
+lexer.performAction = function anonymous(yy,yy_,$avoiding_name_collisions,YY_START) {
+
+var YYSTATE=YY_START;
+switch($avoiding_name_collisions) {
+case 0:/* skip whitespace */
+break;
+case 1:return 20
+break;
+case 2:return 19
+break;
+case 3:return 8
+break;
+case 4:return 9
+break;
+case 5:return 6
+break;
+case 6:return 7
+break;
+case 7:return 11
+break;
+case 8:return 13
+break;
+case 9:return 10
+break;
+case 10:return 12
+break;
+case 11:return 14
+break;
+case 12:return 15
+break;
+case 13:return 16
+break;
+case 14:return 17
+break;
+case 15:return 18
+break;
+case 16:return 5
+break;
+case 17:return 'INVALID'
+break;
+}
+};
+lexer.rules = [/^\s+/,/^[0-9]+(\.[0-9]+)?\b/,/^n\b/,/^\|\|/,/^&&/,/^\?/,/^:/,/^<=/,/^>=/,/^</,/^>/,/^!=/,/^==/,/^%/,/^\(/,/^\)/,/^$/,/^./];
+lexer.conditions = {"INITIAL":{"rules":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17],"inclusive":true}};return lexer;})()
+parser.lexer = lexer;
+return parser;
+})();
+// End parser
+
+  // Handle node, amd, and global systems
+  if (typeof exports !== 'undefined') {
+    if (typeof module !== 'undefined' && module.exports) {
+      exports = module.exports = Jed;
+    }
+    exports.Jed = Jed;
+  }
+  else {
+    if (typeof define === 'function' && define.amd) {
+      define(function() {
+        return Jed;
+      });
+    }
+    // Leak a global regardless of module system
+    root['Jed'] = Jed;
+  }
+
+})(this);
diff --git a/src/web-chatview/linkify-html.js b/src/web-chatview/linkify-html.js
new file mode 100644
index 0000000000000000000000000000000000000000..1e5090c9e6856b3b4a2688345e33b244aee63e25
--- /dev/null
+++ b/src/web-chatview/linkify-html.js
@@ -0,0 +1,827 @@
+/*
+ * Copyright (c) 2016 SoapBox Innovations Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+*/
+
+'use strict';
+
+;(function (window, linkify) {
+  var linkifyHtml = function (linkify) {
+    'use strict';
+
+    var HTML5NamedCharRefs = {};
+
+    function EntityParser(named) {
+      this.named = named;
+    }
+
+    var HEXCHARCODE = /^#[xX]([A-Fa-f0-9]+)$/;
+    var CHARCODE = /^#([0-9]+)$/;
+    var NAMED = /^([A-Za-z0-9]+)$/;
+
+    EntityParser.prototype.parse = function (entity) {
+      if (!entity) {
+        return;
+      }
+      var matches = entity.match(HEXCHARCODE);
+      if (matches) {
+        return '&#x' + matches[1] + ';';
+      }
+      matches = entity.match(CHARCODE);
+      if (matches) {
+        return '&#' + matches[1] + ';';
+      }
+      matches = entity.match(NAMED);
+      if (matches) {
+        return '&' + matches[1] + ';';
+      }
+    };
+
+    var WSP = /[\t\n\f ]/;
+    var ALPHA = /[A-Za-z]/;
+    var CRLF = /\r\n?/g;
+
+    function isSpace(char) {
+      return WSP.test(char);
+    }
+
+    function isAlpha(char) {
+      return ALPHA.test(char);
+    }
+
+    function preprocessInput(input) {
+      return input.replace(CRLF, "\n");
+    }
+
+    function EventedTokenizer(delegate, entityParser) {
+      this.delegate = delegate;
+      this.entityParser = entityParser;
+
+      this.state = null;
+      this.input = null;
+
+      this.index = -1;
+      this.line = -1;
+      this.column = -1;
+      this.tagLine = -1;
+      this.tagColumn = -1;
+
+      this.reset();
+    }
+
+    EventedTokenizer.prototype = {
+      reset: function reset() {
+        this.state = 'beforeData';
+        this.input = '';
+
+        this.index = 0;
+        this.line = 1;
+        this.column = 0;
+
+        this.tagLine = -1;
+        this.tagColumn = -1;
+
+        this.delegate.reset();
+      },
+
+      tokenize: function tokenize(input) {
+        this.reset();
+        this.tokenizePart(input);
+        this.tokenizeEOF();
+      },
+
+      tokenizePart: function tokenizePart(input) {
+        this.input += preprocessInput(input);
+
+        while (this.index < this.input.length) {
+          this.states[this.state].call(this);
+        }
+      },
+
+      tokenizeEOF: function tokenizeEOF() {
+        this.flushData();
+      },
+
+      flushData: function flushData() {
+        if (this.state === 'data') {
+          this.delegate.finishData();
+          this.state = 'beforeData';
+        }
+      },
+
+      peek: function peek() {
+        return this.input.charAt(this.index);
+      },
+
+      consume: function consume() {
+        var char = this.peek();
+
+        this.index++;
+
+        if (char === "\n") {
+          this.line++;
+          this.column = 0;
+        } else {
+          this.column++;
+        }
+
+        return char;
+      },
+
+      consumeCharRef: function consumeCharRef() {
+        var endIndex = this.input.indexOf(';', this.index);
+        if (endIndex === -1) {
+          return;
+        }
+        var entity = this.input.slice(this.index, endIndex);
+        var chars = this.entityParser.parse(entity);
+        if (chars) {
+          this.index = endIndex + 1;
+          return chars;
+        }
+      },
+
+      markTagStart: function markTagStart() {
+        this.tagLine = this.line;
+        this.tagColumn = this.column;
+      },
+
+      states: {
+        beforeData: function beforeData() {
+          var char = this.peek();
+
+          if (char === "<") {
+            this.state = 'tagOpen';
+            this.markTagStart();
+            this.consume();
+          } else {
+            this.state = 'data';
+            this.delegate.beginData();
+          }
+        },
+
+        data: function data() {
+          var char = this.peek();
+
+          if (char === "<") {
+            this.delegate.finishData();
+            this.state = 'tagOpen';
+            this.markTagStart();
+            this.consume();
+          } else if (char === "&") {
+            this.consume();
+            this.delegate.appendToData(this.consumeCharRef() || "&");
+          } else {
+            this.consume();
+            this.delegate.appendToData(char);
+          }
+        },
+
+        tagOpen: function tagOpen() {
+          var char = this.consume();
+
+          if (char === "!") {
+            this.state = 'markupDeclaration';
+          } else if (char === "/") {
+            this.state = 'endTagOpen';
+          } else if (isAlpha(char)) {
+            this.state = 'tagName';
+            this.delegate.beginStartTag();
+            this.delegate.appendToTagName(char.toLowerCase());
+          }
+        },
+
+        markupDeclaration: function markupDeclaration() {
+          var char = this.consume();
+
+          if (char === "-" && this.input.charAt(this.index) === "-") {
+            this.index++;
+            this.state = 'commentStart';
+            this.delegate.beginComment();
+          }
+        },
+
+        commentStart: function commentStart() {
+          var char = this.consume();
+
+          if (char === "-") {
+            this.state = 'commentStartDash';
+          } else if (char === ">") {
+            this.delegate.finishComment();
+            this.state = 'beforeData';
+          } else {
+            this.delegate.appendToCommentData(char);
+            this.state = 'comment';
+          }
+        },
+
+        commentStartDash: function commentStartDash() {
+          var char = this.consume();
+
+          if (char === "-") {
+            this.state = 'commentEnd';
+          } else if (char === ">") {
+            this.delegate.finishComment();
+            this.state = 'beforeData';
+          } else {
+            this.delegate.appendToCommentData("-");
+            this.state = 'comment';
+          }
+        },
+
+        comment: function comment() {
+          var char = this.consume();
+
+          if (char === "-") {
+            this.state = 'commentEndDash';
+          } else {
+            this.delegate.appendToCommentData(char);
+          }
+        },
+
+        commentEndDash: function commentEndDash() {
+          var char = this.consume();
+
+          if (char === "-") {
+            this.state = 'commentEnd';
+          } else {
+            this.delegate.appendToCommentData("-" + char);
+            this.state = 'comment';
+          }
+        },
+
+        commentEnd: function commentEnd() {
+          var char = this.consume();
+
+          if (char === ">") {
+            this.delegate.finishComment();
+            this.state = 'beforeData';
+          } else {
+            this.delegate.appendToCommentData("--" + char);
+            this.state = 'comment';
+          }
+        },
+
+        tagName: function tagName() {
+          var char = this.consume();
+
+          if (isSpace(char)) {
+            this.state = 'beforeAttributeName';
+          } else if (char === "/") {
+            this.state = 'selfClosingStartTag';
+          } else if (char === ">") {
+            this.delegate.finishTag();
+            this.state = 'beforeData';
+          } else {
+            this.delegate.appendToTagName(char);
+          }
+        },
+
+        beforeAttributeName: function beforeAttributeName() {
+          var char = this.consume();
+
+          if (isSpace(char)) {
+            return;
+          } else if (char === "/") {
+            this.state = 'selfClosingStartTag';
+          } else if (char === ">") {
+            this.delegate.finishTag();
+            this.state = 'beforeData';
+          } else {
+            this.state = 'attributeName';
+            this.delegate.beginAttribute();
+            this.delegate.appendToAttributeName(char);
+          }
+        },
+
+        attributeName: function attributeName() {
+          var char = this.consume();
+
+          if (isSpace(char)) {
+            this.state = 'afterAttributeName';
+          } else if (char === "/") {
+            this.delegate.beginAttributeValue(false);
+            this.delegate.finishAttributeValue();
+            this.state = 'selfClosingStartTag';
+          } else if (char === "=") {
+            this.state = 'beforeAttributeValue';
+          } else if (char === ">") {
+            this.delegate.beginAttributeValue(false);
+            this.delegate.finishAttributeValue();
+            this.delegate.finishTag();
+            this.state = 'beforeData';
+          } else {
+            this.delegate.appendToAttributeName(char);
+          }
+        },
+
+        afterAttributeName: function afterAttributeName() {
+          var char = this.consume();
+
+          if (isSpace(char)) {
+            return;
+          } else if (char === "/") {
+            this.delegate.beginAttributeValue(false);
+            this.delegate.finishAttributeValue();
+            this.state = 'selfClosingStartTag';
+          } else if (char === "=") {
+            this.state = 'beforeAttributeValue';
+          } else if (char === ">") {
+            this.delegate.beginAttributeValue(false);
+            this.delegate.finishAttributeValue();
+            this.delegate.finishTag();
+            this.state = 'beforeData';
+          } else {
+            this.delegate.beginAttributeValue(false);
+            this.delegate.finishAttributeValue();
+            this.state = 'attributeName';
+            this.delegate.beginAttribute();
+            this.delegate.appendToAttributeName(char);
+          }
+        },
+
+        beforeAttributeValue: function beforeAttributeValue() {
+          var char = this.consume();
+
+          if (isSpace(char)) {} else if (char === '"') {
+            this.state = 'attributeValueDoubleQuoted';
+            this.delegate.beginAttributeValue(true);
+          } else if (char === "'") {
+            this.state = 'attributeValueSingleQuoted';
+            this.delegate.beginAttributeValue(true);
+          } else if (char === ">") {
+            this.delegate.beginAttributeValue(false);
+            this.delegate.finishAttributeValue();
+            this.delegate.finishTag();
+            this.state = 'beforeData';
+          } else {
+            this.state = 'attributeValueUnquoted';
+            this.delegate.beginAttributeValue(false);
+            this.delegate.appendToAttributeValue(char);
+          }
+        },
+
+        attributeValueDoubleQuoted: function attributeValueDoubleQuoted() {
+          var char = this.consume();
+
+          if (char === '"') {
+            this.delegate.finishAttributeValue();
+            this.state = 'afterAttributeValueQuoted';
+          } else if (char === "&") {
+            this.delegate.appendToAttributeValue(this.consumeCharRef('"') || "&");
+          } else {
+            this.delegate.appendToAttributeValue(char);
+          }
+        },
+
+        attributeValueSingleQuoted: function attributeValueSingleQuoted() {
+          var char = this.consume();
+
+          if (char === "'") {
+            this.delegate.finishAttributeValue();
+            this.state = 'afterAttributeValueQuoted';
+          } else if (char === "&") {
+            this.delegate.appendToAttributeValue(this.consumeCharRef("'") || "&");
+          } else {
+            this.delegate.appendToAttributeValue(char);
+          }
+        },
+
+        attributeValueUnquoted: function attributeValueUnquoted() {
+          var char = this.consume();
+
+          if (isSpace(char)) {
+            this.delegate.finishAttributeValue();
+            this.state = 'beforeAttributeName';
+          } else if (char === "&") {
+            this.delegate.appendToAttributeValue(this.consumeCharRef(">") || "&");
+          } else if (char === ">") {
+            this.delegate.finishAttributeValue();
+            this.delegate.finishTag();
+            this.state = 'beforeData';
+          } else {
+            this.delegate.appendToAttributeValue(char);
+          }
+        },
+
+        afterAttributeValueQuoted: function afterAttributeValueQuoted() {
+          var char = this.peek();
+
+          if (isSpace(char)) {
+            this.consume();
+            this.state = 'beforeAttributeName';
+          } else if (char === "/") {
+            this.consume();
+            this.state = 'selfClosingStartTag';
+          } else if (char === ">") {
+            this.consume();
+            this.delegate.finishTag();
+            this.state = 'beforeData';
+          } else {
+            this.state = 'beforeAttributeName';
+          }
+        },
+
+        selfClosingStartTag: function selfClosingStartTag() {
+          var char = this.peek();
+
+          if (char === ">") {
+            this.consume();
+            this.delegate.markTagAsSelfClosing();
+            this.delegate.finishTag();
+            this.state = 'beforeData';
+          } else {
+            this.state = 'beforeAttributeName';
+          }
+        },
+
+        endTagOpen: function endTagOpen() {
+          var char = this.consume();
+
+          if (isAlpha(char)) {
+            this.state = 'tagName';
+            this.delegate.beginEndTag();
+            this.delegate.appendToTagName(char.toLowerCase());
+          }
+        }
+      }
+    };
+
+    function Tokenizer(entityParser, options) {
+      this.token = null;
+      this.startLine = 1;
+      this.startColumn = 0;
+      this.options = options || {};
+      this.tokenizer = new EventedTokenizer(this, entityParser);
+    }
+
+    Tokenizer.prototype = {
+      tokenize: function tokenize(input) {
+        this.tokens = [];
+        this.tokenizer.tokenize(input);
+        return this.tokens;
+      },
+
+      tokenizePart: function tokenizePart(input) {
+        this.tokens = [];
+        this.tokenizer.tokenizePart(input);
+        return this.tokens;
+      },
+
+      tokenizeEOF: function tokenizeEOF() {
+        this.tokens = [];
+        this.tokenizer.tokenizeEOF();
+        return this.tokens[0];
+      },
+
+      reset: function reset() {
+        this.token = null;
+        this.startLine = 1;
+        this.startColumn = 0;
+      },
+
+      addLocInfo: function addLocInfo() {
+        if (this.options.loc) {
+          this.token.loc = {
+            start: {
+              line: this.startLine,
+              column: this.startColumn
+            },
+            end: {
+              line: this.tokenizer.line,
+              column: this.tokenizer.column
+            }
+          };
+        }
+        this.startLine = this.tokenizer.line;
+        this.startColumn = this.tokenizer.column;
+      },
+
+      // Data
+
+      beginData: function beginData() {
+        this.token = {
+          type: 'Chars',
+          chars: ''
+        };
+        this.tokens.push(this.token);
+      },
+
+      appendToData: function appendToData(char) {
+        this.token.chars += char;
+      },
+
+      finishData: function finishData() {
+        this.addLocInfo();
+      },
+
+      // Comment
+
+      beginComment: function beginComment() {
+        this.token = {
+          type: 'Comment',
+          chars: ''
+        };
+        this.tokens.push(this.token);
+      },
+
+      appendToCommentData: function appendToCommentData(char) {
+        this.token.chars += char;
+      },
+
+      finishComment: function finishComment() {
+        this.addLocInfo();
+      },
+
+      // Tags - basic
+
+      beginStartTag: function beginStartTag() {
+        this.token = {
+          type: 'StartTag',
+          tagName: '',
+          attributes: [],
+          selfClosing: false
+        };
+        this.tokens.push(this.token);
+      },
+
+      beginEndTag: function beginEndTag() {
+        this.token = {
+          type: 'EndTag',
+          tagName: ''
+        };
+        this.tokens.push(this.token);
+      },
+
+      finishTag: function finishTag() {
+        this.addLocInfo();
+      },
+
+      markTagAsSelfClosing: function markTagAsSelfClosing() {
+        this.token.selfClosing = true;
+      },
+
+      // Tags - name
+
+      appendToTagName: function appendToTagName(char) {
+        this.token.tagName += char;
+      },
+
+      // Tags - attributes
+
+      beginAttribute: function beginAttribute() {
+        this._currentAttribute = ["", "", null];
+        this.token.attributes.push(this._currentAttribute);
+      },
+
+      appendToAttributeName: function appendToAttributeName(char) {
+        this._currentAttribute[0] += char;
+      },
+
+      beginAttributeValue: function beginAttributeValue(isQuoted) {
+        this._currentAttribute[2] = isQuoted;
+      },
+
+      appendToAttributeValue: function appendToAttributeValue(char) {
+        this._currentAttribute[1] = this._currentAttribute[1] || "";
+        this._currentAttribute[1] += char;
+      },
+
+      finishAttributeValue: function finishAttributeValue() {}
+    };
+
+    function tokenize(input, options) {
+      var tokenizer = new Tokenizer(new EntityParser(HTML5NamedCharRefs), options);
+      return tokenizer.tokenize(input);
+    }
+
+    var HTML5Tokenizer = {
+      HTML5NamedCharRefs: HTML5NamedCharRefs,
+      EntityParser: EntityParser,
+      EventedTokenizer: EventedTokenizer,
+      Tokenizer: Tokenizer,
+      tokenize: tokenize
+    };
+
+    var options = linkify.options;
+    var Options = options.Options;
+
+
+    var StartTag = 'StartTag';
+    var EndTag = 'EndTag';
+    var Chars = 'Chars';
+    var Comment = 'Comment';
+
+    /**
+    	`tokens` and `token` in this section refer to tokens generated by the HTML
+    	parser.
+    */
+    function linkifyHtml(str) {
+      var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
+
+      var tokens = HTML5Tokenizer.tokenize(str);
+      var linkifiedTokens = [];
+      var linkified = [];
+      var i;
+
+      opts = new Options(opts);
+
+      // Linkify the tokens given by the parser
+      for (i = 0; i < tokens.length; i++) {
+        var token = tokens[i];
+
+        if (token.type === StartTag) {
+          linkifiedTokens.push(token);
+
+          // Ignore all the contents of ignored tags
+          var tagName = token.tagName.toUpperCase();
+          var isIgnored = tagName === 'A' || options.contains(opts.ignoreTags, tagName);
+          if (!isIgnored) {
+            continue;
+          }
+
+          var preskipLen = linkifiedTokens.length;
+          skipTagTokens(tagName, tokens, ++i, linkifiedTokens);
+          i += linkifiedTokens.length - preskipLen - 1;
+          continue;
+        } else if (token.type !== Chars) {
+          // Skip this token, it's not important
+          linkifiedTokens.push(token);
+          continue;
+        }
+
+        // Valid text token, linkify it!
+        var linkifedChars = linkifyChars(token.chars, opts);
+        linkifiedTokens.push.apply(linkifiedTokens, linkifedChars);
+      }
+
+      // Convert the tokens back into a string
+      for (i = 0; i < linkifiedTokens.length; i++) {
+        var _token = linkifiedTokens[i];
+        switch (_token.type) {
+          case StartTag:
+            var link = '<' + _token.tagName;
+            if (_token.attributes.length > 0) {
+              var attrs = attrsToStrings(_token.attributes);
+              link += ' ' + attrs.join(' ');
+            }
+            link += '>';
+            linkified.push(link);
+            break;
+          case EndTag:
+            linkified.push('</' + _token.tagName + '>');
+            break;
+          case Chars:
+            linkified.push(escapeText(_token.chars));
+            break;
+          case Comment:
+            linkified.push('<!--' + escapeText(_token.chars) + '-->');
+            break;
+        }
+      }
+
+      return linkified.join('');
+    }
+
+    /**
+    	`tokens` and `token` in this section referes to tokens returned by
+    	`linkify.tokenize`. `linkified` will contain HTML Parser-style tokens
+    */
+    function linkifyChars(str, opts) {
+      var tokens = linkify.tokenize(str);
+      var result = [];
+
+      for (var i = 0; i < tokens.length; i++) {
+        var token = tokens[i];
+
+        if (token.type === 'nl' && opts.nl2br) {
+          result.push({
+            type: StartTag,
+            tagName: 'br',
+            attributes: [],
+            selfClosing: true
+          });
+          continue;
+        } else if (!token.isLink || !opts.check(token)) {
+          result.push({ type: Chars, chars: token.toString() });
+          continue;
+        }
+
+        var _opts$resolve = opts.resolve(token);
+
+        var href = _opts$resolve.href;
+        var formatted = _opts$resolve.formatted;
+        var formattedHref = _opts$resolve.formattedHref;
+        var tagName = _opts$resolve.tagName;
+        var className = _opts$resolve.className;
+        var target = _opts$resolve.target;
+        var attributes = _opts$resolve.attributes;
+
+        // Build up attributes
+
+        var attributeArray = [['href', formattedHref]];
+
+        if (className) {
+          attributeArray.push(['class', className]);
+        }
+
+        if (target) {
+          attributeArray.push(['target', target]);
+        }
+
+        for (var attr in attributes) {
+          attributeArray.push([attr, attributes[attr]]);
+        }
+
+        // Add the required tokens
+        result.push({
+          type: StartTag,
+          tagName: tagName,
+          attributes: attributeArray,
+          selfClosing: false
+        });
+        result.push({ type: Chars, chars: formatted });
+        result.push({ type: EndTag, tagName: tagName });
+      }
+
+      return result;
+    }
+
+    /**
+    	Returns a list of tokens skipped until the closing tag of tagName.
+
+    	* `tagName` is the closing tag which will prompt us to stop skipping
+    	* `tokens` is the array of tokens generated by HTML5Tokenizer which
+    	* `i` is the index immediately after the opening tag to skip
+    	* `skippedTokens` is an array which skipped tokens are being pushed into
+
+    	Caveats
+
+    	* Assumes that i is the first token after the given opening tagName
+    	* The closing tag will be skipped, but nothing after it
+    	* Will track whether there is a nested tag of the same type
+    */
+    function skipTagTokens(tagName, tokens, i, skippedTokens) {
+
+      // number of tokens of this type on the [fictional] stack
+      var stackCount = 1;
+
+      while (i < tokens.length && stackCount > 0) {
+        var token = tokens[i];
+        if (token.type === StartTag && token.tagName.toUpperCase() === tagName) {
+          // Nested tag of the same type, "add to stack"
+          stackCount++;
+        } else if (token.type === EndTag && token.tagName.toUpperCase() === tagName) {
+          // Closing tag
+          stackCount--;
+        }
+        skippedTokens.push(token);
+        i++;
+      }
+
+      // Note that if stackCount > 0 here, the HTML is probably invalid
+      return skippedTokens;
+    }
+
+    function escapeText(text) {
+      // Not required, HTML tokenizer ensures this occurs properly
+      return text;
+    }
+
+    function escapeAttr(attr) {
+      return attr.replace(/"/g, '&quot;');
+    }
+
+    function attrsToStrings(attrs) {
+      var attrStrs = [];
+      for (var i = 0; i < attrs.length; i++) {
+        var _attrs$i = attrs[i];
+        var name = _attrs$i[0];
+        var value = _attrs$i[1];
+
+        attrStrs.push(name + '="' + escapeAttr(value) + '"');
+      }
+      return attrStrs;
+    }
+
+    return linkifyHtml;
+  }(linkify);
+  window.linkifyHtml = linkifyHtml;
+})(window, linkify);
diff --git a/src/web-chatview/linkify-string.js b/src/web-chatview/linkify-string.js
new file mode 100644
index 0000000000000000000000000000000000000000..699a94d1f47ac7fbc6d1c3dfbd51cc553d12feed
--- /dev/null
+++ b/src/web-chatview/linkify-string.js
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2016 SoapBox Innovations Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+*/
+
+'use strict';
+
+;(function (window, linkify) {
+	var linkifyString = function (linkify) {
+		'use strict';
+
+		/**
+  	Convert strings of text into linkable HTML text
+  */
+
+		var tokenize = linkify.tokenize;
+		var options = linkify.options;
+		var Options = options.Options;
+
+
+		function escapeText(text) {
+			return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+		}
+
+		function escapeAttr(href) {
+			return href.replace(/"/g, '&quot;');
+		}
+
+		function attributesToString(attributes) {
+			if (!attributes) {
+				return '';
+			}
+			var result = [];
+
+			for (var attr in attributes) {
+				var val = attributes[attr] + '';
+				result.push(attr + '="' + escapeAttr(val) + '"');
+			}
+			return result.join(' ');
+		}
+
+		function linkifyStr(str) {
+			var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
+
+			opts = new Options(opts);
+
+			var tokens = tokenize(str);
+			var result = [];
+
+			for (var i = 0; i < tokens.length; i++) {
+				var token = tokens[i];
+
+				if (token.type === 'nl' && opts.nl2br) {
+					result.push('<br>\n');
+					continue;
+				} else if (!token.isLink || !opts.check(token)) {
+					result.push(escapeText(token.toString()));
+					continue;
+				}
+
+				var _opts$resolve = opts.resolve(token);
+
+				var formatted = _opts$resolve.formatted;
+				var formattedHref = _opts$resolve.formattedHref;
+				var tagName = _opts$resolve.tagName;
+				var className = _opts$resolve.className;
+				var target = _opts$resolve.target;
+				var attributes = _opts$resolve.attributes;
+
+
+				var link = '<' + tagName + ' href="' + escapeAttr(formattedHref) + '"';
+
+				if (className) {
+					link += ' class="' + escapeAttr(className) + '"';
+				}
+
+				if (target) {
+					link += ' target="' + escapeAttr(target) + '"';
+				}
+
+				if (attributes) {
+					link += ' ' + attributesToString(attributes);
+				}
+
+				link += '>' + escapeText(formatted) + '</' + tagName + '>';
+				result.push(link);
+			}
+
+			return result.join('');
+		}
+
+		if (!String.prototype.linkify) {
+			String.prototype.linkify = function (opts) {
+				return linkifyStr(this, opts);
+			};
+		}
+
+		return linkifyStr;
+	}(linkify);
+	window.linkifyStr = linkifyString;
+})(window, linkify);
diff --git a/src/web-chatview/linkify.js b/src/web-chatview/linkify.js
new file mode 100644
index 0000000000000000000000000000000000000000..15dc03b1a2e936cfcbafa02788722dc382106da0
--- /dev/null
+++ b/src/web-chatview/linkify.js
@@ -0,0 +1,1271 @@
+/*
+ * Copyright (c) 2016 SoapBox Innovations Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+*/
+
+;(function () {
+'use strict';
+
+var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; };
+
+(function (exports) {
+	'use strict';
+
+	function inherits(parent, child) {
+		var props = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2];
+
+		var extended = Object.create(parent.prototype);
+		for (var p in props) {
+			extended[p] = props[p];
+		}
+		extended.constructor = child;
+		child.prototype = extended;
+		return child;
+	}
+
+	var defaults = {
+		defaultProtocol: 'http',
+		events: null,
+		format: noop,
+		formatHref: noop,
+		nl2br: false,
+		tagName: 'a',
+		target: typeToTarget,
+		validate: true,
+		ignoreTags: [],
+		attributes: null,
+		className: 'linkified' };
+
+	function Options(opts) {
+		opts = opts || {};
+
+		this.defaultProtocol = opts.defaultProtocol || defaults.defaultProtocol;
+		this.events = opts.events || defaults.events;
+		this.format = opts.format || defaults.format;
+		this.formatHref = opts.formatHref || defaults.formatHref;
+		this.nl2br = opts.nl2br || defaults.nl2br;
+		this.tagName = opts.tagName || defaults.tagName;
+		this.target = opts.target || defaults.target;
+		this.validate = opts.validate || defaults.validate;
+		this.ignoreTags = [];
+
+		// linkAttributes and linkClass is deprecated
+		this.attributes = opts.attributes || opts.linkAttributes || defaults.attributes;
+		this.className = opts.className || opts.linkClass || defaults.className;
+
+		// Make all tags names upper case
+
+		var ignoredTags = opts.ignoreTags || defaults.ignoreTags;
+		for (var i = 0; i < ignoredTags.length; i++) {
+			this.ignoreTags.push(ignoredTags[i].toUpperCase());
+		}
+	}
+
+	Options.prototype = {
+		/**
+   * Given the token, return all options for how it should be displayed
+   */
+		resolve: function resolve(token) {
+			var href = token.toHref(this.defaultProtocol);
+			return {
+				formatted: this.get('format', token.toString(), token),
+				formattedHref: this.get('formatHref', href, token),
+				tagName: this.get('tagName', href, token),
+				className: this.get('className', href, token),
+				target: this.get('target', href, token),
+				events: this.getObject('events', href, token),
+				attributes: this.getObject('attributes', href, token)
+			};
+		},
+
+
+		/**
+   * Returns true or false based on whether a token should be displayed as a
+   * link based on the user options. By default,
+   */
+		check: function check(token) {
+			return this.get('validate', token.toString(), token);
+		},
+
+
+		// Private methods
+
+		/**
+   * Resolve an option's value based on the value of the option and the given
+   * params.
+   * @param [String] key Name of option to use
+   * @param operator will be passed to the target option if it's method
+   * @param [MultiToken] token The token from linkify.tokenize
+   */
+		get: function get(key, operator, token) {
+			var option = this[key];
+
+			if (!option) {
+				return option;
+			}
+
+			switch (typeof option === 'undefined' ? 'undefined' : _typeof(option)) {
+				case 'function':
+					return option(operator, token.type);
+				case 'object':
+					var optionValue = option[token.type] || defaults[key];
+					return typeof optionValue === 'function' ? optionValue(operator, token.type) : optionValue;
+			}
+
+			return option;
+		},
+		getObject: function getObject(key, operator, token) {
+			var option = this[key];
+			return typeof option === 'function' ? option(operator, token.type) : option;
+		}
+	};
+
+	/**
+  * Quick indexOf replacement for checking the ignoreTags option
+  */
+	function contains(arr, value) {
+		for (var i = 0; i < arr.length; i++) {
+			if (arr[i] === value) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	function noop(val) {
+		return val;
+	}
+
+	function typeToTarget(href, type) {
+		return type === 'url' ? '_blank' : null;
+	}
+
+	var options = Object.freeze({
+		defaults: defaults,
+		Options: Options,
+		contains: contains
+	});
+
+	function createStateClass() {
+		return function (tClass) {
+			this.j = [];
+			this.T = tClass || null;
+		};
+	}
+
+	/**
+ 	A simple state machine that can emit token classes
+
+ 	The `j` property in this class refers to state jumps. It's a
+ 	multidimensional array where for each element:
+
+ 	* index [0] is a symbol or class of symbols to transition to.
+ 	* index [1] is a State instance which matches
+
+ 	The type of symbol will depend on the target implementation for this class.
+ 	In Linkify, we have a two-stage scanner. Each stage uses this state machine
+ 	but with a slighly different (polymorphic) implementation.
+
+ 	The `T` property refers to the token class.
+
+ 	TODO: Can the `on` and `next` methods be combined?
+
+ 	@class BaseState
+ */
+	var BaseState = createStateClass();
+	BaseState.prototype = {
+		defaultTransition: false,
+
+		/**
+  	@method constructor
+  	@param {Class} tClass Pass in the kind of token to emit if there are
+  		no jumps after this state and the state is accepting.
+  */
+
+		/**
+  	On the given symbol(s), this machine should go to the given state
+  		@method on
+  	@param {Array|Mixed} symbol
+  	@param {BaseState} state Note that the type of this state should be the
+  		same as the current instance (i.e., don't pass in a different
+  		subclass)
+  */
+		on: function on(symbol, state) {
+			if (symbol instanceof Array) {
+				for (var i = 0; i < symbol.length; i++) {
+					this.j.push([symbol[i], state]);
+				}
+				return this;
+			}
+			this.j.push([symbol, state]);
+			return this;
+		},
+
+
+		/**
+  	Given the next item, returns next state for that item
+  	@method next
+  	@param {Mixed} item Should be an instance of the symbols handled by
+  		this particular machine.
+  	@return {State} state Returns false if no jumps are available
+  */
+		next: function next(item) {
+			for (var i = 0; i < this.j.length; i++) {
+				var jump = this.j[i];
+				var symbol = jump[0]; // Next item to check for
+				var state = jump[1]; // State to jump to if items match
+
+				// compare item with symbol
+				if (this.test(item, symbol)) {
+					return state;
+				}
+			}
+
+			// Nowhere left to jump!
+			return this.defaultTransition;
+		},
+
+
+		/**
+  	Does this state accept?
+  	`true` only of `this.T` exists
+  		@method accepts
+  	@return {Boolean}
+  */
+		accepts: function accepts() {
+			return !!this.T;
+		},
+
+
+		/**
+  	Determine whether a given item "symbolizes" the symbol, where symbol is
+  	a class of items handled by this state machine.
+  		This method should be overriden in extended classes.
+  		@method test
+  	@param {Mixed} item Does this item match the given symbol?
+  	@param {Mixed} symbol
+  	@return {Boolean}
+  */
+		test: function test(item, symbol) {
+			return item === symbol;
+		},
+
+
+		/**
+  	Emit the token for this State (just return it in this case)
+  	If this emits a token, this instance is an accepting state
+  	@method emit
+  	@return {Class} T
+  */
+		emit: function emit() {
+			return this.T;
+		}
+	};
+
+	/**
+ 	State machine for string-based input
+
+ 	@class CharacterState
+ 	@extends BaseState
+ */
+	var CharacterState = inherits(BaseState, createStateClass(), {
+		/**
+  	Does the given character match the given character or regular
+  	expression?
+  		@method test
+  	@param {String} char
+  	@param {String|RegExp} charOrRegExp
+  	@return {Boolean}
+  */
+		test: function test(character, charOrRegExp) {
+			return character === charOrRegExp || charOrRegExp instanceof RegExp && charOrRegExp.test(character);
+		}
+	});
+
+	/**
+ 	State machine for input in the form of TextTokens
+
+ 	@class TokenState
+ 	@extends BaseState
+ */
+	var State = inherits(BaseState, createStateClass(), {
+
+		/**
+   * Similar to `on`, but returns the state the results in the transition from
+   * the given item
+   * @method jump
+   * @param {Mixed} item
+   * @param {Token} [token]
+   * @return state
+   */
+		jump: function jump(token) {
+			var tClass = arguments.length <= 1 || arguments[1] === undefined ? null : arguments[1];
+
+			var state = this.next(new token('')); // dummy temp token
+			if (state === this.defaultTransition) {
+				// Make a new state!
+				state = new this.constructor(tClass);
+				this.on(token, state);
+			} else if (tClass) {
+				state.T = tClass;
+			}
+			return state;
+		},
+
+
+		/**
+  	Is the given token an instance of the given token class?
+  		@method test
+  	@param {TextToken} token
+  	@param {Class} tokenClass
+  	@return {Boolean}
+  */
+		test: function test(token, tokenClass) {
+			return token instanceof tokenClass;
+		}
+	});
+
+	/**
+ 	Given a non-empty target string, generates states (if required) for each
+ 	consecutive substring of characters in str starting from the beginning of
+ 	the string. The final state will have a special value, as specified in
+ 	options. All other "in between" substrings will have a default end state.
+
+ 	This turns the state machine into a Trie-like data structure (rather than a
+ 	intelligently-designed DFA).
+
+ 	Note that I haven't really tried these with any strings other than
+ 	DOMAIN.
+
+ 	@param {String} str
+ 	@param {CharacterState} start State to jump from the first character
+ 	@param {Class} endToken Token class to emit when the given string has been
+ 		matched and no more jumps exist.
+ 	@param {Class} defaultToken "Filler token", or which token type to emit when
+ 		we don't have a full match
+ 	@return {Array} list of newly-created states
+ */
+	function stateify(str, start, endToken, defaultToken) {
+		var i = 0,
+		    len = str.length,
+		    state = start,
+		    newStates = [],
+		    nextState = void 0;
+
+		// Find the next state without a jump to the next character
+		while (i < len && (nextState = state.next(str[i]))) {
+			state = nextState;
+			i++;
+		}
+
+		if (i >= len) {
+			return [];
+		} // no new tokens were added
+
+		while (i < len - 1) {
+			nextState = new CharacterState(defaultToken);
+			newStates.push(nextState);
+			state.on(str[i], nextState);
+			state = nextState;
+			i++;
+		}
+
+		nextState = new CharacterState(endToken);
+		newStates.push(nextState);
+		state.on(str[len - 1], nextState);
+
+		return newStates;
+	}
+
+	function createTokenClass() {
+		return function (value) {
+			if (value) {
+				this.v = value;
+			}
+		};
+	}
+
+	/******************************************************************************
+ 	Text Tokens
+ 	Tokens composed of strings
+ ******************************************************************************/
+
+	/**
+ 	Abstract class used for manufacturing text tokens.
+ 	Pass in the value this token represents
+
+ 	@class TextToken
+ 	@abstract
+ */
+	var TextToken = createTokenClass();
+	TextToken.prototype = {
+		toString: function toString() {
+			return this.v + '';
+		}
+	};
+
+	function inheritsToken(value) {
+		var props = value ? { v: value } : {};
+		return inherits(TextToken, createTokenClass(), props);
+	}
+
+	/**
+ 	A valid domain token
+ 	@class DOMAIN
+ 	@extends TextToken
+ */
+	var DOMAIN = inheritsToken();
+
+	/**
+ 	@class AT
+ 	@extends TextToken
+ */
+	var AT = inheritsToken('@');
+
+	/**
+ 	Represents a single colon `:` character
+
+ 	@class COLON
+ 	@extends TextToken
+ */
+	var COLON = inheritsToken(':');
+
+	/**
+ 	@class DOT
+ 	@extends TextToken
+ */
+	var DOT = inheritsToken('.');
+
+	/**
+ 	A character class that can surround the URL, but which the URL cannot begin
+ 	or end with. Does not include certain English punctuation like parentheses.
+
+ 	@class PUNCTUATION
+ 	@extends TextToken
+ */
+	var PUNCTUATION = inheritsToken();
+
+	/**
+ 	The word localhost (by itself)
+ 	@class LOCALHOST
+ 	@extends TextToken
+ */
+	var LOCALHOST = inheritsToken();
+
+	/**
+ 	Newline token
+ 	@class NL
+ 	@extends TextToken
+ */
+	var TNL = inheritsToken('\n');
+
+	/**
+ 	@class NUM
+ 	@extends TextToken
+ */
+	var NUM = inheritsToken();
+
+	/**
+ 	@class PLUS
+ 	@extends TextToken
+ */
+	var PLUS = inheritsToken('+');
+
+	/**
+ 	@class POUND
+ 	@extends TextToken
+ */
+	var POUND = inheritsToken('#');
+
+	/**
+ 	Represents a web URL protocol. Supported types include
+
+ 	* `http:`
+ 	* `https:`
+ 	* `ftp:`
+ 	* `ftps:`
+ 	* There's Another super weird one
+
+ 	@class PROTOCOL
+ 	@extends TextToken
+ */
+	var PROTOCOL = inheritsToken();
+
+	/**
+ 	@class QUERY
+ 	@extends TextToken
+ */
+	var QUERY = inheritsToken('?');
+
+	/**
+ 	@class SLASH
+ 	@extends TextToken
+ */
+	var SLASH = inheritsToken('/');
+
+	/**
+ 	@class UNDERSCORE
+ 	@extends TextToken
+ */
+	var UNDERSCORE = inheritsToken('_');
+
+	/**
+ 	One ore more non-whitespace symbol.
+ 	@class SYM
+ 	@extends TextToken
+ */
+	var SYM = inheritsToken();
+
+	/**
+ 	@class TLD
+ 	@extends TextToken
+ */
+	var TLD = inheritsToken();
+
+	/**
+ 	Represents a string of consecutive whitespace characters
+
+ 	@class WS
+ 	@extends TextToken
+ */
+	var WS = inheritsToken();
+
+	/**
+ 	Opening/closing bracket classes
+ */
+
+	var OPENBRACE = inheritsToken('{');
+	var OPENBRACKET = inheritsToken('[');
+	var OPENANGLEBRACKET = inheritsToken('<');
+	var OPENPAREN = inheritsToken('(');
+	var CLOSEBRACE = inheritsToken('}');
+	var CLOSEBRACKET = inheritsToken(']');
+	var CLOSEANGLEBRACKET = inheritsToken('>');
+	var CLOSEPAREN = inheritsToken(')');
+
+	var TOKENS = Object.freeze({
+		Base: TextToken,
+		DOMAIN: DOMAIN,
+		AT: AT,
+		COLON: COLON,
+		DOT: DOT,
+		PUNCTUATION: PUNCTUATION,
+		LOCALHOST: LOCALHOST,
+		NL: TNL,
+		NUM: NUM,
+		PLUS: PLUS,
+		POUND: POUND,
+		QUERY: QUERY,
+		PROTOCOL: PROTOCOL,
+		SLASH: SLASH,
+		UNDERSCORE: UNDERSCORE,
+		SYM: SYM,
+		TLD: TLD,
+		WS: WS,
+		OPENBRACE: OPENBRACE,
+		OPENBRACKET: OPENBRACKET,
+		OPENANGLEBRACKET: OPENANGLEBRACKET,
+		OPENPAREN: OPENPAREN,
+		CLOSEBRACE: CLOSEBRACE,
+		CLOSEBRACKET: CLOSEBRACKET,
+		CLOSEANGLEBRACKET: CLOSEANGLEBRACKET,
+		CLOSEPAREN: CLOSEPAREN
+	});
+
+	/**
+ 	The scanner provides an interface that takes a string of text as input, and
+ 	outputs an array of tokens instances that can be used for easy URL parsing.
+
+ 	@module linkify
+ 	@submodule scanner
+ 	@main scanner
+ */
+
+	var tlds = 'aaa|aarp|abb|abbott|abogado|ac|academy|accenture|accountant|accountants|aco|active|actor|ad|adac|ads|adult|ae|aeg|aero|af|afl|ag|agency|ai|aig|airforce|airtel|al|alibaba|alipay|allfinanz|alsace|am|amica|amsterdam|an|analytics|android|ao|apartments|app|apple|aq|aquarelle|ar|aramco|archi|army|arpa|arte|as|asia|associates|at|attorney|au|auction|audi|audio|author|auto|autos|avianca|aw|ax|axa|az|azure|ba|baidu|band|bank|bar|barcelona|barclaycard|barclays|bargains|bauhaus|bayern|bb|bbc|bbva|bcg|bcn|bd|be|beats|beer|bentley|berlin|best|bet|bf|bg|bh|bharti|bi|bible|bid|bike|bing|bingo|bio|biz|bj|black|blackfriday|bloomberg|blue|bm|bms|bmw|bn|bnl|bnpparibas|bo|boats|boehringer|bom|bond|boo|book|boots|bosch|bostik|bot|boutique|br|bradesco|bridgestone|broadway|broker|brother|brussels|bs|bt|budapest|bugatti|build|builders|business|buy|buzz|bv|bw|by|bz|bzh|ca|cab|cafe|cal|call|camera|camp|cancerresearch|canon|capetown|capital|car|caravan|cards|care|career|careers|cars|cartier|casa|cash|casino|cat|catering|cba|cbn|cc|cd|ceb|center|ceo|cern|cf|cfa|cfd|cg|ch|chanel|channel|chase|chat|cheap|chloe|christmas|chrome|church|ci|cipriani|circle|cisco|citic|city|cityeats|ck|cl|claims|cleaning|click|clinic|clinique|clothing|cloud|club|clubmed|cm|cn|co|coach|codes|coffee|college|cologne|com|commbank|community|company|compare|computer|comsec|condos|construction|consulting|contact|contractors|cooking|cool|coop|corsica|country|coupon|coupons|courses|cr|credit|creditcard|creditunion|cricket|crown|crs|cruises|csc|cu|cuisinella|cv|cw|cx|cy|cymru|cyou|cz|dabur|dad|dance|date|dating|datsun|day|dclk|de|dealer|deals|degree|delivery|dell|deloitte|delta|democrat|dental|dentist|desi|design|dev|diamonds|diet|digital|direct|directory|discount|dj|dk|dm|dnp|do|docs|dog|doha|domains|download|drive|dubai|durban|dvag|dz|earth|eat|ec|edeka|edu|education|ee|eg|email|emerck|energy|engineer|engineering|enterprises|epson|equipment|er|erni|es|esq|estate|et|eu|eurovision|eus|events|everbank|exchange|expert|exposed|express|fage|fail|fairwinds|faith|family|fan|fans|farm|fashion|fast|feedback|ferrero|fi|film|final|finance|financial|firestone|firmdale|fish|fishing|fit|fitness|fj|fk|flickr|flights|florist|flowers|flsmidth|fly|fm|fo|foo|football|ford|forex|forsale|forum|foundation|fox|fr|fresenius|frl|frogans|frontier|fund|furniture|futbol|fyi|ga|gal|gallery|gallup|game|garden|gb|gbiz|gd|gdn|ge|gea|gent|genting|gf|gg|ggee|gh|gi|gift|gifts|gives|giving|gl|glass|gle|global|globo|gm|gmail|gmbh|gmo|gmx|gn|gold|goldpoint|golf|goo|goog|google|gop|got|gov|gp|gq|gr|grainger|graphics|gratis|green|gripe|group|gs|gt|gu|gucci|guge|guide|guitars|guru|gw|gy|hamburg|hangout|haus|hdfcbank|health|healthcare|help|helsinki|here|hermes|hiphop|hitachi|hiv|hk|hm|hn|hockey|holdings|holiday|homedepot|homes|honda|horse|host|hosting|hoteles|hotmail|house|how|hr|hsbc|ht|hu|hyundai|ibm|icbc|ice|icu|id|ie|ifm|iinet|il|im|immo|immobilien|in|industries|infiniti|info|ing|ink|institute|insurance|insure|int|international|investments|io|ipiranga|iq|ir|irish|is|iselect|ist|istanbul|it|itau|iwc|jaguar|java|jcb|je|jetzt|jewelry|jlc|jll|jm|jmp|jo|jobs|joburg|jot|joy|jp|jpmorgan|jprs|juegos|kaufen|kddi|ke|kerryhotels|kerrylogistics|kerryproperties|kfh|kg|kh|ki|kia|kim|kinder|kitchen|kiwi|km|kn|koeln|komatsu|kp|kpn|kr|krd|kred|kuokgroup|kw|ky|kyoto|kz|la|lacaixa|lamborghini|lamer|lancaster|land|landrover|lanxess|lasalle|lat|latrobe|law|lawyer|lb|lc|lds|lease|leclerc|legal|lexus|lgbt|li|liaison|lidl|life|lifeinsurance|lifestyle|lighting|like|limited|limo|lincoln|linde|link|live|living|lixil|lk|loan|loans|local|locus|lol|london|lotte|lotto|love|lr|ls|lt|ltd|ltda|lu|lupin|luxe|luxury|lv|ly|ma|madrid|maif|maison|makeup|man|management|mango|market|marketing|markets|marriott|mba|mc|md|me|med|media|meet|melbourne|meme|memorial|men|menu|meo|mg|mh|miami|microsoft|mil|mini|mk|ml|mm|mma|mn|mo|mobi|mobily|moda|moe|moi|mom|monash|money|montblanc|mormon|mortgage|moscow|motorcycles|mov|movie|movistar|mp|mq|mr|ms|mt|mtn|mtpc|mtr|mu|museum|mutuelle|mv|mw|mx|my|mz|na|nadex|nagoya|name|natura|navy|nc|ne|nec|net|netbank|network|neustar|new|news|nexus|nf|ng|ngo|nhk|ni|nico|nikon|ninja|nissan|nl|no|nokia|norton|nowruz|np|nr|nra|nrw|ntt|nu|nyc|nz|obi|office|okinawa|om|omega|one|ong|onl|online|ooo|oracle|orange|org|organic|origins|osaka|otsuka|ovh|pa|page|pamperedchef|panerai|paris|pars|partners|parts|party|passagens|pe|pet|pf|pg|ph|pharmacy|philips|photo|photography|photos|physio|piaget|pics|pictet|pictures|pid|pin|ping|pink|pizza|pk|pl|place|play|playstation|plumbing|plus|pm|pn|pohl|poker|porn|post|pr|praxi|press|pro|prod|productions|prof|promo|properties|property|protection|ps|pt|pub|pw|pwc|py|qa|qpon|quebec|quest|racing|re|read|realtor|realty|recipes|red|redstone|redumbrella|rehab|reise|reisen|reit|ren|rent|rentals|repair|report|republican|rest|restaurant|review|reviews|rexroth|rich|ricoh|rio|rip|ro|rocher|rocks|rodeo|room|rs|rsvp|ru|ruhr|run|rw|rwe|ryukyu|sa|saarland|safe|safety|sakura|sale|salon|samsung|sandvik|sandvikcoromant|sanofi|sap|sapo|sarl|sas|saxo|sb|sbs|sc|sca|scb|schaeffler|schmidt|scholarships|school|schule|schwarz|science|scor|scot|sd|se|seat|security|seek|select|sener|services|seven|sew|sex|sexy|sfr|sg|sh|sharp|shell|shia|shiksha|shoes|show|shriram|si|singles|site|sj|sk|ski|skin|sky|skype|sl|sm|smile|sn|sncf|so|soccer|social|softbank|software|sohu|solar|solutions|song|sony|soy|space|spiegel|spot|spreadbetting|sr|srl|st|stada|star|starhub|statefarm|statoil|stc|stcgroup|stockholm|storage|store|studio|study|style|su|sucks|supplies|supply|support|surf|surgery|suzuki|sv|swatch|swiss|sx|sy|sydney|symantec|systems|sz|tab|taipei|taobao|tatamotors|tatar|tattoo|tax|taxi|tc|tci|td|team|tech|technology|tel|telecity|telefonica|temasek|tennis|tf|tg|th|thd|theater|theatre|tickets|tienda|tiffany|tips|tires|tirol|tj|tk|tl|tm|tmall|tn|to|today|tokyo|tools|top|toray|toshiba|total|tours|town|toyota|toys|tp|tr|trade|trading|training|travel|travelers|travelersinsurance|trust|trv|tt|tube|tui|tunes|tushu|tv|tvs|tw|tz|ua|ubs|ug|uk|unicom|university|uno|uol|us|uy|uz|va|vacations|vana|vc|ve|vegas|ventures|verisign|versicherung|vet|vg|vi|viajes|video|viking|villas|vin|vip|virgin|vision|vista|vistaprint|viva|vlaanderen|vn|vodka|volkswagen|vote|voting|voto|voyage|vu|vuelos|wales|walter|wang|wanggou|watch|watches|weather|weatherchannel|webcam|weber|website|wed|wedding|weir|wf|whoswho|wien|wiki|williamhill|win|windows|wine|wme|wolterskluwer|work|works|world|ws|wtc|wtf|xbox|xerox|xin|xperia|xxx|xyz|yachts|yahoo|yamaxun|yandex|ye|yodobashi|yoga|yokohama|youtube|yt|za|zara|zero|zip|zm|zone|zuerich|zw'.split('|'); // macro, see gulpfile.js
+
+	var NUMBERS = '0123456789'.split('');
+	var ALPHANUM = '0123456789abcdefghijklmnopqrstuvwxyz'.split('');
+	var WHITESPACE = [' ', '\f', '\r', '\t', '\v', ' ', ' ', '᠎']; // excluding line breaks
+
+	var domainStates = []; // states that jump to DOMAIN on /[a-z0-9]/
+	var makeState = function makeState(tokenClass) {
+		return new CharacterState(tokenClass);
+	};
+
+	// Frequently used states
+	var S_START = makeState();
+	var S_NUM = makeState(NUM);
+	var S_DOMAIN = makeState(DOMAIN);
+	var S_DOMAIN_HYPHEN = makeState(); // domain followed by 1 or more hyphen characters
+	var S_WS = makeState(WS);
+
+	// States for special URL symbols
+	S_START.on('@', makeState(AT)).on('.', makeState(DOT)).on('+', makeState(PLUS)).on('#', makeState(POUND)).on('?', makeState(QUERY)).on('/', makeState(SLASH)).on('_', makeState(UNDERSCORE)).on(':', makeState(COLON)).on('{', makeState(OPENBRACE)).on('[', makeState(OPENBRACKET)).on('<', makeState(OPENANGLEBRACKET)).on('(', makeState(OPENPAREN)).on('}', makeState(CLOSEBRACE)).on(']', makeState(CLOSEBRACKET)).on('>', makeState(CLOSEANGLEBRACKET)).on(')', makeState(CLOSEPAREN)).on([',', ';', '!', '"', '\''], makeState(PUNCTUATION));
+
+	// Whitespace jumps
+	// Tokens of only non-newline whitespace are arbitrarily long
+	S_START.on('\n', makeState(TNL)).on(WHITESPACE, S_WS);
+
+	// If any whitespace except newline, more whitespace!
+	S_WS.on(WHITESPACE, S_WS);
+
+	// Generates states for top-level domains
+	// Note that this is most accurate when tlds are in alphabetical order
+	for (var i = 0; i < tlds.length; i++) {
+		var newStates = stateify(tlds[i], S_START, TLD, DOMAIN);
+		domainStates.push.apply(domainStates, newStates);
+	}
+
+	// Collect the states generated by different protocls
+	var partialProtocolFileStates = stateify('file', S_START, DOMAIN, DOMAIN);
+	var partialProtocolFtpStates = stateify('ftp', S_START, DOMAIN, DOMAIN);
+	var partialProtocolHttpStates = stateify('http', S_START, DOMAIN, DOMAIN);
+
+	// Add the states to the array of DOMAINeric states
+	domainStates.push.apply(domainStates, partialProtocolFileStates);
+	domainStates.push.apply(domainStates, partialProtocolFtpStates);
+	domainStates.push.apply(domainStates, partialProtocolHttpStates);
+
+	// Protocol states
+	var S_PROTOCOL_FILE = partialProtocolFileStates.pop();
+	var S_PROTOCOL_FTP = partialProtocolFtpStates.pop();
+	var S_PROTOCOL_HTTP = partialProtocolHttpStates.pop();
+	var S_PROTOCOL_SECURE = makeState(DOMAIN);
+	var S_FULL_PROTOCOL = makeState(PROTOCOL); // Full protocol ends with COLON
+
+	// Secure protocols (end with 's')
+	S_PROTOCOL_FTP.on('s', S_PROTOCOL_SECURE).on(':', S_FULL_PROTOCOL);
+
+	S_PROTOCOL_HTTP.on('s', S_PROTOCOL_SECURE).on(':', S_FULL_PROTOCOL);
+
+	domainStates.push(S_PROTOCOL_SECURE);
+
+	// Become protocol tokens after a COLON
+	S_PROTOCOL_FILE.on(':', S_FULL_PROTOCOL);
+	S_PROTOCOL_SECURE.on(':', S_FULL_PROTOCOL);
+
+	// Localhost
+	var partialLocalhostStates = stateify('localhost', S_START, LOCALHOST, DOMAIN);
+	domainStates.push.apply(domainStates, partialLocalhostStates);
+
+	// Everything else
+	// DOMAINs make more DOMAINs
+	// Number and character transitions
+	S_START.on(NUMBERS, S_NUM);
+	S_NUM.on('-', S_DOMAIN_HYPHEN).on(NUMBERS, S_NUM).on(ALPHANUM, S_DOMAIN); // number becomes DOMAIN
+
+	S_DOMAIN.on('-', S_DOMAIN_HYPHEN).on(ALPHANUM, S_DOMAIN);
+
+	// All the generated states should have a jump to DOMAIN
+	for (var _i = 0; _i < domainStates.length; _i++) {
+		domainStates[_i].on('-', S_DOMAIN_HYPHEN).on(ALPHANUM, S_DOMAIN);
+	}
+
+	S_DOMAIN_HYPHEN.on('-', S_DOMAIN_HYPHEN).on(NUMBERS, S_DOMAIN).on(ALPHANUM, S_DOMAIN);
+
+	// Set default transition
+	S_START.defaultTransition = makeState(SYM);
+
+	/**
+ 	Given a string, returns an array of TOKEN instances representing the
+ 	composition of that string.
+
+ 	@method run
+ 	@param {String} str Input string to scan
+ 	@return {Array} Array of TOKEN instances
+ */
+	var run = function run(str) {
+
+		// The state machine only looks at lowercase strings.
+		// This selective `toLowerCase` is used because lowercasing the entire
+		// string causes the length and character position to vary in some in some
+		// non-English strings. This happens only on V8-based runtimes.
+		var lowerStr = str.replace(/[A-Z]/g, function (c) {
+			return c.toLowerCase();
+		});
+		var len = str.length;
+		var tokens = []; // return value
+
+		var cursor = 0;
+
+		// Tokenize the string
+		while (cursor < len) {
+			var state = S_START;
+			var secondState = null;
+			var nextState = null;
+			var tokenLength = 0;
+			var latestAccepting = null;
+			var sinceAccepts = -1;
+
+			while (cursor < len && (nextState = state.next(lowerStr[cursor]))) {
+				secondState = null;
+				state = nextState;
+
+				// Keep track of the latest accepting state
+				if (state.accepts()) {
+					sinceAccepts = 0;
+					latestAccepting = state;
+				} else if (sinceAccepts >= 0) {
+					sinceAccepts++;
+				}
+
+				tokenLength++;
+				cursor++;
+			}
+
+			if (sinceAccepts < 0) {
+				continue;
+			} // Should never happen
+
+			// Roll back to the latest accepting state
+			cursor -= sinceAccepts;
+			tokenLength -= sinceAccepts;
+
+			// Get the class for the new token
+			var TOKEN = latestAccepting.emit(); // Current token class
+
+			// No more jumps, just make a new token
+			tokens.push(new TOKEN(str.substr(cursor - tokenLength, tokenLength)));
+		}
+
+		return tokens;
+	};
+
+	var start = S_START;
+
+	var scanner = Object.freeze({
+		State: CharacterState,
+		TOKENS: TOKENS,
+		run: run,
+		start: start
+	});
+
+	/******************************************************************************
+ 	Multi-Tokens
+ 	Tokens composed of arrays of TextTokens
+ ******************************************************************************/
+
+	// Is the given token a valid domain token?
+	// Should nums be included here?
+	function isDomainToken(token) {
+		return token instanceof DOMAIN || token instanceof TLD;
+	}
+
+	/**
+ 	Abstract class used for manufacturing tokens of text tokens. That is rather
+ 	than the value for a token being a small string of text, it's value an array
+ 	of text tokens.
+
+ 	Used for grouping together URLs, emails, hashtags, and other potential
+ 	creations.
+
+ 	@class MultiToken
+ 	@abstract
+ */
+	var MultiToken = createTokenClass();
+
+	MultiToken.prototype = {
+		/**
+  	String representing the type for this token
+  	@property type
+  	@default 'TOKEN'
+  */
+		type: 'token',
+
+		/**
+  	Is this multitoken a link?
+  	@property isLink
+  	@default false
+  */
+		isLink: false,
+
+		/**
+  	Return the string this token represents.
+  	@method toString
+  	@return {String}
+  */
+		toString: function toString() {
+			var result = [];
+			for (var _i2 = 0; _i2 < this.v.length; _i2++) {
+				result.push(this.v[_i2].toString());
+			}
+			return result.join('');
+		},
+
+
+		/**
+  	What should the value for this token be in the `href` HTML attribute?
+  	Returns the `.toString` value by default.
+  		@method toHref
+  	@return {String}
+  */
+		toHref: function toHref() {
+			return this.toString();
+		},
+
+
+		/**
+  	Returns a hash of relevant values for this token, which includes keys
+  	* type - Kind of token ('url', 'email', etc.)
+  	* value - Original text
+  	* href - The value that should be added to the anchor tag's href
+  		attribute
+  		@method toObject
+  	@param {String} [protocol] `'http'` by default
+  	@return {Object}
+  */
+		toObject: function toObject() {
+			var protocol = arguments.length <= 0 || arguments[0] === undefined ? 'http' : arguments[0];
+
+			return {
+				type: this.type,
+				value: this.toString(),
+				href: this.toHref(protocol)
+			};
+		}
+	};
+
+	/**
+ 	Represents a list of tokens making up a valid email address
+ 	@class EMAIL
+ 	@extends MultiToken
+ */
+	var EMAIL = inherits(MultiToken, createTokenClass(), {
+		type: 'email',
+		isLink: true,
+		toHref: function toHref() {
+			return 'mailto:' + this.toString();
+		}
+	});
+
+	/**
+ 	Represents some plain text
+ 	@class TEXT
+ 	@extends MultiToken
+ */
+	var TEXT = inherits(MultiToken, createTokenClass(), { type: 'text' });
+
+	/**
+ 	Multi-linebreak token - represents a line break
+ 	@class NL
+ 	@extends MultiToken
+ */
+	var NL = inherits(MultiToken, createTokenClass(), { type: 'nl' });
+
+	/**
+ 	Represents a list of tokens making up a valid URL
+ 	@class URL
+ 	@extends MultiToken
+ */
+	var URL = inherits(MultiToken, createTokenClass(), {
+		type: 'url',
+		isLink: true,
+
+		/**
+  	Lowercases relevant parts of the domain and adds the protocol if
+  	required. Note that this will not escape unsafe HTML characters in the
+  	URL.
+  		@method href
+  	@param {String} protocol
+  	@return {String}
+  */
+		toHref: function toHref() {
+			var protocol = arguments.length <= 0 || arguments[0] === undefined ? 'http' : arguments[0];
+
+			var hasProtocol = false;
+			var hasSlashSlash = false;
+			var tokens = this.v;
+			var result = [];
+			var i = 0;
+
+			// Make the first part of the domain lowercase
+			// Lowercase protocol
+			while (tokens[i] instanceof PROTOCOL) {
+				hasProtocol = true;
+				result.push(tokens[i].toString().toLowerCase());
+				i++;
+			}
+
+			// Skip slash-slash
+			while (tokens[i] instanceof SLASH) {
+				hasSlashSlash = true;
+				result.push(tokens[i].toString());
+				i++;
+			}
+
+			// Lowercase all other characters in the domain
+			while (isDomainToken(tokens[i])) {
+				result.push(tokens[i].toString().toLowerCase());
+				i++;
+			}
+
+			// Leave all other characters as they were written
+			for (; i < tokens.length; i++) {
+				result.push(tokens[i].toString());
+			}
+
+			result = result.join('');
+
+			if (!(hasProtocol || hasSlashSlash)) {
+				result = protocol + '://' + result;
+			}
+
+			return result;
+		},
+		hasProtocol: function hasProtocol() {
+			return this.v[0] instanceof PROTOCOL;
+		}
+	});
+
+	var TOKENS$1 = Object.freeze({
+		Base: MultiToken,
+		EMAIL: EMAIL,
+		NL: NL,
+		TEXT: TEXT,
+		URL: URL
+	});
+
+	/**
+ 	Not exactly parser, more like the second-stage scanner (although we can
+ 	theoretically hotswap the code here with a real parser in the future... but
+ 	for a little URL-finding utility abstract syntax trees may be a little
+ 	overkill).
+
+ 	URL format: http://en.wikipedia.org/wiki/URI_scheme
+ 	Email format: http://en.wikipedia.org/wiki/Email_address (links to RFC in
+ 	reference)
+
+ 	@module linkify
+ 	@submodule parser
+ 	@main parser
+ */
+
+	var makeState$1 = function makeState$1(tokenClass) {
+		return new State(tokenClass);
+	};
+
+	// The universal starting state.
+	var S_START$1 = makeState$1();
+
+	// Intermediate states for URLs. Note that domains that begin with a protocol
+	// are treated slighly differently from those that don't.
+	var S_PROTOCOL = makeState$1(); // e.g., 'http:'
+	var S_PROTOCOL_SLASH = makeState$1(); // e.g., '/', 'http:/''
+	var S_PROTOCOL_SLASH_SLASH = makeState$1(); // e.g., '//', 'http://'
+	var S_DOMAIN$1 = makeState$1(); // parsed string ends with a potential domain name (A)
+	var S_DOMAIN_DOT = makeState$1(); // (A) domain followed by DOT
+	var S_TLD = makeState$1(URL); // (A) Simplest possible URL with no query string
+	var S_TLD_COLON = makeState$1(); // (A) URL followed by colon (potential port number here)
+	var S_TLD_PORT = makeState$1(URL); // TLD followed by a port number
+	var S_URL = makeState$1(URL); // Long URL with optional port and maybe query string
+	var S_URL_NON_ACCEPTING = makeState$1(); // URL followed by some symbols (will not be part of the final URL)
+	var S_URL_OPENBRACE = makeState$1(); // URL followed by {
+	var S_URL_OPENBRACKET = makeState$1(); // URL followed by [
+	var S_URL_OPENANGLEBRACKET = makeState$1(); // URL followed by <
+	var S_URL_OPENPAREN = makeState$1(); // URL followed by (
+	var S_URL_OPENBRACE_Q = makeState$1(URL); // URL followed by { and some symbols that the URL can end it
+	var S_URL_OPENBRACKET_Q = makeState$1(URL); // URL followed by [ and some symbols that the URL can end it
+	var S_URL_OPENANGLEBRACKET_Q = makeState$1(URL); // URL followed by < and some symbols that the URL can end it
+	var S_URL_OPENPAREN_Q = makeState$1(URL); // URL followed by ( and some symbols that the URL can end it
+	var S_URL_OPENBRACE_SYMS = makeState$1(); // S_URL_OPENBRACE_Q followed by some symbols it cannot end it
+	var S_URL_OPENBRACKET_SYMS = makeState$1(); // S_URL_OPENBRACKET_Q followed by some symbols it cannot end it
+	var S_URL_OPENANGLEBRACKET_SYMS = makeState$1(); // S_URL_OPENANGLEBRACKET_Q followed by some symbols it cannot end it
+	var S_URL_OPENPAREN_SYMS = makeState$1(); // S_URL_OPENPAREN_Q followed by some symbols it cannot end it
+	var S_EMAIL_DOMAIN = makeState$1(); // parsed string starts with local email info + @ with a potential domain name (C)
+	var S_EMAIL_DOMAIN_DOT = makeState$1(); // (C) domain followed by DOT
+	var S_EMAIL = makeState$1(EMAIL); // (C) Possible email address (could have more tlds)
+	var S_EMAIL_COLON = makeState$1(); // (C) URL followed by colon (potential port number here)
+	var S_EMAIL_PORT = makeState$1(EMAIL); // (C) Email address with a port
+	var S_LOCALPART = makeState$1(); // Local part of the email address
+	var S_LOCALPART_AT = makeState$1(); // Local part of the email address plus @
+	var S_LOCALPART_DOT = makeState$1(); // Local part of the email address plus '.' (localpart cannot end in .)
+	var S_NL = makeState$1(NL); // single new line
+
+	// Make path from start to protocol (with '//')
+	S_START$1.on(TNL, S_NL).on(PROTOCOL, S_PROTOCOL).on(SLASH, S_PROTOCOL_SLASH);
+
+	S_PROTOCOL.on(SLASH, S_PROTOCOL_SLASH);
+	S_PROTOCOL_SLASH.on(SLASH, S_PROTOCOL_SLASH_SLASH);
+
+	// The very first potential domain name
+	S_START$1.on(TLD, S_DOMAIN$1).on(DOMAIN, S_DOMAIN$1).on(LOCALHOST, S_TLD).on(NUM, S_DOMAIN$1);
+
+	// Force URL for anything sane followed by protocol
+	S_PROTOCOL_SLASH_SLASH.on(TLD, S_URL).on(DOMAIN, S_URL).on(NUM, S_URL).on(LOCALHOST, S_URL);
+
+	// Account for dots and hyphens
+	// hyphens are usually parts of domain names
+	S_DOMAIN$1.on(DOT, S_DOMAIN_DOT);
+	S_EMAIL_DOMAIN.on(DOT, S_EMAIL_DOMAIN_DOT);
+
+	// Hyphen can jump back to a domain name
+
+	// After the first domain and a dot, we can find either a URL or another domain
+	S_DOMAIN_DOT.on(TLD, S_TLD).on(DOMAIN, S_DOMAIN$1).on(NUM, S_DOMAIN$1).on(LOCALHOST, S_DOMAIN$1);
+
+	S_EMAIL_DOMAIN_DOT.on(TLD, S_EMAIL).on(DOMAIN, S_EMAIL_DOMAIN).on(NUM, S_EMAIL_DOMAIN).on(LOCALHOST, S_EMAIL_DOMAIN);
+
+	// S_TLD accepts! But the URL could be longer, try to find a match greedily
+	// The `run` function should be able to "rollback" to the accepting state
+	S_TLD.on(DOT, S_DOMAIN_DOT);
+	S_EMAIL.on(DOT, S_EMAIL_DOMAIN_DOT);
+
+	// Become real URLs after `SLASH` or `COLON NUM SLASH`
+	// Here PSS and non-PSS converge
+	S_TLD.on(COLON, S_TLD_COLON).on(SLASH, S_URL);
+	S_TLD_COLON.on(NUM, S_TLD_PORT);
+	S_TLD_PORT.on(SLASH, S_URL);
+	S_EMAIL.on(COLON, S_EMAIL_COLON);
+	S_EMAIL_COLON.on(NUM, S_EMAIL_PORT);
+
+	// Types of characters the URL can definitely end in
+	var qsAccepting = [DOMAIN, AT, LOCALHOST, NUM, PLUS, POUND, PROTOCOL, SLASH, TLD, UNDERSCORE, SYM];
+
+	// Types of tokens that can follow a URL and be part of the query string
+	// but cannot be the very last characters
+	// Characters that cannot appear in the URL at all should be excluded
+	var qsNonAccepting = [COLON, DOT, QUERY, PUNCTUATION, CLOSEBRACE, CLOSEBRACKET, CLOSEANGLEBRACKET, CLOSEPAREN, OPENBRACE, OPENBRACKET, OPENANGLEBRACKET, OPENPAREN];
+
+	// These states are responsible primarily for determining whether or not to
+	// include the final round bracket.
+
+	// URL, followed by an opening bracket
+	S_URL.on(OPENBRACE, S_URL_OPENBRACE).on(OPENBRACKET, S_URL_OPENBRACKET).on(OPENANGLEBRACKET, S_URL_OPENANGLEBRACKET).on(OPENPAREN, S_URL_OPENPAREN);
+
+	// URL with extra symbols at the end, followed by an opening bracket
+	S_URL_NON_ACCEPTING.on(OPENBRACE, S_URL_OPENBRACE).on(OPENBRACKET, S_URL_OPENBRACKET).on(OPENANGLEBRACKET, S_URL_OPENANGLEBRACKET).on(OPENPAREN, S_URL_OPENPAREN);
+
+	// Closing bracket component. This character WILL be included in the URL
+	S_URL_OPENBRACE.on(CLOSEBRACE, S_URL);
+	S_URL_OPENBRACKET.on(CLOSEBRACKET, S_URL);
+	S_URL_OPENANGLEBRACKET.on(CLOSEANGLEBRACKET, S_URL);
+	S_URL_OPENPAREN.on(CLOSEPAREN, S_URL);
+	S_URL_OPENBRACE_Q.on(CLOSEBRACE, S_URL);
+	S_URL_OPENBRACKET_Q.on(CLOSEBRACKET, S_URL);
+	S_URL_OPENANGLEBRACKET_Q.on(CLOSEANGLEBRACKET, S_URL);
+	S_URL_OPENPAREN_Q.on(CLOSEPAREN, S_URL);
+	S_URL_OPENBRACE_SYMS.on(CLOSEBRACE, S_URL);
+	S_URL_OPENBRACKET_SYMS.on(CLOSEBRACKET, S_URL);
+	S_URL_OPENANGLEBRACKET_SYMS.on(CLOSEANGLEBRACKET, S_URL);
+	S_URL_OPENPAREN_SYMS.on(CLOSEPAREN, S_URL);
+
+	// URL that beings with an opening bracket, followed by a symbols.
+	// Note that the final state can still be `S_URL_OPENBRACE_Q` (if the URL only
+	// has a single opening bracket for some reason).
+	S_URL_OPENBRACE.on(qsAccepting, S_URL_OPENBRACE_Q);
+	S_URL_OPENBRACKET.on(qsAccepting, S_URL_OPENBRACKET_Q);
+	S_URL_OPENANGLEBRACKET.on(qsAccepting, S_URL_OPENANGLEBRACKET_Q);
+	S_URL_OPENPAREN.on(qsAccepting, S_URL_OPENPAREN_Q);
+	S_URL_OPENBRACE.on(qsNonAccepting, S_URL_OPENBRACE_SYMS);
+	S_URL_OPENBRACKET.on(qsNonAccepting, S_URL_OPENBRACKET_SYMS);
+	S_URL_OPENANGLEBRACKET.on(qsNonAccepting, S_URL_OPENANGLEBRACKET_SYMS);
+	S_URL_OPENPAREN.on(qsNonAccepting, S_URL_OPENPAREN_SYMS);
+
+	// URL that begins with an opening bracket, followed by some symbols
+	S_URL_OPENBRACE_Q.on(qsAccepting, S_URL_OPENBRACE_Q);
+	S_URL_OPENBRACKET_Q.on(qsAccepting, S_URL_OPENBRACKET_Q);
+	S_URL_OPENANGLEBRACKET_Q.on(qsAccepting, S_URL_OPENANGLEBRACKET_Q);
+	S_URL_OPENPAREN_Q.on(qsAccepting, S_URL_OPENPAREN_Q);
+	S_URL_OPENBRACE_Q.on(qsNonAccepting, S_URL_OPENBRACE_Q);
+	S_URL_OPENBRACKET_Q.on(qsNonAccepting, S_URL_OPENBRACKET_Q);
+	S_URL_OPENANGLEBRACKET_Q.on(qsNonAccepting, S_URL_OPENANGLEBRACKET_Q);
+	S_URL_OPENPAREN_Q.on(qsNonAccepting, S_URL_OPENPAREN_Q);
+
+	S_URL_OPENBRACE_SYMS.on(qsAccepting, S_URL_OPENBRACE_Q);
+	S_URL_OPENBRACKET_SYMS.on(qsAccepting, S_URL_OPENBRACKET_Q);
+	S_URL_OPENANGLEBRACKET_SYMS.on(qsAccepting, S_URL_OPENANGLEBRACKET_Q);
+	S_URL_OPENPAREN_SYMS.on(qsAccepting, S_URL_OPENPAREN_Q);
+	S_URL_OPENBRACE_SYMS.on(qsNonAccepting, S_URL_OPENBRACE_SYMS);
+	S_URL_OPENBRACKET_SYMS.on(qsNonAccepting, S_URL_OPENBRACKET_SYMS);
+	S_URL_OPENANGLEBRACKET_SYMS.on(qsNonAccepting, S_URL_OPENANGLEBRACKET_SYMS);
+	S_URL_OPENPAREN_SYMS.on(qsNonAccepting, S_URL_OPENPAREN_SYMS);
+
+	// Account for the query string
+	S_URL.on(qsAccepting, S_URL);
+	S_URL_NON_ACCEPTING.on(qsAccepting, S_URL);
+
+	S_URL.on(qsNonAccepting, S_URL_NON_ACCEPTING);
+	S_URL_NON_ACCEPTING.on(qsNonAccepting, S_URL_NON_ACCEPTING);
+
+	// Email address-specific state definitions
+	// Note: We are not allowing '/' in email addresses since this would interfere
+	// with real URLs
+
+	// Tokens allowed in the localpart of the email
+	var localpartAccepting = [DOMAIN, NUM, PLUS, POUND, QUERY, UNDERSCORE, SYM, TLD];
+
+	// Some of the tokens in `localpartAccepting` are already accounted for here and
+	// will not be overwritten (don't worry)
+	S_DOMAIN$1.on(localpartAccepting, S_LOCALPART).on(AT, S_LOCALPART_AT);
+	S_TLD.on(localpartAccepting, S_LOCALPART).on(AT, S_LOCALPART_AT);
+	S_DOMAIN_DOT.on(localpartAccepting, S_LOCALPART);
+
+	// Okay we're on a localpart. Now what?
+	// TODO: IP addresses and what if the email starts with numbers?
+	S_LOCALPART.on(localpartAccepting, S_LOCALPART).on(AT, S_LOCALPART_AT) // close to an email address now
+	.on(DOT, S_LOCALPART_DOT);
+	S_LOCALPART_DOT.on(localpartAccepting, S_LOCALPART);
+	S_LOCALPART_AT.on(TLD, S_EMAIL_DOMAIN).on(DOMAIN, S_EMAIL_DOMAIN).on(LOCALHOST, S_EMAIL);
+	// States following `@` defined above
+
+	var run$1 = function run$1(tokens) {
+		var len = tokens.length;
+		var cursor = 0;
+		var multis = [];
+		var textTokens = [];
+
+		while (cursor < len) {
+			var state = S_START$1;
+			var secondState = null;
+			var nextState = null;
+			var multiLength = 0;
+			var latestAccepting = null;
+			var sinceAccepts = -1;
+
+			while (cursor < len && !(secondState = state.next(tokens[cursor]))) {
+				// Starting tokens with nowhere to jump to.
+				// Consider these to be just plain text
+				textTokens.push(tokens[cursor++]);
+			}
+
+			while (cursor < len && (nextState = secondState || state.next(tokens[cursor]))) {
+
+				// Get the next state
+				secondState = null;
+				state = nextState;
+
+				// Keep track of the latest accepting state
+				if (state.accepts()) {
+					sinceAccepts = 0;
+					latestAccepting = state;
+				} else if (sinceAccepts >= 0) {
+					sinceAccepts++;
+				}
+
+				cursor++;
+				multiLength++;
+			}
+
+			if (sinceAccepts < 0) {
+
+				// No accepting state was found, part of a regular text token
+				// Add all the tokens we looked at to the text tokens array
+				for (var _i3 = cursor - multiLength; _i3 < cursor; _i3++) {
+					textTokens.push(tokens[_i3]);
+				}
+			} else {
+
+				// Accepting state!
+
+				// First close off the textTokens (if available)
+				if (textTokens.length > 0) {
+					multis.push(new TEXT(textTokens));
+					textTokens = [];
+				}
+
+				// Roll back to the latest accepting state
+				cursor -= sinceAccepts;
+				multiLength -= sinceAccepts;
+
+				// Create a new multitoken
+				var MULTI = latestAccepting.emit();
+				multis.push(new MULTI(tokens.slice(cursor - multiLength, cursor)));
+			}
+		}
+
+		// Finally close off the textTokens (if available)
+		if (textTokens.length > 0) {
+			multis.push(new TEXT(textTokens));
+		}
+
+		return multis;
+	};
+
+	var parser = Object.freeze({
+		State: State,
+		TOKENS: TOKENS$1,
+		run: run$1,
+		start: S_START$1
+	});
+
+	if (!Array.isArray) {
+		Array.isArray = function (arg) {
+			return Object.prototype.toString.call(arg) === '[object Array]';
+		};
+	}
+
+	/**
+ 	Converts a string into tokens that represent linkable and non-linkable bits
+ 	@method tokenize
+ 	@param {String} str
+ 	@return {Array} tokens
+ */
+	var tokenize = function tokenize(str) {
+		return run$1(run(str));
+	};
+
+	/**
+ 	Returns a list of linkable items in the given string.
+ */
+	var find = function find(str) {
+		var type = arguments.length <= 1 || arguments[1] === undefined ? null : arguments[1];
+
+		var tokens = tokenize(str);
+		var filtered = [];
+
+		for (var i = 0; i < tokens.length; i++) {
+			var token = tokens[i];
+			if (token.isLink && (!type || token.type === type)) {
+				filtered.push(token.toObject());
+			}
+		}
+
+		return filtered;
+	};
+
+	/**
+ 	Is the given string valid linkable text of some sort
+ 	Note that this does not trim the text for you.
+
+ 	Optionally pass in a second `type` param, which is the type of link to test
+ 	for.
+
+ 	For example,
+
+ 		test(str, 'email');
+
+ 	Will return `true` if str is a valid email.
+ */
+	var test = function test(str) {
+		var type = arguments.length <= 1 || arguments[1] === undefined ? null : arguments[1];
+
+		var tokens = tokenize(str);
+		return tokens.length === 1 && tokens[0].isLink && (!type || tokens[0].type === type);
+	};
+
+	exports.find = find;
+	exports.inherits = inherits;
+	exports.options = options;
+	exports.parser = parser;
+	exports.scanner = scanner;
+	exports.test = test;
+	exports.tokenize = tokenize;
+})(window.linkify = window.linkify || {});
+})();
diff --git a/src/web-chatview/qwebchannel.js b/src/web-chatview/qwebchannel.js
new file mode 100644
index 0000000000000000000000000000000000000000..abf8461be4f726e3b6ea4b8a239f507b05f61c31
--- /dev/null
+++ b/src/web-chatview/qwebchannel.js
@@ -0,0 +1,427 @@
+/****************************************************************************
+**
+** Copyright (C) 2016 The Qt Company Ltd.
+** Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff <milian.wolff@kdab.com>
+** Contact: https://www.qt.io/licensing/
+**
+** This file is part of the QtWebChannel module of the Qt Toolkit.
+**
+** $QT_BEGIN_LICENSE:LGPL$
+** Commercial License Usage
+** Licensees holding valid commercial Qt licenses may use this file in
+** accordance with the commercial license agreement provided with the
+** Software or, alternatively, in accordance with the terms contained in
+** a written agreement between you and The Qt Company. For licensing terms
+** and conditions see https://www.qt.io/terms-conditions. For further
+** information use the contact form at https://www.qt.io/contact-us.
+**
+** GNU Lesser General Public License Usage
+** Alternatively, this file may be used under the terms of the GNU Lesser
+** General Public License version 3 as published by the Free Software
+** Foundation and appearing in the file LICENSE.LGPL3 included in the
+** packaging of this file. Please review the following information to
+** ensure the GNU Lesser General Public License version 3 requirements
+** will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
+**
+** GNU General Public License Usage
+** Alternatively, this file may be used under the terms of the GNU
+** General Public License version 2.0 or (at your option) the GNU General
+** Public license version 3 or any later version approved by the KDE Free
+** Qt Foundation. The licenses are as published by the Free Software
+** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
+** included in the packaging of this file. Please review the following
+** information to ensure the GNU General Public License requirements will
+** be met: https://www.gnu.org/licenses/gpl-2.0.html and
+** https://www.gnu.org/licenses/gpl-3.0.html.
+**
+** $QT_END_LICENSE$
+**
+****************************************************************************/
+
+"use strict";
+
+var QWebChannelMessageTypes = {
+    signal: 1,
+    propertyUpdate: 2,
+    init: 3,
+    idle: 4,
+    debug: 5,
+    invokeMethod: 6,
+    connectToSignal: 7,
+    disconnectFromSignal: 8,
+    setProperty: 9,
+    response: 10,
+};
+
+var QWebChannel = function(transport, initCallback)
+{
+    if (typeof transport !== "object" || typeof transport.send !== "function") {
+        console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." +
+                      " Given is: transport: " + typeof(transport) + ", transport.send: " + typeof(transport.send));
+        return;
+    }
+
+    var channel = this;
+    this.transport = transport;
+
+    this.send = function(data)
+    {
+        if (typeof(data) !== "string") {
+            data = JSON.stringify(data);
+        }
+        channel.transport.send(data);
+    }
+
+    this.transport.onmessage = function(message)
+    {
+        var data = message.data;
+        if (typeof data === "string") {
+            data = JSON.parse(data);
+        }
+        switch (data.type) {
+            case QWebChannelMessageTypes.signal:
+                channel.handleSignal(data);
+                break;
+            case QWebChannelMessageTypes.response:
+                channel.handleResponse(data);
+                break;
+            case QWebChannelMessageTypes.propertyUpdate:
+                channel.handlePropertyUpdate(data);
+                break;
+            default:
+                console.error("invalid message received:", message.data);
+                break;
+        }
+    }
+
+    this.execCallbacks = {};
+    this.execId = 0;
+    this.exec = function(data, callback)
+    {
+        if (!callback) {
+            // if no callback is given, send directly
+            channel.send(data);
+            return;
+        }
+        if (channel.execId === Number.MAX_VALUE) {
+            // wrap
+            channel.execId = Number.MIN_VALUE;
+        }
+        if (data.hasOwnProperty("id")) {
+            console.error("Cannot exec message with property id: " + JSON.stringify(data));
+            return;
+        }
+        data.id = channel.execId++;
+        channel.execCallbacks[data.id] = callback;
+        channel.send(data);
+    };
+
+    this.objects = {};
+
+    this.handleSignal = function(message)
+    {
+        var object = channel.objects[message.object];
+        if (object) {
+            object.signalEmitted(message.signal, message.args);
+        } else {
+            console.warn("Unhandled signal: " + message.object + "::" + message.signal);
+        }
+    }
+
+    this.handleResponse = function(message)
+    {
+        if (!message.hasOwnProperty("id")) {
+            console.error("Invalid response message received: ", JSON.stringify(message));
+            return;
+        }
+        channel.execCallbacks[message.id](message.data);
+        delete channel.execCallbacks[message.id];
+    }
+
+    this.handlePropertyUpdate = function(message)
+    {
+        for (var i in message.data) {
+            var data = message.data[i];
+            var object = channel.objects[data.object];
+            if (object) {
+                object.propertyUpdate(data.signals, data.properties);
+            } else {
+                console.warn("Unhandled property update: " + data.object + "::" + data.signal);
+            }
+        }
+        channel.exec({type: QWebChannelMessageTypes.idle});
+    }
+
+    this.debug = function(message)
+    {
+        channel.send({type: QWebChannelMessageTypes.debug, data: message});
+    };
+
+    channel.exec({type: QWebChannelMessageTypes.init}, function(data) {
+        for (var objectName in data) {
+            var object = new QObject(objectName, data[objectName], channel);
+        }
+        // now unwrap properties, which might reference other registered objects
+        for (var objectName in channel.objects) {
+            channel.objects[objectName].unwrapProperties();
+        }
+        if (initCallback) {
+            initCallback(channel);
+        }
+        channel.exec({type: QWebChannelMessageTypes.idle});
+    });
+};
+
+function QObject(name, data, webChannel)
+{
+    this.__id__ = name;
+    webChannel.objects[name] = this;
+
+    // List of callbacks that get invoked upon signal emission
+    this.__objectSignals__ = {};
+
+    // Cache of all properties, updated when a notify signal is emitted
+    this.__propertyCache__ = {};
+
+    var object = this;
+
+    // ----------------------------------------------------------------------
+
+    this.unwrapQObject = function(response)
+    {
+        if (response instanceof Array) {
+            // support list of objects
+            var ret = new Array(response.length);
+            for (var i = 0; i < response.length; ++i) {
+                ret[i] = object.unwrapQObject(response[i]);
+            }
+            return ret;
+        }
+        if (!response
+            || !response["__QObject*__"]
+            || response.id === undefined) {
+            return response;
+        }
+
+        var objectId = response.id;
+        if (webChannel.objects[objectId])
+            return webChannel.objects[objectId];
+
+        if (!response.data) {
+            console.error("Cannot unwrap unknown QObject " + objectId + " without data.");
+            return;
+        }
+
+        var qObject = new QObject( objectId, response.data, webChannel );
+        qObject.destroyed.connect(function() {
+            if (webChannel.objects[objectId] === qObject) {
+                delete webChannel.objects[objectId];
+                // reset the now deleted QObject to an empty {} object
+                // just assigning {} though would not have the desired effect, but the
+                // below also ensures all external references will see the empty map
+                // NOTE: this detour is necessary to workaround QTBUG-40021
+                var propertyNames = [];
+                for (var propertyName in qObject) {
+                    propertyNames.push(propertyName);
+                }
+                for (var idx in propertyNames) {
+                    delete qObject[propertyNames[idx]];
+                }
+            }
+        });
+        // here we are already initialized, and thus must directly unwrap the properties
+        qObject.unwrapProperties();
+        return qObject;
+    }
+
+    this.unwrapProperties = function()
+    {
+        for (var propertyIdx in object.__propertyCache__) {
+            object.__propertyCache__[propertyIdx] = object.unwrapQObject(object.__propertyCache__[propertyIdx]);
+        }
+    }
+
+    function addSignal(signalData, isPropertyNotifySignal)
+    {
+        var signalName = signalData[0];
+        var signalIndex = signalData[1];
+        object[signalName] = {
+            connect: function(callback) {
+                if (typeof(callback) !== "function") {
+                    console.error("Bad callback given to connect to signal " + signalName);
+                    return;
+                }
+
+                object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || [];
+                object.__objectSignals__[signalIndex].push(callback);
+
+                if (!isPropertyNotifySignal && signalName !== "destroyed") {
+                    // only required for "pure" signals, handled separately for properties in propertyUpdate
+                    // also note that we always get notified about the destroyed signal
+                    webChannel.exec({
+                        type: QWebChannelMessageTypes.connectToSignal,
+                        object: object.__id__,
+                        signal: signalIndex
+                    });
+                }
+            },
+            disconnect: function(callback) {
+                if (typeof(callback) !== "function") {
+                    console.error("Bad callback given to disconnect from signal " + signalName);
+                    return;
+                }
+                object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || [];
+                var idx = object.__objectSignals__[signalIndex].indexOf(callback);
+                if (idx === -1) {
+                    console.error("Cannot find connection of signal " + signalName + " to " + callback.name);
+                    return;
+                }
+                object.__objectSignals__[signalIndex].splice(idx, 1);
+                if (!isPropertyNotifySignal && object.__objectSignals__[signalIndex].length === 0) {
+                    // only required for "pure" signals, handled separately for properties in propertyUpdate
+                    webChannel.exec({
+                        type: QWebChannelMessageTypes.disconnectFromSignal,
+                        object: object.__id__,
+                        signal: signalIndex
+                    });
+                }
+            }
+        };
+    }
+
+    /**
+     * Invokes all callbacks for the given signalname. Also works for property notify callbacks.
+     */
+    function invokeSignalCallbacks(signalName, signalArgs)
+    {
+        var connections = object.__objectSignals__[signalName];
+        if (connections) {
+            connections.forEach(function(callback) {
+                callback.apply(callback, signalArgs);
+            });
+        }
+    }
+
+    this.propertyUpdate = function(signals, propertyMap)
+    {
+        // update property cache
+        for (var propertyIndex in propertyMap) {
+            var propertyValue = propertyMap[propertyIndex];
+            object.__propertyCache__[propertyIndex] = propertyValue;
+        }
+
+        for (var signalName in signals) {
+            // Invoke all callbacks, as signalEmitted() does not. This ensures the
+            // property cache is updated before the callbacks are invoked.
+            invokeSignalCallbacks(signalName, signals[signalName]);
+        }
+    }
+
+    this.signalEmitted = function(signalName, signalArgs)
+    {
+        invokeSignalCallbacks(signalName, this.unwrapQObject(signalArgs));
+    }
+
+    function addMethod(methodData)
+    {
+        var methodName = methodData[0];
+        var methodIdx = methodData[1];
+        object[methodName] = function() {
+            var args = [];
+            var callback;
+            for (var i = 0; i < arguments.length; ++i) {
+                var argument = arguments[i];
+                if (typeof argument === "function")
+                    callback = argument;
+                else if (argument instanceof QObject && webChannel.objects[argument.__id__] !== undefined)
+                    args.push({
+                        "id": argument.__id__
+                    });
+                else
+                    args.push(argument);
+            }
+
+            webChannel.exec({
+                "type": QWebChannelMessageTypes.invokeMethod,
+                "object": object.__id__,
+                "method": methodIdx,
+                "args": args
+            }, function(response) {
+                if (response !== undefined) {
+                    var result = object.unwrapQObject(response);
+                    if (callback) {
+                        (callback)(result);
+                    }
+                }
+            });
+        };
+    }
+
+    function bindGetterSetter(propertyInfo)
+    {
+        var propertyIndex = propertyInfo[0];
+        var propertyName = propertyInfo[1];
+        var notifySignalData = propertyInfo[2];
+        // initialize property cache with current value
+        // NOTE: if this is an object, it is not directly unwrapped as it might
+        // reference other QObject that we do not know yet
+        object.__propertyCache__[propertyIndex] = propertyInfo[3];
+
+        if (notifySignalData) {
+            if (notifySignalData[0] === 1) {
+                // signal name is optimized away, reconstruct the actual name
+                notifySignalData[0] = propertyName + "Changed";
+            }
+            addSignal(notifySignalData, true);
+        }
+
+        Object.defineProperty(object, propertyName, {
+            configurable: true,
+            get: function () {
+                var propertyValue = object.__propertyCache__[propertyIndex];
+                if (propertyValue === undefined) {
+                    // This shouldn't happen
+                    console.warn("Undefined value in property cache for property \"" + propertyName + "\" in object " + object.__id__);
+                }
+
+                return propertyValue;
+            },
+            set: function(value) {
+                if (value === undefined) {
+                    console.warn("Property setter for " + propertyName + " called with undefined value!");
+                    return;
+                }
+                object.__propertyCache__[propertyIndex] = value;
+                var valueToSend = value;
+                if (valueToSend instanceof QObject && webChannel.objects[valueToSend.__id__] !== undefined)
+                    valueToSend = { "id": valueToSend.__id__ };
+                webChannel.exec({
+                    "type": QWebChannelMessageTypes.setProperty,
+                    "object": object.__id__,
+                    "property": propertyIndex,
+                    "value": valueToSend
+                });
+            }
+        });
+
+    }
+
+    // ----------------------------------------------------------------------
+
+    data.methods.forEach(addMethod);
+
+    data.properties.forEach(bindGetterSetter);
+
+    data.signals.forEach(function(signal) { addSignal(signal, false); });
+
+    for (var name in data.enums) {
+        object[name] = data.enums[name];
+    }
+}
+
+//required for use with nodejs
+if (typeof module === 'object') {
+    module.exports = {
+        QWebChannel: QWebChannel
+    };
+}
diff --git a/src/web-chatview/web.gresource.xml b/src/web-chatview/web.gresource.xml
new file mode 100644
index 0000000000000000000000000000000000000000..de86990317a2fd73592ee6d16669e82ddd298fd8
--- /dev/null
+++ b/src/web-chatview/web.gresource.xml
@@ -0,0 +1,20 @@
+  <?xml version="1.0" encoding="UTF-8"?>
+  <gresources>
+    <gresource prefix="/net/jami/JamiGnome">
+      <!-- HTML -->
+      <file>chatview.html</file>
+
+      <!-- JavaScript -->
+      <file>chatview.js</file>
+      <file>linkify.js</file>
+      <file>linkify-string.js</file>
+      <file>linkify-html.js</file>
+      <file>jed.js</file>
+
+      <!-- CSS -->
+      <file>chatview.css</file>
+      <file>chatview-gnome.css</file>
+
+      <!-- Locale -->
+    </gresource>
+  </gresources>