From 2a72da564e746a0422c36a1654e90d3defc4419b Mon Sep 17 00:00:00 2001
From: Jerome Lamy <jerome.lamy@savoirfairelinux.com>
Date: Mon, 15 Apr 2024 13:58:17 -0400
Subject: [PATCH] spellcheck: for linux system dicts

Implement a first version of the spellcheck for linux that use the
systemwide installed dictionaries.

GitLab: #1997

Change-Id: I7158e6c61061e7d0a7fe651069247227bbe399a4
---
 .gitmodules                                   |   4 +
 3rdparty/hunspell                             |   1 +
 CMakeLists.txt                                |  72 ++++++++-
 build.py                                      |   8 +-
 src/app/appsettingsmanager.h                  |   2 +
 .../commoncomponents/LineEditContextMenu.qml  |  82 +++++++++-
 .../SpellLanguageContextMenu.qml              |  77 +++++++++
 .../contextmenu/BaseContextMenu.qml           |   4 +
 .../contextmenu/ContextMenuAutoLoader.qml     |   3 +
 .../contextmenu/GeneralMenuItem.qml           |   1 +
 ...imagedownloader.cpp => filedownloader.cpp} |  24 +--
 .../{imagedownloader.h => filedownloader.h}   |  20 +--
 src/app/mainapplication.cpp                   |   3 +
 src/app/mainapplication.h                     |   2 +
 src/app/mainview/components/CachedFile.qml    |  34 ++++
 src/app/mainview/components/CachedImage.qml   |   6 +-
 .../mainview/components/ChatViewHeader.qml    |  28 ++--
 .../components/MessageBarTextArea.qml         | 143 ++++++++++++++++-
 src/app/messagesadapter.cpp                   |  59 +++++++
 src/app/messagesadapter.h                     |  20 ++-
 src/app/net/jami/Constants/JamiStrings.qml    |  11 +-
 src/app/net/jami/Constants/JamiTheme.qml      |   1 +
 src/app/qmlregister.cpp                       |  10 +-
 src/app/qmlregister.h                         |   2 +
 .../components/ManageAccountPage.qml          |   1 -
 .../components/SystemSettingsPage.qml         | 147 ++++++++++++++++-
 src/app/spellcheckdictionarymanager.cpp       | 149 ++++++++++++++++++
 src/app/spellcheckdictionarymanager.h         |  39 +++++
 src/app/spellchecker.cpp                      | 117 ++++++++++++++
 src/app/spellchecker.h                        |  63 ++++++++
 src/app/utilsadapter.cpp                      |   6 +-
 src/app/utilsadapter.h                        |   2 +
 tests/qml/main.cpp                            |   4 +
 tests/qml/src/tst_CachedImage.qml             |   8 +-
 34 files changed, 1076 insertions(+), 77 deletions(-)
 create mode 160000 3rdparty/hunspell
 create mode 100644 src/app/commoncomponents/SpellLanguageContextMenu.qml
 rename src/app/{imagedownloader.cpp => filedownloader.cpp} (66%)
 rename src/app/{imagedownloader.h => filedownloader.h} (66%)
 create mode 100644 src/app/mainview/components/CachedFile.qml
 create mode 100644 src/app/spellcheckdictionarymanager.cpp
 create mode 100644 src/app/spellcheckdictionarymanager.h
 create mode 100644 src/app/spellchecker.cpp
 create mode 100644 src/app/spellchecker.h

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 <https://www.gnu.org/licenses/>.
  */
 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<GeneralMenuItem> 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 <https://www.gnu.org/licenses/>.
+ */
+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 <https://www.gnu.org/licenses/>.
  */
 
-#include "imagedownloader.h"
+#include "filedownloader.h"
 
 #include <QDir>
 #include <QLockFile>
 
-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 <QQmlEngine>   // QML registration
 #include <QApplication> // 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<ConnectivityMonitor*>());
     }
 
-    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 <https://www.gnu.org/licenses/>.
+ */
+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 <api/datatransfermodel.h>
 #include <api/contact.h>
@@ -39,17 +40,25 @@
 #include <QtMath>
 #include <QRegExp>
 
+#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<MessageListModel>(nullptr))
     , timestampTimer_(new QTimer(this))
 {
+    #if defined(Q_OS_LINUX)
+    // Initialize with make_shared
+    spellChecker_ = std::make_shared<SpellChecker>(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<MessageListModel*>(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 <QObject>
 #include <QString>
@@ -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<AppSettingsManager*>(),
                                    qApp->property("PreviewEngine").value<PreviewEngine*>(),
+                                   qApp->property("SpellCheckDictionaryManager")
+                                       .value<SpellCheckDictionaryManager*>(),
                                    qApp->property("LRCInstance").value<LRCInstance*>());
     }
 
     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<QString> conversationTypersUrlToName(const QSet<QString>& typersSet);
 
     AppSettingsManager* settingsManager_;
+    SpellCheckDictionaryManager* spellCheckDictionaryManager_;
     MessageParser* messageParser_;
-
     FilteredMsgListModel* filteredMsgListModel_;
-
-    static constexpr const int loadChunkSize_ {20};
-
     std::unique_ptr<MessageListModel> mediaInteractions_;
-
-    QTimer* timestampTimer_ {nullptr};
+    QTimer* timestampTimer_;
+    std::shared_ptr<SpellChecker> 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 <http://www.gnu.org/licenses/>.
+ */
+
+#include "spellcheckdictionarymanager.h"
+
+#include <QApplication>
+#include <QBuffer>
+#include <QClipboard>
+#include <QFileInfo>
+#include <QRegExp>
+#include <QMimeData>
+#include <QDir>
+#include <QMimeDatabase>
+#include <QUrl>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QEventLoop>
+#include <QRegularExpression>
+
+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<QString> 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 <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include "appsettingsmanager.h"
+
+#include <QObject>
+#include <QApplication>
+#include <QQmlEngine>
+
+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 <https://www.gnu.org/licenses/>.
+ *
+ * \file spellchecker.c
+ */
+
+#include "spellchecker.h"
+
+#include <QString>
+#include <QFile>
+#include <QTextStream>
+#include <QTextCodec>
+#include <QStringList>
+#include <QDebug>
+#include <QRegExp>
+#include <QRegularExpression>
+#include <QRegularExpressionMatchIterator>
+
+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<std::string> 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<Hunspell>(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::WordInfo>
+SpellChecker::findWords(const QString& text)
+{
+    // This is in the C++ part of the code because QML regex does not support unicode
+    QList<WordInfo> 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 <https://www.gnu.org/licenses/>.
+ *
+ * \file spellchecker.h
+ */
+
+#pragma once
+
+#include "lrcinstance.h"
+#include "qmladapterbase.h"
+#include "previewengine.h"
+
+#include <QTextCodec>
+#include <QString>
+#include <QStringList>
+#include <QDebug>
+#include <QObject>
+#include <string>
+
+#include <hunspell/hunspell.hxx>
+
+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<WordInfo> findWords(const QString& text);
+
+private:
+    void put_word(const QString& word);
+    std::shared_ptr<Hunspell> 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 <api/datatransfermodel.h>
 #include <api/contact.h>
 
@@ -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> connectivityMonitor_;
     QScopedPointer<AppSettingsManager> settingsManager_;
+    QScopedPointer<SpellCheckDictionaryManager> spellCheckDictionaryManager_;
     QScopedPointer<SystemTray> systemTray_;
     QScopedPointer<PreviewEngine> 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() {
-- 
GitLab