1
0
Fork 0
mirror of https://git.jami.net/savoirfairelinux/jami-client-qt.git synced 2025-09-10 12:03:18 +02:00

chat: preprocess text msgs w/md4c+tidy-html5

Introduces MessageParser to encapsulate text treatment for raw text messages.

The async parsing sequence is as follows:
- Markdown -> HTML (md4c)
- link coloration (tidy-html5)
- notify UI
- request link preview info from PreviewEngine for the first link
- Preview engine uses QtNetwork instead of QtWebengine
- Linkification is handled by MessageParser instead of linkify.js

QtWebengine is no longer required for message parsing.

Gitlab: #1033
Gitlab: #855
Change-Id: Ief9b91aa291caf284f08230acaf57976f80fa05b
This commit is contained in:
Andreas Traczyk 2023-03-20 16:26:37 -04:00 committed by Sébastien Blin
parent 07527be378
commit 8db188c513
37 changed files with 979 additions and 4312 deletions

8
.gitmodules vendored
View file

@ -19,3 +19,11 @@
path = extras/packaging/update/sparkle/Sparkle
url = https://github.com/sparkle-project/Sparkle.git
ignore = dirty
[submodule "3rdparty/md4c"]
path = 3rdparty/md4c
url = https://github.com/mity/md4c.git
ignore = dirty
[submodule "3rdparty/tidy-html5"]
path = 3rdparty/tidy-html5
url = https://github.com/htacg/tidy-html5.git
ignore = dirty

1
3rdparty/md4c vendored Submodule

@ -0,0 +1 @@
Subproject commit e9ff661ff818ee94a4a231958d9b6768dc6882c9

1
3rdparty/tidy-html5 vendored Submodule

@ -0,0 +1 @@
Subproject commit d08ddc2860aa95ba8e301343a30837f157977cba

View file

@ -29,6 +29,18 @@ else()
project(jami)
endif()
option(WITH_DAEMON_SUBMODULE "Build with daemon submodule" OFF)
option(ENABLE_TESTS "Build with tests" OFF)
option(WITH_WEBENGINE "Build with WebEngine" ON)
if(WITH_WEBENGINE)
add_definitions(-DWITH_WEBENGINE)
endif()
# init some variables for includes, libs, etc.
set(CLIENT_INCLUDE_DIRS, "")
set(CLIENT_LINK_DIRS, "")
set(CLIENT_LIBS, "")
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_FLAGS_DEBUG "-Og -ggdb")
@ -41,12 +53,9 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON)
# Main project directories:
# jami-daemon
if(NOT DEFINED WITH_DAEMON_SUBMODULE)
set(WITH_DAEMON_SUBMODULE false)
# daemon
if(NOT WITH_DAEMON_SUBMODULE)
set(DAEMON_DIR ${PROJECT_SOURCE_DIR}/../daemon)
else()
# daemon
set(DAEMON_DIR ${PROJECT_SOURCE_DIR}/daemon)
endif()
# src
@ -93,7 +102,6 @@ add_subdirectory(${LIBCLIENT_SRC_DIR})
set(QT_MODULES
Quick
Network
NetworkAuth
Svg
Gui
Qml
@ -106,10 +114,6 @@ set(QT_MODULES
Widgets
Positioning)
if(NOT DEFINED WITH_WEBENGINE)
set(WITH_WEBENGINE true)
endif()
if(WITH_WEBENGINE)
list(APPEND QT_MODULES
WebEngineCore
@ -232,7 +236,9 @@ set(COMMON_SOURCES
${APP_SRC_DIR}/callparticipantsmodel.cpp
${APP_SRC_DIR}/tipsmodel.cpp
${APP_SRC_DIR}/positioning.cpp
${APP_SRC_DIR}/currentcall.cpp)
${APP_SRC_DIR}/currentcall.cpp
${APP_SRC_DIR}/messageparser.cpp
${APP_SRC_DIR}/previewengine.cpp)
set(COMMON_HEADERS
${APP_SRC_DIR}/avatarimageprovider.h
@ -293,16 +299,9 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/callparticipantsmodel.h
${APP_SRC_DIR}/tipsmodel.h
${APP_SRC_DIR}/positioning.h
${APP_SRC_DIR}/currentcall.h)
if(WITH_WEBENGINE)
list(APPEND COMMON_SOURCES
${APP_SRC_DIR}/previewengine.cpp)
add_definitions(-DWITH_WEBENGINE)
else()
list(APPEND COMMON_SOURCES
${APP_SRC_DIR}/nowebengine/previewengine.cpp)
endif()
${APP_SRC_DIR}/currentcall.h
${APP_SRC_DIR}/messageparser.h
${APP_SRC_DIR}/htmlparser.h)
# For libavutil/avframe.
set(LIBJAMI_CONTRIB_DIR "${DAEMON_DIR}/contrib")
@ -494,10 +493,26 @@ if(ENABLE_LIBWRAP)
${LIBCLIENT_SRC_DIR}/qtwrapper/instancemanager_wrap.h)
endif()
# SFPM
set(BUILD_SFPM_PIC ON CACHE BOOL "enable -fPIC for SFPM" FORCE)
add_subdirectory(3rdparty/SortFilterProxyModel)
set(SFPM_OBJECTS $<TARGET_OBJECTS:SortFilterProxyModel>)
# md4c
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Don't build shared md4c library" FORCE)
add_subdirectory(3rdparty/md4c EXCLUDE_FROM_ALL)
list(APPEND CLIENT_LINK_DIRS ${MD4C_BINARY_DIR}/src)
list(APPEND CLIENT_INCLUDE_DIRS ${MD4C_SOURCE_DIR}/src)
list(APPEND CLIENT_LIBS md4c-html)
# tidy-html5
set(BUILD_SHARED_LIB OFF CACHE BOOL "Don't build shared tidy library" FORCE)
set(SUPPORT_CONSOLE_APP OFF CACHE BOOL "Don't build tidy console app" FORCE)
add_subdirectory(3rdparty/tidy-html5 EXCLUDE_FROM_ALL)
list(APPEND CLIENT_LINK_DIRS ${tidy_BINARY_DIR}/Release)
list(APPEND CLIENT_INCLUDE_DIRS ${tidy_SOURCE_DIR}/include)
list(APPEND CLIENT_LIBS tidy-static)
# common executable sources
qt_add_executable(
${PROJECT_NAME}
@ -520,9 +535,7 @@ if(MSVC)
PROPERTIES
WIN32_EXECUTABLE TRUE)
target_link_libraries(
${PROJECT_NAME}
PRIVATE
list(APPEND CLIENT_LIBS
${JAMID_LIB}
${GNUTLS_LIB}
${LIBCLIENT_NAME}
@ -559,9 +572,7 @@ if(MSVC)
# executable name
set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "Jami")
elseif (NOT APPLE)
target_link_libraries(
${PROJECT_NAME}
PRIVATE
list(APPEND CLIENT_LIBS
${QT_LIBS}
${LIBCLIENT_NAME}
${qrencode}
@ -693,13 +704,14 @@ else()
set(libs ${libs} ${SPARKLE_FRAMEWORK})
endif(ENABLE_SPARKLE)
target_sources(${PROJECT_NAME} PRIVATE ${resources})
target_link_libraries(${PROJECT_NAME} PRIVATE ${libs})
FILE(GLOB CONTRIB ${LIBJAMI_CONTRIB_DIR}/apple-darwin/lib/*.a)
list(APPEND CLIENT_LIBS ${libs})
file(GLOB CONTRIB ${LIBJAMI_CONTRIB_DIR}/apple-darwin/lib/*.a)
list(APPEND CLIENT_LIBS ${CONTRIB})
target_link_libraries(${PROJECT_NAME} PRIVATE ${CONTRIB})
find_package(Iconv REQUIRED)
target_link_libraries(${PROJECT_NAME} PRIVATE Iconv::Iconv)
target_link_libraries(${PROJECT_NAME} PRIVATE
list(APPEND CLIENT_LIBS Iconv::Iconv)
list(APPEND CLIENT_LIBS
"-framework AVFoundation"
"-framework CoreAudio -framework CoreMedia -framework CoreVideo"
"-framework VideoToolbox -framework AudioUnit"
@ -761,6 +773,10 @@ else()
endif()
endif()
target_include_directories(${PROJECT_NAME} PRIVATE ${CLIENT_INCLUDE_DIRS})
target_link_directories(${PROJECT_NAME} PRIVATE ${CLIENT_LINK_DIRS})
target_link_libraries(${PROJECT_NAME} PRIVATE ${CLIENT_LIBS})
qt_import_qml_plugins(${PROJECT_NAME})
qt_finalize_executable(${PROJECT_NAME})

View file

@ -34,6 +34,8 @@
// Configuration globals.
def SUBMODULES = ['daemon',
'3rdparty/SortFilterProxyModel',
'3rdparty/md4c',
'3rdparty/tidy-html5',
'3rdparty/qrencode-win32',
'extras/packaging/update/sparkle/Sparkle']
def TARGETS = [:]

View file

@ -1,100 +0,0 @@
/*
* Copyright (c) 2021 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.
*/
var linkifyStr = (function (linkifyjs) {
'use strict';
/**
Convert strings of text into linkable HTML text
*/
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) {
var result = [];
for (var attr in attributes) {
var val = attributes[attr] + '';
result.push(attr + "=\"" + escapeAttr(val) + "\"");
}
return result.join(' ');
}
function defaultRender(_ref) {
var tagName = _ref.tagName,
attributes = _ref.attributes,
content = _ref.content;
return "<" + tagName + " " + attributesToString(attributes) + ">" + escapeText(content) + "</" + tagName + ">";
}
/**
* Convert a plan text string to an HTML string with links. Expects that the
* given strings does not contain any HTML entities. Use the linkify-html
* interface if you need to parse HTML entities.
*
* @param {string} str string to linkify
* @param {import('linkifyjs').Opts} [opts] overridable options
* @returns {string}
*/
function linkifyStr(str, opts) {
if (opts === void 0) {
opts = {};
}
opts = new linkifyjs.Options(opts, defaultRender);
var tokens = linkifyjs.tokenize(str);
var result = [];
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
if (token.t === 'nl' && opts.get('nl2br')) {
result.push('<br>\n');
} else if (!token.isLink || !opts.check(token)) {
result.push(escapeText(token.toString()));
} else {
result.push(opts.render(token));
}
}
return result.join('');
}
if (!String.prototype.linkify) {
Object.defineProperty(String.prototype, 'linkify', {
writable: false,
value: function linkify(options) {
return linkifyStr(this, options);
}
});
}
return linkifyStr;
})(linkify);

File diff suppressed because one or more lines are too long

View file

@ -1,126 +0,0 @@
/* MIT License
Copyright (c) 2019 Andrej Gajdos
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.*/
/**
* Retrieves the title of a webpage which is used to fill out the preview of a hyperlink
* @param doc the DOM of the url that is being previewed
* @returns the title of the given webpage
*/
function getTitle(doc){
const og_title = doc.querySelector('meta[property="og:title"]')
if (og_title !== null && og_title.content.length > 0) {
return og_title.content
}
const twitter_title = doc.querySelector('meta[name="twitter:title"]')
if (twitter_title !== null && twitter_title.content.length > 0) {
return twitter_title.content
}
const doc_title = doc.title
if (doc_title !== null && doc_title.length > 0) {
return doc_title
}
if (doc.querySelector("h1") !== null){
const header_1 = doc.querySelector("h1").innerHTML
if (header_1 !== null && header_1.length > 0) {
return header_1
}
}
if (doc.querySelector("h2") !== null){
const header_2 = doc.querySelector("h2").innerHTML
if (header_2 !== null && header_2.length > 0) {
return header_2
}
}
return null
}
/**
* Obtains a description of the webpage for the hyperlink preview
* @param doc the DOM of the url that is being previewed
* @returns a description of the webpage
*/
function getDescription(doc){
const og_description = doc.querySelector('meta[property="og:description"]')
if (og_description !== null && og_description.content.length > 0) {
return og_description.content
}
const twitter_description = doc.querySelector('meta[name="twitter:description"]')
if (twitter_description !== null && twitter_description.content.length > 0) {
return twitter_description.content
}
const meta_description = doc.querySelector('meta[name="description"]')
if (meta_description !== null && meta_description.content.length > 0) {
return meta_description.content
}
var all_paragraphs = doc.querySelectorAll("p")
let first_visible_paragraph = null
for (var i = 0; i < all_paragraphs.length; i++) {
if (all_paragraphs[i].offsetParent !== null &&
!all_paragraphs[i].childElementCount !== 0) {
first_visible_paragraph = all_paragraphs[i].textContent
break
}
}
return first_visible_paragraph
}
/**
* Gets the image that represents a webpage.
* @param doc the DOM of the url that is being previewed
* @returns the image representing the url or null if no such image was found
*/
function getImage(doc) {
const og_image = doc.querySelector('meta[property="og:image"]')
if (og_image !== null && og_image.content.length > 0){
return og_image.content
}
const image_rel_link = doc.querySelector('link[rel="image_src"]')
if (image_rel_link !== null && image_rel_link.href.length > 0){
return image_rel_link.href
}
const twitter_img = doc.querySelector('meta[name="twitter:image"]')
if (twitter_img !== null && twitter_img.content.length > 0) {
return twitter_img.content
}
let imgs = Array.from(doc.getElementsByTagName("img"))
if (imgs.length > 0) {
imgs = imgs.filter(img => {
let add_image = true
if (img.naturalWidth > img.naturalHeight) {
if (img.naturalWidth / img.naturalHeight > 3) {
add_image = false
}
} else {
if (img.naturalHeight / img.naturalWidth > 3) {
add_image = false
}
}
if (img.naturalHeight <= 50 || img.naturalWidth <= 50) {
add_image = false
}
return add_image
})
}
return null
}

View file

@ -1,93 +0,0 @@
_ = new QWebChannel(qt.webChannelTransport, function (channel) {
window.jsbridge = channel.objects.jsbridge
})
function log(msg) {
window.jsbridge.log(msg)
}
function getPreviewInfo(messageId, url) {
var title = null
var description = null
var image = null
var u = new URL(url)
if (u.protocol === '') {
url = "https://".concat(url)
}
var domain = (new URL(url))
fetch(url, {
mode: 'no-cors',
headers: {'Set-Cookie': 'SameSite=None; Secure'}
}).then(function (response) {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('text/html')) {
return
}
return response.body
}).then(body => {
const reader = body.getReader();
return new ReadableStream({
start(controller) {
return pump();
function pump() {
return reader.read().then(({ done, value }) => {
// When no more data needs to be consumed, close the stream
if (done) {
controller.close();
return;
}
if(value.byteLength > 2*1024*1024) {
controller.close();
return;
}
// Enqueue the next data chunk into our target stream
controller.enqueue(value);
return pump();
});
}
}
})
}, e => Promise.reject(e))
.then(stream => new Response(stream))
.then(response => response.text())
.then(function (html) {
// create DOM from html string
var parser = new DOMParser()
var doc = parser.parseFromString(html, "text/html")
if (!url.includes("twitter.com")){
title = getTitle(doc)
} else {
title = "Twitter. It's what's happening."
}
image = getImage(doc, url)
description = getDescription(doc)
domain = (domain.hostname).replace("www.", "")
}).catch(function (err) {
log("Error occured while fetching document: " + err)
}).finally(() => {
window.jsbridge.emitInfoReady(messageId, {
'title': title,
'image': image,
'description': description,
'url': url,
'domain': domain,
})
})
}
function parseMessage(messageId, message, showPreview, color='#0645AD') {
var links = linkify.find(message)
if (links.length === 0) {
return
}
if (showPreview)
getPreviewInfo(messageId, links[0].href)
window.jsbridge.emitLinkified(messageId, linkifyStr(message, {
attributes: {
style: "color:" + color + ";"
}
}))
}

View file

@ -329,15 +329,15 @@ ApplicationWindow {
function onUpdateErrorOccurred(error) {
presentUpdateInfoDialog((function () {
switch (error) {
case NetWorkManager.ACCESS_DENIED:
case NetworkManager.ACCESS_DENIED:
return JamiStrings.genericError;
case NetWorkManager.DISCONNECTED:
case NetworkManager.DISCONNECTED:
return JamiStrings.networkDisconnected;
case NetWorkManager.NETWORK_ERROR:
case NetworkManager.NETWORK_ERROR:
return JamiStrings.updateNetworkError;
case NetWorkManager.SSL_ERROR:
case NetworkManager.SSL_ERROR:
return JamiStrings.updateSSLError;
case NetWorkManager.CANCELED:
case NetworkManager.CANCELED:
return JamiStrings.updateDownloadCanceled;
default:
return {};

View file

@ -51,10 +51,11 @@ SBSMessageBase {
padding: isEmojiOnly ? 0 : JamiTheme.preferredMarginSize
anchors.right: isOutgoing ? parent.right : undefined
text: {
if (LinkifiedBody !== "" && Linkified.length === 0) {
MessagesAdapter.parseMessageUrls(Id, Body, UtilsAdapter.getAppValue(Settings.DisplayHyperlinkPreviews), root.colorUrl);
if (Body !== "" && ParsedBody.length === 0) {
MessagesAdapter.parseMessage(Id, Body, UtilsAdapter.getAppValue(Settings.DisplayHyperlinkPreviews), root.colorUrl, CurrentConversation.color);
return ""
}
return (LinkifiedBody !== "") ? LinkifiedBody : "*(" + JamiStrings.deletedMessage + ")*";
return (ParsedBody !== "") ? ParsedBody : "*(" + JamiStrings.deletedMessage + ")*";
}
horizontalAlignment: Text.AlignLeft
@ -76,7 +77,8 @@ SBSMessageBase {
font.pixelSize: isEmojiOnly ? JamiTheme.chatviewEmojiSize : JamiTheme.emojiBubbleSize
font.hintingPreference: Font.PreferNoHinting
renderType: Text.NativeRendering
textFormat: Text.MarkdownText
textFormat: Text.RichText
clip: true
onLinkHovered: root.hoveredLink = hoveredLink
onLinkActivated: Qt.openUrlExternally(new URL(hoveredLink))
readOnly: true
@ -227,7 +229,7 @@ SBSMessageBase {
renderType: Text.NativeRendering
textFormat: TextEdit.RichText
color: UtilsAdapter.luma(bubble.color) ? JamiTheme.chatviewTextColorLight : JamiTheme.chatviewTextColorDark
visible: LinkPreviewInfo.title !== null
visible: LinkPreviewInfo.title.length > 0
text: LinkPreviewInfo.title
}
Label {
@ -237,7 +239,7 @@ SBSMessageBase {
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
renderType: Text.NativeRendering
textFormat: TextEdit.RichText
visible: LinkPreviewInfo.description !== null
visible: LinkPreviewInfo.description.length > 0
font.underline: root.hoveredLink
text: LinkPreviewInfo.description
color: root.colorUrl
@ -263,10 +265,5 @@ SBSMessageBase {
duration: 100
}
}
Component.onCompleted: {
if (Linkified.length === 0) {
MessagesAdapter.parseMessageUrls(Id, Body, UtilsAdapter.getAppValue(Settings.DisplayHyperlinkPreviews), root.colorUrl);
}
opacity = 1;
}
Component.onCompleted: opacity = 1;
}

View file

@ -183,7 +183,7 @@ primaryConnectionChanged(NMClient* nm, GParamSpec*, ConnectivityMonitor* cm)
{
auto connection = nm_client_get_primary_connection(nm);
logConnectionInfo(connection);
cm->connectivityChanged();
Q_EMIT cm->connectivityChanged();
}
static void

View file

@ -20,7 +20,6 @@
#include <QObject>
#ifdef Q_OS_WIN
class ConnectivityMonitor final : public QObject
{
Q_OBJECT
@ -34,6 +33,7 @@ Q_SIGNALS:
void connectivityChanged();
private:
#ifdef Q_OS_WIN
void destroy();
struct INetworkListManager* pNetworkListManager_;
@ -41,21 +41,5 @@ private:
struct IConnectionPoint* pConnectPoint_;
class NetworkEventHandler* netEventHandler_;
unsigned long cookie_;
};
#else
// TODO: platform implementations should be in the daemon.
class ConnectivityMonitor final : public QObject
{
Q_OBJECT
public:
explicit ConnectivityMonitor(QObject* parent = nullptr);
~ConnectivityMonitor();
bool isOnline();
Q_SIGNALS:
void connectivityChanged();
};
#endif // Q_OS_WIN
};

107
src/app/htmlparser.h Normal file
View file

@ -0,0 +1,107 @@
/*
* Copyright (C) 2023 Savoir-faire Linux Inc.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QObject>
#include <QVariantMap>
#include "tidy.h"
#include "tidybuffio.h"
// This class is used to parse HTML strings. It uses the libtidy library to parse
// the HTML and traverse the DOM tree. It can be used to extract a list of tags
// and their values from an HTML string.
// Currently, it is used to extract the <a> and <pre> tags from a message body,
// and in the future it can be used in conjunction with QtNetwork to generate link
// previews without having to use QtWebEngine.
class HtmlParser : public QObject
{
Q_OBJECT
public:
HtmlParser(QObject* parent = nullptr)
: QObject(parent)
{
doc_ = tidyCreate();
tidyOptSetBool(doc_, TidyQuiet, yes);
tidyOptSetBool(doc_, TidyShowWarnings, no);
}
~HtmlParser()
{
tidyRelease(doc_);
}
bool parseHtmlString(const QString& html)
{
return tidyParseString(doc_, html.toLocal8Bit().data()) >= 0;
}
using TagInfoList = QMap<TidyTagId, QList<QString>>;
// A function that traverses the DOM tree and fills a QVariantMap with a list
// of the tags and their values. The result is structured as follows:
// {tagId1: ["tagValue1", "tagValue2", ...],
// tagId: ["tagValue1", "tagValue2", ...],
// ... }
TagInfoList getTags(QList<TidyTagId> tags, int maxDepth = -1)
{
TagInfoList result;
traverseNode(
tidyGetRoot(doc_),
tags,
[&result](const QString& value, TidyTagId tag) { result[tag].append(value); },
maxDepth);
return result;
}
QString getFirstTagValue(TidyTagId tag, int maxDepth = -1)
{
QString result;
traverseNode(
tidyGetRoot(doc_),
{tag},
[&result](const QString& value, TidyTagId) { result = value; },
maxDepth);
return result;
}
private:
void traverseNode(TidyNode node,
QList<TidyTagId> tags,
const std::function<void(const QString&, TidyTagId)>& cb,
int depth = -1)
{
TidyBuffer nodeValue = {};
for (auto tag : tags) {
if (tidyNodeGetId(node) == tag && tidyNodeGetText(doc_, node, &nodeValue) == yes && cb) {
cb(QString::fromLocal8Bit(nodeValue.bp), tag);
if (depth != -1 && --depth == 0) {
return;
}
}
}
// Traverse the children of the current node.
for (TidyNode child = tidyGetChild(node); child; child = tidyGetNext(child)) {
traverseNode(child, tags, cb, depth);
}
}
TidyDoc doc_;
};

View file

@ -122,7 +122,7 @@ MainApplication::init()
connectivityMonitor_.reset(new ConnectivityMonitor(this));
settingsManager_.reset(new AppSettingsManager(this));
systemTray_.reset(new SystemTray(settingsManager_.get(), this));
previewEngine_.reset(new PreviewEngine(this));
previewEngine_.reset(new PreviewEngine(connectivityMonitor_.get(), this));
QObject::connect(settingsManager_.get(),
&AppSettingsManager::retranslate,

View file

@ -31,7 +31,7 @@ Rectangle {
property var body: {
if (MessagesAdapter.editId === "")
return "";
return MessagesAdapter.dataForInteraction(MessagesAdapter.editId, MessageList.LinkifiedBody);
return MessagesAdapter.dataForInteraction(MessagesAdapter.editId, MessageList.ParsedBody);
}
RowLayout {

184
src/app/messageparser.cpp Normal file
View file

@ -0,0 +1,184 @@
/*
* Copyright (C) 2023 Savoir-faire Linux Inc.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "messageparser.h"
#include "previewengine.h"
#include "htmlparser.h"
#include <QRegularExpression>
#include <QtConcurrent>
#include "md4c-html.h"
MessageParser::MessageParser(PreviewEngine* previewEngine, QObject* parent)
: QObject(parent)
, previewEngine_(previewEngine)
, htmlParser_(new HtmlParser(this))
, threadPool_(new QThreadPool(this))
{
threadPool_->setMaxThreadCount(1);
connect(previewEngine_, &PreviewEngine::infoReady, this, &MessageParser::linkInfoReady);
}
void
MessageParser::parseMessage(const QString& messageId,
const QString& msg,
bool previewLinks,
const QColor& linkColor,
const QColor& backgroundColor)
{
// Run everything here on a separate thread.
threadPool_->start(
[this, messageId, md = msg, previewLinks, linkColor, backgroundColor]() mutable -> void {
preprocessMarkdown(md);
auto html = markdownToHtml(md.toUtf8().constData());
// Now that we have the HTML, we can parse it to get a list of tags and their values.
// We are only interested in the <a> and <pre> tags.
htmlParser_->parseHtmlString(html);
auto tagsMap = htmlParser_->getTags({TidyTag_A, TidyTag_DEL, TidyTag_PRE});
static QString styleTag("<style>%1</style>");
QString style;
// Check for any <pre> tags. If there are any, we need to:
// 1. add some CSS to color them.
// 2. add some CSS to make them wrap.
if (tagsMap.contains(TidyTag_PRE)) {
auto textColor = (0.2126 * backgroundColor.red() + 0.7152 * backgroundColor.green()
+ 0.0722 * backgroundColor.blue())
< 153
? QColor(255, 255, 255)
: QColor(0, 0, 0);
style += QString("pre,code{background-color:%1;"
"color:%2;white-space:pre-wrap;}")
.arg(backgroundColor.name(), textColor.name());
}
// md4c makes DEL tags instead of S tags for ~~strikethrough-text~~,
// so we need to style the DEL tag content.
if (tagsMap.contains(TidyTag_DEL)) {
style += QString("del{text-decoration:line-through;}");
}
// Check for any <a> tags. If there are any, we need to:
// 1. add some CSS to color them.
// 2. parse them to get a preview IF the user has enabled link previews.
if (tagsMap.contains(TidyTag_A)) {
style += QString("a{color:%1;}").arg(linkColor.name());
// Update the UI before we start parsing the link.
html.prepend(QString(styleTag).arg(style));
Q_EMIT messageParsed(messageId, html);
// If the user has enabled link previews, then we need to generate the link preview.
if (previewLinks) {
// Get the first link in the message.
auto anchorTag = tagsMap[TidyTag_A].first();
static QRegularExpression hrefRegex("href=\"(.*?)\"");
auto match = hrefRegex.match(anchorTag);
if (match.hasMatch()) {
Q_EMIT previewEngine_->parseLink(messageId, match.captured(1));
}
}
return;
}
// If the message didn't contain any links, then we can just update the UI.
html.prepend(QString(styleTag).arg(style));
Q_EMIT messageParsed(messageId, html);
});
}
void
MessageParser::preprocessMarkdown(QString& markdown)
{
// Match all instances of the linefeed character.
static QRegularExpression newlineRegex("\n");
static const QString newline = " \n";
// Replace all instances of the linefeed character with 2 spaces + a linefeed character
// in order to force a line break in the HTML.
// Note: we should only do this for non-code fenced blocks.
static QRegularExpression codeFenceRe("`{1,3}([\\s\\S]*?)`{1,3}");
auto match = codeFenceRe.globalMatch(markdown);
// If there are no code blocks, then we can just replace all linefeeds with 2 spaces
// + a linefeed, and we're done.
if (!match.hasNext()) {
markdown.replace(newlineRegex, newline);
return;
}
// Save each block of text and code. The text blocks will be
// processed for line breaks and the code blocks will be left
// as is.
enum BlockType { Text, Code };
QVector<QPair<BlockType, QString>> codeBlocks;
int start = 0;
while (match.hasNext()) {
auto m = match.next();
auto nonCodelength = m.capturedStart() - start;
if (nonCodelength) {
codeBlocks.push_back({Text, markdown.mid(start, nonCodelength)});
}
codeBlocks.push_back({Code, m.captured(0)});
start = m.capturedStart() + m.capturedLength();
}
// There may be some text after the last code block.
if (start < markdown.size()) {
codeBlocks.push_back({Text, markdown.mid(start)});
}
// Now we can process the text blocks.
markdown.clear();
for (auto& block : codeBlocks) {
if (block.first == Text) {
// Replace all newlines with two spaces and a newline.
block.second.replace(newlineRegex, newline);
}
markdown += block.second;
}
}
// A callback function that will be called by the md4c library (`md_html`) to output the HTML.
static void
htmlChunkCb(const MD_CHAR* data, MD_SIZE data_size, void* userData)
{
QByteArray* array = static_cast<QByteArray*>(userData);
if (data_size > 0) {
array->append(data, int(data_size));
}
};
QString
MessageParser::markdownToHtml(const char* markdown)
{
static auto md_flags = MD_FLAG_PERMISSIVEAUTOLINKS | MD_FLAG_NOINDENTEDCODEBLOCKS
| MD_FLAG_TASKLISTS | MD_FLAG_STRIKETHROUGH | MD_FLAG_UNDERLINE;
size_t data_len = strlen(markdown);
if (data_len <= 0) {
return QString();
} else {
QByteArray array;
int result = md_html(markdown, MD_SIZE(data_len), &htmlChunkCb, &array, md_flags, 0);
return result == 0 ? QString::fromUtf8(array) : QString();
}
}

77
src/app/messageparser.h Normal file
View file

@ -0,0 +1,77 @@
/*
* Copyright (C) 2023 Savoir-faire Linux Inc.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QObject>
#include <QColor>
#include <QThreadPool>
class PreviewEngine;
class HtmlParser;
// This class is used to parse messages and encapsulate the logic that
// prepares a message for display in the UI. The basic steps are:
// 1. preprocess the markdown message (e.g. handle line breaks)
// 2. transform markdown syntax into HTML
// 3. generate previews for the first link in the message (if any)
// 4. add the appropriate CSS classes to the HTML (e.g. for links, code blocks, etc.)
//
// Step 3. is done asynchronously, so the message is displayed as soon as possible
// and the preview is added later.
class MessageParser final : public QObject
{
Q_OBJECT
Q_DISABLE_COPY(MessageParser)
public:
// Create a new MessageParser instance. We take an instance of PreviewEngine.
explicit MessageParser(PreviewEngine* previewEngine, QObject* parent = nullptr);
~MessageParser() = default;
// Parse the message. This will emit the messageParsed signal when the
// message is ready to be displayed.
void parseMessage(const QString& messageId,
const QString& msg,
bool previewLinks,
const QColor& linkColor,
const QColor& backgroundColor);
// Emitted when the message is ready to be displayed.
Q_SIGNAL void messageParsed(const QString& msgId, const QString& msg);
// Emitted when the message preview is ready to be displayed.
Q_SIGNAL void linkInfoReady(const QString& msgId, const QVariantMap& info);
private:
// Preprocess the markdown message (e.g. handle line breaks).
void preprocessMarkdown(QString& markdown);
// Transform markdown syntax into HTML.
QString markdownToHtml(const char* markdown);
// Generate a preview for the given link, then emit the messageParsed signal.
void generatePreview(const QString& msgId, const QString& link);
// The PreviewEngine instance used to generate previews.
PreviewEngine* previewEngine_;
// An instance of HtmlParser used to parse HTML.
HtmlParser* htmlParser_;
// Used to queue parse operations.
QThreadPool* threadPool_;
};

View file

@ -25,6 +25,7 @@
#include "appsettingsmanager.h"
#include "qtutils.h"
#include "messageparser.h"
#include <api/datatransfermodel.h>
@ -48,7 +49,7 @@ MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
QObject* parent)
: QmlAdapterBase(instance, parent)
, settingsManager_(settingsManager)
, previewEngine_(previewEngine)
, messageParser_(new MessageParser(previewEngine, this))
, filteredMsgListModel_(new FilteredMsgListModel(this))
, mediaInteractions_(std::make_unique<MessageListModel>())
, timestampTimer_(new QTimer(this))
@ -71,8 +72,8 @@ MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
set_mediaMessageListModel(QVariant::fromValue(mediaInteractions_.get()));
});
connect(previewEngine_, &PreviewEngine::infoReady, this, &MessagesAdapter::onPreviewInfoReady);
connect(previewEngine_, &PreviewEngine::linkified, this, &MessagesAdapter::onMessageLinkified);
connect(messageParser_, &MessageParser::messageParsed, this, &MessagesAdapter::onMessageParsed);
connect(messageParser_, &MessageParser::linkInfoReady, this, &MessagesAdapter::onLinkInfoReady);
connect(timestampTimer_, &QTimer::timeout, this, &MessagesAdapter::timestampUpdated);
timestampTimer_->start(timestampUpdateIntervalMs_);
@ -445,6 +446,24 @@ MessagesAdapter::onNewInteraction(const QString& convUid,
}
}
void
MessagesAdapter::onMessageParsed(const QString& messageId, const QString& parsed)
{
const QString& convId = lrcInstance_->get_selectedConvUid();
const QString& accId = lrcInstance_->get_currentAccountId();
auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
conversation.interactions->setParsedMessage(messageId, parsed);
}
void
MessagesAdapter::onLinkInfoReady(const QString& messageId, const QVariantMap& info)
{
const QString& convId = lrcInstance_->get_selectedConvUid();
const QString& accId = lrcInstance_->get_currentAccountId();
auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
conversation.interactions->addHyperlinkInfo(messageId, info);
}
void
MessagesAdapter::acceptInvitation(const QString& convId)
{
@ -540,15 +559,6 @@ MessagesAdapter::removeContact(const QString& convUid, bool banContact)
accInfo.contactModel->removeContact(contactUri, banContact);
}
void
MessagesAdapter::onPreviewInfoReady(QString messageId, QVariantMap info)
{
const QString& convId = lrcInstance_->get_selectedConvUid();
const QString& accId = lrcInstance_->get_currentAccountId();
auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
conversation.interactions->addHyperlinkInfo(messageId, info);
}
void
MessagesAdapter::onConversationMessagesLoaded(uint32_t loadingRequestId, const QString& convId)
{
@ -558,21 +568,13 @@ MessagesAdapter::onConversationMessagesLoaded(uint32_t loadingRequestId, const Q
}
void
MessagesAdapter::parseMessageUrls(const QString& messageId,
const QString& msg,
bool showPreview,
QColor color)
MessagesAdapter::parseMessage(const QString& msgId,
const QString& msg,
bool showPreview,
const QColor& linkColor,
const QColor& backgroundColor)
{
previewEngine_->parseMessage(messageId, msg, showPreview, color);
}
void
MessagesAdapter::onMessageLinkified(const QString& messageId, const QString& linkified)
{
const QString& convId = lrcInstance_->get_selectedConvUid();
const QString& accId = lrcInstance_->get_currentAccountId();
auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
conversation.interactions->linkifyMessage(messageId, linkified);
messageParser_->parseMessage(msgId, msg, showPreview, linkColor, backgroundColor);
}
void

View file

@ -28,6 +28,9 @@
#include <QSortFilterProxyModel>
class AppSettingsManager;
class MessageParser;
class FilteredMsgListModel final : public QSortFilterProxyModel
{
Q_OBJECT
@ -50,8 +53,6 @@ public:
};
};
class AppSettingsManager;
class MessagesAdapter final : public QmlAdapterBase
{
Q_OBJECT
@ -74,7 +75,6 @@ Q_SIGNALS:
void newMessageBarPlaceholderText(QString& placeholderText);
void newFilePasted(QString filePath);
void newTextPasted();
void previewInformationToQML(QString messageId, QStringList previewInformation);
void moreMessagesLoaded(qint32 loadingRequestId);
void timestampUpdated();
void fileCopied(const QString& dest);
@ -123,10 +123,11 @@ protected:
Q_INVOKABLE QString getFormattedDay(const quint64 timestamp);
Q_INVOKABLE QString getFormattedTime(const quint64 timestamp);
Q_INVOKABLE QString getBestFormattedDate(const quint64 timestamp);
Q_INVOKABLE void parseMessageUrls(const QString& messageId,
const QString& msg,
bool showPreview,
QColor color = "#0645AD");
Q_INVOKABLE void parseMessage(const QString& msgId,
const QString& msg,
bool previewLinks,
const QColor& linkColor = QColor(0x06, 0x45, 0xad),
const QColor& backgroundColor = QColor(0x0, 0x0, 0x0));
Q_INVOKABLE void onPaste();
Q_INVOKABLE int getIndexOfMessage(const QString& messageId) const;
Q_INVOKABLE QString getStatusString(int status);
@ -147,9 +148,9 @@ private Q_SLOTS:
void onNewInteraction(const QString& convUid,
const QString& interactionId,
const interaction::Info& interaction);
void onPreviewInfoReady(QString messageIndex, QVariantMap urlInMessage);
void onMessageParsed(const QString& messageId, const QString& parsed);
void onLinkInfoReady(const QString& messageIndex, const QVariantMap& info);
void onConversationMessagesLoaded(uint32_t requestId, const QString& convId);
void onMessageLinkified(const QString& messageId, const QString& linkified);
void onComposingStatusChanged(const QString& convId,
const QString& contactUri,
bool isComposing);
@ -160,7 +161,8 @@ private:
QList<QString> conversationTypersUrlToName(const QSet<QString>& typersSet);
AppSettingsManager* settingsManager_;
PreviewEngine* previewEngine_;
MessageParser* messageParser_;
FilteredMsgListModel* filteredMsgListModel_;
static constexpr const int loadChunkSize_ {20};

View file

@ -1,7 +1,5 @@
/*!
/*
* Copyright (C) 2019-2023 Savoir-faire Linux Inc.
* Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com>
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,23 +18,29 @@
#include "networkmanager.h"
#include "connectivitymonitor.h"
#include "utils.h"
#include <QMetaEnum>
#include <QtNetwork>
NetWorkManager::NetWorkManager(ConnectivityMonitor* cm, QObject* parent)
NetworkManager::NetworkManager(ConnectivityMonitor* cm, QObject* parent)
: QObject(parent)
, manager_(new QNetworkAccessManager(this))
, reply_(nullptr)
, connectivityMonitor_(cm)
, lastConnectionState_(cm->isOnline())
{
Q_EMIT statusChanged(GetStatus::IDLE);
connect(connectivityMonitor_, &ConnectivityMonitor::connectivityChanged, [this] {
cancelRequest();
#if QT_CONFIG(ssl)
connect(manager_,
&QNetworkAccessManager::sslErrors,
this,
[this](QNetworkReply* reply, const QList<QSslError>& errors) {
Q_UNUSED(reply);
Q_FOREACH (const QSslError& error, errors) {
qWarning() << Q_FUNC_INFO << error.errorString();
Q_EMIT errorOccured(GetError::SSL_ERROR, error.errorString());
}
});
#endif
connect(connectivityMonitor_, &ConnectivityMonitor::connectivityChanged, this, [this] {
auto connected = connectivityMonitor_->isOnline();
if (connected && !lastConnectionState_) {
manager_->deleteLater();
@ -48,126 +52,16 @@ NetWorkManager::NetWorkManager(ConnectivityMonitor* cm, QObject* parent)
}
void
NetWorkManager::get(const QUrl& url, const DoneCallBack& doneCb, const QString& path)
NetworkManager::sendGetRequest(const QUrl& url,
std::function<void(const QByteArray&)> onDoneCallback)
{
if (!connectivityMonitor_->isOnline()) {
Q_EMIT errorOccured(GetError::DISCONNECTED);
return;
}
if (reply_ && reply_->isRunning()) {
qWarning() << Q_FUNC_INFO << "currently downloading";
return;
} else if (url.isEmpty()) {
qWarning() << Q_FUNC_INFO << "missing url";
return;
}
if (!path.isEmpty()) {
QFileInfo fileInfo(url.path());
QString fileName = fileInfo.fileName();
file_.reset(new QFile(path + "/" + fileName));
if (!file_->open(QIODevice::WriteOnly)) {
Q_EMIT errorOccured(GetError::ACCESS_DENIED);
file_.reset(nullptr);
return;
auto reply = manager_->get(QNetworkRequest(url));
QObject::connect(reply, &QNetworkReply::finished, this, [reply, onDoneCallback]() {
if (reply->error() == QNetworkReply::NoError) {
onDoneCallback(reply->readAll());
} else {
onDoneCallback(reply->errorString().toUtf8());
}
}
QNetworkRequest request(url);
reply_ = manager_->get(request);
Q_EMIT statusChanged(GetStatus::STARTED);
connect(reply_,
QOverload<QNetworkReply::NetworkError>::of(&QNetworkReply::errorOccurred),
[this, doneCb, path](QNetworkReply::NetworkError error) {
reply_->disconnect();
reset(true);
qWarning() << Q_FUNC_INFO << "NetworkError: "
<< QMetaEnum::fromType<QNetworkReply::NetworkError>().valueToKey(error);
Q_EMIT errorOccured(GetError::NETWORK_ERROR);
});
connect(reply_, &QNetworkReply::finished, [this, doneCb, path]() {
reply_->disconnect();
QString response = {};
if (path.isEmpty())
response = QString(reply_->readAll());
reset(!path.isEmpty());
Q_EMIT statusChanged(GetStatus::FINISHED);
if (doneCb)
doneCb(response);
reply->deleteLater();
});
connect(reply_,
&QNetworkReply::downloadProgress,
this,
&NetWorkManager::downloadProgressChanged);
connect(reply_, &QNetworkReply::readyRead, this, &NetWorkManager::onHttpReadyRead);
#if QT_CONFIG(ssl)
connect(reply_,
SIGNAL(sslErrors(const QList<QSslError>&)),
this,
SLOT(onSslErrors(QList<QSslError>)),
Qt::UniqueConnection);
#endif
}
void
NetWorkManager::reset(bool flush)
{
reply_->deleteLater();
reply_ = nullptr;
if (file_ && flush) {
file_->flush();
file_->close();
file_.reset(nullptr);
}
}
void
NetWorkManager::onSslErrors(const QList<QSslError>& sslErrors)
{
#if QT_CONFIG(ssl)
reply_->disconnect();
reset(true);
QString errorsString;
for (const QSslError& error : sslErrors) {
if (errorsString.length() > 0) {
errorsString += "\n";
}
errorsString += error.errorString();
}
Q_EMIT errorOccured(GetError::SSL_ERROR, errorsString);
return;
#else
Q_UNUSED(sslErrors);
#endif
}
void
NetWorkManager::onHttpReadyRead()
{
/*
* This slot gets called every time the QNetworkReply has new data.
* We read all of its new data and write it into the file.
* That way we use less RAM than when reading it at the finished()
* signal of the QNetworkReply
*/
if (file_)
file_->write(reply_->readAll());
}
void
NetWorkManager::cancelRequest()
{
if (reply_) {
reply_->abort();
Q_EMIT errorOccured(GetError::CANCELED);
}
}

View file

@ -1,7 +1,5 @@
/*!
/*
* Copyright (C) 2019-2023 Savoir-faire Linux Inc.
* Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com>
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -27,50 +25,26 @@
class QNetworkAccessManager;
class ConnectivityMonitor;
class NetWorkManager : public QObject
class NetworkManager : public QObject
{
Q_OBJECT
public:
explicit NetWorkManager(ConnectivityMonitor* cm, QObject* parent = nullptr);
virtual ~NetWorkManager() = default;
enum GetStatus { IDLE, STARTED, FINISHED };
explicit NetworkManager(ConnectivityMonitor* cm, QObject* parent = nullptr);
virtual ~NetworkManager() = default;
enum GetError { DISCONNECTED, NETWORK_ERROR, ACCESS_DENIED, SSL_ERROR, CANCELED };
Q_ENUM(GetError)
using DoneCallBack = std::function<void(const QString&)>;
/*!
* using qt get request to store the reply in file
* @param url - network address
* @param doneCb - done callback
* @param path - optional file saving path, if empty
* a string will be passed as the second paramter of doneCb
*/
void get(const QUrl& url, const DoneCallBack& doneCb = {}, const QString& path = {});
/*!
* manually abort the current request
*/
Q_INVOKABLE void cancelRequest();
void sendGetRequest(const QUrl& url, std::function<void(const QByteArray&)> onDoneCallback);
Q_SIGNALS:
void statusChanged(GetStatus error);
void downloadProgressChanged(qint64 bytesRead, qint64 totalBytes);
void errorOccured(GetError error, const QString& msg = {});
private Q_SLOTS:
void onSslErrors(const QList<QSslError>& sslErrors);
void onHttpReadyRead();
protected:
QNetworkAccessManager* manager_;
private:
void reset(bool flush = true);
QNetworkAccessManager* manager_;
QNetworkReply* reply_;
QScopedPointer<QFile> file_;
ConnectivityMonitor* connectivityMonitor_;
bool lastConnectionState_;
};
Q_DECLARE_METATYPE(NetWorkManager*)
Q_DECLARE_METATYPE(NetworkManager*)

View file

@ -1,52 +0,0 @@
/*
* Copyright (C) 2022-2023 Savoir-faire Linux Inc.
* Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "previewengine.h"
struct PreviewEngine::Impl : public QObject
{
Impl(PreviewEngine&)
: QObject(nullptr)
{}
};
PreviewEngine::PreviewEngine(QObject* parent)
: QObject(parent)
, pimpl_(std::make_unique<Impl>(*this))
{}
PreviewEngine::~PreviewEngine() {}
void
PreviewEngine::parseMessage(const QString&, const QString&, bool, QColor)
{}
void
PreviewEngine::log(const QString&)
{}
void
PreviewEngine::emitInfoReady(const QString&, const QVariantMap&)
{}
void
PreviewEngine::emitLinkified(const QString&, const QString&)
{}
#include "moc_previewengine.cpp"
#include "previewengine.moc"

View file

@ -84,7 +84,7 @@ UpdateManager::UpdateManager(const QString& url,
ConnectivityMonitor* cm,
LRCInstance* instance,
QObject* parent)
: NetWorkManager(cm, parent)
: NetworkManager(cm, parent)
, pimpl_(std::make_unique<Impl>())
{}

View file

@ -1,7 +1,5 @@
/*
* Copyright (C) 2021-2023 Savoir-faire Linux Inc.
* Author: Trevor Tabah <trevor.tabah@savoirfairelinux.com>
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -19,101 +17,103 @@
#include "previewengine.h"
#include "utils.h"
#include <QRegularExpression>
#include <QWebEngineScript>
#include <QWebEngineProfile>
#include <QWebEngineSettings>
#include <QtWebChannel>
#include <QWebEnginePage>
struct PreviewEngine::Impl : public QWebEnginePage
static QString
getInnerHtml(const QString& tag)
{
public:
PreviewEngine& parent_;
QWebChannel* channel_;
Impl(PreviewEngine& parent)
: QWebEnginePage((QObject*) nullptr)
, parent_(parent)
{
QWebEngineProfile* profile = QWebEngineProfile::defaultProfile();
QDir dataDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation));
dataDir.cdUp();
auto cachePath = dataDir.absolutePath() + "/jami";
profile->setCachePath(cachePath);
profile->setPersistentStoragePath(cachePath);
profile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies);
profile->setHttpCacheType(QWebEngineProfile::NoCache);
settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, true);
settings()->setAttribute(QWebEngineSettings::ScrollAnimatorEnabled, false);
settings()->setAttribute(QWebEngineSettings::ErrorPageEnabled, false);
settings()->setAttribute(QWebEngineSettings::PluginsEnabled, false);
settings()->setAttribute(QWebEngineSettings::ScreenCaptureEnabled, false);
settings()->setAttribute(QWebEngineSettings::LinksIncludedInFocusChain, false);
settings()->setAttribute(QWebEngineSettings::LocalStorageEnabled, false);
settings()->setAttribute(QWebEngineSettings::AllowRunningInsecureContent, true);
settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, true);
settings()->setAttribute(QWebEngineSettings::XSSAuditingEnabled, false);
settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, true);
channel_ = new QWebChannel(this);
channel_->registerObject(QStringLiteral("jsbridge"), &parent_);
setWebChannel(channel_);
runJavaScript(Utils::QByteArrayFromFile(":webengine/linkify.js"),
QWebEngineScript::MainWorld);
runJavaScript(Utils::QByteArrayFromFile(":webengine/linkify-string.js"),
QWebEngineScript::MainWorld);
runJavaScript(Utils::QByteArrayFromFile(":webengine/qwebchannel.js"),
QWebEngineScript::MainWorld);
runJavaScript(Utils::QByteArrayFromFile(":webengine/previewInfo.js"),
QWebEngineScript::MainWorld);
runJavaScript(Utils::QByteArrayFromFile(":webengine/previewInterop.js"),
QWebEngineScript::MainWorld);
}
void parseMessage(const QString& messageId, const QString& msg, bool showPreview, QColor color)
{
QString colorStr = "'" + color.name() + "'";
runJavaScript(QString("parseMessage(`%1`, `%2`, %3, %4)")
.arg(messageId, msg, showPreview ? "true" : "false", colorStr));
}
static const QRegularExpression re(">([^<]+)<");
const auto match = re.match(tag);
return match.hasMatch() ? match.captured(1) : QString {};
};
PreviewEngine::PreviewEngine(QObject* parent)
: QObject(parent)
, pimpl_(std::make_unique<Impl>(*this))
{}
const QRegularExpression PreviewEngine::newlineRe("\\n");
PreviewEngine::~PreviewEngine() {}
void
PreviewEngine::parseMessage(const QString& messageId,
const QString& msg,
bool showPreview,
QColor color)
PreviewEngine::PreviewEngine(ConnectivityMonitor* cm, QObject* parent)
: NetworkManager(cm, parent)
, htmlParser_(new HtmlParser(this))
{
pimpl_->parseMessage(messageId, msg, showPreview, color);
// Connect on a queued connection to avoid blocking caller thread.
connect(this, &PreviewEngine::parseLink, this, &PreviewEngine::onParseLink, Qt::QueuedConnection);
}
QString
PreviewEngine::getTagContent(QList<QString>& tags, const QString& value)
{
Q_FOREACH (auto tag, tags) {
const QRegularExpression re("(property|name)=\"(og:|twitter:|)" + value
+ "\".*?content=\"([^\"]+)\"");
const auto match = re.match(tag.remove(newlineRe));
if (match.hasMatch()) {
return match.captured(3);
}
}
return QString {};
}
QString
PreviewEngine::getTitle(HtmlParser::TagInfoList& metaTags)
{
// Try with opengraph/twitter props
QString title = getTagContent(metaTags[TidyTag_META], "title");
if (title.isEmpty()) { // Try with title tag
title = getInnerHtml(htmlParser_->getFirstTagValue(TidyTag_TITLE));
}
if (title.isEmpty()) { // Try with h1 tag
title = getInnerHtml(htmlParser_->getFirstTagValue(TidyTag_H1));
}
if (title.isEmpty()) { // Try with h2 tag
title = getInnerHtml(htmlParser_->getFirstTagValue(TidyTag_H2));
}
return title;
}
QString
PreviewEngine::getDescription(HtmlParser::TagInfoList& metaTags)
{
// Try with og/twitter props
QString d = getTagContent(metaTags[TidyTag_META], "description");
if (d.isEmpty()) { // Try with first paragraph
d = getInnerHtml(htmlParser_->getFirstTagValue(TidyTag_P));
}
return d;
}
QString
PreviewEngine::getImage(HtmlParser::TagInfoList& metaTags)
{
static const QRegularExpression newlineRe("\\n");
// Try with og/twitter props
QString image = getTagContent(metaTags[TidyTag_META], "image");
if (image.isEmpty()) { // Try with href of link tag (rel="image_src")
auto tags = htmlParser_->getTags({TidyTag_LINK});
Q_FOREACH (auto tag, tags[TidyTag_LINK]) {
static const QRegularExpression re("rel=\"image_src\".*?href=\"([^\"]+)\"");
const auto match = re.match(tag.remove(newlineRe));
if (match.hasMatch()) {
return match.captured(1);
}
}
}
return image;
}
void
PreviewEngine::log(const QString& str)
PreviewEngine::onParseLink(const QString& messageId, const QString& link)
{
qDebug() << str;
}
void
PreviewEngine::emitInfoReady(const QString& messageId, const QVariantMap& info)
{
Q_EMIT infoReady(messageId, info);
}
void
PreviewEngine::emitLinkified(const QString& messageId, const QString& linkifiedStr)
{
Q_EMIT linkified(messageId, linkifiedStr);
sendGetRequest(QUrl(link), [this, messageId, link](const QByteArray& html) {
htmlParser_->parseHtmlString(html);
auto metaTags = htmlParser_->getTags({TidyTag_META});
QString domain = QUrl(link).host();
if (domain.isEmpty()) {
domain = link;
}
Q_EMIT infoReady(messageId,
{{"title", getTitle(metaTags)},
{"description", getDescription(metaTags)},
{"image", getImage(metaTags)},
{"url", link},
{"domain", domain}});
});
}

View file

@ -1,7 +1,5 @@
/*
* Copyright (C) 2021-2023 Savoir-faire Linux Inc.
* Author: Trevor Tabah <trevor.tabah@savoirfairelinux.com>
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -19,31 +17,32 @@
#pragma once
#include <QColor>
#include <QObject>
#include "networkmanager.h"
class PreviewEngine : public QObject
#include "htmlparser.h"
class PreviewEngine final : public NetworkManager
{
Q_OBJECT
Q_DISABLE_COPY(PreviewEngine)
public:
PreviewEngine(QObject* parent = nullptr);
~PreviewEngine();
void parseMessage(const QString& messageId,
const QString& msg,
bool showPreview,
QColor color = "#0645AD");
Q_INVOKABLE void log(const QString& str);
Q_INVOKABLE void emitInfoReady(const QString& messageId, const QVariantMap& info);
Q_INVOKABLE void emitLinkified(const QString& messageId, const QString& linkifiedStr);
PreviewEngine(ConnectivityMonitor* cm, QObject* parent = nullptr);
~PreviewEngine() = default;
Q_SIGNALS:
void parseLink(const QString& messageId, const QString& link);
void infoReady(const QString& messageId, const QVariantMap& info);
void linkified(const QString& messageId, const QString& linkifiedStr);
private:
struct Impl;
std::unique_ptr<Impl> pimpl_;
Q_SLOT void onParseLink(const QString& messageId, const QString& link);
// An instance of HtmlParser used to parse HTML.
HtmlParser* htmlParser_;
QString getTagContent(QList<QString>& tags, const QString& value);
QString getTitle(HtmlParser::TagInfoList& metaTags);
QString getDescription(HtmlParser::TagInfoList& metaTags);
QString getImage(HtmlParser::TagInfoList& metaTags);
static const QRegularExpression newlineRe;
};

View file

@ -226,7 +226,7 @@ registerTypes(QQmlEngine* engine,
// Enums
QML_REGISTERUNCREATABLE(NS_ENUMS, Settings)
QML_REGISTERUNCREATABLE(NS_ENUMS, NetWorkManager)
QML_REGISTERUNCREATABLE(NS_ENUMS, NetworkManager)
QML_REGISTERUNCREATABLE(NS_ENUMS, WizardViewStepModel)
QML_REGISTERUNCREATABLE(NS_ENUMS, DeviceItemListModel)
QML_REGISTERUNCREATABLE(NS_ENUMS, VideoInputDeviceModel)

View file

@ -36,7 +36,7 @@ SimpleMessageDialog {
Connections {
target: UpdateManager
function onUpdateDownloadProgressChanged(bytesRead, totalBytes) {
function onDownloadProgressChanged(bytesRead, totalBytes) {
downloadDialog.setDownloadProgress(bytesRead, totalBytes);
}
@ -98,10 +98,10 @@ SimpleMessageDialog {
buttonTitles: [JamiStrings.optionCancel]
buttonStyles: [SimpleMessageDialog.ButtonStyle.TintedBlue]
buttonCallBacks: [function () {
UpdateManager.cancelUpdate();
UpdateManager.cancelDownload();
}]
onVisibleChanged: {
if (!visible)
UpdateManager.cancelUpdate();
UpdateManager.cancelDownload();
}
}

View file

@ -39,7 +39,7 @@ static constexpr char betaMsiSubUrl[] = "/beta/jami.beta.x64.msi";
struct UpdateManager::Impl : public QObject
{
Impl(const QString& url, ConnectivityMonitor* cm, LRCInstance* instance, UpdateManager& parent)
Impl(const QString& url, LRCInstance* instance, UpdateManager& parent)
: QObject(nullptr)
, parent_(parent)
, lrcInstance_(instance)
@ -60,14 +60,14 @@ struct UpdateManager::Impl : public QObject
// Fail without UI if this is a programmatic check.
if (!quiet)
connect(&parent_,
&NetWorkManager::errorOccured,
&NetworkManager::errorOccured,
&parent_,
&UpdateManager::updateErrorOccurred);
cleanUpdateFiles();
QUrl versionUrl {isBeta ? QUrl::fromUserInput(baseUrlString_ + betaVersionSubUrl)
: QUrl::fromUserInput(baseUrlString_ + versionSubUrl)};
parent_.get(versionUrl, [this, quiet](const QString& latestVersionString) {
parent_.sendGetRequest(versionUrl, [this, quiet](const QByteArray& latestVersionString) {
if (latestVersionString.isEmpty()) {
qWarning() << "Error checking version";
if (!quiet)
@ -92,16 +92,12 @@ struct UpdateManager::Impl : public QObject
{
parent_.disconnect();
connect(&parent_,
&NetWorkManager::errorOccured,
&NetworkManager::errorOccured,
&parent_,
&UpdateManager::updateErrorOccurred);
connect(&parent_, &NetWorkManager::statusChanged, this, [this](GetStatus status) {
connect(&parent_, &UpdateManager::statusChanged, this, [this](GetStatus status) {
switch (status) {
case GetStatus::STARTED:
connect(&parent_,
&NetWorkManager::downloadProgressChanged,
&parent_,
&UpdateManager::updateDownloadProgressChanged);
Q_EMIT parent_.updateDownloadStarted();
break;
case GetStatus::FINISHED:
@ -115,9 +111,11 @@ struct UpdateManager::Impl : public QObject
QUrl downloadUrl {(beta || isBeta) ? QUrl::fromUserInput(baseUrlString_ + betaMsiSubUrl)
: QUrl::fromUserInput(baseUrlString_ + msiSubUrl)};
parent_.get(
parent_.downloadFile(
downloadUrl,
[this, downloadUrl](const QString&) {
[this, downloadUrl](bool success, const QString& errorMessage) {
Q_UNUSED(success)
Q_UNUSED(errorMessage)
lrcInstance_->finish();
Q_EMIT lrcInstance_->quitEngineRequested();
auto args = QString(" /passive /norestart WIXNONUILAUNCH=1");
@ -132,7 +130,7 @@ struct UpdateManager::Impl : public QObject
void cancelUpdate()
{
parent_.cancelRequest();
parent_.cancelDownload();
};
void setAutoUpdateCheck(bool state)
@ -175,11 +173,14 @@ UpdateManager::UpdateManager(const QString& url,
ConnectivityMonitor* cm,
LRCInstance* instance,
QObject* parent)
: NetWorkManager(cm, parent)
, pimpl_(std::make_unique<Impl>(url, cm, instance, *this))
: NetworkManager(cm, parent)
, pimpl_(std::make_unique<Impl>(url, instance, *this))
{}
UpdateManager::~UpdateManager() {}
UpdateManager::~UpdateManager()
{
cancelDownload();
}
void
UpdateManager::checkForUpdates(bool quiet)
@ -225,3 +226,112 @@ UpdateManager::isAutoUpdaterEnabled()
{
return false;
}
void
UpdateManager::cancelDownload()
{
if (downloadReply_) {
Q_EMIT errorOccured(GetError::CANCELED);
downloadReply_->abort();
resetDownload();
}
}
void
UpdateManager::downloadFile(const QUrl& url,
std::function<void(bool, const QString&)> onDoneCallback,
const QString& filePath)
{
// If there is already a download in progress, return.
if (downloadReply_ && downloadReply_->isRunning()) {
qWarning() << Q_FUNC_INFO << "Download already in progress";
return;
}
// Clean up any previous download.
resetDownload();
// If the url is invalid, return.
if (!url.isValid()) {
Q_EMIT errorOccured(GetError::NETWORK_ERROR, "Invalid url");
return;
}
// If the file path is empty, return.
if (filePath.isEmpty()) {
Q_EMIT errorOccured(GetError::NETWORK_ERROR, "Invalid file path");
return;
}
// Create the file. Return if it cannot be created.
QFileInfo fileInfo(url.path());
QString fileName = fileInfo.fileName();
file_.reset(new QFile(filePath + "/" + fileName));
if (!file_->open(QIODevice::WriteOnly)) {
Q_EMIT errorOccured(GetError::ACCESS_DENIED);
file_.reset();
qWarning() << Q_FUNC_INFO << "Could not open file for writing";
return;
}
// Start the download.
QNetworkRequest request(url);
downloadReply_ = manager_->get(request);
connect(downloadReply_, &QNetworkReply::readyRead, this, [=]() {
if (file_ && file_->isOpen()) {
file_->write(downloadReply_->readAll());
}
});
connect(downloadReply_,
&QNetworkReply::downloadProgress,
this,
[=](qint64 bytesReceived, qint64 bytesTotal) {
Q_EMIT downloadProgressChanged(bytesReceived, bytesTotal);
});
connect(downloadReply_,
QOverload<QNetworkReply::NetworkError>::of(&QNetworkReply::errorOccurred),
this,
[this](QNetworkReply::NetworkError error) {
downloadReply_->disconnect();
resetDownload();
qWarning() << Q_FUNC_INFO
<< QMetaEnum::fromType<QNetworkReply::NetworkError>().valueToKey(error);
Q_EMIT errorOccured(GetError::NETWORK_ERROR);
});
connect(downloadReply_, &QNetworkReply::finished, this, [this, onDoneCallback]() {
bool success = false;
QString errorMessage;
if (downloadReply_->error() == QNetworkReply::NoError) {
resetDownload();
success = true;
} else {
errorMessage = downloadReply_->errorString();
resetDownload();
}
onDoneCallback(success, errorMessage);
Q_EMIT statusChanged(GetStatus::FINISHED);
});
Q_EMIT statusChanged(GetStatus::STARTED);
}
void
UpdateManager::resetDownload()
{
if (downloadReply_) {
downloadReply_->deleteLater();
downloadReply_ = nullptr;
}
if (file_) {
if (file_->isOpen()) {
file_->flush();
file_->close();
}
file_->deleteLater();
file_.reset();
}
}

View file

@ -25,7 +25,7 @@
class LRCInstance;
class ConnectivityMonitor;
class UpdateManager final : public NetWorkManager
class UpdateManager final : public NetworkManager
{
Q_OBJECT
Q_DISABLE_COPY(UpdateManager)
@ -36,6 +36,9 @@ public:
QObject* parent = nullptr);
~UpdateManager();
enum GetStatus { STARTED, FINISHED };
Q_ENUM(GetStatus)
Q_INVOKABLE void checkForUpdates(bool quiet = false);
Q_INVOKABLE void applyUpdates(bool beta = false);
Q_INVOKABLE void cancelUpdate();
@ -43,15 +46,28 @@ public:
Q_INVOKABLE bool isCurrentVersionBeta();
Q_INVOKABLE bool isUpdaterEnabled();
Q_INVOKABLE bool isAutoUpdaterEnabled();
Q_INVOKABLE void cancelDownload();
void downloadFile(const QUrl& url,
std::function<void(bool, const QString&)> onDoneCallback,
const QString& filePath);
Q_SIGNALS:
void statusChanged(GetStatus status);
void downloadProgressChanged(qint64 bytesRead, qint64 totalBytes);
void updateCheckReplyReceived(bool ok, bool found = false);
void updateErrorOccurred(const NetWorkManager::GetError& error);
void updateErrorOccurred(const NetworkManager::GetError& error);
void updateDownloadStarted();
void updateDownloadProgressChanged(qint64 bytesRead, qint64 totalBytes);
void updateDownloadFinished();
void appCloseRequested();
private:
void resetDownload();
QNetworkReply* downloadReply_ {nullptr};
QScopedPointer<QFile> file_;
private:
struct Impl;
std::unique_ptr<Impl> pimpl_;

View file

@ -297,7 +297,7 @@ public:
* @var isRead
* @var commit
* @var linkPreviewInfo
* @var linkified
* @var parsedBody
*/
struct Info
{
@ -312,7 +312,7 @@ struct Info
bool isRead = false;
MapStringString commit;
QVariantMap linkPreviewInfo = {};
QString linkified;
QString parsedBody = {};
QVariantMap reactions;
QString react_to;
QVector<Body> previousBodies;

View file

@ -454,14 +454,8 @@ MessageListModel::dataForItem(item_t item, int, int role) const
return QVariant(item.second.isRead);
case Role::LinkPreviewInfo:
return QVariant(item.second.linkPreviewInfo);
case Role::Linkified:
return QVariant(item.second.linkified);
case Role::LinkifiedBody: {
if (!item.second.linkified.isEmpty()) {
return QVariant(item.second.linkified);
}
return QVariant(item.second.body);
}
case Role::ParsedBody:
return QVariant(item.second.parsedBody);
case Role::ActionUri:
return QVariant(item.second.commit["uri"]);
case Role::ConfId:
@ -484,9 +478,9 @@ MessageListModel::dataForItem(item_t item, int, int role) const
case Role::ReplyToBody: {
if (repliedMsg == -1)
return QVariant("");
auto linkified = data(repliedMsg, Role::Linkified).toString();
if (!linkified.isEmpty())
return QVariant(linkified);
auto parsed = data(repliedMsg, Role::ParsedBody).toString();
if (!parsed.isEmpty())
return QVariant(parsed);
return QVariant(data(repliedMsg, Role::Body).toString());
}
case Role::TotalSize:
@ -551,15 +545,15 @@ MessageListModel::addHyperlinkInfo(const QString& messageId, const QVariantMap&
}
void
MessageListModel::linkifyMessage(const QString& messageId, const QString& linkified)
MessageListModel::setParsedMessage(const QString& messageId, const QString& parsed)
{
int index = getIndexOfMessage(messageId);
if (index == -1) {
return;
}
QModelIndex modelIndex = QAbstractListModel::index(index, 0);
interactions_[index].second.linkified = linkified;
Q_EMIT dataChanged(modelIndex, modelIndex, {Role::Linkified, Role::LinkifiedBody});
interactions_[index].second.parsedBody = parsed;
Q_EMIT dataChanged(modelIndex, modelIndex, {Role::ParsedBody});
}
void
@ -712,12 +706,11 @@ MessageListModel::editMessage(const QString& msgId, interaction::Info& info)
}
}
info.body = it->rbegin()->body;
info.linkified.clear();
info.parsedBody.clear();
editedBodies_.erase(it);
emitDataChanged(msgId,
{MessageList::Role::Body,
MessageList::Role::Linkified,
MessageList::Role::LinkifiedBody,
MessageList::Role::ParsedBody,
MessageList::Role::PreviousBodies,
MessageList::Role::IsEmojiOnly});

View file

@ -45,8 +45,7 @@ struct Info;
X(ConfId) \
X(DeviceId) \
X(LinkPreviewInfo) \
X(Linkified) \
X(LinkifiedBody) \
X(ParsedBody) \
X(PreviousBodies) \
X(Reactions) \
X(ReplyTo) \
@ -124,7 +123,7 @@ public:
bool contains(const QString& msgId);
int getIndexOfMessage(const QString& messageId) const;
void addHyperlinkInfo(const QString& messageId, const QVariantMap& info);
void linkifyMessage(const QString& messageId, const QString& linkified);
void setParsedMessage(const QString& messageId, const QString& parsed);
void setRead(const QString& peer, const QString& messageId);
QString getRead(const QString& peer);

View file

@ -38,12 +38,18 @@ set(TEST_QML_RESOURCES
${CMAKE_SOURCE_DIR}/src/app/resources.qrc)
# Common jami files
add_library(test_common_obj OBJECT ${COMMON_SOURCES} ${COMMON_HEADERS})
add_library(test_common_obj OBJECT
${COMMON_SOURCES}
${COMMON_HEADERS})
target_include_directories(test_common_obj PRIVATE
${CLIENT_INCLUDE_DIRS}
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/src)
target_link_directories(test_common_obj PRIVATE ${CLIENT_LINK_DIRS})
target_link_libraries(test_common_obj ${QML_TEST_LIBS})
target_compile_definitions(test_common_obj PRIVATE ENABLE_TESTS="ON")
include_directories(${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/src)
set(COMMON_TESTS_SOURCES
${QML_RESOURCES}
${QML_RESOURCES_QML}
@ -77,28 +83,18 @@ set(UNIT_TESTS_SOURCE_FILES
${CMAKE_SOURCE_DIR}/tests/unittests/main_unittest.cpp
${CMAKE_SOURCE_DIR}/tests/unittests/account_unittest.cpp
${CMAKE_SOURCE_DIR}/tests/unittests/contact_unittest.cpp
${CMAKE_SOURCE_DIR}/tests/unittests/messageparser_unittest.cpp
${CMAKE_SOURCE_DIR}/tests/unittests/globaltestenvironment.h
${COMMON_TESTS_SOURCES})
set(ALL_TESTS_LIBS
${QML_TEST_LIBS}
gtest
${ringclient}
${qrencode}
${X11}
${LIBNM_LIBRARIES}
${LIBNOTIFY_LIBRARIES}
${LIBGDKPIXBUF_LIBRARIES}
${WINDOWS_LIBS})
${CLIENT_LIBS})
set(ALL_TESTS_INCLUDES
${TESTS_INCLUDES}
${LRC}/include/libringclient
${LRC}/include
${LIBNM_INCLUDE_DIRS}
${LIBNOTIFY_INCLUDE_DIRS}
${LIBGDKPIXBUF_INCLUDE_DIRS}
${WINDOWS_INCLUDES})
${CLIENT_INCLUDE_DIRS})
function(setup_test TEST_NAME TEST_SOURCES TEST_INPUT)
string(TOLOWER ${TEST_NAME} TEST_BINARY_NAME)
@ -120,4 +116,4 @@ setup_test(Qml_Tests
# Unit tests
setup_test(Unit_Tests
"${UNIT_TESTS_SOURCE_FILES}" "")
"${UNIT_TESTS_SOURCE_FILES}" "")

View file

@ -59,7 +59,7 @@ public Q_SLOTS:
connectivityMonitor_.reset(new ConnectivityMonitor(this));
settingsManager_.reset(new AppSettingsManager(this));
systemTray_.reset(new SystemTray(settingsManager_.get(), this));
previewEngine_.reset(new PreviewEngine(this));
previewEngine_.reset(new PreviewEngine(connectivityMonitor_.get(), this));
QFontDatabase::addApplicationFont(":/images/FontAwesome.otf");

View file

@ -21,7 +21,8 @@
#include "appsettingsmanager.h"
#include "connectivitymonitor.h"
#include "systemtray.h"
#include "previewengine.h"
#include "messageparser.h"
#include "accountadapter.h"
#include <QTest>
@ -55,6 +56,9 @@ public:
systemTray.get(),
lrcInstance.data(),
nullptr));
previewEngine.reset(new PreviewEngine(connectivityMonitor.get(), nullptr));
messageParser.reset(new MessageParser(previewEngine.data(), nullptr));
}
void TearDown()
@ -75,6 +79,8 @@ public:
QScopedPointer<ConnectivityMonitor> connectivityMonitor;
QScopedPointer<AppSettingsManager> settingsManager;
QScopedPointer<SystemTray> systemTray;
QScopedPointer<PreviewEngine> previewEngine;
QScopedPointer<MessageParser> messageParser;
};
extern TestEnvironment globalEnv;

View file

@ -0,0 +1,171 @@
/*
* Copyright (C) 2023 Savoir-faire Linux Inc.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "globaltestenvironment.h"
class MessageParserFixture : public ::testing::Test
{
public:
// Prepare unit test context. Called at
// prior each unit test execution
void SetUp() override {}
// Close unit test context. Called
// after each unit test ending
void TearDown() override {}
};
/*!
* WHEN We parse a markdown text body with no link.
* THEN The HTML body should be returned correctly without the link.
*/
TEST_F(MessageParserFixture, TextIsParsedCorrectly)
{
auto linkColor = QColor::fromRgb(0, 0, 255);
auto backgroundColor = QColor::fromRgb(0, 0, 255);
QSignalSpy messageParsedSpy(globalEnv.messageParser.data(), &MessageParser::messageParsed);
QSignalSpy linkInfoReadySpy(globalEnv.messageParser.data(), &MessageParser::linkInfoReady);
globalEnv.messageParser->parseMessage("msgId_01",
"This is a **bold** text",
true,
linkColor,
backgroundColor);
// Wait for the messageParsed signal which should be emitted once.
messageParsedSpy.wait();
EXPECT_EQ(messageParsedSpy.count(), 1);
QList<QVariant> messageParserArguments = messageParsedSpy.takeFirst();
EXPECT_TRUE(messageParserArguments.at(0).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(messageParserArguments.at(0).toString(), "msgId_01");
EXPECT_TRUE(messageParserArguments.at(1).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(messageParserArguments.at(1).toString(),
"<style></style><p>This is a <strong>bold</strong> text</p>\n");
// No link info should be returned.
linkInfoReadySpy.wait();
EXPECT_EQ(linkInfoReadySpy.count(), 0);
}
/*!
* WHEN We parse a text body with a link.
* THEN The HTML body should be returned correctly including the link.
*/
TEST_F(MessageParserFixture, ALinkIsParsedCorrectly)
{
auto linkColor = QColor::fromRgb(0, 0, 255);
auto backgroundColor = QColor::fromRgb(0, 0, 255);
QSignalSpy messageParsedSpy(globalEnv.messageParser.data(), &MessageParser::messageParsed);
QSignalSpy linkInfoReadySpy(globalEnv.messageParser.data(), &MessageParser::linkInfoReady);
// Parse a message with a link.
globalEnv.messageParser->parseMessage("msgId_02",
"https://www.google.com",
true,
linkColor,
backgroundColor);
// Wait for the messageParsed signal which should be emitted once.
messageParsedSpy.wait();
EXPECT_EQ(messageParsedSpy.count(), 1);
QList<QVariant> messageParserArguments = messageParsedSpy.takeFirst();
EXPECT_TRUE(messageParserArguments.at(0).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(messageParserArguments.at(0).toString(), "msgId_02");
EXPECT_TRUE(messageParserArguments.at(1).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(messageParserArguments.at(1).toString(),
"<style>a{color:#0000ff;}</style><p><a "
"href=\"https://www.google.com\">https://www.google.com</a></p>\n");
// Wait for the linkInfoReady signal which should be emitted once.
linkInfoReadySpy.wait();
EXPECT_EQ(linkInfoReadySpy.count(), 1);
QList<QVariant> linkInfoReadyArguments = linkInfoReadySpy.takeFirst();
EXPECT_TRUE(linkInfoReadyArguments.at(0).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(linkInfoReadyArguments.at(0).toString(), "msgId_02");
EXPECT_TRUE(linkInfoReadyArguments.at(1).typeId() == qMetaTypeId<QVariantMap>());
QVariantMap linkInfo = linkInfoReadyArguments.at(1).toMap();
EXPECT_EQ(linkInfo["url"].toString(), "https://www.google.com");
// The rest of the link info is not tested here.
}
/*!
* WHEN We parse a text body with end of line characters.
* THEN The HTML body should be returned correctly with the end of line characters.
*/
TEST_F(MessageParserFixture, EndOfLineCharactersAreParsedCorrectly)
{
auto linkColor = QColor::fromRgb(0, 0, 255);
auto backgroundColor = QColor::fromRgb(0, 0, 255);
QSignalSpy messageParsedSpy(globalEnv.messageParser.data(), &MessageParser::messageParsed);
QSignalSpy linkInfoReadySpy(globalEnv.messageParser.data(), &MessageParser::linkInfoReady);
// Parse a message with a link.
globalEnv.messageParser->parseMessage("msgId_03",
"Text with\n2 lines",
true,
linkColor,
backgroundColor);
// Wait for the messageParsed signal which should be emitted once.
messageParsedSpy.wait();
EXPECT_EQ(messageParsedSpy.count(), 1);
QList<QVariant> messageParserArguments = messageParsedSpy.takeFirst();
EXPECT_TRUE(messageParserArguments.at(0).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(messageParserArguments.at(0).toString(), "msgId_03");
EXPECT_TRUE(messageParserArguments.at(1).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(messageParserArguments.at(1).toString(),
"<style></style><p>Text with<br>\n2 lines</p>\n");
}
/*!
* WHEN We parse a text body with some fenced code.
* THEN The HTML body should be returned correctly with the code wrapped in a <pre> tag.
*/
TEST_F(MessageParserFixture, FencedCodeIsParsedCorrectly)
{
auto linkColor = QColor::fromRgb(0, 0, 255);
auto backgroundColor = QColor::fromRgb(0, 0, 255);
QSignalSpy messageParsedSpy(globalEnv.messageParser.data(), &MessageParser::messageParsed);
QSignalSpy linkInfoReadySpy(globalEnv.messageParser.data(), &MessageParser::linkInfoReady);
// Parse a message with a link.
globalEnv.messageParser->parseMessage("msgId_04",
"Text with \n```\ncode\n```",
true,
linkColor,
backgroundColor);
// Wait for the messageParsed signal which should be emitted once.
messageParsedSpy.wait();
EXPECT_EQ(messageParsedSpy.count(), 1);
QList<QVariant> messageParserArguments = messageParsedSpy.takeFirst();
EXPECT_TRUE(messageParserArguments.at(0).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(messageParserArguments.at(0).toString(), "msgId_04");
EXPECT_TRUE(messageParserArguments.at(1).typeId() == qMetaTypeId<QString>());
EXPECT_EQ(messageParserArguments.at(1).toString(),
"<style>pre,code{background-color:#0000ff;color:#ffffff;white-space:pre-wrap;"
"}</style><p>Text with</p>\n<pre><code>code\n</code></pre>\n");
}