diff --git a/.gitmodules b/.gitmodules
index b06ae33b..44fd42b5 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -31,3 +31,7 @@
path = 3rdparty/zxing-cpp
url = https://github.com/nu-book/zxing-cpp.git
ignore = dirty
+[submodule "3rdparty/hunspell"]
+ path = 3rdparty/hunspell
+ url = https://gitlab.savoirfairelinux.com/jami/hunspell.git
+ ignore = dirty
diff --git a/3rdparty/hunspell b/3rdparty/hunspell
new file mode 160000
index 00000000..525f9f22
--- /dev/null
+++ b/3rdparty/hunspell
@@ -0,0 +1 @@
+Subproject commit 525f9f22766a28e0f81c435217fcf4528e01c013
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e96fbf61..268c08a3 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -255,7 +255,7 @@ set(PYTHON_EXEC ${Python3_EXECUTABLE})
# Versioning and build ID generation
set(VERSION_FILE ${CMAKE_CURRENT_BINARY_DIR}/version_info.cpp)
-# Touch the file to make sure it exists at configure time as
+# Touch the file to ensure it exists at configure time as
# we add it to the target_sources below.
file(TOUCH ${VERSION_FILE})
add_custom_target(
@@ -347,6 +347,7 @@ set(COMMON_SOURCES
${APP_SRC_DIR}/conversationlistmodel.cpp
${APP_SRC_DIR}/searchresultslistmodel.cpp
${APP_SRC_DIR}/calloverlaymodel.cpp
+ ${APP_SRC_DIR}/spellcheckdictionarymanager.cpp
${APP_SRC_DIR}/filestosendlistmodel.cpp
${APP_SRC_DIR}/wizardviewstepmodel.cpp
${APP_SRC_DIR}/avatarregistry.cpp
@@ -361,13 +362,13 @@ set(COMMON_SOURCES
${APP_SRC_DIR}/currentcall.cpp
${APP_SRC_DIR}/messageparser.cpp
${APP_SRC_DIR}/previewengine.cpp
- ${APP_SRC_DIR}/imagedownloader.cpp
+ ${APP_SRC_DIR}/filedownloader.cpp
${APP_SRC_DIR}/pluginversionmanager.cpp
${APP_SRC_DIR}/connectioninfolistmodel.cpp
${APP_SRC_DIR}/pluginversionmanager.cpp
${APP_SRC_DIR}/linkdevicemodel.cpp
${APP_SRC_DIR}/qrcodescannermodel.cpp
-)
+ ${APP_SRC_DIR}/spellchecker.cpp)
set(COMMON_HEADERS
${APP_SRC_DIR}/global.h
@@ -419,6 +420,7 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/conversationlistmodel.h
${APP_SRC_DIR}/searchresultslistmodel.h
${APP_SRC_DIR}/calloverlaymodel.h
+ ${APP_SRC_DIR}/spellcheckdictionarymanager.h
${APP_SRC_DIR}/filestosendlistmodel.h
${APP_SRC_DIR}/wizardviewstepmodel.h
${APP_SRC_DIR}/avatarregistry.h
@@ -433,7 +435,7 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/currentcall.h
${APP_SRC_DIR}/messageparser.h
${APP_SRC_DIR}/htmlparser.h
- ${APP_SRC_DIR}/imagedownloader.h
+ ${APP_SRC_DIR}/filedownloader.h
${APP_SRC_DIR}/pluginversionmanager.h
${APP_SRC_DIR}/connectioninfolistmodel.h
${APP_SRC_DIR}/pttlistener.h
@@ -441,7 +443,7 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/crashreporter.h
${APP_SRC_DIR}/linkdevicemodel.h
${APP_SRC_DIR}/qrcodescannermodel.h
-)
+ ${APP_SRC_DIR}/spellchecker.h)
# For libavutil/avframe.
set(LIBJAMI_CONTRIB_DIR "${DAEMON_DIR}/contrib")
@@ -469,6 +471,48 @@ if(ENABLE_CRASHREPORTS)
endif()
endif()
+find_package(PkgConfig REQUIRED)
+
+# hunspell
+pkg_check_modules(HUNSPELL hunspell)
+if(MSVC)
+elseif (NOT APPLE)
+ set(HUNSPELL_DICT_DIR "/usr/share/hunspell/")
+else()
+ set(HUNSPELL_DICT_DIR "/Library/Spelling/")
+endif()
+
+if(HUNSPELL_FOUND)
+ message(STATUS "hunspell found")
+ include_directories(${HUNSPELL_INCLUDE_DIR})
+ find_path(HUNSPELL_INCLUDE_DIRS
+ NAMES hunspell.hxx
+ PATH_SUFFIXES hunspell
+ HINTS ${HUNSPELL_INCLUDE_DIRS}
+ )
+
+ find_library(HUNSPELL_LIBRARIES
+ NAMES ${HUNSPELL_LIBRARIES}
+ hunspell
+ hunspell-1.7
+ libhunspell
+ libhunspell-1.7
+ libhunspell-devel
+ libhunspell-dev
+ HINTS ${HUNSPELL_LIBRARY_DIRS}
+ )
+else()
+ message(STATUS "hunspell not found - building hunspell")
+
+ set(HUNSPELL_DIR ${PROJECT_SOURCE_DIR}/3rdparty/hunspell)
+
+ # Build using the submodule and its CMakeLists.txt
+ add_subdirectory(${HUNSPELL_DIR} hunspell_build)
+
+ set(HUNSPELL_INCLUDE_DIR ${HUNSPELL_DIR}/src)
+ set(HUNSPELL_LIBRARIES hunspell::hunspell)
+endif()
+
if(MSVC)
set(WINDOWS_SYS_LIBS
windowsapp.lib
@@ -531,8 +575,6 @@ elseif (NOT APPLE)
${APP_SRC_DIR}/screencastportal.h)
list(APPEND QT_MODULES DBus)
- find_package(PkgConfig REQUIRED)
-
pkg_check_modules(GLIB REQUIRED glib-2.0)
if(GLIB_FOUND)
add_definitions(${GLIB_CFLAGS_OTHER})
@@ -615,6 +657,13 @@ else() # APPLE
endif()
endif()
+message(STATUS "Adding HUNSPELL_INCLUDE_DIR" ${HUNSPELL_INCLUDE_DIR})
+list(APPEND CLIENT_INCLUDE_DIRS ${HUNSPELL_INCLUDE_DIR} ${CMAKE_BINARY_DIR}/include
+${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/hunspell/src)
+
+message(STATUS "Adding HUNSPELL_LIBRARIES" ${HUNSPELL_INCLUDE_DIR})
+list(APPEND CLIENT_LIBS ${HUNSPELL_LIBRARIES})
+
# Qt find package
if(QT6_VER AND QT6_PATH)
message(STATUS "Using custom Qt version")
@@ -703,7 +752,9 @@ qt_add_executable(
${QML_RESOURCES_QML}
${SFPM_OBJECTS})
-# Make sure we can find the generated version file
+add_dependencies(${PROJECT_NAME} hunspell)
+
+# Ensure the generated version file can be found.
add_dependencies(${PROJECT_NAME} generate_version_info)
foreach(MODULE ${QT_MODULES})
@@ -797,6 +848,11 @@ elseif (NOT APPLE)
PRIVATE
JAMI_INSTALL_PREFIX="${JAMI_DATA_PREFIX}")
+ target_compile_definitions(
+ ${PROJECT_NAME}
+ PRIVATE
+ HUNSPELL_INSTALL_DIR="${HUNSPELL_DICT_DIR}")
+
# Logos
install(
FILES resources/images/jami.svg
diff --git a/build.py b/build.py
index 4a9abee9..20876783 100755
--- a/build.py
+++ b/build.py
@@ -112,7 +112,7 @@ ZYPPER_CLIENT_DEPENDENCIES = [
'qt6-svg-devel', 'qt6-multimedia-devel', 'qt6-multimedia-imports',
'qt6-declarative-devel', 'qt6-qmlcompiler-private-devel',
'qt6-quickcontrols2-devel', 'qt6-shadertools-devel',
- 'qrencode-devel', 'NetworkManager-devel'
+ 'qrencode-devel', 'NetworkManager-devel', 'hunspell-devel', 'libhunspell-devel'
]
ZYPPER_QT_WEBENGINE = [
@@ -139,7 +139,7 @@ DNF_CLIENT_DEPENDENCIES = [
'libnotify-devel',
'qt6-qtbase-devel',
'qt6-qtsvg-devel', 'qt6-qtmultimedia-devel', 'qt6-qtdeclarative-devel',
- 'qrencode-devel', 'NetworkManager-libnm-devel'
+ 'qrencode-devel', 'NetworkManager-libnm-devel', 'hunspell-devel', 'libhunspell-devel'
]
DNF_QT_WEBENGINE = ['qt6-qtwebengine-devel']
@@ -171,7 +171,7 @@ APT_CLIENT_DEPENDENCIES = [
'qml6-module-qtquick-dialogs', 'qml6-module-qtquick-layouts',
'qml6-module-qtquick-shapes', 'qml6-module-qtquick-window',
'qml6-module-qtquick-templates', 'qml6-module-qt-labs-platform',
- 'libqrencode-dev', 'libnm-dev'
+ 'libqrencode-dev', 'libnm-dev', 'hunspell', 'libhunspell-dev'
]
APT_QT_WEBENGINE = [
@@ -194,7 +194,7 @@ PACMAN_CLIENT_DEPENDENCIES = [
'qt6-declarative', 'qt6-5compat', 'qt6-multimedia',
'qt6-networkauth', 'qt6-shadertools',
'qt6-svg', 'qt6-tools',
- 'qrencode', 'libnm'
+ 'qrencode', 'libnm', 'hunspell'
]
PACMAN_QT_WEBENGINE = ['qt6-webengine']
diff --git a/src/app/appsettingsmanager.h b/src/app/appsettingsmanager.h
index 5c4f489c..211f8dd5 100644
--- a/src/app/appsettingsmanager.h
+++ b/src/app/appsettingsmanager.h
@@ -63,6 +63,8 @@ extern const QString defaultDownloadPath;
X(WindowState, QWindow::AutomaticVisibility) \
X(EnableExperimentalSwarm, false) \
X(LANG, "SYSTEM") \
+ X(SpellLang, "None") \
+ X(EnableSpellCheck, true) \
X(PluginStoreEndpoint, "https://plugins.jami.net") \
X(PositionShareDuration, 15) \
X(PositionShareLimit, true) \
diff --git a/src/app/commoncomponents/LineEditContextMenu.qml b/src/app/commoncomponents/LineEditContextMenu.qml
index d3b4e9e4..e7974683 100644
--- a/src/app/commoncomponents/LineEditContextMenu.qml
+++ b/src/app/commoncomponents/LineEditContextMenu.qml
@@ -15,8 +15,11 @@
* along with this program. If not, see .
*/
import QtQuick
+import net.jami.Adapters 1.1
import net.jami.Constants 1.1
import "contextmenu"
+import "../mainview"
+import "../mainview/components"
ContextMenuAutoLoader {
id: root
@@ -27,8 +30,16 @@ ContextMenuAutoLoader {
property var selectionEnd
property bool customizePaste: false
property bool selectOnly: false
+ property bool checkSpell: false
+ property var suggestionList
+ property var menuItemsLength
+ property var language
signal contextMenuRequirePaste
+ SpellLanguageContextMenu {
+ id: spellLanguageContextMenu
+ active: checkSpell
+ }
property list menuItems: [
GeneralMenuItem {
@@ -38,9 +49,8 @@ ContextMenuAutoLoader {
isActif: lineEditObj.selectedText.length
itemName: JamiStrings.copy
hasIcon: false
- onClicked: {
+ onClicked:
lineEditObj.copy();
- }
},
GeneralMenuItem {
id: cut
@@ -49,9 +59,8 @@ ContextMenuAutoLoader {
isActif: lineEditObj.selectedText.length && !selectOnly
itemName: JamiStrings.cut
hasIcon: false
- onClicked: {
+ onClicked:
lineEditObj.cut();
- }
},
GeneralMenuItem {
id: paste
@@ -65,9 +74,68 @@ ContextMenuAutoLoader {
else
lineEditObj.paste();
}
+ },
+ GeneralMenuItem {
+ id: language
+ visible: checkSpell
+ canTrigger: checkSpell
+ itemName: JamiStrings.language
+ hasIcon: false
+ onClicked: {
+ spellLanguageContextMenu.openMenu();
+ }
}
]
+ ListView {
+ model: ListModel {
+ id: dynamicModel
+ }
+
+ Instantiator {
+ model: dynamicModel
+ delegate: GeneralMenuItem {
+ id: suggestion
+
+ canTrigger: true
+ isActif: true
+ itemName: model.name
+ hasIcon: false
+ onClicked: {
+ replaceWord(model.name);
+ }
+ }
+
+ onObjectAdded: {
+ menuItems.push(object);
+ }
+
+ onObjectRemoved: {
+ menuItems.splice(menuItemsLength, suggestionList.length);
+ }
+ }
+ }
+
+ function removeItems() {
+ dynamicModel.remove(0, suggestionList.length);
+ suggestionList.length = 0;
+ }
+
+ function addMenuItem(wordList) {
+ menuItemsLength = menuItems.length; // Keep initial number of items for easier removal
+ suggestionList = wordList;
+ for (var i = 0; i < suggestionList.length; ++i) {
+ dynamicModel.append({
+ "name": suggestionList[i]
+ });
+ }
+ }
+
+ function replaceWord(word) {
+ lineEditObj.remove(selectionStart, selectionEnd);
+ lineEditObj.insert(lineEditObj.cursorPosition, word);
+ }
+
function openMenuAt(mouseEvent) {
if (lineEditObj.selectedText.length === 0 && selectOnly)
return;
@@ -85,6 +153,12 @@ ContextMenuAutoLoader {
function onOpened() {
lineEditObj.select(selectionStart, selectionEnd);
}
+ function onClosed() {
+ if (!suggestionList || suggestionList.length == 0) {
+ return;
+ }
+ removeItems();
+ }
}
Component.onCompleted: menuItemsToLoad = menuItems
diff --git a/src/app/commoncomponents/SpellLanguageContextMenu.qml b/src/app/commoncomponents/SpellLanguageContextMenu.qml
new file mode 100644
index 00000000..7d2aea21
--- /dev/null
+++ b/src/app/commoncomponents/SpellLanguageContextMenu.qml
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2020-2025 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 .
+ */
+import QtQuick
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+import net.jami.Models 1.1
+import net.jami.Enums 1.1
+import "contextmenu"
+import "../mainview"
+import "../mainview/components"
+
+ContextMenuAutoLoader {
+ id: root
+
+ signal languageChanged()
+
+ CachedFile {
+ id: cachedFile
+ }
+
+ function openMenuAt(mouseEvent) {
+ x = mouseEvent.x;
+ y = mouseEvent.y;
+ root.openMenu();
+ }
+
+ onOpenRequested: {
+ // Create the menu items from the installed dictionaries
+ menuItemsToLoad = generateMenuItems();
+ }
+
+ function generateMenuItems() {
+ var menuItems = [];
+ // Create new menu items
+ var dictionaries = SpellCheckDictionaryManager.installedDictionaries();
+ var keys = Object.keys(dictionaries);
+ for (var i = 0; i < keys.length; ++i) {
+ var menuItem = Qt.createComponent("qrc:/commoncomponents/contextmenu/GeneralMenuItem.qml", Component.PreferSynchronous);
+ if (menuItem.status !== Component.Ready) {
+ console.error("Error loading component:", menuItem.errorString());
+ continue;
+ }
+ let menuItemObject = menuItem.createObject(root, {
+ "parent": root,
+ "canTrigger": true,
+ "isActif": true,
+ "itemName": dictionaries[keys[i]],
+ "hasIcon": false,
+ "content": keys[i],
+ });
+ if (menuItemObject === null) {
+ console.error("Error creating menu item:", menuItem.errorString());
+ continue;
+ }
+ menuItemObject.clicked.connect(function () {
+ UtilsAdapter.setAppValue(Settings.Key.SpellLang, menuItemObject.content);
+ });
+ // Log the object pointer
+ menuItems.push(menuItemObject);
+ }
+ return menuItems;
+ }
+}
diff --git a/src/app/commoncomponents/contextmenu/BaseContextMenu.qml b/src/app/commoncomponents/contextmenu/BaseContextMenu.qml
index d721626b..5eb23485 100644
--- a/src/app/commoncomponents/contextmenu/BaseContextMenu.qml
+++ b/src/app/commoncomponents/contextmenu/BaseContextMenu.qml
@@ -44,11 +44,15 @@ Menu {
function loadMenuItems(menuItems) {
root.addItem(menuTopBorder);
+
+ // Establish the preferred width of the menu by taking the maximum width of the items
for (var j = 0; j < menuItems.length; ++j) {
var currentItemWidth = menuItems[j].itemPreferredWidth;
if (currentItemWidth !== JamiTheme.menuItemsPreferredWidth && currentItemWidth > menuPreferredWidth && menuItems[j].canTrigger)
menuPreferredWidth = currentItemWidth;
}
+
+ // Add the items to the menu
for (var i = 0; i < menuItems.length; ++i) {
if (menuItems[i].canTrigger) {
menuItems[i].parentMenu = root;
diff --git a/src/app/commoncomponents/contextmenu/ContextMenuAutoLoader.qml b/src/app/commoncomponents/contextmenu/ContextMenuAutoLoader.qml
index baee2dfb..a0850d0b 100644
--- a/src/app/commoncomponents/contextmenu/ContextMenuAutoLoader.qml
+++ b/src/app/commoncomponents/contextmenu/ContextMenuAutoLoader.qml
@@ -27,11 +27,14 @@ Loader {
property int contextMenuItemPreferredHeight: 0
property int contextMenuSeparatorPreferredHeight: 0
+ signal openRequested
+
active: false
visible: false
function openMenu() {
+ openRequested();
root.active = true;
root.sourceComponent = menuComponent;
}
diff --git a/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml b/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
index ba8ab606..02a9fb45 100644
--- a/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
+++ b/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
@@ -28,6 +28,7 @@ MenuItem {
id: menuItem
property string itemName: ""
+ property string content: ""
property alias iconSource: contextMenuItemImage.source
property string iconColor: ""
property bool canTrigger: true
diff --git a/src/app/imagedownloader.cpp b/src/app/filedownloader.cpp
similarity index 66%
rename from src/app/imagedownloader.cpp
rename to src/app/filedownloader.cpp
index 19318536..9b7ce833 100644
--- a/src/app/imagedownloader.cpp
+++ b/src/app/filedownloader.cpp
@@ -15,32 +15,32 @@
* along with this program. If not, see .
*/
-#include "imagedownloader.h"
+#include "filedownloader.h"
#include
#include
-ImageDownloader::ImageDownloader(ConnectivityMonitor* cm, QObject* parent)
+FileDownloader::FileDownloader(ConnectivityMonitor* cm, QObject* parent)
: NetworkManager(cm, parent)
{}
void
-ImageDownloader::downloadImage(const QUrl& url, const QString& localPath)
+FileDownloader::downloadFile(const QUrl& url, const QString& localPath)
{
Utils::oneShotConnect(this, &NetworkManager::errorOccurred, this, [this, localPath]() {
- onDownloadImageFinished({}, localPath);
+ onDownloadFileFinished({}, localPath);
});
- sendGetRequest(url, [this, localPath](const QByteArray& imageData) {
- onDownloadImageFinished(imageData, localPath);
+ sendGetRequest(url, [this, localPath](const QByteArray& fileData) {
+ onDownloadFileFinished(fileData, localPath);
});
}
void
-ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString& localPath)
+FileDownloader::onDownloadFileFinished(const QByteArray& data, const QString& localPath)
{
if (data.isEmpty()) {
- Q_EMIT downloadImageFailed(localPath);
+ Q_EMIT downloadFileFailed(localPath);
return;
}
@@ -49,7 +49,7 @@ ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString&
const QDir dir;
if (!dir.mkpath(dirPath)) {
qWarning() << Q_FUNC_INFO << "Failed to create directory" << dirPath;
- Q_EMIT downloadImageFailed(localPath);
+ Q_EMIT downloadFileFailed(localPath);
return;
}
@@ -58,10 +58,10 @@ ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString&
if (lf.lock() && file.open(QIODevice::WriteOnly)) {
file.write(data);
file.close();
- Q_EMIT downloadImageSuccessful(localPath);
+ Q_EMIT downloadFileSuccessful(localPath);
return;
}
- qWarning() << Q_FUNC_INFO << "Failed to write image to" << localPath;
- Q_EMIT downloadImageFailed(localPath);
+ qWarning() << Q_FUNC_INFO << "Failed to write file to" << localPath;
+ Q_EMIT downloadFileFailed(localPath);
}
diff --git a/src/app/imagedownloader.h b/src/app/filedownloader.h
similarity index 66%
rename from src/app/imagedownloader.h
rename to src/app/filedownloader.h
index 32dac08d..b92f24cb 100644
--- a/src/app/imagedownloader.h
+++ b/src/app/filedownloader.h
@@ -24,7 +24,7 @@
#include // QML registration
#include // QML registration
-class ImageDownloader : public NetworkManager
+class FileDownloader : public NetworkManager
{
Q_OBJECT
QML_SINGLETON
@@ -32,23 +32,23 @@ class ImageDownloader : public NetworkManager
QML_PROPERTY(QString, cachePath)
public:
- static ImageDownloader* create(QQmlEngine*, QJSEngine*)
+ static FileDownloader* create(QQmlEngine*, QJSEngine*)
{
- return new ImageDownloader(
+ return new FileDownloader(
qApp->property("ConnectivityMonitor").value());
}
- explicit ImageDownloader(ConnectivityMonitor* cm, QObject* parent = nullptr);
- ~ImageDownloader() = default;
+ explicit FileDownloader(ConnectivityMonitor* cm, QObject* parent = nullptr);
+ ~FileDownloader() = default;
- // Download an image and call onDownloadImageFinished when done
- Q_INVOKABLE void downloadImage(const QUrl& url, const QString& localPath);
+ // Download an image and call onDownloadFileFinished when done
+ Q_INVOKABLE void downloadFile(const QUrl& url, const QString& localPath);
Q_SIGNALS:
- void downloadImageSuccessful(const QString& localPath);
- void downloadImageFailed(const QString& localPath);
+ void downloadFileSuccessful(const QString& localPath);
+ void downloadFileFailed(const QString& localPath);
private Q_SLOTS:
// Saves the image to the localPath and emits the appropriate signal
- void onDownloadImageFinished(const QByteArray& reply, const QString& localPath);
+ void onDownloadFileFinished(const QByteArray& reply, const QString& localPath);
};
diff --git a/src/app/mainapplication.cpp b/src/app/mainapplication.cpp
index 044879b5..f2ba599b 100644
--- a/src/app/mainapplication.cpp
+++ b/src/app/mainapplication.cpp
@@ -20,6 +20,7 @@
#include "global.h"
#include "qmlregister.h"
#include "appsettingsmanager.h"
+#include "spellcheckdictionarymanager.h"
#include "connectivitymonitor.h"
#include "systemtray.h"
#include "previewengine.h"
@@ -190,6 +191,7 @@ MainApplication::init()
// to any other initialization. This won't do anything if crashpad isn't
// enabled.
settingsManager_ = new AppSettingsManager(this);
+ spellCheckDictionaryManager_ = new SpellCheckDictionaryManager(settingsManager_, this);
crashReporter_ = new CrashReporter(settingsManager_, this);
// This 2-phase initialisation prevents ephemeral instances from
@@ -423,6 +425,7 @@ MainApplication::initQmlLayer()
lrcInstance_.get(),
systemTray_,
settingsManager_,
+ spellCheckDictionaryManager_,
connectivityMonitor_,
previewEngine_,
&screenInfo_,
diff --git a/src/app/mainapplication.h b/src/app/mainapplication.h
index 64a0f346..a9fc3999 100644
--- a/src/app/mainapplication.h
+++ b/src/app/mainapplication.h
@@ -31,6 +31,7 @@
class ConnectivityMonitor;
class SystemTray;
class AppSettingsManager;
+class SpellCheckDictionaryManager;
class CrashReporter;
class PreviewEngine;
@@ -118,6 +119,7 @@ private:
ConnectivityMonitor* connectivityMonitor_;
SystemTray* systemTray_;
AppSettingsManager* settingsManager_;
+ SpellCheckDictionaryManager* spellCheckDictionaryManager_;
PreviewEngine* previewEngine_;
CrashReporter* crashReporter_;
diff --git a/src/app/mainview/components/CachedFile.qml b/src/app/mainview/components/CachedFile.qml
new file mode 100644
index 00000000..cdd2b862
--- /dev/null
+++ b/src/app/mainview/components/CachedFile.qml
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2025 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 .
+ */
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+import net.jami.Helpers 1.1
+import net.jami.Models 1.1
+import "../../commoncomponents"
+
+Item {
+ id: cachedFile
+ property string dictionaryPath: SpellCheckDictionaryManager.getDictionariesPath()
+
+ function updateDictionnary(languagePath) {
+ var file = dictionaryPath + languagePath;
+ MessagesAdapter.updateDictionnary(file);
+ }
+}
diff --git a/src/app/mainview/components/CachedImage.qml b/src/app/mainview/components/CachedImage.qml
index 8ce9cfb8..fdebe99a 100644
--- a/src/app/mainview/components/CachedImage.qml
+++ b/src/app/mainview/components/CachedImage.qml
@@ -64,8 +64,8 @@ Item {
}
Connections {
- target: ImageDownloader
- function onDownloadImageSuccessful(localPath) {
+ target: FileDownloader
+ function onDownloadFileSuccessful(localPath) {
if (localPath === cachedImage.localPath) {
image.source = UtilsAdapter.urlFromLocalPath(localPath);
}
@@ -90,7 +90,7 @@ Item {
}
if (downloadUrl && downloadUrl !== "" && localPath !== "") {
if (!UtilsAdapter.fileExists(localPath)) {
- ImageDownloader.downloadImage(downloadUrl, localPath);
+ FileDownloader.downloadFile(downloadUrl, localPath);
} else {
image.source = UtilsAdapter.urlFromLocalPath(localPath);
if (image.isGif) {
diff --git a/src/app/mainview/components/ChatViewHeader.qml b/src/app/mainview/components/ChatViewHeader.qml
index 60e8e3b9..7546cbd6 100644
--- a/src/app/mainview/components/ChatViewHeader.qml
+++ b/src/app/mainview/components/ChatViewHeader.qml
@@ -116,23 +116,23 @@ Rectangle {
spacing: 0
- LineEditContextMenu {
- id: displayNameContextMenu
- lineEditObj: title
- selectOnly: true
- }
- MouseArea {
- anchors.fill: parent
- acceptedButtons: Qt.RightButton
- cursorShape: Qt.IBeamCursor
- onClicked: function (mouse) {
- displayNameContextMenu.openMenuAt(mouse);
- }
- }
-
ElidedTextLabel {
id: title
+ LineEditContextMenu {
+ id: displayNameContextMenu
+ lineEditObj: title
+ selectOnly: true
+ }
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.RightButton
+ cursorShape: Qt.IBeamCursor
+ onClicked: function (mouse) {
+ displayNameContextMenu.openMenuAt(mouse);
+ }
+ }
+
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
font.pointSize: JamiTheme.textFontSize + 2
diff --git a/src/app/mainview/components/MessageBarTextArea.qml b/src/app/mainview/components/MessageBarTextArea.qml
index 5d5cf378..04401cf7 100644
--- a/src/app/mainview/components/MessageBarTextArea.qml
+++ b/src/app/mainview/components/MessageBarTextArea.qml
@@ -17,19 +17,17 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
-
import net.jami.Adapters 1.1
import net.jami.Constants 1.1
import net.jami.Enums 1.1
import net.jami.Models 1.1
-
import SortFilterProxyModel 0.2
-
import "../../commoncomponents"
JamiFlickable {
id: root
+ property int underlineHeight: JamiTheme.messageUnderlineHeight
property alias text: textArea.text
property var textAreaObj: textArea
property alias placeholderText: textArea.placeholderText
@@ -39,9 +37,12 @@ JamiFlickable {
property bool showPreview: false
property bool isShowTypo: UtilsAdapter.getAppValue(Settings.Key.ShowMardownOption)
property int textWidth: textArea.contentWidth
+ property var spellCheckActive: AppSettingsManager.getValue(Settings.EnableSpellCheck)
+ property var language: AppSettingsManager.getValue(Settings.SpellLang)
// Used to cache the editable text when showing the preview message
// and also to debounce the textChanged signal's effect on the composing status.
+ property var underlineList: []
property string cachedText
property string debounceText
@@ -72,6 +73,7 @@ JamiFlickable {
lineEditObj: textArea
customizePaste: true
+ checkSpell: (Qt.platform.os.toString() === "linux") ? true : false
onContextMenuRequirePaste: {
// Intercept paste event to use C++ QMimeData
@@ -115,9 +117,79 @@ JamiFlickable {
TextArea.flickable: TextArea {
id: textArea
+ CachedFile {
+ id: cachedFile
+ }
+
+ function updateCorrection(language) {
+ cachedFile.updateDictionnary(language);
+ textArea.updateUnderlineText();
+ }
+
+ // Listen to settings changes and apply it to this widget
+ Connections {
+ target: UtilsAdapter
+
+ function onChangeLanguage() {
+ textArea.updateUnderlineText();
+ }
+
+ function onChangeFontSize() {
+ textArea.updateUnderlineText();
+ }
+
+ function onSpellLanguageChanged() {
+ root.language = SpellCheckDictionaryManager.getSpellLanguage();
+ if ((Qt.platform.os.toString() !== "linux") || (AppSettingsManager.getValue(Settings.SpellLang) === "NONE")) {
+ spellCheckActive = false;
+ } else {
+ spellCheckActive = AppSettingsManager.getValue(Settings.EnableSpellCheck);
+ }
+ if (spellCheckActive === true) {
+ root.language = SpellCheckDictionaryManager.getSpellLanguage();
+ textArea.updateCorrection(root.language);
+ } else {
+ textArea.clearUnderlines();
+ }
+ }
+
+ function onEnableSpellCheckChanged() {
+ // Disable spell check on non-linux platforms yet
+ if ((Qt.platform.os.toString() !== "linux") || (AppSettingsManager.getValue(Settings.SpellLang) === "NONE")) {
+ spellCheckActive = false;
+ } else {
+ spellCheckActive = AppSettingsManager.getValue(Settings.EnableSpellCheck);
+ }
+ if (spellCheckActive === true) {
+ root.language = SpellCheckDictionaryManager.getSpellLanguage();
+ textArea.updateCorrection(root.language);
+ } else {
+ textArea.clearUnderlines();
+ }
+ }
+ }
+
+ // Initialize the settings if the component wasn't loaded when changing settings
+ Component.onCompleted: {
+ if ((Qt.platform.os.toString() !== "linux") || (AppSettingsManager.getValue(Settings.SpellLang) === "NONE")) {
+ spellCheckActive = false;
+ } else {
+ spellCheckActive = AppSettingsManager.getValue(Settings.EnableSpellCheck);
+ }
+ if (spellCheckActive === true) {
+ root.language = SpellCheckDictionaryManager.getSpellLanguage();
+ textArea.updateCorrection(root.language);
+ } else {
+ textArea.clearUnderlines();
+ }
+ }
+
readOnly: showPreview
leftPadding: JamiTheme.scrollBarHandleSize
rightPadding: JamiTheme.scrollBarHandleSize
+ topPadding: 0
+ bottomPadding: underlineHeight
+
persistentSelection: true
verticalAlignment: TextEdit.AlignVCenter
font.pointSize: JamiTheme.textFontSize + 2
@@ -135,12 +207,37 @@ JamiFlickable {
color: "transparent"
}
+ TextMetrics {
+ id: textMetrics
+ elide: Text.ElideMiddle
+ font.family: textArea.font.family
+ font.pointSize: JamiTheme.textFontSize + 2
+ }
+
+ Text {
+ id: highlight
+ color: "black"
+ font.bold: true
+ visible: false
+ }
+
onReleased: function (event) {
- if (event.button === Qt.RightButton)
+ if (event.button === Qt.RightButton) {
+ var position = textArea.positionAt(event.x, event.y);
+ textArea.moveCursorSelection(position, TextInput.SelectWords);
+ textArea.selectWord();
+ if (!MessagesAdapter.spell(textArea.selectedText)) {
+ var wordList = MessagesAdapter.spellSuggestionsRequest(textArea.selectedText);
+ if (wordList.length !== 0) {
+ textAreaContextMenu.addMenuItem(wordList);
+ }
+ }
textAreaContextMenu.openMenuAt(event);
+ }
}
onTextChanged: {
+ updateUnderlineText();
if (text !== debounceText && !showPreview) {
debounceText = text;
MessagesAdapter.userIsComposing(text ? true : false);
@@ -152,6 +249,8 @@ JamiFlickable {
// eg. Enter -> Send messages
// Shift + Enter -> Next Line
Keys.onPressed: function (keyEvent) {
+ // Update underline on each input to take into account deleted text and sent ones
+ updateUnderlineText();
if (keyEvent.matches(StandardKey.Paste)) {
MessagesAdapter.onPaste();
keyEvent.accepted = true;
@@ -180,5 +279,41 @@ JamiFlickable {
keyEvent.accepted = true;
}
}
+
+ function updateUnderlineText() {
+ clearUnderlines();
+ // We iterate over the whole text to find words to check and underline them if needed
+ if (spellCheckActive) {
+ var text = textArea.text;
+ var words = MessagesAdapter.findWords(text);
+ if (!words)
+ return;
+ for (var i = 0; i < words.length; i++) {
+ var wordInfo = words[i];
+ if (wordInfo && wordInfo.word && !MessagesAdapter.spell(wordInfo.word)) {
+ textMetrics.text = wordInfo.word;
+ var xPos = textArea.positionToRectangle(wordInfo.position).x;
+ var yPos = textArea.positionToRectangle(wordInfo.position).y + textArea.positionToRectangle(wordInfo.position).height;
+ var underlineObject = Qt.createQmlObject('import QtQuick; Rectangle {height: 2; color: "red";}', textArea);
+ underlineObject.x = xPos;
+ underlineObject.y = yPos;
+ underlineObject.width = textMetrics.width;
+ underlineList.push(underlineObject);
+ }
+ }
+ }
+ }
+
+ function clearUnderlines() {
+ // Destroy all of the underline boxes
+ while (underlineList.length > 0) {
+ // Get the previous item
+ var underlineObject = underlineList[underlineList.length - 1];
+ // Remove the last item
+ underlineList.pop();
+ // Destroy the removed item
+ underlineObject.destroy();
+ }
+ }
}
}
diff --git a/src/app/messagesadapter.cpp b/src/app/messagesadapter.cpp
index ca90f74b..56dddba0 100644
--- a/src/app/messagesadapter.cpp
+++ b/src/app/messagesadapter.cpp
@@ -21,6 +21,7 @@
#include "qtutils.h"
#include "messageparser.h"
#include "previewengine.h"
+#include "spellchecker.h"
#include
#include
@@ -39,17 +40,25 @@
#include
#include
+#define SUGGESTIONS_MAX_SIZE 3 // limit the number of spelling suggestions
+
MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine,
+ SpellCheckDictionaryManager* spellCheckDictionaryManager,
LRCInstance* instance,
QObject* parent)
: QmlAdapterBase(instance, parent)
, settingsManager_(settingsManager)
+ , spellCheckDictionaryManager_(spellCheckDictionaryManager)
, messageParser_(new MessageParser(previewEngine, this))
, filteredMsgListModel_(new FilteredMsgListModel(this))
, mediaInteractions_(std::make_unique(nullptr))
, timestampTimer_(new QTimer(this))
{
+ #if defined(Q_OS_LINUX)
+ // Initialize with make_shared
+ spellChecker_ = std::make_shared(spellCheckDictionaryManager_->getDictionaryPath());
+ #endif
setObjectName(typeid(*this).name());
set_messageListModel(QVariant::fromValue(filteredMsgListModel_));
@@ -727,3 +736,53 @@ MessagesAdapter::getMsgListSourceModel() const
// However it may be a nullptr if not yet set.
return static_cast(filteredMsgListModel_->sourceModel());
}
+
+bool
+MessagesAdapter::spell(const QString& word)
+{
+ return spellChecker_->spell(word);
+}
+
+QVariantList
+MessagesAdapter::spellSuggestionsRequest(const QString& word)
+{
+ QStringList suggestionsList;
+ QVariantList variantList;
+ if (spellChecker_ == nullptr || spellChecker_->spell(word)) {
+ return variantList;
+ }
+
+ suggestionsList = spellChecker_->suggest(word);
+ for (const auto& suggestion : suggestionsList) {
+ if (variantList.size() >= SUGGESTIONS_MAX_SIZE) {
+ break;
+ }
+ variantList.append(QVariant(suggestion));
+ }
+
+ return variantList;
+}
+
+QVariantList
+MessagesAdapter::findWords(const QString& text)
+{
+ QVariantList result;
+ if (!spellChecker_)
+ return result;
+
+ auto words = spellChecker_->findWords(text);
+ for (const auto& word : words) {
+ QVariantMap wordInfo;
+ wordInfo["word"] = word.word;
+ wordInfo["position"] = word.position;
+ wordInfo["length"] = word.length;
+ result.append(wordInfo);
+ }
+ return result;
+}
+
+void
+MessagesAdapter::updateDictionnary(const QString& path)
+{
+ return spellChecker_->replaceDictionary(path);
+}
diff --git a/src/app/messagesadapter.h b/src/app/messagesadapter.h
index f5eb053c..e7b94ae5 100644
--- a/src/app/messagesadapter.h
+++ b/src/app/messagesadapter.h
@@ -23,6 +23,8 @@
#include "previewengine.h"
#include "messageparser.h"
#include "appsettingsmanager.h"
+#include "spellchecker.h"
+#include "spellcheckdictionarymanager.h"
#include
#include
@@ -46,7 +48,6 @@ public:
connect(this, &QAbstractItemModel::rowsRemoved, this, &FilteredMsgListModel::countChanged);
connect(this, &QAbstractItemModel::modelReset, this, &FilteredMsgListModel::countChanged);
connect(this, &QAbstractItemModel::layoutChanged, this, &FilteredMsgListModel::countChanged);
-
}
bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override
{
@@ -101,11 +102,14 @@ public:
{
return new MessagesAdapter(qApp->property("AppSettingsManager").value(),
qApp->property("PreviewEngine").value(),
+ qApp->property("SpellCheckDictionaryManager")
+ .value(),
qApp->property("LRCInstance").value());
}
explicit MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine,
+ SpellCheckDictionaryManager* spellCheckDictionaryManager,
LRCInstance* instance,
QObject* parent = nullptr);
~MessagesAdapter() = default;
@@ -164,6 +168,10 @@ public:
Q_INVOKABLE QVariant dataForInteraction(const QString& interactionId,
int role = Qt::DisplayRole) const;
Q_INVOKABLE void startSearch(const QString& text, bool isMedia);
+ Q_INVOKABLE QVariantList spellSuggestionsRequest(const QString& word);
+ Q_INVOKABLE bool spell(const QString& word);
+ Q_INVOKABLE void updateDictionnary(const QString& path);
+ Q_INVOKABLE QVariantList findWords(const QString& text);
// Run corrsponding js functions, c++ to qml.
void setMessagesImageContent(const QString& path, bool isBased64 = false);
@@ -198,14 +206,12 @@ private:
QList conversationTypersUrlToName(const QSet& typersSet);
AppSettingsManager* settingsManager_;
+ SpellCheckDictionaryManager* spellCheckDictionaryManager_;
MessageParser* messageParser_;
-
FilteredMsgListModel* filteredMsgListModel_;
-
- static constexpr const int loadChunkSize_ {20};
-
std::unique_ptr mediaInteractions_;
-
- QTimer* timestampTimer_ {nullptr};
+ QTimer* timestampTimer_;
+ std::shared_ptr spellChecker_;
+ static constexpr const int loadChunkSize_ {20};
static constexpr const int timestampUpdateIntervalMs_ {1000};
};
diff --git a/src/app/net/jami/Constants/JamiStrings.qml b/src/app/net/jami/Constants/JamiStrings.qml
index 942d5c6b..70f67d4e 100644
--- a/src/app/net/jami/Constants/JamiStrings.qml
+++ b/src/app/net/jami/Constants/JamiStrings.qml
@@ -275,6 +275,7 @@ Item {
property string share: qsTr("Share")
property string cut: qsTr("Cut")
property string paste: qsTr("Paste")
+ property string language: qsTr("Language")
// ConversationContextMenu
property string startAudioCall: qsTr("Start audio call")
@@ -508,7 +509,7 @@ Item {
property string displayHyperlinkPreviews: qsTr("Web link previews")
property string displayHyperlinkPreviewsDescription: qsTr("Preview requires downloading content from third-party servers.")
- property string language: qsTr("User interface language")
+ property string userInterfaceLanguage: qsTr("User interface language")
property string verticalViewOpt: qsTr("Vertical view")
property string horizontalViewOpt: qsTr("Horizontal view")
@@ -905,4 +906,12 @@ Item {
property string copyAllData: qsTr("Copy all data")
property string remote: qsTr("Remote: %1")
property string view: qsTr("View")
+
+ // Spellchecker
+ property string checkSpelling: qsTr("Check spelling while typing")
+ property string textLanguage: qsTr("Text language")
+ property string textLanguageDescription: qsTr("To install new dictionaries, use the system package manager.")
+ property string spellchecking: qsTr("Spellchecking")
+ property string refresh: qsTr("Refresh")
+ property string refreshInstalledDictionaries: qsTr("Refresh installed dictionaries")
}
diff --git a/src/app/net/jami/Constants/JamiTheme.qml b/src/app/net/jami/Constants/JamiTheme.qml
index 48f38724..57b04ef5 100644
--- a/src/app/net/jami/Constants/JamiTheme.qml
+++ b/src/app/net/jami/Constants/JamiTheme.qml
@@ -516,6 +516,7 @@ Item {
property int showTypoSecondToggleWidth: 540
property int messageBarMaximumHeight: 150
property int messageBarMinimumHeight: 36
+ property int messageUnderlineHeight: 2
// InvitationView
property real invitationViewAvatarSize: 112
diff --git a/src/app/qmlregister.cpp b/src/app/qmlregister.cpp
index eacff9e2..615fef8e 100644
--- a/src/app/qmlregister.cpp
+++ b/src/app/qmlregister.cpp
@@ -26,7 +26,7 @@
#include "positionmanager.h"
#include "tipsmodel.h"
#include "connectivitymonitor.h"
-#include "imagedownloader.h"
+#include "filedownloader.h"
#include "utilsadapter.h"
#include "conversationsadapter.h"
#include "currentcall.h"
@@ -36,6 +36,7 @@
#include "currentaccounttomigrate.h"
#include "pttlistener.h"
#include "calloverlaymodel.h"
+#include "spellcheckdictionarymanager.h"
#include "accountlistmodel.h"
#include "mediacodeclistmodel.h"
#include "audiodevicemodel.h"
@@ -64,6 +65,7 @@
#include "wizardviewstepmodel.h"
#include "linkdevicemodel.h"
#include "qrcodescannermodel.h"
+#include "spellchecker.h"
#include "api/peerdiscoverymodel.h"
#include "api/codecmodel.h"
@@ -117,6 +119,7 @@ registerTypes(QQmlEngine* engine,
LRCInstance* lrcInstance,
SystemTray* systemTray,
AppSettingsManager* settingsManager,
+ SpellCheckDictionaryManager* spellCheckDictionaryManager,
ConnectivityMonitor* connectivityMonitor,
PreviewEngine* previewEngine,
ScreenInfo* screenInfo,
@@ -201,6 +204,7 @@ registerTypes(QQmlEngine* engine,
qApp->setProperty("AppSettingsManager", QVariant::fromValue(settingsManager));
qApp->setProperty("ConnectivityMonitor", QVariant::fromValue(connectivityMonitor));
qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine));
+ qApp->setProperty("SpellCheckDictionaryManager", QVariant::fromValue(spellCheckDictionaryManager));
// qml adapter registration
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, QRCodeScannerModel);
@@ -220,7 +224,7 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, TipsModel);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, VideoDevices);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CurrentAccountToMigrate);
- QML_REGISTERSINGLETON_TYPE(NS_HELPERS, ImageDownloader);
+ QML_REGISTERSINGLETON_TYPE(NS_HELPERS, FileDownloader);
// TODO: remove these
QML_REGISTERSINGLETONTYPE_CUSTOM(NS_MODELS, AVModel, &lrcInstance->avModel())
@@ -237,6 +241,7 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERTYPE(NS_MODELS, PluginListPreferenceModel);
QML_REGISTERTYPE(NS_MODELS, FilesToSendListModel);
QML_REGISTERTYPE(NS_MODELS, CallInformationListModel);
+ QML_REGISTERTYPE(NS_MODELS, SpellChecker);
// Roles & type enums for models
QML_REGISTERNAMESPACE(NS_MODELS, AccountList::staticMetaObject, "AccountList");
@@ -250,6 +255,7 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "CurrentScreenInfo")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, settingsManager, "AppSettingsManager")
+ QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, spellCheckDictionaryManager, "SpellCheckDictionaryManager")
// Lrc namespaces, models, and singletons
QML_REGISTERNAMESPACE(NS_MODELS, lrc::api::staticMetaObject, "Lrc");
diff --git a/src/app/qmlregister.h b/src/app/qmlregister.h
index fe4f2a59..090047ac 100644
--- a/src/app/qmlregister.h
+++ b/src/app/qmlregister.h
@@ -32,6 +32,7 @@
class SystemTray;
class LRCInstance;
class AppSettingsManager;
+class SpellCheckDictionaryManager;
class PreviewEngine;
class ScreenInfo;
class MainApplication;
@@ -61,6 +62,7 @@ void registerTypes(QQmlEngine* engine,
LRCInstance* lrcInstance,
SystemTray* systemTray,
AppSettingsManager* appSettingsManager,
+ SpellCheckDictionaryManager* spellCheckDictionaryManager,
ConnectivityMonitor* connectivityMonitor,
PreviewEngine* previewEngine,
ScreenInfo* screenInfo,
diff --git a/src/app/settingsview/components/ManageAccountPage.qml b/src/app/settingsview/components/ManageAccountPage.qml
index 08951bc3..21e107d6 100644
--- a/src/app/settingsview/components/ManageAccountPage.qml
+++ b/src/app/settingsview/components/ManageAccountPage.qml
@@ -38,7 +38,6 @@ SettingsPageBase {
flickableContent: ColumnLayout {
id: manageAccountColumnLayout
- width: contentFlickableWidth
spacing: JamiTheme.settingsBlockSpacing
anchors.left: parent.left
anchors.leftMargin: JamiTheme.preferredSettingsMarginSize
diff --git a/src/app/settingsview/components/SystemSettingsPage.qml b/src/app/settingsview/components/SystemSettingsPage.qml
index 8abd146f..f7223c08 100644
--- a/src/app/settingsview/components/SystemSettingsPage.qml
+++ b/src/app/settingsview/components/SystemSettingsPage.qml
@@ -23,6 +23,7 @@ import net.jami.Constants 1.1
import net.jami.Enums 1.1
import net.jami.Models 1.1
import "../../commoncomponents"
+import SortFilterProxyModel 0.2
SettingsPageBase {
id: root
@@ -157,8 +158,8 @@ SettingsPageBase {
Layout.fillWidth: true
height: JamiTheme.preferredFieldHeight
- labelText: JamiStrings.language
- tipText: JamiStrings.language
+ labelText: JamiStrings.userInterfaceLanguage
+ tipText: JamiStrings.userInterfaceLanguage
comboModel: ListModel {
id: langModel
Component.onCompleted: {
@@ -183,6 +184,132 @@ SettingsPageBase {
UtilsAdapter.setAppValue(Settings.Key.LANG, comboModel.get(modelIndex).id);
}
}
+ }
+ ColumnLayout {
+
+ width: parent.width
+ spacing: JamiTheme.settingsCategorySpacing
+ visible: (Qt.platform.os.toString() !== "linux") ? false : true
+
+ Text {
+ id: spellcheckingTitle
+
+ Layout.alignment: Qt.AlignLeft
+ Layout.preferredWidth: parent.width
+
+ text: JamiStrings.spellchecking
+ color: JamiTheme.textColor
+ horizontalAlignment: Text.AlignLeft
+ verticalAlignment: Text.AlignVCenter
+ wrapMode: Text.WordWrap
+
+ font.pixelSize: JamiTheme.settingsTitlePixelSize
+ font.kerning: true
+ }
+
+ ToggleSwitch {
+ id: enableSpellCheckToggleSwitch
+ Layout.fillWidth: true
+ visible: true
+
+ checked: UtilsAdapter.getAppValue(Settings.Key.EnableSpellCheck)
+ labelText: JamiStrings.checkSpelling
+ descText: JamiStrings.textLanguageDescription
+ tooltipText: JamiStrings.checkSpelling
+ onSwitchToggled: {
+ UtilsAdapter.setAppValue(Settings.Key.EnableSpellCheck, checked);
+ }
+ }
+
+ SettingsComboBox {
+ id: spellCheckLangComboBoxSetting
+
+ Layout.fillWidth: true
+ height: JamiTheme.preferredFieldHeight
+
+ labelText: JamiStrings.textLanguage
+ tipText: JamiStrings.textLanguage
+ comboModel: ListModel {
+ id: installedSpellCheckLangModel
+ Component.onCompleted: {
+ var supported = SpellCheckDictionaryManager.installedDictionaries();
+ var keys = Object.keys(supported);
+ var currentKey = UtilsAdapter.getAppValue(Settings.Key.SpellLang);
+ for (var i = 0; i < keys.length; ++i) {
+ append({
+ "textDisplay": supported[keys[i]],
+ "id": keys[i]
+ });
+ if (keys[i] === currentKey)
+ spellCheckLangComboBoxSetting.modelIndex = i;
+ }
+ }
+ }
+
+ widthOfComboBox: itemWidth
+ role: "textDisplay"
+
+ onActivated: {
+ UtilsAdapter.setAppValue(Settings.Key.SpellLang, comboModel.get(modelIndex).id);
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.minimumHeight: JamiTheme.preferredFieldHeight
+
+ Text {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.rightMargin: JamiTheme.preferredMarginSize
+
+ color: JamiTheme.textColor
+ wrapMode: Text.WordWrap
+ text: JamiStrings.refreshInstalledDictionaries
+ font.pointSize: JamiTheme.settingsFontSize
+ font.kerning: true
+
+ horizontalAlignment: Text.AlignLeft
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ MaterialButton {
+ id: refreshInstalledDictionariesPushButton
+
+ Layout.alignment: Qt.AlignCenter
+
+ preferredWidth: textSizeRefresh.width + 2 * JamiTheme.buttontextWizzardPadding
+ buttontextHeightMargin: JamiTheme.buttontextHeightMargin
+
+ primary: true
+ toolTipText: JamiStrings.refresh
+
+ text: JamiStrings.refresh
+
+ onClicked: {
+ SpellCheckDictionaryManager.refreshDictionaries();
+ var langIdx = spellCheckLangComboBoxSetting.modelIndex;
+ installedSpellCheckLangModel.clear();
+ var supported = SpellCheckDictionaryManager.installedDictionaries();
+ var keys = Object.keys(supported);
+ for (var i = 0; i < keys.length; ++i) {
+ installedSpellCheckLangModel.append({
+ "textDisplay": supported[keys[i]],
+ "id": keys[i]
+ });
+ }
+ spellCheckLangComboBoxSetting.modelIndex = langIdx;
+ }
+
+ TextMetrics {
+ id: textSizeRefresh
+ font.weight: Font.Bold
+ font.pixelSize: JamiTheme.wizardViewButtonFontPixelSize
+ font.capitalization: Font.AllUppercase
+ text: refreshInstalledDictionariesPushButton.text
+ }
+ }
+ }
Connections {
target: UtilsAdapter
@@ -200,6 +327,21 @@ SettingsPageBase {
}
langComboBoxSetting.modelIndex = langIdx;
}
+
+ // Repopulate the spell check language list
+ function onSpellLanguageChanged() {
+ var langIdx = spellCheckLangComboBoxSetting.modelIndex;
+ installedSpellCheckLangModel.clear();
+ var supported = SpellCheckDictionaryManager.installedDictionaries();
+ var keys = Object.keys(supported);
+ for (var i = 0; i < keys.length; ++i) {
+ installedSpellCheckLangModel.append({
+ "textDisplay": supported[keys[i]],
+ "id": keys[i]
+ });
+ }
+ spellCheckLangComboBoxSetting.modelIndex = langIdx;
+ }
}
}
@@ -257,6 +399,7 @@ SettingsPageBase {
closeOrMinCheckBox.checked = UtilsAdapter.getDefault(Settings.Key.MinimizeOnClose);
checkboxCallSwarm.checked = UtilsAdapter.getDefault(Settings.Key.EnableExperimentalSwarm);
langComboBoxSetting.modelIndex = 0;
+ spellCheckLangComboBoxSetting.modelIndex = 0;
UtilsAdapter.setToDefault(Settings.Key.EnableNotifications);
UtilsAdapter.setToDefault(Settings.Key.MinimizeOnClose);
UtilsAdapter.setToDefault(Settings.Key.LANG);
diff --git a/src/app/spellcheckdictionarymanager.cpp b/src/app/spellcheckdictionarymanager.cpp
new file mode 100644
index 00000000..1e7d1286
--- /dev/null
+++ b/src/app/spellcheckdictionarymanager.cpp
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2025 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 .
+ */
+
+#include "spellcheckdictionarymanager.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+SpellCheckDictionaryManager::SpellCheckDictionaryManager(AppSettingsManager* settingsManager,
+ QObject* parent)
+ : QObject {parent}
+ , settingsManager_ {settingsManager}
+{}
+
+QVariantMap
+SpellCheckDictionaryManager::installedDictionaries()
+{
+ // If we already have a cache of the installed dictionaries, return it
+ if (cachedInstalledDictionaries_.size() > 0) {
+ return cachedInstalledDictionaries_;
+
+ // If not, we need to check the dictionaries directory
+ } else {
+ QString hunspellDataDir = getDictionariesPath();
+
+ auto dictionariesDir = QDir(hunspellDataDir);
+ QRegExp regex("(.*).dic");
+ QSet nativeNames;
+
+ QVariantMap result;
+ result["NONE"] = tr("None");
+ QStringList folders = dictionariesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
+ // Check for dictionary files in the base directory
+ QStringList rootDicFiles = dictionariesDir.entryList(QStringList() << "*.dic", QDir::Files);
+ for (const auto& dicFile : rootDicFiles) {
+ regex.indexIn(dicFile);
+ auto captured = regex.capturedTexts();
+ if (captured.size() == 2) {
+ auto nativeName = QLocale(captured[1]).nativeLanguageName();
+ if (!nativeName.isEmpty() && !nativeNames.contains(nativeName)) {
+ result[captured[1]] = nativeName;
+ nativeNames.insert(nativeName);
+ }
+ }
+ }
+ // Check for dictionary files in subdirectories
+ for (const auto& folder : folders) {
+ QDir subDir = dictionariesDir.absoluteFilePath(folder);
+ QStringList dicFiles = subDir.entryList(QStringList() << "*.dic", QDir::Files);
+ subDir.setFilter(QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot);
+ subDir.setSorting(QDir::DirsFirst);
+ QFileInfoList list = subDir.entryInfoList();
+ for (const auto& fileInfo : list) {
+ if (fileInfo.isDir()) {
+ QDir recursiveDir(fileInfo.absoluteFilePath());
+ QStringList recursiveDicFiles = recursiveDir.entryList(QStringList() << "*.dic",
+ QDir::Files);
+ if (!recursiveDicFiles.isEmpty()) {
+ dicFiles.append(recursiveDicFiles);
+ }
+ }
+ }
+
+ // Extract the locale from the dictionary file names
+ for (const auto& dicFile : dicFiles) {
+ regex.indexIn(dicFile);
+ auto captured = regex.capturedTexts();
+
+ if (captured.size() == 2) {
+ auto nativeName = QLocale(captured[1]).nativeLanguageName();
+
+ if (nativeName.isEmpty()) {
+ continue;
+ }
+
+ if (!nativeNames.contains(nativeName)) {
+ result[folder + QDir::separator() + captured[1]] = nativeName;
+ nativeNames.insert(nativeName);
+ } else {
+ qWarning() << "Duplicate native name found, skipping:" << nativeName;
+ }
+ }
+ }
+ }
+ cachedInstalledDictionaries_ = result;
+ return result;
+ }
+}
+
+QString
+SpellCheckDictionaryManager::getDictionariesPath()
+{
+#if defined(Q_OS_LINUX)
+ QString hunDir = "/usr/share/hunspell/";
+ ;
+
+#elif defined(Q_OS_MACOS)
+ QString hunDir = "/Library/Spelling/";
+#else
+ QString hunDir = "";
+#endif
+ return hunDir;
+}
+
+
+void
+SpellCheckDictionaryManager::refreshDictionaries()
+{
+ cachedInstalledDictionaries_.clear();
+}
+
+QString
+SpellCheckDictionaryManager::getSpellLanguage()
+{
+ auto pref = settingsManager_->getValue(Settings::Key::SpellLang).toString();
+ return pref ;
+}
+
+// Is only used at application boot time
+QString
+SpellCheckDictionaryManager::getDictionaryPath()
+{
+ return "/usr/share/hunspell/" + getSpellLanguage();
+}
diff --git a/src/app/spellcheckdictionarymanager.h b/src/app/spellcheckdictionarymanager.h
new file mode 100644
index 00000000..66755445
--- /dev/null
+++ b/src/app/spellcheckdictionarymanager.h
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2025 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 .
+ */
+
+#pragma once
+#include "appsettingsmanager.h"
+
+#include
+#include
+#include
+
+class SpellCheckDictionaryManager : public QObject
+{
+ Q_OBJECT
+ QVariantMap cachedInstalledDictionaries_;
+ AppSettingsManager* settingsManager_;
+public:
+ explicit SpellCheckDictionaryManager(AppSettingsManager* settingsManager,
+ QObject* parent = nullptr);
+
+ Q_INVOKABLE QVariantMap installedDictionaries();
+ Q_INVOKABLE QString getDictionariesPath();
+ Q_INVOKABLE void refreshDictionaries();
+ Q_INVOKABLE QString getDictionaryPath();
+ Q_INVOKABLE QString getSpellLanguage();
+};
diff --git a/src/app/spellchecker.cpp b/src/app/spellchecker.cpp
new file mode 100644
index 00000000..1b14235f
--- /dev/null
+++ b/src/app/spellchecker.cpp
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2020-2025 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 .
+ *
+ * \file spellchecker.c
+ */
+
+#include "spellchecker.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+SpellChecker::SpellChecker(const QString& dictionaryPath)
+{
+ replaceDictionary(dictionaryPath);
+}
+
+bool
+SpellChecker::spell(const QString& word)
+{
+ // Encode from Unicode to the encoding used by current dictionary
+ return hunspell_->spell(word.toStdString()) != 0;
+}
+
+QStringList
+SpellChecker::suggest(const QString& word)
+{
+ // Encode from Unicode to the encoding used by current dictionary
+ std::vector numSuggestions = hunspell_->suggest(word.toStdString());
+ QStringList suggestions;
+
+ for (size_t i = 0; i < numSuggestions.size(); ++i) {
+ suggestions << QString::fromStdString(numSuggestions.at(i));
+ }
+ return suggestions;
+}
+
+void
+SpellChecker::ignoreWord(const QString& word)
+{
+ put_word(word);
+}
+
+void
+SpellChecker::put_word(const QString& word)
+{
+ hunspell_->add(codec_->fromUnicode(word).constData());
+}
+
+void
+SpellChecker::replaceDictionary(const QString& dictionaryPath)
+{
+ QString dictFile = dictionaryPath + ".dic";
+ QString affixFile = dictionaryPath + ".aff";
+ QByteArray dictFilePathBA = dictFile.toLocal8Bit();
+ QByteArray affixFilePathBA = affixFile.toLocal8Bit();
+ if (hunspell_) {
+ hunspell_.reset();
+ }
+ hunspell_ = std::make_shared(affixFilePathBA.constData(), dictFilePathBA.constData());
+
+ // detect encoding analyzing the SET option in the affix file
+ encoding_ = "ISO8859-1";
+ QFile _affixFile(affixFile);
+ if (_affixFile.open(QIODevice::ReadOnly)) {
+ QTextStream stream(&_affixFile);
+ QRegExp enc_detector("^\\s*SET\\s+([A-Z0-9\\-]+)\\s*", Qt::CaseInsensitive);
+ for (QString line = stream.readLine(); !line.isEmpty(); line = stream.readLine()) {
+ if (enc_detector.indexIn(line) > -1) {
+ encoding_ = enc_detector.cap(1);
+ break;
+ }
+ }
+ _affixFile.close();
+ }
+
+ codec_ = QTextCodec::codecForName(this->encoding_.toLatin1().constData());
+}
+
+QList
+SpellChecker::findWords(const QString& text)
+{
+ // This is in the C++ part of the code because QML regex does not support unicode
+ QList results;
+ QRegularExpression regex("\\p{L}+|\\p{N}+");
+ QRegularExpressionMatchIterator iter = regex.globalMatch(text);
+
+ while (iter.hasNext()) {
+ QRegularExpressionMatch match = iter.next();
+ WordInfo info;
+ info.word = match.captured();
+ info.position = match.capturedStart();
+ info.length = match.capturedLength();
+ results.append(info);
+ }
+
+ return results;
+}
diff --git a/src/app/spellchecker.h b/src/app/spellchecker.h
new file mode 100644
index 00000000..9ef6dd97
--- /dev/null
+++ b/src/app/spellchecker.h
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2020-2025 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 .
+ *
+ * \file spellchecker.h
+ */
+
+#pragma once
+
+#include "lrcinstance.h"
+#include "qmladapterbase.h"
+#include "previewengine.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+class Hunspell;
+
+class SpellChecker : public QObject
+{
+ Q_OBJECT
+public:
+ explicit SpellChecker(const QString&);
+ ~SpellChecker() = default;
+ void replaceDictionary(const QString& dictionaryPath);
+
+ Q_INVOKABLE bool spell(const QString& word);
+ Q_INVOKABLE QStringList suggest(const QString& word);
+ Q_INVOKABLE void ignoreWord(const QString& word);
+
+ // Used to find words and their position in a text
+ struct WordInfo {
+ QString word;
+ int position;
+ int length;
+ };
+
+ Q_INVOKABLE QList findWords(const QString& text);
+
+private:
+ void put_word(const QString& word);
+ std::shared_ptr hunspell_;
+ QString encoding_;
+ QTextCodec* codec_;
+};
diff --git a/src/app/utilsadapter.cpp b/src/app/utilsadapter.cpp
index c0504992..225ee7b1 100644
--- a/src/app/utilsadapter.cpp
+++ b/src/app/utilsadapter.cpp
@@ -23,7 +23,6 @@
#include "version.h"
#include "version_info.h"
#include "global.h"
-
#include
#include
@@ -93,6 +92,11 @@ UtilsAdapter::setAppValue(const Settings::Key key, const QVariant& value)
Q_EMIT appThemeChanged();
else if (key == Settings::Key::UseFramelessWindow)
Q_EMIT useFramelessWindowChanged();
+ else if (key == Settings::Key::SpellLang) {
+ Q_EMIT spellLanguageChanged();
+ } else if (key == Settings::Key::EnableSpellCheck) {
+ Q_EMIT enableSpellCheckChanged();
+ }
#if !APPSTORE
// Any donation campaign-related keys can trigger a donation campaign check
else if (key == Settings::Key::IsDonationVisible
diff --git a/src/app/utilsadapter.h b/src/app/utilsadapter.h
index 445c397c..aa0c853b 100644
--- a/src/app/utilsadapter.h
+++ b/src/app/utilsadapter.h
@@ -181,6 +181,8 @@ Q_SIGNALS:
void changeLanguage();
void donationCampaignSettingsChanged();
void useFramelessWindowChanged();
+ void spellLanguageChanged();
+ void enableSpellCheckChanged();
private:
QClipboard* clipboard_;
diff --git a/tests/qml/main.cpp b/tests/qml/main.cpp
index a037b848..3e4943ad 100644
--- a/tests/qml/main.cpp
+++ b/tests/qml/main.cpp
@@ -16,6 +16,7 @@
*/
#include "appsettingsmanager.h"
+#include "spellcheckdictionarymanager.h"
#include "connectivitymonitor.h"
#include "mainapplication.h"
#include "previewengine.h"
@@ -94,6 +95,7 @@ public Q_SLOTS:
settingsManager_.reset(new AppSettingsManager(this));
systemTray_.reset(new SystemTray(settingsManager_.get(), this));
previewEngine_.reset(new PreviewEngine(connectivityMonitor_.get(), this));
+ spellCheckDictionaryManager_.reset(new SpellCheckDictionaryManager(settingsManager_.get(), this));
QFontDatabase::addApplicationFont(":/images/FontAwesome.otf");
@@ -152,6 +154,7 @@ public Q_SLOTS:
lrcInstance_.get(),
systemTray_.get(),
settingsManager_.get(),
+ spellCheckDictionaryManager_.get(),
connectivityMonitor_.get(),
previewEngine_.get(),
&screenInfo_,
@@ -169,6 +172,7 @@ private:
QScopedPointer connectivityMonitor_;
QScopedPointer settingsManager_;
+ QScopedPointer spellCheckDictionaryManager_;
QScopedPointer systemTray_;
QScopedPointer previewEngine_;
ScreenInfo screenInfo_;
diff --git a/tests/qml/src/tst_CachedImage.qml b/tests/qml/src/tst_CachedImage.qml
index 6727e887..bd8b5f36 100644
--- a/tests/qml/src/tst_CachedImage.qml
+++ b/tests/qml/src/tst_CachedImage.qml
@@ -44,14 +44,14 @@ Item {
SignalSpy {
id: spyDownloadSuccessful
- target: ImageDownloader
- signalName: "onDownloadImageSuccessful"
+ target: FileDownloader
+ signalName: "onDownloadFileSuccessful"
}
SignalSpy {
id: spyDownloadFailed
- target: ImageDownloader
- signalName: "onDownloadImageFailed"
+ target: FileDownloader
+ signalName: "onDownloadFileFailed"
}
function test_goodDownLoad() {