From 64d6bc07c91cd0726f0f28ea705b43dc669cc5a6 Mon Sep 17 00:00:00 2001 From: Javier Date: Sat, 5 Sep 2026 03:03:43 +0200 Subject: [PATCH] fix: external images now load + list pane can shrink to compact mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 - 'Cargar imágenes' did nothing: QTextBrowser cannot fetch http(s) images itself, so re-rendering with m_allowExternalImages=true changed nothing. Add ReaderView::processImages() which downloads each external image via QNetworkAccessManager (sync QEventLoop, 8s timeout) and replaces the src with a data: URI that QTextBrowser renders. Add a second button 'Mostrar siempre imágenes de este remitente' that persists the sender in QSettings (ReaderView/trustedSenders); sanitizeHtml auto-loads images for trusted senders. Bug 2 - list pane could not shrink (compact mode never appeared): fixed-width combos (pivot 140 / display 120) + 'Agrupar:' label + search inflated the layout minimumSizeHint, so the QSplitter would not reduce the list below the 650px compact threshold. Fix: layout->setSizeConstraint(SetNoConstraint) on the root layout + combos use MaximumWidth/Preferred instead of Fixed. Verified: compiles, app runs clean, no crashes. --- src/ui/maillistview.cpp | 11 +-- src/ui/readerview.cpp | 146 ++++++++++++++++++++++++++++++++++++++-- src/ui/readerview.h | 16 +++++ 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/src/ui/maillistview.cpp b/src/ui/maillistview.cpp index 3e4babc..a0ca89a 100644 --- a/src/ui/maillistview.cpp +++ b/src/ui/maillistview.cpp @@ -32,6 +32,9 @@ void MailListView::setupUI() QVBoxLayout *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); + // Let the view shrink freely (esp. to trigger compact mode); the filter/search + // controls must not inflate the minimum width of the enclosing splitter pane. + layout->setSizeConstraint(QLayout::SetNoConstraint); // Search bar QWidget *searchBar = new QWidget(); @@ -103,8 +106,8 @@ void MailListView::setupUI() m_pivotCombo = new QComboBox(); m_pivotCombo->addItem("Principal"); // Focused m_pivotCombo->addItem("Otros"); // Other - m_pivotCombo->setFixedWidth(140); - m_pivotCombo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + m_pivotCombo->setMaximumWidth(140); + m_pivotCombo->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); m_pivotCombo->setStyleSheet( "QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; " "background: white; min-height: 20px; }" @@ -121,8 +124,8 @@ void MailListView::setupUI() m_displayModeCombo->addItem("Compacto"); m_displayModeCombo->addItem("Medio"); m_displayModeCombo->addItem("Espacioso"); - m_displayModeCombo->setFixedWidth(120); - m_displayModeCombo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + m_displayModeCombo->setMaximumWidth(120); + m_displayModeCombo->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); m_displayModeCombo->setStyleSheet( "QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; " "background: white; min-height: 20px; }" diff --git a/src/ui/readerview.cpp b/src/ui/readerview.cpp index cf2710b..a66a6bc 100644 --- a/src/ui/readerview.cpp +++ b/src/ui/readerview.cpp @@ -24,11 +24,17 @@ #include #include #include +#include +#include +#include +#include #include #include #include "db/dao/mailitemdao.h" ReaderView::ReaderView(QWidget *parent) : QWidget(parent) { + m_network = new QNetworkAccessManager(this); + loadTrustedSenders(); setupUI(); applyEmailCss(); } @@ -114,6 +120,37 @@ void ReaderView::setupUI() { refreshCurrentMail(); }); + // "Always show images from this sender" button (persists the choice) + m_alwaysLoadImagesButton = new QToolButton(); + m_alwaysLoadImagesButton->setText("🙂 Mostrar siempre imágenes de este remitente"); + m_alwaysLoadImagesButton->setToolTip("Confiar en este remitente: sus imágenes externas se cargarán automáticamente en el futuro"); + m_alwaysLoadImagesButton->setFixedHeight(28); + m_alwaysLoadImagesButton->setVisible(false); + m_alwaysLoadImagesButton->setStyleSheet( + "QToolButton {" + " background: #e8f5e9;" + " border: 1px solid #2e7d32;" + " border-radius: 4px;" + " padding: 4px 10px;" + " font-size: 11px;" + " color: #2e7d32;" + " font-weight: 500;" + "}" + "QToolButton:hover {" + " background: #c8e6c9;" + "}" + ); + connect(m_alwaysLoadImagesButton, &QToolButton::clicked, [this]() { + if (!m_currentSender.isEmpty()) { + m_trustedSenders.insert(trustKey(m_currentSender)); + saveTrustedSenders(); + } + m_allowExternalImages = true; + m_alwaysLoadImagesButton->setVisible(false); + m_loadImagesButton->setVisible(false); + refreshCurrentMail(); + }); + m_zoomOutButton = new QToolButton(); m_zoomOutButton->setText("−"); m_zoomOutButton->setFixedSize(28, 28); @@ -147,6 +184,7 @@ void ReaderView::setupUI() { }); toolbarLayout->addWidget(m_loadImagesButton); + toolbarLayout->addWidget(m_alwaysLoadImagesButton); toolbarLayout->addStretch(); toolbarLayout->addWidget(m_zoomOutButton); toolbarLayout->addWidget(m_zoomLabel); @@ -599,6 +637,89 @@ void ReaderView::updateZoom() { // The font scaling above handles it } +void ReaderView::loadTrustedSenders() +{ + m_trustedSenders.clear(); + QSettings s; + s.beginGroup("ReaderView"); + const QStringList list = s.value("trustedSenders").toStringList(); + for (const QString &key : list) m_trustedSenders.insert(key.trimmed().toLower()); + s.endGroup(); +} + +void ReaderView::saveTrustedSenders() +{ + QSettings s; + s.beginGroup("ReaderView"); + QStringList list = m_trustedSenders.values(); + QStringList cleaned; + for (const QString &k : list) if (!k.isEmpty()) cleaned << k; + s.setValue("trustedSenders", cleaned); + s.endGroup(); + s.sync(); +} + +QString ReaderView::processImages(QString html, bool allow) +{ + if (!allow) return html; + + // Find all external (http/https) image URLs in the html. + QRegularExpression re("(?i)]*\\bsrc\\s*=\\s*[\"'])(https?://[^\"'>\\s]+)([\"'][^>]*)>"); + QStringList urls; + auto mit = re.globalMatch(html); + while (mit.hasNext()) { + auto m = mit.next(); + urls << m.captured(2); + } + + if (urls.isEmpty()) return html; + + // Download each unique URL and substitute with a data: URI (QTextBrowser + // cannot fetch http images itself). Synchronous within reason; a short + // per-request timeout keeps the UI responsive. + QStringList done; + for (const QString &url : urls) { + if (done.contains(url)) continue; + done << url; + QUrl u(url); + if (!u.isValid() || (u.scheme() != "http" && u.scheme() != "https")) continue; + + QNetworkRequest req(u); + req.setTransferTimeout(8000); + QNetworkReply *reply = m_network->get(req); + QEventLoop loop; + QTimer timer; timer.setSingleShot(true); + connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + timer.start(8000); + loop.exec(); + + QString replacement; + if (reply->error() == QNetworkReply::NoError) { + const QByteArray data = reply->readAll(); + if (!data.isEmpty()) { + const QByteArray mime = reply->header(QNetworkRequest::ContentTypeHeader) + .toByteArray().split(';').first().trimmed(); + const QByteArray b64 = data.toBase64(); + replacement = QString("data:%1;base64,%2").arg(QString::fromLatin1(mime.isEmpty() ? "image/png" : mime), + QString::fromLatin1(b64)); + } + } else { + qDebug() << "[ReaderView] image load failed:" << url << reply->errorString(); + } + reply->deleteLater(); + + if (!replacement.isEmpty()) { + // Replace this exact src URL (the first occurrence only risk is fine here because + // we iterate unique URLs and each appears verbatim). + html.replace(QUrl::fromPercentEncoding(url.toUtf8()), replacement); + // Also try replacing the raw-encoded form if slightly different. + html.replace(url, replacement); + } + } + return html; +} + QString ReaderView::sanitizeHtml(const QString &html) { if (html.isEmpty()) return ""; @@ -617,7 +738,10 @@ QString ReaderView::sanitizeHtml(const QString &html) { result.remove(QRegularExpression("(?is)\\s(on\\w+)\\s*=\\s*\\w+")); // Block external images (tracking pixels, etc.) - replace with placeholder, unless allowed - if (!m_allowExternalImages) { + // An image is "allowed" if the user clicked "Cargar imágenes", or if this sender is trusted + // ("Mostrar siempre imágenes de este remitente" persisted choice). + const bool trusted = !m_currentSender.isEmpty() && isSenderTrusted(m_currentSender); + if (!m_allowExternalImages && !trusted) { // Match images with http/https/data URLs in src attribute QRegularExpression extImgRegex("(?i)]*src\\s*=\\s*[\"'](?:https?|data):[^\"']*[\"'][^>]*)>"); int beforeCount = result.count("data-blocked=\"true\""); @@ -625,12 +749,18 @@ QString ReaderView::sanitizeHtml(const QString &html) { ""); int afterCount = result.count("data-blocked=\"true\""); - qDebug() << "[ReaderView] External images blocked:" << (afterCount - beforeCount); + qDebug() << "[ReaderView] External images blocked count:" << (afterCount - beforeCount); - // Show load images button if there were blocked images - if (m_loadImagesButton && result.contains("data-blocked=\"true\"")) { - m_loadImagesButton->setVisible(true); - } + // Show the two buttons if there were blocked images. + bool hasBlocked = result.contains("data-blocked=\"true\""); + if (m_loadImagesButton) m_loadImagesButton->setVisible(hasBlocked); + if (m_alwaysLoadImagesButton) m_alwaysLoadImagesButton->setVisible(hasBlocked); + } else { + // Images are allowed for this view: hide both buttons and download+render + // the external images as data: URIs (QTextBrowser cannot fetch http itself). + if (m_loadImagesButton) m_loadImagesButton->setVisible(false); + if (m_alwaysLoadImagesButton) m_alwaysLoadImagesButton->setVisible(false); + result = processImages(result, true); } // Add dark mode class if needed @@ -755,8 +885,10 @@ void ReaderView::showAttachmentContextMenu(const QPoint &pos, const QString &pat void ReaderView::setMailItem(const MailItem* item) { if (!item) { m_currentMailId = -1; + m_currentSender.clear(); m_allowExternalImages = false; m_loadImagesButton->setVisible(false); + if (m_alwaysLoadImagesButton) m_alwaysLoadImagesButton->setVisible(false); m_subjectLabel->setText("(Sin asunto)"); m_avatarLabel->setText("?"); m_fromLabel->setText("Remitente: —"); @@ -769,8 +901,10 @@ void ReaderView::setMailItem(const MailItem* item) { } m_currentMailId = item->id(); + m_currentSender = item->sender().trimmed(); m_allowExternalImages = false; m_loadImagesButton->setVisible(false); + if (m_alwaysLoadImagesButton) m_alwaysLoadImagesButton->setVisible(false); // Subject m_subjectLabel->setText(item->subject().isEmpty() ? "(Sin asunto)" : item->subject()); diff --git a/src/ui/readerview.h b/src/ui/readerview.h index 441e810..337455e 100644 --- a/src/ui/readerview.h +++ b/src/ui/readerview.h @@ -12,6 +12,9 @@ #include #include #include +#include +#include +#include #include "core/mailitem.h" class ReaderView : public QWidget { @@ -101,4 +104,17 @@ private: QString m_baseCss; bool m_allowExternalImages = false; QToolButton *m_loadImagesButton = nullptr; + QToolButton *m_alwaysLoadImagesButton = nullptr; + QString m_currentSender; // sender of the currently shown mail + QSet m_trustedSenders; // senders whose images load automatically (persisted) + QNetworkAccessManager *m_network = nullptr; // downloads external images when allowed + + // Image loading + bool isSenderTrusted(const QString &sender) const { return m_trustedSenders.contains(trustKey(sender)); } + static QString trustKey(const QString &sender) { return sender.trimmed().toLower(); } + void loadTrustedSenders(); + void saveTrustedSenders(); + /// Post-process html: if images allowed (via button or trusted sender), replace + /// external http(s) image src with downloaded data: URIs so QTextBrowser shows them. + QString processImages(QString html, bool allow); };