fix: external images now load + list pane can shrink to compact mode
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.
This commit is contained in:
@@ -32,6 +32,9 @@ void MailListView::setupUI()
|
|||||||
QVBoxLayout *layout = new QVBoxLayout(this);
|
QVBoxLayout *layout = new QVBoxLayout(this);
|
||||||
layout->setContentsMargins(0, 0, 0, 0);
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
layout->setSpacing(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
|
// Search bar
|
||||||
QWidget *searchBar = new QWidget();
|
QWidget *searchBar = new QWidget();
|
||||||
@@ -103,8 +106,8 @@ void MailListView::setupUI()
|
|||||||
m_pivotCombo = new QComboBox();
|
m_pivotCombo = new QComboBox();
|
||||||
m_pivotCombo->addItem("Principal"); // Focused
|
m_pivotCombo->addItem("Principal"); // Focused
|
||||||
m_pivotCombo->addItem("Otros"); // Other
|
m_pivotCombo->addItem("Otros"); // Other
|
||||||
m_pivotCombo->setFixedWidth(140);
|
m_pivotCombo->setMaximumWidth(140);
|
||||||
m_pivotCombo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
m_pivotCombo->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
|
||||||
m_pivotCombo->setStyleSheet(
|
m_pivotCombo->setStyleSheet(
|
||||||
"QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; "
|
"QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; "
|
||||||
"background: white; min-height: 20px; }"
|
"background: white; min-height: 20px; }"
|
||||||
@@ -121,8 +124,8 @@ void MailListView::setupUI()
|
|||||||
m_displayModeCombo->addItem("Compacto");
|
m_displayModeCombo->addItem("Compacto");
|
||||||
m_displayModeCombo->addItem("Medio");
|
m_displayModeCombo->addItem("Medio");
|
||||||
m_displayModeCombo->addItem("Espacioso");
|
m_displayModeCombo->addItem("Espacioso");
|
||||||
m_displayModeCombo->setFixedWidth(120);
|
m_displayModeCombo->setMaximumWidth(120);
|
||||||
m_displayModeCombo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
m_displayModeCombo->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
|
||||||
m_displayModeCombo->setStyleSheet(
|
m_displayModeCombo->setStyleSheet(
|
||||||
"QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; "
|
"QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; "
|
||||||
"background: white; min-height: 20px; }"
|
"background: white; min-height: 20px; }"
|
||||||
|
|||||||
+140
-6
@@ -24,11 +24,17 @@
|
|||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QTimer>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <QGraphicsDropShadowEffect>
|
#include <QGraphicsDropShadowEffect>
|
||||||
#include "db/dao/mailitemdao.h"
|
#include "db/dao/mailitemdao.h"
|
||||||
|
|
||||||
ReaderView::ReaderView(QWidget *parent) : QWidget(parent) {
|
ReaderView::ReaderView(QWidget *parent) : QWidget(parent) {
|
||||||
|
m_network = new QNetworkAccessManager(this);
|
||||||
|
loadTrustedSenders();
|
||||||
setupUI();
|
setupUI();
|
||||||
applyEmailCss();
|
applyEmailCss();
|
||||||
}
|
}
|
||||||
@@ -114,6 +120,37 @@ void ReaderView::setupUI() {
|
|||||||
refreshCurrentMail();
|
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 = new QToolButton();
|
||||||
m_zoomOutButton->setText("−");
|
m_zoomOutButton->setText("−");
|
||||||
m_zoomOutButton->setFixedSize(28, 28);
|
m_zoomOutButton->setFixedSize(28, 28);
|
||||||
@@ -147,6 +184,7 @@ void ReaderView::setupUI() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toolbarLayout->addWidget(m_loadImagesButton);
|
toolbarLayout->addWidget(m_loadImagesButton);
|
||||||
|
toolbarLayout->addWidget(m_alwaysLoadImagesButton);
|
||||||
toolbarLayout->addStretch();
|
toolbarLayout->addStretch();
|
||||||
toolbarLayout->addWidget(m_zoomOutButton);
|
toolbarLayout->addWidget(m_zoomOutButton);
|
||||||
toolbarLayout->addWidget(m_zoomLabel);
|
toolbarLayout->addWidget(m_zoomLabel);
|
||||||
@@ -599,6 +637,89 @@ void ReaderView::updateZoom() {
|
|||||||
// The font scaling above handles it
|
// 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)<img\\b([^>]*\\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) {
|
QString ReaderView::sanitizeHtml(const QString &html) {
|
||||||
if (html.isEmpty()) return "<div class='email-body'><i style='color:#999'>(Mensaje vacío)</i></div>";
|
if (html.isEmpty()) return "<div class='email-body'><i style='color:#999'>(Mensaje vacío)</i></div>";
|
||||||
|
|
||||||
@@ -617,7 +738,10 @@ QString ReaderView::sanitizeHtml(const QString &html) {
|
|||||||
result.remove(QRegularExpression("(?is)\\s(on\\w+)\\s*=\\s*\\w+"));
|
result.remove(QRegularExpression("(?is)\\s(on\\w+)\\s*=\\s*\\w+"));
|
||||||
|
|
||||||
// Block external images (tracking pixels, etc.) - replace with placeholder, unless allowed
|
// 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
|
// Match images with http/https/data URLs in src attribute
|
||||||
QRegularExpression extImgRegex("(?i)<img([^>]*src\\s*=\\s*[\"'](?:https?|data):[^\"']*[\"'][^>]*)>");
|
QRegularExpression extImgRegex("(?i)<img([^>]*src\\s*=\\s*[\"'](?:https?|data):[^\"']*[\"'][^>]*)>");
|
||||||
int beforeCount = result.count("data-blocked=\"true\"");
|
int beforeCount = result.count("data-blocked=\"true\"");
|
||||||
@@ -625,12 +749,18 @@ QString ReaderView::sanitizeHtml(const QString &html) {
|
|||||||
"<img\\1 style=\"max-width:100%;height:auto;\" data-blocked=\"true\" title=\"Imagen externa bloqueada por privacidad\">");
|
"<img\\1 style=\"max-width:100%;height:auto;\" data-blocked=\"true\" title=\"Imagen externa bloqueada por privacidad\">");
|
||||||
int afterCount = result.count("data-blocked=\"true\"");
|
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
|
// Show the two buttons if there were blocked images.
|
||||||
if (m_loadImagesButton && result.contains("data-blocked=\"true\"")) {
|
bool hasBlocked = result.contains("data-blocked=\"true\"");
|
||||||
m_loadImagesButton->setVisible(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
|
// 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) {
|
void ReaderView::setMailItem(const MailItem* item) {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
m_currentMailId = -1;
|
m_currentMailId = -1;
|
||||||
|
m_currentSender.clear();
|
||||||
m_allowExternalImages = false;
|
m_allowExternalImages = false;
|
||||||
m_loadImagesButton->setVisible(false);
|
m_loadImagesButton->setVisible(false);
|
||||||
|
if (m_alwaysLoadImagesButton) m_alwaysLoadImagesButton->setVisible(false);
|
||||||
m_subjectLabel->setText("(Sin asunto)");
|
m_subjectLabel->setText("(Sin asunto)");
|
||||||
m_avatarLabel->setText("?");
|
m_avatarLabel->setText("?");
|
||||||
m_fromLabel->setText("Remitente: —");
|
m_fromLabel->setText("Remitente: —");
|
||||||
@@ -769,8 +901,10 @@ void ReaderView::setMailItem(const MailItem* item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m_currentMailId = item->id();
|
m_currentMailId = item->id();
|
||||||
|
m_currentSender = item->sender().trimmed();
|
||||||
m_allowExternalImages = false;
|
m_allowExternalImages = false;
|
||||||
m_loadImagesButton->setVisible(false);
|
m_loadImagesButton->setVisible(false);
|
||||||
|
if (m_alwaysLoadImagesButton) m_alwaysLoadImagesButton->setVisible(false);
|
||||||
|
|
||||||
// Subject
|
// Subject
|
||||||
m_subjectLabel->setText(item->subject().isEmpty() ? "(Sin asunto)" : item->subject());
|
m_subjectLabel->setText(item->subject().isEmpty() ? "(Sin asunto)" : item->subject());
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QSettings>
|
||||||
#include "core/mailitem.h"
|
#include "core/mailitem.h"
|
||||||
|
|
||||||
class ReaderView : public QWidget {
|
class ReaderView : public QWidget {
|
||||||
@@ -101,4 +104,17 @@ private:
|
|||||||
QString m_baseCss;
|
QString m_baseCss;
|
||||||
bool m_allowExternalImages = false;
|
bool m_allowExternalImages = false;
|
||||||
QToolButton *m_loadImagesButton = nullptr;
|
QToolButton *m_loadImagesButton = nullptr;
|
||||||
|
QToolButton *m_alwaysLoadImagesButton = nullptr;
|
||||||
|
QString m_currentSender; // sender of the currently shown mail
|
||||||
|
QSet<QString> 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);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user