Merge "persistent color scheme selection"
[trojita.git] / src / Gui / MessageView.cpp
blob7a4e1a225601cb96b4d753242ae6c8fb739995dd
1 /* Copyright (C) 2006 - 2016 Jan Kundrát <jkt@kde.org>
2 Copyright (C) 2014 Luke Dashjr <luke+trojita@dashjr.org>
3 Copyright (C) 2014 - 2015 Stephan Platz <trojita@paalsteek.de>
5 This file is part of the Trojita Qt IMAP e-mail client,
6 http://trojita.flaska.net/
8 This program is free software; you can redistribute it and/or
9 modify it under the terms of the GNU General Public License as
10 published by the Free Software Foundation; either version 2 of
11 the License or (at your option) version 3 or any later version
12 accepted by the membership of KDE e.V. (or its successor approved
13 by the membership of KDE e.V.), which shall act as a proxy
14 defined in Section 14 of version 3 of the license.
16 This program is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with this program. If not, see <http://www.gnu.org/licenses/>.
25 #include <QKeyEvent>
26 #include <QMenu>
27 #include <QSettings>
28 #include <QStackedLayout>
30 #include "Common/InvokeMethod.h"
31 #include "Common/SettingsNames.h"
32 #include "Composer/QuoteText.h"
33 #include "Composer/SubjectMangling.h"
34 #include "Cryptography/MessageModel.h"
35 #include "Gui/MessageView.h"
36 #include "Gui/ComposeWidget.h"
37 #include "Gui/EnvelopeView.h"
38 #include "Gui/ExternalElementsWidget.h"
39 #include "Gui/OverlayWidget.h"
40 #include "Gui/PartWidgetFactoryVisitor.h"
41 #include "Gui/ShortcutHandler/ShortcutHandler.h"
42 #include "Gui/SimplePartWidget.h"
43 #include "Gui/Spinner.h"
44 #include "Gui/TagListWidget.h"
45 #include "Gui/UserAgentWebPage.h"
46 #include "Gui/Window.h"
47 #include "Imap/Model/MailboxTree.h"
48 #include "Imap/Model/MsgListModel.h"
49 #include "Imap/Model/NetworkWatcher.h"
50 #include "Imap/Model/Utils.h"
51 #include "Imap/Network/MsgPartNetAccessManager.h"
52 #include "Plugins/PluginManager.h"
53 #include "UiUtils/IconLoader.h"
55 namespace Gui
58 MessageView::MessageView(QWidget *parent, QSettings *settings, Plugins::PluginManager *pluginManager,
59 Imap::Mailbox::FavoriteTagsModel *m_favoriteTags)
60 : QWidget(parent)
61 , m_stack(new QStackedLayout(this))
62 , messageModel(0)
63 , netAccess(new Imap::Network::MsgPartNetAccessManager(this))
64 , factory(new PartWidgetFactory(netAccess, this,
65 std::unique_ptr<PartWidgetFactoryVisitor>(new PartWidgetFactoryVisitor())))
66 , m_settings(settings)
67 , m_pluginManager(pluginManager)
69 connect(netAccess, &Imap::Network::MsgPartNetAccessManager::requestingExternal, this, &MessageView::externalsRequested);
72 setBackgroundRole(QPalette::Base);
73 setForegroundRole(QPalette::Text);
74 setAutoFillBackground(true);
75 setFocusPolicy(Qt::StrongFocus); // not by the wheel
78 m_zoomIn = ShortcutHandler::instance()->createAction(QStringLiteral("action_zoom_in"), this);
79 addAction(m_zoomIn);
80 connect(m_zoomIn, &QAction::triggered, this, &MessageView::zoomIn);
82 m_zoomOut = ShortcutHandler::instance()->createAction(QStringLiteral("action_zoom_out"), this);
83 addAction(m_zoomOut);
84 connect(m_zoomOut, &QAction::triggered, this, &MessageView::zoomOut);
86 m_zoomOriginal = ShortcutHandler::instance()->createAction(QStringLiteral("action_zoom_original"), this);
87 addAction(m_zoomOriginal);
88 connect(m_zoomOriginal, &QAction::triggered, this, &MessageView::zoomOriginal);
90 m_findAction = new QAction(UiUtils::loadIcon(QStringLiteral("edit-find")), tr("Search..."), this);
91 m_findAction->setShortcut(tr("Ctrl+F"));
92 // We can search only in one part of the message at a time. If searching is initiated by shortcut,
93 // there is no better solution than ask Part Widgets in visible order to handle this request. A
94 // callback method called MessageView::triggerSearchDialogBy is provided for this purpose.
95 // Other parts are searchable via the context menu only.
96 connect(m_findAction, &QAction::triggered, this, &MessageView::searchDialogRequested);
97 addAction(m_findAction);
99 // The homepage widget -- our poor man's splashscreen
100 m_homePage = new EmbeddedWebView(this, new QNetworkAccessManager(this), settings);
101 m_homePage->setFixedSize(450,300);
102 CALL_LATER_NOARG(m_homePage, handlePageLoadFinished);
103 m_homePage->setPage(new UserAgentWebPage(m_homePage));
104 m_homePage->installEventFilter(this);
105 m_homePage->setAutoFillBackground(false);
106 m_stack->addWidget(m_homePage);
109 // The actual widget for the actual message
110 m_messageWidget = new QWidget(this);
111 auto fullMsgLayout = new QVBoxLayout(m_messageWidget);
112 m_stack->addWidget(m_messageWidget);
114 m_envelope = new EnvelopeView(m_messageWidget, this);
115 fullMsgLayout->addWidget(m_envelope, 1);
117 tags = new TagListWidget(m_messageWidget, m_favoriteTags);
118 connect(tags, &TagListWidget::tagAdded, this, &MessageView::newLabelAction);
119 connect(tags, &TagListWidget::tagRemoved, this, &MessageView::deleteLabelAction);
120 fullMsgLayout->addWidget(tags, 3);
122 externalElements = new ExternalElementsWidget(this);
123 externalElements->hide();
124 connect(externalElements, &ExternalElementsWidget::loadingEnabled, this, &MessageView::enableExternalData);
125 fullMsgLayout->addWidget(externalElements, 1);
127 // put the actual messages into an extra horizontal view
128 // this allows us easy usage of the trailing stretch and also to indent the message a bit
129 m_msgLayout = new QHBoxLayout;
130 m_msgLayout->setContentsMargins(6,6,6,0);
131 fullMsgLayout->addLayout(m_msgLayout, 1);
132 // add a strong stretch to squeeze header and message to the top
133 // possibly passing a large stretch factor to the message could be enough...
134 fullMsgLayout->addStretch(1000);
137 markAsReadTimer = new QTimer(this);
138 markAsReadTimer->setSingleShot(true);
139 connect(markAsReadTimer, &QTimer::timeout, this, &MessageView::markAsRead);
141 m_loadingSpinner = new Spinner(this);
142 m_loadingSpinner->setText(tr("Fetching\nMessage"));
143 m_loadingSpinner->setType(Spinner::Sun);
146 MessageView::~MessageView()
148 // Redmine #496 -- the default order of destruction starts with our QNAM subclass which in turn takes care of all pending
149 // QNetworkReply instances created by that manager. When the destruction goes to the WebKit objects, they try to disconnect
150 // from the network replies which are however gone already. We can mitigate that by simply making sure that the destruction
151 // starts with the QWebView subclasses and only after that proceeds to the QNAM. Qt's default order leads to segfaults here.
152 unsetPreviousMessage();
155 void MessageView::unsetPreviousMessage()
157 clearWaitingConns();
158 m_loadingItems.clear();
159 message = QModelIndex();
160 markAsReadTimer->stop();
161 if (auto w = bodyWidget()) {
162 m_stack->removeWidget(dynamic_cast<QWidget *>(w));
163 delete w;
165 m_envelope->setMessage(QModelIndex());
166 delete messageModel;
167 messageModel = nullptr;
170 void MessageView::setEmpty()
172 unsetPreviousMessage();
173 m_loadingSpinner->stop();
174 m_stack->setCurrentWidget(m_homePage);
175 emit messageChanged();
178 AbstractPartWidget *MessageView::bodyWidget() const
180 if (m_msgLayout->itemAt(0) && m_msgLayout->itemAt(0)->widget()) {
181 return dynamic_cast<AbstractPartWidget *>(m_msgLayout->itemAt(0)->widget());
182 } else {
183 return nullptr;
187 void MessageView::setMessage(const QModelIndex &index)
189 Q_ASSERT(index.isValid());
190 QModelIndex messageIndex = Imap::deproxifiedIndex(index);
191 Q_ASSERT(messageIndex.isValid());
193 if (message == messageIndex) {
194 // This is a duplicate call, let's do nothing.
195 // It might not be our fat-fingered user, but also just a side-effect of our duplicate invocation through
196 // QAbstractItemView::clicked() and activated().
197 return;
200 unsetPreviousMessage();
202 message = messageIndex;
203 messageModel = new Cryptography::MessageModel(this, message);
204 messageModel->setObjectName(QStringLiteral("cryptoMessageModel-%1-%2")
205 .arg(message.data(Imap::Mailbox::RoleMailboxName).toString(),
206 message.data(Imap::Mailbox::RoleMessageUid).toString()));
207 for (const auto &module: m_pluginManager->mimePartReplacers()) {
208 messageModel->registerPartHandler(module);
210 emit messageModelChanged(messageModel);
212 // The data might be available from the local cache, so let's try to save a possible roundtrip here
213 // by explicitly requesting the data
214 message.data(Imap::Mailbox::RolePartData);
216 if (!message.data(Imap::Mailbox::RoleIsFetched).toBool()) {
217 // This happens when the message placeholder is already available in the GUI, but the actual message data haven't been
218 // loaded yet. This is especially common with the threading model, but also with bigger unsynced mailboxes.
219 // Note that the data might be already available in the cache, it's just that it isn't in the mailbox tree yet.
220 m_waitingMessageConns.emplace_back(
221 connect(messageModel, &QAbstractItemModel::dataChanged, this, [this](const QModelIndex &topLeft){
222 if (topLeft.data(Imap::Mailbox::RoleIsFetched).toBool()) {
223 // OK, message is fully fetched now
224 showMessageNow();
226 }));
227 m_loadingSpinner->setText(tr("Waiting\nfor\nMessage..."));
228 m_loadingSpinner->start();
229 } else {
230 showMessageNow();
234 /** @short Implementation of the "hey, let's really display the message, its BODYSTRUCTURE is available now" */
235 void MessageView::showMessageNow()
237 Q_ASSERT(message.data(Imap::Mailbox::RoleIsFetched).toBool());
239 clearWaitingConns();
241 QModelIndex rootPartIndex = messageModel->index(0,0);
242 Q_ASSERT(rootPartIndex.child(0,0).isValid());
244 netAccess->setExternalsEnabled(false);
245 externalElements->hide();
247 netAccess->setModelMessage(rootPartIndex);
249 m_loadingItems.clear();
250 m_loadingSpinner->stop();
252 m_envelope->setMessage(message);
254 auto updateTagList = [this]() {
255 tags->setTagList(message.data(Imap::Mailbox::RoleMessageFlags).toStringList());
257 connect(messageModel, &QAbstractItemModel::dataChanged, this, updateTagList);
258 updateTagList();
260 UiUtils::PartLoadingOptions loadingMode;
261 if (m_settings->value(Common::SettingsNames::guiPreferPlaintextRendering, QVariant(true)).toBool())
262 loadingMode |= UiUtils::PART_PREFER_PLAINTEXT_OVER_HTML;
263 auto viewer = factory->walk(rootPartIndex.child(0,0), 0, loadingMode);
264 viewer->setParent(this);
265 m_msgLayout->addWidget(viewer);
266 m_msgLayout->setAlignment(viewer, Qt::AlignTop|Qt::AlignLeft);
267 viewer->show();
268 // We want to propagate the QWheelEvent to upper layers
269 viewer->installEventFilter(this);
270 m_stack->setCurrentWidget(m_messageWidget);
272 if (m_netWatcher && m_netWatcher->effectiveNetworkPolicy() != Imap::Mailbox::NETWORK_OFFLINE
273 && m_settings->value(Common::SettingsNames::autoMarkReadEnabled, QVariant(true)).toBool()) {
274 // No additional delay is needed here because the MsgListView won't open a message while the user keeps scrolling,
275 // which was AFAIK the original intention
276 markAsReadTimer->start(m_settings->value(Common::SettingsNames::autoMarkReadSeconds, QVariant(0)).toUInt() * 1000);
279 emit messageChanged();
282 /** @short There's no point in waiting for the message to appear */
283 void MessageView::clearWaitingConns()
285 for (auto &conn: m_waitingMessageConns) {
286 disconnect(conn);
288 m_waitingMessageConns.clear();
291 void MessageView::markAsRead()
293 if (!message.isValid())
294 return;
295 Imap::Mailbox::Model *model = const_cast<Imap::Mailbox::Model *>(dynamic_cast<const Imap::Mailbox::Model *>(message.model()));
296 Q_ASSERT(model);
297 if (!model->isNetworkAvailable())
298 return;
299 if (!message.data(Imap::Mailbox::RoleMessageIsMarkedRead).toBool())
300 model->markMessagesRead(QModelIndexList() << message, Imap::Mailbox::FLAG_ADD);
303 /** @short Inhibit the automatic marking of the current message as already read
305 The user might have e.g. explicitly marked a previously read message as unread again immediately after navigating back to it
306 in the message listing. In that situation, the message viewer shall respect this decision and inhibit the helper which would
307 otherwise mark the current message as read after a short timeout.
309 void MessageView::stopAutoMarkAsRead()
311 markAsReadTimer->stop();
314 bool MessageView::eventFilter(QObject *object, QEvent *event)
316 if (event->type() == QEvent::Wheel) {
317 if (static_cast<QWheelEvent *>(event)->modifiers() == Qt::ControlModifier) {
318 if (static_cast<QWheelEvent *>(event)->delta() > 0) {
319 zoomIn();
320 } else {
321 zoomOut();
323 } else {
324 // while the containing scrollview has Qt::StrongFocus, the event forwarding breaks that
325 // -> completely disable focus for the following wheel event ...
326 parentWidget()->setFocusPolicy(Qt::NoFocus);
327 MessageView::event(event);
328 // ... set reset it
329 parentWidget()->setFocusPolicy(Qt::StrongFocus);
331 return true;
332 } else if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) {
333 QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
334 switch (keyEvent->key()) {
335 case Qt::Key_Left:
336 case Qt::Key_Right:
337 case Qt::Key_Up:
338 case Qt::Key_Down:
339 case Qt::Key_PageUp:
340 case Qt::Key_PageDown:
341 MessageView::event(event);
342 return true;
343 case Qt::Key_Home:
344 case Qt::Key_End:
345 return false;
346 default:
347 return QObject::eventFilter(object, event);
349 } else {
350 return QObject::eventFilter(object, event);
354 QString MessageView::quoteText() const
356 if (auto w = bodyWidget()) {
357 const Imap::Message::Envelope &e = message.data(Imap::Mailbox::RoleMessageEnvelope).value<Imap::Message::Envelope>();
358 QString sender;
359 if (!e.from.isEmpty())
360 sender = e.from[0].prettyName(Imap::Message::MailAddress::FORMAT_JUST_NAME);
361 if (e.from.isEmpty())
362 sender = tr("you");
364 if (messageModel->index(0, 0) /* fake message root */.child(0, 0) /* first MIME part */.data(Imap::Mailbox::RolePartDecryptionSupported).toBool()) {
365 // This is just an UX improvement shortcut: real filtering for CVE-2019-10734 is in
366 // MultipartSignedEncryptedWidget::quoteMe().
367 // That is required because the encrypted part might not be the root part of the message.
368 return tr("On %1, %2 sent an encrypted message:\n> ...\n\n").arg(e.date.toLocalTime().toString(Qt::SystemLocaleLongDate), sender);
371 QStringList quote = Composer::quoteText(w->quoteMe().split(QLatin1Char('\n')));
372 // One extra newline at the end of the quoted text to separate the response
373 quote << QString();
375 return tr("On %1, %2 wrote:\n").arg(e.date.toLocalTime().toString(Qt::SystemLocaleLongDate), sender) + quote.join(QStringLiteral("\n"));
377 return QString();
380 #define FORWARD_METHOD(METHOD) \
381 void MessageView::METHOD() \
383 if (auto w = bodyWidget()) { \
384 w->METHOD(); \
387 FORWARD_METHOD(zoomIn)
388 FORWARD_METHOD(zoomOut)
389 FORWARD_METHOD(zoomOriginal)
390 FORWARD_METHOD(searchDialogRequested)
392 void MessageView::setNetworkWatcher(Imap::Mailbox::NetworkWatcher *netWatcher)
394 m_netWatcher = netWatcher;
395 factory->setNetworkWatcher(netWatcher);
398 void MessageView::reply(MainWindow *mainWindow, Composer::ReplyMode mode)
400 if (!message.isValid())
401 return;
403 // The Message-Id of the original message might have been empty; be sure we can handle that
404 QByteArray messageId = message.data(Imap::Mailbox::RoleMessageMessageId).toByteArray();
405 QList<QByteArray> messageIdList;
406 if (!messageId.isEmpty()) {
407 messageIdList.append(messageId);
410 ComposeWidget::warnIfMsaNotConfigured(
411 ComposeWidget::createReply(mainWindow, mode, message, QList<QPair<Composer::RecipientKind,QString> >(),
412 Composer::Util::replySubject(message.data(Imap::Mailbox::RoleMessageSubject).toString()),
413 quoteText(), messageIdList,
414 message.data(Imap::Mailbox::RoleMessageHeaderReferences).value<QList<QByteArray> >() + messageIdList),
415 mainWindow);
418 void MessageView::forward(MainWindow *mainWindow, const Composer::ForwardMode mode)
420 if (!message.isValid())
421 return;
423 // The Message-Id of the original message might have been empty; be sure we can handle that
424 QByteArray messageId = message.data(Imap::Mailbox::RoleMessageMessageId).toByteArray();
425 QList<QByteArray> messageIdList;
426 if (!messageId.isEmpty()) {
427 messageIdList.append(messageId);
430 ComposeWidget::warnIfMsaNotConfigured(
431 ComposeWidget::createForward(mainWindow, mode, message, Composer::Util::forwardSubject(message.data(Imap::Mailbox::RoleMessageSubject).toString()),
432 messageIdList, message.data(Imap::Mailbox::RoleMessageHeaderReferences).value<QList<QByteArray>>() + messageIdList),
433 mainWindow);
436 void MessageView::externalsRequested(const QUrl &url)
438 Q_UNUSED(url);
439 externalElements->show();
442 void MessageView::enableExternalData()
444 netAccess->setExternalsEnabled(true);
445 externalElements->hide();
446 if (auto w = bodyWidget()) {
447 w->reloadContents();
451 void MessageView::newLabelAction(const QString &tag)
453 if (!message.isValid())
454 return;
456 Imap::Mailbox::Model *model = dynamic_cast<Imap::Mailbox::Model *>(const_cast<QAbstractItemModel *>(message.model()));
457 model->setMessageFlags(QModelIndexList() << message, tag, Imap::Mailbox::FLAG_ADD);
460 void MessageView::deleteLabelAction(const QString &tag)
462 if (!message.isValid())
463 return;
465 Imap::Mailbox::Model *model = dynamic_cast<Imap::Mailbox::Model *>(const_cast<QAbstractItemModel *>(message.model()));
466 model->setMessageFlags(QModelIndexList() << message, tag, Imap::Mailbox::FLAG_REMOVE);
469 void MessageView::setHomepageUrl(const QUrl &homepage)
471 m_homePage->load(homepage);
474 void MessageView::showEvent(QShowEvent *se)
476 QWidget::showEvent(se);
477 // The Oxygen style reset the attribute - since we're gonna cause an update() here anyway, it's
478 // a good moment to stress that "we know better, Hugo ;-)" -- Thomas
479 setAutoFillBackground(true);
482 void MessageView::partContextMenuRequested(const QPoint &point)
484 if (SimplePartWidget *w = qobject_cast<SimplePartWidget *>(sender())) {
485 QMenu menu(w);
486 w->buildContextMenu(point, menu);
487 menu.exec(w->mapToGlobal(point));
491 void MessageView::partLinkHovered(const QString &link, const QString &title, const QString &textContent)
493 Q_UNUSED(title);
494 Q_UNUSED(textContent);
495 emit linkHovered(link);
498 QSettings* MessageView::profileSettings() const
500 return m_settings;
503 QModelIndex MessageView::currentMessage() const
505 return message;
508 void MessageView::onWebViewLoadStarted()
510 QWebView *wv = qobject_cast<QWebView*>(sender());
511 Q_ASSERT(wv);
513 if (m_netWatcher && m_netWatcher->effectiveNetworkPolicy() != Imap::Mailbox::NETWORK_OFFLINE) {
514 m_loadingItems << wv;
515 m_loadingSpinner->start(250);
519 void MessageView::onWebViewLoadFinished()
521 QWebView *wv = qobject_cast<QWebView*>(sender());
522 Q_ASSERT(wv);
523 m_loadingItems.remove(wv);
524 if (m_loadingItems.isEmpty())
525 m_loadingSpinner->stop();
528 Plugins::PluginManager *MessageView::pluginManager() const
530 return m_pluginManager;
533 /** @short Callback for AbstractPartWidget */
534 void MessageView::triggerSearchDialogBy(EmbeddedWebView *w)
536 emit searchRequestedBy(w);