Files
jami-client-qt/src/app/messagesadapter.cpp
pmagnier-slimani ceec1f95b9 spellcheck: windows and macos
Implement the hunspell spellchecker for Windows and MacOS. It also
changes the base implementation for Linux. The system dictionaries
(if any) are aggregated with those installed from the LibreOffice
repository via Jami's dictionary management interface.

This commit implements a major refactoring of the spellcheck system
to improve UI responsiveness and user experience:

Core Changes:
- Used QAbstractListModel to represent the list of dictionaries
- Added new QML components:
  - DictionaryInstallView.qml
  - ManageDictionariesDialog.qml
  - SpellCheckLanguageComboBox.qml
- Updated property names for clarity
- Fixed a bug in the settings combo box custom component that caused
  out-of-range errors for filtered models

GitLab: #1997
Change-Id: Ibd0879f957f27f4c7c5720762ace553ca84e2bc3
2025-06-03 19:24:16 -04:00

730 lines
25 KiB
C++

/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "messagesadapter.h"
#include "appsettingsmanager.h"
#include "qtutils.h"
#include "messageparser.h"
#include "previewengine.h"
#include <api/datatransfermodel.h>
#include <api/contact.h>
#include <QApplication>
#include <QBuffer>
#include <QClipboard>
#include <QDesktopServices>
#include <QDir>
#include <QFileInfo>
#include <QImageReader>
#include <QList>
#include <QMimeData>
#include <QMimeDatabase>
#include <QUrl>
#include <QtMath>
#include <QRegExp>
MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine,
LRCInstance* instance,
QObject* parent)
: QmlAdapterBase(instance, parent)
, settingsManager_(settingsManager)
, messageParser_(new MessageParser(previewEngine, this))
, filteredMsgListModel_(new FilteredMsgListModel(this))
, mediaInteractions_(std::make_unique<MessageListModel>(nullptr))
, timestampTimer_(new QTimer(this))
{
setObjectName(typeid(*this).name());
set_messageListModel(QVariant::fromValue(filteredMsgListModel_));
connect(settingsManager_,
&AppSettingsManager::reloadHistory,
&lrcInstance_->accountModel(),
&AccountModel::reloadHistory);
connect(lrcInstance_, &LRCInstance::selectedConvUidChanged, this, [this]() {
set_replyToId("");
set_editId("");
const QString& convId = lrcInstance_->get_selectedConvUid();
const auto& conversation = lrcInstance_->getConversationFromConvUid(convId);
// Reset the source model for the proxy model.
filteredMsgListModel_->setSourceModel(conversation.interactions.get());
set_currentConvComposingList(conversationTypersUrlToName(conversation.typers));
});
connect(messageParser_, &MessageParser::messageParsed, this, &MessagesAdapter::onMessageParsed);
connect(messageParser_, &MessageParser::linkInfoReady, this, &MessagesAdapter::onLinkInfoReady);
connect(timestampTimer_, &QTimer::timeout, this, &MessagesAdapter::timestampUpdated);
timestampTimer_->start(timestampUpdateIntervalMs_);
connect(lrcInstance_,
&LRCInstance::currentAccountIdChanged,
this,
&MessagesAdapter::connectConversationModel);
connectConversationModel();
}
bool
MessagesAdapter::isDocument(const interaction::Type& type)
{
return interaction::Type::DATA_TRANSFER == type;
}
void
MessagesAdapter::loadMoreMessages()
{
auto accountId = lrcInstance_->get_currentAccountId();
auto convId = lrcInstance_->get_selectedConvUid();
try {
const auto& convInfo = lrcInstance_->getConversationFromConvUid(convId, accountId);
if (convInfo.isSwarm())
lrcInstance_->getCurrentConversationModel()->loadConversationMessages(convId,
loadChunkSize_);
} catch (const std::exception& e) {
qWarning() << e.what();
}
}
void
MessagesAdapter::connectConversationModel()
{
auto currentConversationModel = lrcInstance_->getCurrentConversationModel();
if (currentConversationModel == nullptr) {
return;
}
QObject::connect(currentConversationModel,
&ConversationModel::newInteraction,
this,
&MessagesAdapter::onNewInteraction,
Qt::UniqueConnection);
QObject::connect(currentConversationModel,
&ConversationModel::conversationMessagesLoaded,
this,
&MessagesAdapter::onConversationMessagesLoaded,
Qt::UniqueConnection);
QObject::connect(currentConversationModel,
&ConversationModel::composingStatusChanged,
this,
&MessagesAdapter::onComposingStatusChanged,
Qt::UniqueConnection);
QObject::connect(currentConversationModel,
&ConversationModel::messagesFoundProcessed,
this,
&MessagesAdapter::onMessagesFoundProcessed,
Qt::UniqueConnection);
mediaInteractions_.reset(new MessageListModel(&lrcInstance_->getCurrentAccountInfo(), this));
set_mediaMessageListModel(QVariant::fromValue(mediaInteractions_.get()));
}
void
MessagesAdapter::sendConversationRequest()
{
lrcInstance_->makeConversationPermanent();
}
void
MessagesAdapter::sendMessage(const QString& message)
{
try {
const auto convUid = lrcInstance_->get_selectedConvUid();
lrcInstance_->getCurrentConversationModel()->sendMessage(convUid, message, replyToId_);
} catch (...) {
qDebug() << "Exception during sendMessage:" << message;
}
}
void
MessagesAdapter::sendMessageToUid(const QString& message, const QString& convUid)
{
try {
lrcInstance_->getCurrentConversationModel()->sendMessage(convUid, message, replyToId_);
} catch (...) {
qDebug() << "Exception during sendMessage:" << message;
}
}
void
MessagesAdapter::editMessage(const QString& convId, const QString& newBody, const QString& messageId)
{
try {
auto editId = !messageId.isEmpty() ? messageId : editId_;
if (editId.isEmpty()) {
return;
}
set_editId("");
lrcInstance_->getCurrentConversationModel()->editMessage(convId, newBody, editId);
} catch (...) {
qDebug() << "Exception during message edition:" << messageId;
}
}
void
MessagesAdapter::removeEmojiReaction(const QString& convId,
const QString& emoji,
const QString& messageId)
{
try {
// check if this emoji has already been added by this author
editMessage(convId, "", messageId);
} catch (...) {
qDebug() << "Exception during removeEmojiReaction():" << messageId;
}
}
void
MessagesAdapter::addEmojiReaction(const QString& convId,
const QString& emoji,
const QString& messageId)
{
try {
lrcInstance_->getCurrentConversationModel()->reactMessage(convId, emoji, messageId);
} catch (...) {
qDebug() << "Exception during addEmojiReaction():" << messageId;
}
}
void
MessagesAdapter::sendFile(const QString& message)
{
QFileInfo fi(message);
QString fileName = fi.fileName();
try {
auto convUid = lrcInstance_->get_selectedConvUid();
lrcInstance_->getCurrentConversationModel()->sendFile(convUid,
message,
fileName,
replyToId_);
} catch (...) {
qDebug() << "Exception during sendFile";
}
}
void
MessagesAdapter::sendFileToUid(const QString& message, const QString& convUid)
{
QFileInfo fi(message);
QString fileName = fi.fileName();
try {
lrcInstance_->getCurrentConversationModel()->sendFile(convUid,
message,
fileName,
replyToId_);
} catch (...) {
qDebug() << "Exception during sendFile";
}
}
void
MessagesAdapter::joinCall(const QString& uri,
const QString& deviceId,
const QString& confId,
bool isAudioOnly)
{
lrcInstance_->getCurrentConversationModel()->joinCall(lrcInstance_->get_selectedConvUid(),
uri,
deviceId,
confId,
isAudioOnly);
}
void
MessagesAdapter::copyToDownloads(const QString& interactionId, const QString& displayName)
{
auto downloadDir = lrcInstance_->accountModel().downloadDirectory;
if (auto accInfo = &lrcInstance_->getCurrentAccountInfo()) {
auto dest = accInfo->dataTransferModel->copyTo(lrcInstance_->get_currentAccountId(),
lrcInstance_->get_selectedConvUid(),
interactionId,
downloadDir,
displayName);
if (!dest.isEmpty()) {
Q_EMIT fileCopied(dest);
}
}
}
void
MessagesAdapter::openUrl(const QString& url)
{
if (!QDesktopServices::openUrl(url)) {
qDebug() << "Couldn't open url: " << url;
}
}
void
MessagesAdapter::openDirectory(const QString& path)
{
QString p = path;
QFileInfo f(p);
if (f.exists()) {
if (!f.isDir())
p = f.dir().absolutePath();
QString url;
if (!p.startsWith("file:/"))
url = "file:///" + p;
else
url = p;
openUrl(url);
}
}
void
MessagesAdapter::removeFile(const QString& interactionId, const QString& path)
{
auto convUid = lrcInstance_->get_selectedConvUid();
lrcInstance_->getCurrentConversationModel()->removeFile(convUid, interactionId, path);
}
void
MessagesAdapter::acceptFile(const QString& interactionId)
{
auto convUid = lrcInstance_->get_selectedConvUid();
lrcInstance_->getCurrentConversationModel()->acceptTransfer(convUid, interactionId);
}
void
MessagesAdapter::cancelFile(const QString& interactionId)
{
const auto convUid = lrcInstance_->get_selectedConvUid();
lrcInstance_->getCurrentConversationModel()->cancelTransfer(convUid, interactionId);
}
void
MessagesAdapter::onPaste()
{
const QMimeData* mimeData = QApplication::clipboard()->mimeData();
if (mimeData->hasImage()) {
// Save temp data into a temp file.
QPixmap pixmap = qvariant_cast<QPixmap>(mimeData->imageData());
auto img_name_hash
= QCryptographicHash::hash(QString::number(pixmap.cacheKey()).toLocal8Bit(),
QCryptographicHash::Sha1);
QString fileName = "img_" + QString(img_name_hash.toHex()) + ".png";
QString path = QDir::temp().filePath(fileName);
if (!pixmap.save(path, "PNG")) {
qDebug().noquote() << "Errors during QPixmap save" << "\n";
return;
}
Q_EMIT newFilePasted(path);
} else if (mimeData->hasUrls()) {
QList<QUrl> urlList = mimeData->urls();
// Extract the local paths of the files.
for (int i = 0; i < urlList.size(); ++i) {
// Trim file:// or file:/// from url.
const static QRegularExpression fileSchemeRe("^file:\\/{2,3}");
QString filePath = urlList.at(i).toString().remove(fileSchemeRe);
Q_EMIT newFilePasted(filePath);
}
} else {
// Treat as text content, make chatview.js handle in order to
// avoid string escape problems
Q_EMIT newTextPasted();
}
}
QVariantMap
MessagesAdapter::getTransferStats(const QString& msgId, int status)
{
Q_UNUSED(status)
auto convModel = lrcInstance_->getCurrentConversationModel();
lrc::api::datatransfer::Info info = {};
convModel->getTransferInfo(lrcInstance_->get_selectedConvUid(), msgId, info);
return {{"totalSize", qint64(info.totalSize)}, {"progress", qint64(info.progress)}};
}
QVariant
MessagesAdapter::dataForInteraction(const QString& interactionId, int role) const
{
if (auto* model = getMsgListSourceModel()) {
return model->data(interactionId, role);
}
return {};
}
void
MessagesAdapter::userIsComposing(bool isComposing)
{
if (lrcInstance_->get_selectedConvUid().isEmpty())
return;
lrcInstance_->getCurrentConversationModel()->setIsComposing(lrcInstance_->get_selectedConvUid(),
isComposing);
}
void
MessagesAdapter::onNewInteraction(const QString& convUid,
const QString& interactionId,
const interaction::Info& interaction)
{
Q_UNUSED(interactionId);
try {
if (convUid.isEmpty() || convUid != lrcInstance_->get_selectedConvUid()) {
return;
}
auto accountId = lrcInstance_->get_currentAccountId();
auto& accountInfo = lrcInstance_->getAccountInfo(accountId);
auto& convModel = accountInfo.conversationModel;
convModel->clearUnreadInteractions(convUid);
Q_EMIT newInteraction(interactionId, static_cast<int>(interaction.type));
} catch (...) {
}
}
void
MessagesAdapter::onMessageParsed(const QString& messageId, const QString& parsed)
{
if (messageId.isEmpty()) {
Q_EMIT messageParsed(messageId, parsed);
return;
}
const QString& convId = lrcInstance_->get_selectedConvUid();
const QString& accId = lrcInstance_->get_currentAccountId();
auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
conversation.interactions->setParsedMessage(messageId, parsed);
}
void
MessagesAdapter::onLinkInfoReady(const QString& messageId, const QVariantMap& info)
{
const QString& convId = lrcInstance_->get_selectedConvUid();
const QString& accId = lrcInstance_->get_currentAccountId();
auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
conversation.interactions->addHyperlinkInfo(messageId, info);
}
void
MessagesAdapter::acceptInvitation(const QString& convId)
{
auto conversationId = convId.isEmpty() ? lrcInstance_->get_selectedConvUid() : convId;
auto* convModel = lrcInstance_->getCurrentConversationModel();
convModel->acceptConversationRequest(conversationId);
}
void
MessagesAdapter::refuseInvitation(const QString& convUid)
{
const auto currentConvUid = convUid.isEmpty() ? lrcInstance_->get_selectedConvUid() : convUid;
lrcInstance_->getCurrentConversationModel()->removeConversation(currentConvUid, false);
}
void
MessagesAdapter::blockConversation(const QString& convUid)
{
const auto currentConvUid = convUid.isEmpty() ? lrcInstance_->get_selectedConvUid() : convUid;
lrcInstance_->getCurrentConversationModel()->removeConversation(currentConvUid, true);
}
void
MessagesAdapter::unbanContact(int index)
{
auto& accountInfo = lrcInstance_->getCurrentAccountInfo();
auto bannedContactList = accountInfo.contactModel->getBannedContacts();
auto it = bannedContactList.begin();
std::advance(it, index);
try {
auto contactInfo = accountInfo.contactModel->getContact(*it);
accountInfo.contactModel->addContact(contactInfo);
} catch (const std::out_of_range& e) {
qDebug() << e.what();
}
}
void
MessagesAdapter::unbanConversation(const QString& convUid)
{
auto& accInfo = lrcInstance_->getCurrentAccountInfo();
try {
const auto contactUri = accInfo.conversationModel->peersForConversation(convUid).at(0);
auto contactInfo = accInfo.contactModel->getContact(contactUri);
accInfo.contactModel->addContact(contactInfo);
} catch (const std::out_of_range& e) {
qDebug() << e.what();
}
}
void
MessagesAdapter::clearConversationHistory(const QString& accountId, const QString& convUid)
{
lrcInstance_->getAccountInfo(accountId).conversationModel->clearHistory(convUid);
}
void
MessagesAdapter::removeConversation(const QString& convUid)
{
auto& accInfo = lrcInstance_->getCurrentAccountInfo();
accInfo.conversationModel->removeConversation(convUid);
}
void
MessagesAdapter::removeConversationMember(const QString& convUid, const QString& memberUri)
{
auto& accInfo = lrcInstance_->getCurrentAccountInfo();
accInfo.conversationModel->removeConversationMember(convUid, memberUri);
}
void
MessagesAdapter::addConversationMember(const QString& convUid, const QString& memberUri)
{
auto& accInfo = lrcInstance_->getCurrentAccountInfo();
accInfo.conversationModel->addConversationMember(convUid, memberUri);
}
void
MessagesAdapter::removeContact(const QString& convUid, bool banContact)
{
auto& accInfo = lrcInstance_->getCurrentAccountInfo();
// remove the uri from the default moderators list
// TODO: seems like this should be done in libringclient
QStringList list = lrcInstance_->accountModel().getDefaultModerators(accInfo.id);
const auto contactUri = accInfo.conversationModel->peersForConversation(convUid).at(0);
if (!contactUri.isEmpty() && list.contains(contactUri)) {
lrcInstance_->accountModel().setDefaultModerator(accInfo.id, contactUri, false);
}
// actually remove the contact
accInfo.contactModel->removeContact(contactUri, banContact);
}
void
MessagesAdapter::onConversationMessagesLoaded(uint32_t loadingRequestId, const QString& convId)
{
if (convId != lrcInstance_->get_selectedConvUid())
return;
Q_EMIT moreMessagesLoaded(loadingRequestId);
}
void
MessagesAdapter::parseMessage(const QString& msgId,
const QString& msg,
bool showPreview,
const QColor& linkColor,
const QColor& backgroundColor)
{
messageParser_->parseMessage(msgId, msg, showPreview, linkColor, backgroundColor);
}
void
MessagesAdapter::onComposingStatusChanged(const QString& convId,
const QString& contactUri,
bool isComposing)
{
Q_UNUSED(contactUri)
if (lrcInstance_->get_selectedConvUid() == convId) {
const QString& accId = lrcInstance_->get_currentAccountId();
auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
set_currentConvComposingList(conversationTypersUrlToName(conversation.typers));
}
}
void
MessagesAdapter::onMessagesFoundProcessed(const QString& accountId,
const QMap<QString, interaction::Info>& messageInformation)
{
if (lrcInstance_->get_currentAccountId() != accountId) {
return;
}
bool isSearchInProgress = messageInformation.size();
if (isSearchInProgress) {
for (auto it = messageInformation.begin(); it != messageInformation.end(); it++) {
mediaInteractions_->append(it.key(), it.value());
}
} else {
set_mediaMessageListModel(QVariant::fromValue(mediaInteractions_.get()));
}
}
QList<QString>
MessagesAdapter::conversationTypersUrlToName(const QSet<QString>& typersSet)
{
QList<QString> nameList;
for (const auto& id : typersSet) {
auto name = lrcInstance_->getCurrentContactModel()->bestNameForContact(id);
nameList.append(name);
}
return nameList;
}
QVariantMap
MessagesAdapter::isLocalImage(const QString& mimename)
{
if (mimename.startsWith("image/")) {
QString fileFormat = mimename;
fileFormat.replace("image/", "");
QImageReader reader;
QList<QByteArray> supportedFormats = reader.supportedImageFormats();
auto iterator = std::find_if(supportedFormats.begin(),
supportedFormats.end(),
[fileFormat](QByteArray format) {
return format == fileFormat;
});
if (iterator != supportedFormats.end() && *iterator == "gif") {
return {{"isAnimatedImage", true}};
}
return {{"isImage", iterator != supportedFormats.end()}};
}
return {{"isImage", false}};
}
QVariantMap
MessagesAdapter::getMediaInfo(const QString& msg)
{
auto filePath = QFileInfo(msg).absoluteFilePath();
static const QString html
= "<body style='margin:0;padding:0;'>"
"<%1 style='width:100%;height:%2;outline:none;background-color:#f1f3f4;"
"object-fit:cover;' "
"controls controlsList='nodownload noplaybackrate' src='file://%3' type='%4'/></body>";
QMimeDatabase db;
QMimeType mime = db.mimeTypeForFile(filePath);
QVariantMap fileInfo = isLocalImage(mime.name());
if (fileInfo["isImage"].toBool() || fileInfo["isAnimatedImage"].toBool()) {
return fileInfo;
}
static const QRegExp vPattern("(video/)(avi|mov|webm|webp|rmvb)$", Qt::CaseInsensitive);
vPattern.indexIn(mime.name());
auto captured = vPattern.capturedTexts();
QString type = captured.size() == 3 ? captured[1] : "";
if (!type.isEmpty()) {
return {
{"isVideo", true},
{"isAudio", false},
{"html", html.arg("video", "100%", filePath, mime.name())},
};
} else {
static const QRegExp aPattern("(audio/)(ogg|flac|wav|mpeg|mp3)$", Qt::CaseInsensitive);
aPattern.indexIn(mime.name());
captured = aPattern.capturedTexts();
type = captured.size() == 3 ? captured[1] : "";
if (!type.isEmpty()) {
return {
{"isVideo", false},
{"isAudio", true},
{"html", html.arg("audio", "54px", filePath, mime.name())},
};
}
}
return {};
}
bool
MessagesAdapter::isRemoteImage(const QString& msg)
{
// TODO: test if all these open in the AnimatedImage component
const static QRegularExpression
imageRe("[^\\s]+(.*?)\\.(jpg|jpeg|png|gif|apng|webp|avif|flif)$",
QRegularExpression::CaseInsensitiveOption);
QRegularExpressionMatch match = imageRe.match(msg);
return match.hasMatch();
}
QString
MessagesAdapter::getFormattedTime(const quint64 timestamp)
{
const auto currentTime = QDateTime::currentDateTime();
const auto seconds = currentTime.toSecsSinceEpoch() - timestamp;
auto interval = qFloor(seconds / 60);
if (interval > 1) {
auto curLang = settingsManager_->getLanguage();
auto curLocal = QLocale(curLang);
auto curTime = QDateTime::fromSecsSinceEpoch(timestamp).time();
QString timeLocale;
timeLocale = curLocal.toString(curTime, curLocal.ShortFormat).toLower();
return timeLocale;
}
return QObject::tr("Just now");
}
QString
MessagesAdapter::getBestFormattedDate(const quint64 timestamp)
{
auto currentDate = QDate::currentDate();
auto timestampDate = QDateTime::fromSecsSinceEpoch(timestamp).date();
if (timestampDate == currentDate)
return getFormattedTime(timestamp);
return getFormattedDay(timestamp);
}
QString
MessagesAdapter::getFormattedDay(const quint64 timestamp)
{
auto currentDate = QDate::currentDate();
auto timestampDate = QDateTime::fromSecsSinceEpoch(timestamp).date();
if (timestampDate == currentDate)
return QObject::tr("Today");
if (timestampDate.daysTo(currentDate) == 1)
return QObject::tr("Yesterday");
auto curLang = settingsManager_->getLanguage();
auto curLocal = QLocale(curLang);
auto curDate = QDateTime::fromSecsSinceEpoch(timestamp).date();
QString dateLocale;
dateLocale = curLocal.toString(curDate, curLocal.ShortFormat);
return dateLocale;
}
void
MessagesAdapter::startSearch(const QString& text, bool isMedia)
{
auto accountId = lrcInstance_->get_currentAccountId();
mediaInteractions_.reset(new MessageListModel(&lrcInstance_->getCurrentAccountInfo(), this));
set_mediaMessageListModel(QVariant::fromValue(mediaInteractions_.get()));
if (text.isEmpty() && !isMedia)
return;
auto convId = lrcInstance_->get_selectedConvUid();
try {
lrcInstance_->getCurrentConversationModel()->getConvMediasInfos(accountId,
convId,
text,
isMedia);
} catch (...) {
qDebug() << "Exception during startSearch()";
}
}
MessageListModel*
MessagesAdapter::getMsgListSourceModel() const
{
// We are certain that filteredMsgListModel_'s source model is a MessageListModel,
// However it may be a nullptr if not yet set.
return static_cast<MessageListModel*>(filteredMsgListModel_->sourceModel());
}