Compare commits

...

8 Commits

Author SHA1 Message Date
32b7525ee3 packaging: add missing submodule
The Hunspell library was added to the project as a git submodule in
commit 2a72da5, but some packaging scripts were not updated accordingly.

GitLab: #1997
Change-Id: I5e6079fc23aeecfba2076067ecf5c4ed1f5a591a
2025-05-09 21:23:13 -04:00
32b941ab96 misc: bump daemon
Change-Id: I6f53893b30c2cff8761f6d0ab4a92b85f7f873fb
2025-05-09 17:00:32 -04:00
e4932abd39 spellcheck: fix default language value
The default value was set to "None" in appsettingsmanager.h but the rest
of the code assumes that the default is "NONE" (see commit 2a72da5).

GitLab: #1997
Change-Id: Ifeac3399b1b48bad0863a1b7da9228df43f73b76
2025-05-09 16:46:22 -04:00
718d1d266d mainapplicationwindow: raise when called
Add a setting to allow the app to be raised to foreground when called.
Some special setups like virtual machines can prevent this behaviour.

GitLab: #2004
Change-Id: I0f6ea442f6abe88109e81933b731f81d10bd6c8f
2025-05-09 16:13:39 -04:00
8d55f352b4 settings: allow devs to modify default settings
Delete the default settings in the settings file so that only user
defined ones are stored locally.

Previously, we stored every value in the settings file. This created a problem where we could not modify a default value as we had no way of knowing if it was user defined or not.

This patch allow us to know that if a value was in the file, it's that it was different from the default.

GitLab: #2000
Change-Id: I7d5154b258c2ca97a1976dc006a70db0d5fe0b31
2025-05-09 15:37:06 -04:00
2a72da564e 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
2025-05-09 15:05:19 -04:00
88d0539085 packaging: add Fedora 42
Change-Id: Ia03ae84d8ae435dd4e24fc1d0d64a9c70580e768
2025-05-08 14:16:55 -04:00
4b1c299a1d INSTALL.md: update windows build instructions
Change-Id: Ia6c246539ab5d76cd6a66df484427e94149d8791
2025-05-05 15:35:30 -04:00
44 changed files with 1224 additions and 84 deletions

4
.gitmodules vendored
View File

@ -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

1
3rdparty/hunspell vendored Submodule

Submodule 3rdparty/hunspell added at 525f9f2276

View File

@ -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

View File

@ -189,7 +189,7 @@ Only 64-bit MSVC build can be compiled.
- Download [Qt (Open Source)](https://www.qt.io/download-open-source?hsCtaTracking=9f6a2170-a938-42df-a8e2-a9f0b1d6cdce%7C6cb0de4f-9bb5-4778-ab02-bfb62735f3e5)
- Using the online installer, install the following Qt 6.6.1 components:
- Using the online installer, install the following Qt 6.6.2 components:
- Git 2.10.2
- MSVC 2019 64-bit
@ -244,6 +244,7 @@ Only 64-bit MSVC build can be compiled.
```bash
python build.py --install --qt <path-to-qt-bin-folder> (e.g. C:/Qt/6.6.2/msvc2019_64)
```
> **CMake** Note: The build script does not specify what CMake generator should be used. This means CMake will search the system for the appropriate generator, which might not always select the right one if, for instance, Ninja is installed. To resolve that, the CMAKE_GENERATOR environment variable can be used, set to "Visual Studio 16 2019" or "Visual Studio 19 2022" depending on the installed Visual Studio version.
> **SDK** Note:
> Jami can be build with more recent Windows SDK than the one specified in the table above. However, if your have another version than SDK 10.0.18362.0 installed, you need to identify it according to the example below. And you still need to have the required version in addition to the one you chose.

View File

@ -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']

2
daemon

Submodule daemon updated: 68fc552fca...c231df3e05

View File

@ -34,7 +34,8 @@ def SUBMODULES = ['daemon',
'3rdparty/SortFilterProxyModel',
'3rdparty/md4c',
'3rdparty/tidy-html5',
'3rdparty/zxing-cpp']
'3rdparty/zxing-cpp',
'3rdparty/hunspell']
def TARGETS = [:]
def REMOTE_HOST = env.SSH_HOST_DL_RING_CX
def REMOTE_BASE_DIR = '/srv/repository/ring'

View File

@ -128,7 +128,8 @@ $(RELEASE_TARBALL_FILENAME): tarballs.manifest
./3rdparty/SortFilterProxyModel \
./3rdparty/md4c \
./3rdparty/tidy-html5 \
./3rdparty/zxing-cpp; do \
./3rdparty/zxing-cpp \
./3rdparty/hunspell; do \
(cd "$$m" && git archive --prefix "$$m/" HEAD \
| tar xf - -C $(TMPDIR)/$(RELEASE_DIRNAME)); \
done
@ -171,6 +172,7 @@ DISTRIBUTIONS := \
fedora_39 \
fedora_40 \
fedora_41 \
fedora_42 \
alma_9 \
opensuse-leap_15.5 \
opensuse-leap_15.6 \

View File

@ -0,0 +1,97 @@
FROM fedora:42
RUN dnf clean all
RUN dnf update -y
RUN dnf install -y dnf-command\(builddep\) rpmdevtools && \
dnf install -y mock
RUN dnf group install -y x-software-development
RUN dnf install -y \
git \
make \
autoconf \
automake \
nasm \
speexdsp-devel \
pulseaudio-libs-devel \
libcanberra-devel \
libcurl-devel \
libtool \
mesa-libgbm-devel \
dbus-devel \
expat-devel \
pcre-devel \
yaml-cpp-devel \
yasm \
speex-devel \
gsm-devel \
chrpath \
check \
astyle \
uuid-c++-devel \
gettext-devel \
gcc14 \
gcc14-c++ \
which \
alsa-lib-devel \
systemd-devel \
uuid-devel \
gnutls-devel \
nettle-devel \
opus-devel \
jsoncpp-devel \
libnatpmp-devel \
webkitgtk4-devel \
cryptopp-devel \
libva-devel \
libvdpau-devel \
msgpack-devel \
NetworkManager-libnm-devel \
openssl-devel \
clutter-devel \
clutter-gtk-devel \
libappindicator-gtk3-devel \
libnotify-devel \
libupnp-devel \
qrencode-devel \
libargon2-devel \
libsndfile-devel \
gperf \
bison \
clang18-devel \
llvm18-devel \
nodejs \
flex \
gstreamer1-plugins-base-devel \
gstreamer1-plugins-good \
gstreamer1-plugins-bad-free-devel \
nss-devel \
libxcb* \
libxkb* \
vulkan-devel \
xcb-util-* \
wget \
libstdc++-static \
sqlite-devel \
perl-generators \
perl-English \
libxshmfence-devel \
ninja-build \
cmake \
fmt-devel \
python3.10 \
cups-devel \
pipewire-devel
# Use GCC 14 instead of GCC 15 (the default on Fedora 42)
# because Qt 6.6.3 fails to build when using the latter.
RUN rm /usr/bin/gcc /usr/bin/g++ /usr/bin/c++ && \
ln -s /usr/bin/gcc-14 /usr/bin/gcc && \
ln -s /usr/bin/g++-14 /usr/bin/g++ && \
ln -s /usr/bin/g++-14 /usr/bin/c++
ADD extras/packaging/gnu-linux/scripts/build-package-rpm.sh /opt/build-package-rpm.sh
CMD ["/opt/build-package-rpm.sh"]

View File

@ -107,6 +107,8 @@ if [ ! -f "${RPM_PATH}" ]; then
cp /root/rpmbuild/RPMS/x86_64/jami-libqt-$QT_MAJOR_MINOR_PATCH-*.fc40.x86_64.rpm "${RPM_PATH}"
elif [[ "${DISTRIBUTION}" == "fedora_41" ]]; then
cp /root/rpmbuild/RPMS/x86_64/jami-libqt-$QT_MAJOR_MINOR_PATCH-*.fc41.x86_64.rpm "${RPM_PATH}"
elif [[ "${DISTRIBUTION}" == "fedora_42" ]]; then
cp /root/rpmbuild/RPMS/x86_64/jami-libqt-$QT_MAJOR_MINOR_PATCH-*.fc42.x86_64.rpm "${RPM_PATH}"
elif [[ "${DISTRIBUTION}" == "alma_9" ]]; then
cp /root/rpmbuild/RPMS/x86_64/jami-libqt-$QT_MAJOR_MINOR_PATCH-*.el9.x86_64.rpm "${RPM_PATH}"
else

View File

@ -212,6 +212,7 @@ def init_submodules():
"3rdparty/md4c",
"3rdparty/tidy-html5",
"3rdparty/zxing-cpp",
"3rdparty/hunspell",
]
if execute_cmd(["git", "submodule", "update", "--init" ] + submodules,
False):

View File

@ -37,6 +37,7 @@ ApplicationWindow {
property bool isRTL: UtilsAdapter.isRTL
LayoutMirroring.enabled: isRTL
LayoutMirroring.childrenInherit: isRTL
property var raiseWhenCalled: AppSettingsManager.getValue(Settings.RaiseWhenCalled)
onActiveFocusItemChanged: {
focusOverlay.margin = -5;
@ -291,6 +292,26 @@ ApplicationWindow {
}
}
Connections {
target: UtilsAdapter
function onRaiseWhenCalled() {
raiseWhenCalled = AppSettingsManager.getValue(Settings.RaiseWhenCalled);
}
}
Connections {
target: CallAdapter
function onCallStatusChanged(index, accountId, convUid) {
//If we are starting a call with raiseWhenCalled activated
if (raiseWhenCalled && index === Call.Status.INCOMING_RINGING) {
appWindow.raise();
appWindow.requestActivate();
layoutManager.restoreApp();
}
}
}
Connections {
target: MainApplication

View File

@ -34,8 +34,12 @@ AppSettingsManager::AppSettingsManager(QObject* parent)
{
for (int i = 0; i < static_cast<int>(Settings::Key::COUNT__); ++i) {
auto key = static_cast<Settings::Key>(i);
if (!settings_->contains(Settings::toString(key)))
setValue(key, Settings::defaultValue(key));
auto strKey= Settings::toString(key);
// If the setting is written in the settings file and is equal to the default value,
// remove it from the settings file.
// This allow us to change default values without risking to remove user settings
if ((settings_->contains(strKey)) && (settings_->value(strKey) == Settings::defaultValue(key)))
settings_->remove(strKey);
}
}

View File

@ -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) \
@ -74,7 +76,8 @@ extern const QString defaultDownloadPath;
X(PttKeys, 32) \
X(UseFramelessWindow, USE_FRAMELESS_WINDOW_DEFAULT) \
X(EnableCrashReporting, true) \
X(EnableAutomaticCrashReporting, false)
X(EnableAutomaticCrashReporting, false) \
X(RaiseWhenCalled, false)
#if APPSTORE
#define KEYS COMMON_KEYS
#else

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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);
}

View File

@ -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);
};

View File

@ -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_,

View File

@ -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_;

View File

@ -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);
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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();
}
}
}
}

View File

@ -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);
}

View File

@ -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};
};

View File

@ -110,6 +110,7 @@ Item {
property string enablePTT: qsTr("Enable push-to-talk")
property string keyboardShortcut: qsTr("Keyboard shortcut")
property string changeKeyboardShortcut: qsTr("Change keyboard shortcut")
property string raiseWhenCalled: qsTr("Send the application to the front for incoming calls")
// ChangePttKeyPopup
property string changeShortcut: qsTr("Change shortcut")
@ -275,6 +276,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 +510,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 +907,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")
}

View File

@ -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

View File

@ -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");

View File

@ -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,

View File

@ -95,6 +95,14 @@ SettingsPageBase {
checked: CurrentAccount.autoAnswer
onSwitchToggled: CurrentAccount.autoAnswer = checked
}
ToggleSwitch {
id: checkBoxRaiseWhenCalled
labelText: JamiStrings.raiseWhenCalled
checked: UtilsAdapter.getAppValue(Settings.RaiseWhenCalled)
onSwitchToggled: UtilsAdapter.setAppValue(Settings.Key.RaiseWhenCalled, checked)
}
}
ColumnLayout {

View File

@ -38,7 +38,6 @@ SettingsPageBase {
flickableContent: ColumnLayout {
id: manageAccountColumnLayout
width: contentFlickableWidth
spacing: JamiTheme.settingsBlockSpacing
anchors.left: parent.left
anchors.leftMargin: JamiTheme.preferredSettingsMarginSize

View File

@ -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);

View File

@ -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();
}

View File

@ -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();
};

117
src/app/spellchecker.cpp Normal file
View File

@ -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;
}

63
src/app/spellchecker.h Normal file
View File

@ -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_;
};

View File

@ -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,12 @@ 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();
else if (key == Settings::Key::RaiseWhenCalled)
Q_EMIT raiseWhenCalledChanged();
#if !APPSTORE
// Any donation campaign-related keys can trigger a donation campaign check
else if (key == Settings::Key::IsDonationVisible

View File

@ -181,6 +181,9 @@ Q_SIGNALS:
void changeLanguage();
void donationCampaignSettingsChanged();
void useFramelessWindowChanged();
void spellLanguageChanged();
void enableSpellCheckChanged();
void raiseWhenCalledChanged();
private:
QClipboard* clipboard_;

View File

@ -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_;

View File

@ -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() {