clazy: fix QString(QLatin1String(...))
[trojita.git] / src / Gui / ComposeWidget.cpp
blob4c21e2e62794634ba9dae5dfff6cfc59a938f5dc
1 /* Copyright (C) 2006 - 2014 Jan Kundrát <jkt@flaska.net>
2 Copyright (C) 2012 Peter Amidon <peter@picnicpark.org>
3 Copyright (C) 2013 - 2014 Pali Rohár <pali.rohar@gmail.com>
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/>.
24 #include <QAbstractProxyModel>
25 #include <QBuffer>
26 #include <QDesktopWidget>
27 #include <QFileDialog>
28 #include <QGraphicsOpacityEffect>
29 #include <QKeyEvent>
30 #include <QMenu>
31 #include <QMessageBox>
32 #include <QProgressDialog>
33 #include <QPropertyAnimation>
34 #include <QPushButton>
35 #include <QSettings>
36 #include <QTimer>
37 #include <QToolButton>
38 #include <QUrlQuery>
40 #include "ui_ComposeWidget.h"
41 #include "Composer/MessageComposer.h"
42 #include "Composer/ReplaceSignature.h"
43 #include "Composer/Mailto.h"
44 #include "Composer/SenderIdentitiesModel.h"
45 #include "Composer/Submission.h"
46 #include "Common/InvokeMethod.h"
47 #include "Common/Paths.h"
48 #include "Common/SettingsNames.h"
49 #include "Gui/ComposeWidget.h"
50 #include "Gui/FromAddressProxyModel.h"
51 #include "Gui/LineEdit.h"
52 #include "Gui/OverlayWidget.h"
53 #include "Gui/PasswordDialog.h"
54 #include "Gui/ProgressPopUp.h"
55 #include "Gui/Util.h"
56 #include "Gui/Window.h"
57 #include "Imap/Model/ImapAccess.h"
58 #include "Imap/Model/ItemRoles.h"
59 #include "Imap/Model/Model.h"
60 #include "Imap/Parser/MailAddress.h"
61 #include "Imap/Tasks/AppendTask.h"
62 #include "Imap/Tasks/GenUrlAuthTask.h"
63 #include "Imap/Tasks/UidSubmitTask.h"
64 #include "Plugins/AddressbookPlugin.h"
65 #include "Plugins/PluginManager.h"
66 #include "ShortcutHandler/ShortcutHandler.h"
67 #include "UiUtils/Color.h"
68 #include "UiUtils/IconLoader.h"
70 namespace
72 enum { OFFSET_OF_FIRST_ADDRESSEE = 1, MIN_MAX_VISIBLE_RECIPIENTS = 4 };
75 namespace Gui
78 static const QString trojita_opacityAnimation = QStringLiteral("trojita_opacityAnimation");
80 /** @short Ignore dirtying events while we're preparing the widget's contents
82 Under the normal course of operation, there's plenty of events (user typing some text, etc) which lead to the composer widget
83 "remembering" that the human being has made some changes, and that these changes are probably worth a prompt for saving them
84 upon a close.
86 This guard object makes sure (via RAII) that these dirtifying events are ignored during its lifetime.
88 class InhibitComposerDirtying
90 public:
91 explicit InhibitComposerDirtying(ComposeWidget *w): w(w), wasEverEdited(w->m_messageEverEdited), wasEverUpdated(w->m_messageUpdated) {}
92 ~InhibitComposerDirtying()
94 w->m_messageEverEdited = wasEverEdited;
95 w->m_messageUpdated = wasEverUpdated;
97 private:
98 ComposeWidget *w;
99 bool wasEverEdited, wasEverUpdated;
102 ComposeWidget::ComposeWidget(MainWindow *mainWindow, MSA::MSAFactory *msaFactory) :
103 QWidget(0, Qt::Window),
104 ui(new Ui::ComposeWidget),
105 m_maxVisibleRecipients(MIN_MAX_VISIBLE_RECIPIENTS),
106 m_sentMail(false),
107 m_messageUpdated(false),
108 m_messageEverEdited(false),
109 m_explicitDraft(false),
110 m_appendUidReceived(false), m_appendUidValidity(0), m_appendUid(0), m_genUrlAuthReceived(false),
111 m_mainWindow(mainWindow),
112 m_settings(mainWindow->settings()),
113 m_submission(nullptr),
114 m_completionPopup(nullptr),
115 m_completionReceiver(nullptr)
117 setAttribute(Qt::WA_DeleteOnClose, true);
119 QIcon winIcon;
120 winIcon.addFile(QStringLiteral(":/icons/trojita-edit-big.png"), QSize(128, 128));
121 winIcon.addFile(QStringLiteral(":/icons/trojita-edit-small.png"), QSize(22, 22));
122 setWindowIcon(winIcon);
124 Q_ASSERT(m_mainWindow);
125 m_submission = new Composer::Submission(this, m_mainWindow->imapModel(), msaFactory);
126 connect(m_submission, &Composer::Submission::succeeded, this, &ComposeWidget::sent);
127 connect(m_submission, &Composer::Submission::failed, this, &ComposeWidget::gotError);
128 connect(m_submission, &Composer::Submission::passwordRequested, this, &ComposeWidget::passwordRequested, Qt::QueuedConnection);
129 m_submission->composer()->setReportTrojitaVersions(m_settings->value(Common::SettingsNames::interopRevealVersions, true).toBool());
131 ui->setupUi(this);
132 ui->attachmentsView->setComposer(m_submission->composer());
133 sendButton = ui->buttonBox->addButton(tr("Send"), QDialogButtonBox::AcceptRole);
134 sendButton->setIcon(UiUtils::loadIcon(QStringLiteral("mail-send")));
135 connect(sendButton, &QAbstractButton::clicked, this, &ComposeWidget::send);
136 cancelButton = ui->buttonBox->addButton(QDialogButtonBox::Cancel);
137 cancelButton->setIcon(UiUtils::loadIcon(QStringLiteral("dialog-cancel")));
138 connect(cancelButton, &QAbstractButton::clicked, this, &QWidget::close);
139 connect(ui->attachButton, &QAbstractButton::clicked, this, &ComposeWidget::slotAskForFileAttachment);
141 m_completionPopup = new QMenu(this);
142 m_completionPopup->installEventFilter(this);
143 connect(m_completionPopup, &QMenu::triggered, this, &ComposeWidget::completeRecipient);
145 // TODO: make this configurable?
146 m_completionCount = 8;
148 m_recipientListUpdateTimer = new QTimer(this);
149 m_recipientListUpdateTimer->setSingleShot(true);
150 m_recipientListUpdateTimer->setInterval(250);
151 connect(m_recipientListUpdateTimer, &QTimer::timeout, this, &ComposeWidget::updateRecipientList);
153 connect(ui->verticalSplitter, &QSplitter::splitterMoved, this, &ComposeWidget::calculateMaxVisibleRecipients);
154 calculateMaxVisibleRecipients();
156 connect(ui->recipientSlider, &QAbstractSlider::valueChanged, this, &ComposeWidget::scrollRecipients);
157 connect(qApp, &QApplication::focusChanged, this, &ComposeWidget::handleFocusChange);
158 ui->recipientSlider->setMinimum(0);
159 ui->recipientSlider->setMaximum(0);
160 ui->recipientSlider->setVisible(false);
161 ui->envelopeWidget->installEventFilter(this);
163 m_markButton = new QToolButton(ui->buttonBox);
164 m_markButton->setPopupMode(QToolButton::MenuButtonPopup);
165 m_markButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
166 m_markAsReply = new QActionGroup(m_markButton);
167 m_markAsReply->setExclusive(true);
168 auto *asReplyMenu = new QMenu(m_markButton);
169 m_markButton->setMenu(asReplyMenu);
170 m_actionStandalone = asReplyMenu->addAction(UiUtils::loadIcon(QStringLiteral("mail-view-flat")), tr("New Thread"));
171 m_actionStandalone->setActionGroup(m_markAsReply);
172 m_actionStandalone->setCheckable(true);
173 m_actionStandalone->setToolTip(tr("This mail will be sent as a standalone message.<hr/>Change to preserve the reply hierarchy."));
174 m_actionInReplyTo = asReplyMenu->addAction(UiUtils::loadIcon(QStringLiteral("mail-view-threaded")), tr("Threaded"));
175 m_actionInReplyTo->setActionGroup(m_markAsReply);
176 m_actionInReplyTo->setCheckable(true);
178 // This is a "quick shortcut action". It shows the UI bits of the current option, but when the user clicks it,
179 // the *other* action is triggered.
180 m_actionToggleMarking = new QAction(m_markButton);
181 connect(m_actionToggleMarking, &QAction::triggered, this, &ComposeWidget::toggleReplyMarking);
182 m_markButton->setDefaultAction(m_actionToggleMarking);
184 // Unfortunately, there's no signal for toggled(QAction*), so we'll have to call QAction::trigger() to have this working
185 connect(m_markAsReply, &QActionGroup::triggered, this, &ComposeWidget::updateReplyMarkingAction);
186 m_actionStandalone->trigger();
188 m_replyModeButton = new QToolButton(ui->buttonBox);
189 m_replyModeButton->setPopupMode(QToolButton::InstantPopup);
190 m_replyModeButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
192 QMenu *replyModeMenu = new QMenu(m_replyModeButton);
193 m_replyModeButton->setMenu(replyModeMenu);
195 m_replyModeActions = new QActionGroup(m_replyModeButton);
196 m_replyModeActions->setExclusive(true);
198 m_actionHandPickedRecipients = new QAction(UiUtils::loadIcon(QStringLiteral("document-edit")) ,QStringLiteral("Hand Picked Recipients"), this);
199 replyModeMenu->addAction(m_actionHandPickedRecipients);
200 m_actionHandPickedRecipients->setActionGroup(m_replyModeActions);
201 m_actionHandPickedRecipients->setCheckable(true);
203 replyModeMenu->addSeparator();
205 QAction *placeHolderAction = ShortcutHandler::instance()->action(QStringLiteral("action_reply_private"));
206 m_actionReplyModePrivate = replyModeMenu->addAction(placeHolderAction->icon(), placeHolderAction->text());
207 m_actionReplyModePrivate->setActionGroup(m_replyModeActions);
208 m_actionReplyModePrivate->setCheckable(true);
210 placeHolderAction = ShortcutHandler::instance()->action(QStringLiteral("action_reply_all_but_me"));
211 m_actionReplyModeAllButMe = replyModeMenu->addAction(placeHolderAction->icon(), placeHolderAction->text());
212 m_actionReplyModeAllButMe->setActionGroup(m_replyModeActions);
213 m_actionReplyModeAllButMe->setCheckable(true);
215 placeHolderAction = ShortcutHandler::instance()->action(QStringLiteral("action_reply_all"));
216 m_actionReplyModeAll = replyModeMenu->addAction(placeHolderAction->icon(), placeHolderAction->text());
217 m_actionReplyModeAll->setActionGroup(m_replyModeActions);
218 m_actionReplyModeAll->setCheckable(true);
220 placeHolderAction = ShortcutHandler::instance()->action(QStringLiteral("action_reply_list"));
221 m_actionReplyModeList = replyModeMenu->addAction(placeHolderAction->icon(), placeHolderAction->text());
222 m_actionReplyModeList->setActionGroup(m_replyModeActions);
223 m_actionReplyModeList->setCheckable(true);
225 connect(m_replyModeActions, &QActionGroup::triggered, this, &ComposeWidget::updateReplyMode);
227 // We want to have the button aligned to the left; the only "portable" way of this is the ResetRole
228 // (thanks to TL for mentioning this, and for the Qt's doc for providing pretty pictures on different platforms)
229 ui->buttonBox->addButton(m_markButton, QDialogButtonBox::ResetRole);
230 // Using ResetRole for reasons same as with m_markButton. We want this button to be second from the left.
231 ui->buttonBox->addButton(m_replyModeButton, QDialogButtonBox::ResetRole);
233 m_markButton->hide();
234 m_replyModeButton->hide();
236 connect(ui->mailText, &ComposerTextEdit::urlsAdded, this, &ComposeWidget::slotAttachFiles);
237 connect(ui->mailText, &ComposerTextEdit::sendRequest, this, &ComposeWidget::send);
238 connect(ui->mailText, &QTextEdit::textChanged, this, &ComposeWidget::setMessageUpdated);
239 connect(ui->subject, &QLineEdit::textChanged, this, &ComposeWidget::updateWindowTitle);
240 connect(ui->subject, &QLineEdit::textChanged, this, &ComposeWidget::setMessageUpdated);
241 updateWindowTitle();
243 FromAddressProxyModel *proxy = new FromAddressProxyModel(this);
244 proxy->setSourceModel(m_mainWindow->senderIdentitiesModel());
245 ui->sender->setModel(proxy);
247 connect(ui->sender, static_cast<void (QComboBox::*)(const int)>(&QComboBox::currentIndexChanged), this, &ComposeWidget::slotUpdateSignature);
248 connect(ui->sender, &QComboBox::editTextChanged, this, &ComposeWidget::setMessageUpdated);
249 connect(ui->sender->lineEdit(), &QLineEdit::textChanged, this, &ComposeWidget::slotCheckAddressOfSender);
251 QTimer *autoSaveTimer = new QTimer(this);
252 connect(autoSaveTimer, &QTimer::timeout, this, &ComposeWidget::autoSaveDraft);
253 autoSaveTimer->start(30*1000);
255 // these are for the automatically saved drafts, i.e. no i18n for the dir name
256 m_autoSavePath = QString(Common::writablePath(Common::LOCATION_CACHE) + QLatin1String("Drafts/"));
257 QDir().mkpath(m_autoSavePath);
259 m_autoSavePath += QString::number(QDateTime::currentMSecsSinceEpoch()) + QLatin1String(".draft");
261 // Add a blank recipient row to start with
262 addRecipient(m_recipients.count(), Composer::ADDRESS_TO, QString());
263 ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE, QFormLayout::FieldRole)->widget()->setFocus();
265 slotUpdateSignature();
267 // default size
268 int sz = ui->mailText->idealWidth();
269 ui->mailText->setMinimumSize(sz, 1000*sz/1618); // golden mean editor
270 adjustSize();
271 ui->mailText->setMinimumSize(0, 0);
272 resize(size().boundedTo(qApp->desktop()->availableGeometry().size()));
275 ComposeWidget::~ComposeWidget()
277 delete ui;
280 /** @short Throw a warning at an attempt to create a Compose Widget while the MSA is not configured */
281 ComposeWidget *ComposeWidget::warnIfMsaNotConfigured(ComposeWidget *widget, MainWindow *mainWindow)
283 if (!widget)
284 QMessageBox::critical(mainWindow, tr("Error"), tr("Please set appropriate settings for outgoing messages."));
285 return widget;
288 /** @short Find a nice position near the mid of the main window, try to not fully occlude another sibling */
289 void ComposeWidget::placeOnMainWindow()
291 QRect area = m_mainWindow->geometry();
292 QRect origin(0, 0, width(), height());
293 origin.moveTo(area.x() + (area.width() - width()) / 2,
294 area.y() + (area.height() - height()) / 2);
295 QRect target = origin;
297 QWidgetList siblings;
298 foreach(const QWidget *w, QApplication::topLevelWidgets()) {
299 if (w == this)
300 continue; // I'm not a sibling of myself
301 if (!qobject_cast<const ComposeWidget*>(w))
302 continue; // random other stuff
303 siblings << const_cast<QWidget*>(w);
305 int dx = 20, dy = 20;
306 int i = 0;
307 // look for a position where the window would not fully cover another composer
308 // (we don't want to mass open 10 composers stashing each other)
309 // if such composer blocks our desired geometry, the new desired geometry is
310 // tested at positions shifted by 20px circling around the original one.
311 // if we're already more than 100px off the center (what implies the user
312 // has > 20 composers open ...) we give up to not shift the window
313 // too far away, maybe even off-screen.
314 // Notice that it may still happen that some composers *together* stash a 3rd one
315 while (i < siblings.count()) {
316 if (target.contains(siblings.at(i)->geometry())) {
317 target = origin.translated(dx, dy);
318 if (dx < 0 && dy < 0) {
319 dx = dy = -dx + 20;
320 if (dx >= 120) // give up
321 break;
322 } else if (dx < 0 || dy < 0) {
323 dx = -dx;
324 if (dy > 0)
325 dy = -dy;
326 } else {
327 dx = -dx;
329 i = 0;
330 } else {
331 ++i;
334 setGeometry(target);
337 /** @short Create a blank composer window */
338 ComposeWidget *ComposeWidget::createBlank(MainWindow *mainWindow)
340 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
341 if (!msaFactory)
342 return 0;
344 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
345 w->placeOnMainWindow();
346 w->show();
347 return w;
350 /** @short Load a draft in composer window */
351 ComposeWidget *ComposeWidget::createDraft(MainWindow *mainWindow, const QString &path)
353 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
354 if (!msaFactory)
355 return 0;
357 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
358 w->loadDraft(path);
359 w->placeOnMainWindow();
360 w->show();
361 return w;
364 /** @short Create a composer window with data from a URL */
365 ComposeWidget *ComposeWidget::createFromUrl(MainWindow *mainWindow, const QUrl &url)
367 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
368 if (!msaFactory)
369 return 0;
371 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
372 InhibitComposerDirtying inhibitor(w);
373 QString subject;
374 QString body;
375 QList<QPair<Composer::RecipientKind,QString> > recipients;
376 QList<QByteArray> inReplyTo;
377 QList<QByteArray> references;
378 const QUrlQuery q(url);
380 if (!q.queryItemValue(QStringLiteral("X-Trojita-DisplayName")).isEmpty()) {
381 // There should be only single email address created by Imap::Message::MailAddress::asUrl()
382 Imap::Message::MailAddress addr;
383 if (Imap::Message::MailAddress::fromUrl(addr, url, QStringLiteral("mailto")))
384 recipients << qMakePair(Composer::ADDRESS_TO, addr.asPrettyString());
385 } else {
386 // This should be real RFC 6068 mailto:
387 Composer::parseRFC6068Mailto(url, subject, body, recipients, inReplyTo, references);
390 // NOTE: we need inReplyTo and references parameters without angle brackets, so remove them
391 for (int i = 0; i < inReplyTo.size(); ++i) {
392 if (inReplyTo[i].startsWith('<') && inReplyTo[i].endsWith('>')) {
393 inReplyTo[i] = inReplyTo[i].mid(1, inReplyTo[i].size()-2);
396 for (int i = 0; i < references.size(); ++i) {
397 if (references[i].startsWith('<') && references[i].endsWith('>')) {
398 references[i] = references[i].mid(1, references[i].size()-2);
402 w->setResponseData(recipients, subject, body, inReplyTo, references, QModelIndex());
403 if (!inReplyTo.isEmpty() || !references.isEmpty()) {
404 // We don't need to expose any UI here, but we want the in-reply-to and references information to be carried with this message
405 w->m_actionInReplyTo->setChecked(true);
407 w->placeOnMainWindow();
408 w->show();
409 return w;
412 /** @short Create a composer window for a reply */
413 ComposeWidget *ComposeWidget::createReply(MainWindow *mainWindow, const Composer::ReplyMode &mode, const QModelIndex &replyingToMessage,
414 const QList<QPair<Composer::RecipientKind, QString> > &recipients, const QString &subject,
415 const QString &body, const QList<QByteArray> &inReplyTo, const QList<QByteArray> &references)
417 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
418 if (!msaFactory)
419 return 0;
421 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
422 InhibitComposerDirtying inhibitor(w);
423 w->setResponseData(recipients, subject, body, inReplyTo, references, replyingToMessage);
424 bool ok = w->setReplyMode(mode);
425 if (!ok) {
426 QString err;
427 switch (mode) {
428 case Composer::REPLY_ALL:
429 case Composer::REPLY_ALL_BUT_ME:
430 // do nothing
431 break;
432 case Composer::REPLY_LIST:
433 err = tr("It doesn't look like this is a message to the mailing list. Please fill in the recipients manually.");
434 break;
435 case Composer::REPLY_PRIVATE:
436 err = trUtf8("Trojitá was unable to safely determine the real e-mail address of the author of the message. "
437 "You might want to use the \"Reply All\" function and trim the list of addresses manually.");
438 break;
440 if (!err.isEmpty())
441 QMessageBox::warning(w, tr("Cannot Determine Recipients"), err);
443 w->placeOnMainWindow();
444 w->show();
445 return w;
448 /** @short Create a composer window for a mail-forward action */
449 ComposeWidget *ComposeWidget::createForward(MainWindow *mainWindow, const Composer::ForwardMode mode, const QModelIndex &forwardingMessage,
450 const QString &subject, const QList<QByteArray> &inReplyTo, const QList<QByteArray> &references)
452 MSA::MSAFactory *msaFactory = mainWindow->msaFactory();
453 if (!msaFactory)
454 return 0;
456 ComposeWidget *w = new ComposeWidget(mainWindow, msaFactory);
457 InhibitComposerDirtying inhibitor(w);
458 w->setResponseData(QList<QPair<Composer::RecipientKind, QString>>(), subject, QString(), inReplyTo, references, QModelIndex());
459 // We don't need to expose any UI here, but we want the in-reply-to and references information to be carried with this message
460 w->m_actionInReplyTo->setChecked(true);
462 // Prepare the message to be forwarded and add it to the attachments view
463 w->m_submission->composer()->prepareForwarding(forwardingMessage, mode);
465 w->placeOnMainWindow();
466 w->show();
467 return w;
470 void ComposeWidget::updateReplyMode()
472 bool replyModeSet = false;
473 if (m_actionReplyModePrivate->isChecked()) {
474 replyModeSet = setReplyMode(Composer::REPLY_PRIVATE);
475 } else if (m_actionReplyModeAllButMe->isChecked()) {
476 replyModeSet = setReplyMode(Composer::REPLY_ALL_BUT_ME);
477 } else if (m_actionReplyModeAll->isChecked()) {
478 replyModeSet = setReplyMode(Composer::REPLY_ALL);
479 } else if (m_actionReplyModeList->isChecked()) {
480 replyModeSet = setReplyMode(Composer::REPLY_LIST);
483 if (!replyModeSet) {
484 // This is for now by design going in one direction only, from enabled to disabled.
485 // The index to the message cannot become valid again, and simply marking the buttons as disabled does the trick quite neatly.
486 m_replyModeButton->setEnabled(m_actionHandPickedRecipients->isChecked());
487 markReplyModeHandpicked();
491 void ComposeWidget::markReplyModeHandpicked()
493 m_actionHandPickedRecipients->setChecked(true);
494 m_replyModeButton->setText(m_actionHandPickedRecipients->text());
495 m_replyModeButton->setIcon(m_actionHandPickedRecipients->icon());
498 void ComposeWidget::passwordRequested(const QString &user, const QString &host)
500 if (m_settings->value(Common::SettingsNames::smtpAuthReuseImapCredsKey, false).toBool()) {
501 auto password = qobject_cast<const Imap::Mailbox::Model*>(m_mainWindow->imapAccess()->imapModel())->imapPassword();
502 if (password.isNull()) {
503 // This can happen for example when we've always been offline since the last profile change,
504 // and the IMAP password is therefore not already cached in the IMAP model.
506 // FIXME: it would be nice to "just" call out to MainWindow::authenticationRequested() in that case,
507 // but there's no async callback when the password is available. Just some food for thought when
508 // that part gets refactored :), eventually...
509 askPassword(user, host);
510 } else {
511 m_submission->setPassword(password);
513 return;
516 Plugins::PasswordPlugin *password = m_mainWindow->pluginManager()->password();
517 if (!password) {
518 askPassword(user, host);
519 return;
522 Plugins::PasswordJob *job = password->requestPassword(QStringLiteral("account-0"), QStringLiteral("smtp"));
523 if (!job) {
524 askPassword(user, host);
525 return;
528 connect(job, &Plugins::PasswordJob::passwordAvailable, m_submission, &Composer::Submission::setPassword);
529 connect(job, &Plugins::PasswordJob::error, this, &ComposeWidget::passwordError);
531 job->setAutoDelete(true);
532 job->setProperty("user", user);
533 job->setProperty("host", host);
534 job->start();
537 void ComposeWidget::passwordError()
539 Plugins::PasswordJob *job = static_cast<Plugins::PasswordJob *>(sender());
540 const QString &user = job->property("user").toString();
541 const QString &host = job->property("host").toString();
542 askPassword(user, host);
545 void ComposeWidget::askPassword(const QString &user, const QString &host)
547 bool ok;
548 const QString &password = Gui::PasswordDialog::getPassword(this, tr("Authentication Required"),
549 tr("<p>Please provide SMTP password for user <b>%1</b> on <b>%2</b>:</p>").arg(
550 user.toHtmlEscaped(),
551 host.toHtmlEscaped()
553 QString(), &ok);
554 if (ok)
555 m_submission->setPassword(password);
556 else
557 m_submission->cancelPassword();
560 void ComposeWidget::changeEvent(QEvent *e)
562 QWidget::changeEvent(e);
563 switch (e->type()) {
564 case QEvent::LanguageChange:
565 ui->retranslateUi(this);
566 break;
567 default:
568 break;
573 * We capture the close event and check whether there's something to save
574 * (not sent, not up-to-date or persistent autostore)
575 * The offer the user to store or omit the message or not close at all
578 void ComposeWidget::closeEvent(QCloseEvent *ce)
580 const bool noSaveRequired = m_sentMail || !m_messageEverEdited ||
581 (m_explicitDraft && !m_messageUpdated); // autosave to permanent draft and no update
582 if (!noSaveRequired) { // save is required
583 QMessageBox msgBox(this);
584 msgBox.setWindowModality(Qt::WindowModal);
585 msgBox.setWindowTitle(tr("Save Draft?"));
586 QString message(tr("The mail has not been sent.<br>Do you want to save the draft?"));
587 if (ui->attachmentsView->model()->rowCount() > 0)
588 message += tr("<br><span style=\"color:red\">Warning: Attachments are <b>not</b> saved with the draft!</span>");
589 msgBox.setText(message);
590 msgBox.setStandardButtons(QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel);
591 msgBox.setDefaultButton(QMessageBox::Save);
592 int ret = msgBox.exec();
593 if (ret == QMessageBox::Save) {
594 if (m_explicitDraft) { // editing a present draft - override it
595 saveDraft(m_autoSavePath);
596 } else {
597 // Explicitly stored drafts should be saved in a location with proper i18n support, so let's make sure both main
598 // window and this code uses the same tr() calls
599 QString path(Common::writablePath(Common::LOCATION_DATA) + Gui::MainWindow::tr("Drafts"));
600 QDir().mkpath(path);
601 QString filename = ui->subject->text();
602 if (filename.isEmpty()) {
603 filename = QDateTime::currentDateTime().toString(Qt::ISODate);
605 // Some characters are best avoided in file names. This is probably not a definitive list, but the hope is that
606 // it's going to be more readable than an unformatted hash or similar stuff. The list of characters was taken
607 // from http://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words .
608 filename.replace(QRegExp(QLatin1String("[/\\\\:\"|<>*?]")), QStringLiteral("_"));
609 path = QFileDialog::getSaveFileName(this, tr("Save as"), path + QLatin1Char('/') + filename + QLatin1String(".draft"),
610 tr("Drafts") + QLatin1String(" (*.draft)"));
611 if (path.isEmpty()) { // cancelled save
612 ret = QMessageBox::Cancel;
613 } else {
614 m_explicitDraft = true;
615 saveDraft(path);
616 if (path != m_autoSavePath) // we can remove the temp save
617 QFile::remove(m_autoSavePath);
621 if (ret == QMessageBox::Cancel) {
622 ce->ignore(); // don't close the window
623 return;
626 if (m_sentMail || !m_explicitDraft) // is the mail has been sent or the user does not want to store it
627 QFile::remove(m_autoSavePath); // get rid of draft
628 ce->accept(); // ultimately close the window
633 bool ComposeWidget::buildMessageData()
635 QList<QPair<Composer::RecipientKind,Imap::Message::MailAddress> > recipients;
636 QString errorMessage;
637 if (!parseRecipients(recipients, errorMessage)) {
638 gotError(tr("Cannot parse recipients:\n%1").arg(errorMessage));
639 return false;
641 if (recipients.isEmpty()) {
642 gotError(tr("You haven't entered any recipients"));
643 return false;
645 m_submission->composer()->setRecipients(recipients);
647 Imap::Message::MailAddress fromAddress;
648 if (!Imap::Message::MailAddress::fromPrettyString(fromAddress, ui->sender->currentText())) {
649 gotError(tr("The From: address does not look like a valid one"));
650 return false;
652 if (ui->subject->text().isEmpty()) {
653 gotError(tr("You haven't entered any subject. Cannot send such a mail, sorry."));
654 ui->subject->setFocus();
655 return false;
657 m_submission->composer()->setFrom(fromAddress);
659 m_submission->composer()->setTimestamp(QDateTime::currentDateTime());
660 m_submission->composer()->setSubject(ui->subject->text());
662 QAbstractProxyModel *proxy = qobject_cast<QAbstractProxyModel*>(ui->sender->model());
663 Q_ASSERT(proxy);
665 if (ui->sender->findText(ui->sender->currentText()) != -1) {
666 QModelIndex proxyIndex = ui->sender->model()->index(ui->sender->currentIndex(), 0, ui->sender->rootModelIndex());
667 Q_ASSERT(proxyIndex.isValid());
668 m_submission->composer()->setOrganization(
669 proxy->mapToSource(proxyIndex).sibling(proxyIndex.row(), Composer::SenderIdentitiesModel::COLUMN_ORGANIZATION)
670 .data().toString());
672 m_submission->composer()->setText(ui->mailText->toPlainText());
674 if (m_actionInReplyTo->isChecked()) {
675 m_submission->composer()->setInReplyTo(m_inReplyTo);
676 m_submission->composer()->setReferences(m_references);
677 m_submission->composer()->setReplyingToMessage(m_replyingToMessage);
678 } else {
679 m_submission->composer()->setInReplyTo(QList<QByteArray>());
680 m_submission->composer()->setReferences(QList<QByteArray>());
681 m_submission->composer()->setReplyingToMessage(QModelIndex());
684 return m_submission->composer()->isReadyForSerialization();
687 void ComposeWidget::send()
689 // Well, Trojita is of course rock solid and will never ever crash :), but experience has shown that every now and then,
690 // there is a subtle issue $somewhere. This means that it's probably a good idea to save the draft explicitly -- better
691 // than losing some work. It's cheap anyway.
692 saveDraft(m_autoSavePath);
694 if (!buildMessageData())
695 return;
697 const bool reuseImapCreds = m_settings->value(Common::SettingsNames::smtpAuthReuseImapCredsKey, false).toBool();
698 m_submission->setImapOptions(m_settings->value(Common::SettingsNames::composerSaveToImapKey, true).toBool(),
699 m_settings->value(Common::SettingsNames::composerImapSentKey, tr("Sent")).toString(),
700 m_settings->value(Common::SettingsNames::imapHostKey).toString(),
701 m_settings->value(Common::SettingsNames::imapUserKey).toString(),
702 m_settings->value(Common::SettingsNames::msaMethodKey).toString() == Common::SettingsNames::methodImapSendmail);
703 m_submission->setSmtpOptions(m_settings->value(Common::SettingsNames::smtpUseBurlKey, false).toBool(),
704 reuseImapCreds ?
705 m_mainWindow->imapAccess()->username() :
706 m_settings->value(Common::SettingsNames::smtpUserKey).toString());
708 ProgressPopUp *progress = new ProgressPopUp();
709 OverlayWidget *overlay = new OverlayWidget(progress, this);
710 overlay->show();
711 setUiWidgetsEnabled(false);
713 connect(m_submission, &Composer::Submission::progressMin, progress, &ProgressPopUp::setMinimum);
714 connect(m_submission, &Composer::Submission::progressMax, progress, &ProgressPopUp::setMaximum);
715 connect(m_submission, &Composer::Submission::progress, progress, &ProgressPopUp::setValue);
716 connect(m_submission, &Composer::Submission::updateStatusMessage, progress, &ProgressPopUp::setLabelText);
717 connect(m_submission, &Composer::Submission::succeeded, overlay, &QObject::deleteLater);
718 connect(m_submission, &Composer::Submission::failed, overlay, &QObject::deleteLater);
720 m_submission->send();
723 void ComposeWidget::setUiWidgetsEnabled(const bool enabled)
725 ui->verticalSplitter->setEnabled(enabled);
726 ui->buttonBox->setEnabled(enabled);
729 /** @short Set private data members to get pre-filled by available parameters
731 The semantics of the @arg inReplyTo and @arg references are the same as described for the Composer::MessageComposer,
732 i.e. the data are not supposed to contain the angle bracket. If the @arg replyingToMessage is present, it will be used
733 as an index to a message which will get marked as replied to. This is needed because IMAP doesn't really support site-wide
734 search by a Message-Id (and it cannot possibly support it in general, either), and because Trojita's lazy loading and lack
735 of cross-session persistent indexes means that "mark as replied" and "extract message-id from" are effectively two separate
736 operations.
738 void ComposeWidget::setResponseData(const QList<QPair<Composer::RecipientKind, QString> > &recipients,
739 const QString &subject, const QString &body, const QList<QByteArray> &inReplyTo,
740 const QList<QByteArray> &references, const QModelIndex &replyingToMessage)
742 InhibitComposerDirtying inhibitor(this);
743 for (int i = 0; i < recipients.size(); ++i) {
744 addRecipient(i, recipients.at(i).first, recipients.at(i).second);
746 updateRecipientList();
747 ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE, QFormLayout::FieldRole)->widget()->setFocus();
748 ui->subject->setText(subject);
749 ui->mailText->setText(body);
750 m_inReplyTo = inReplyTo;
752 // Trim the References header as per RFC 5537
753 QList<QByteArray> trimmedReferences = references;
754 int referencesSize = QByteArray("References: ").size();
755 const int lineOverhead = 3; // one for the " " prefix, two for the \r\n suffix
756 Q_FOREACH(const QByteArray &item, references)
757 referencesSize += item.size() + lineOverhead;
758 // The magic numbers are from RFC 5537
759 while (referencesSize >= 998 && trimmedReferences.size() > 3) {
760 referencesSize -= trimmedReferences.takeAt(1).size() + lineOverhead;
762 m_references = trimmedReferences;
763 m_replyingToMessage = replyingToMessage;
764 if (m_replyingToMessage.isValid()) {
765 m_markButton->show();
766 m_replyModeButton->show();
767 // Got to use trigger() so that the default action of the QToolButton is updated
768 m_actionInReplyTo->setToolTip(tr("This mail will be marked as a response<hr/>%1").arg(
769 m_replyingToMessage.data(Imap::Mailbox::RoleMessageSubject).toString().toHtmlEscaped()
771 m_actionInReplyTo->trigger();
773 // Enable only those Reply Modes that are applicable to the message to be replied
774 Composer::RecipientList dummy;
775 m_actionReplyModePrivate->setEnabled(Composer::Util::replyRecipientList(Composer::REPLY_PRIVATE,
776 m_mainWindow->senderIdentitiesModel(),
777 m_replyingToMessage, dummy));
778 m_actionReplyModeAllButMe->setEnabled(Composer::Util::replyRecipientList(Composer::REPLY_ALL_BUT_ME,
779 m_mainWindow->senderIdentitiesModel(),
780 m_replyingToMessage, dummy));
781 m_actionReplyModeAll->setEnabled(Composer::Util::replyRecipientList(Composer::REPLY_ALL,
782 m_mainWindow->senderIdentitiesModel(),
783 m_replyingToMessage, dummy));
784 m_actionReplyModeList->setEnabled(Composer::Util::replyRecipientList(Composer::REPLY_LIST,
785 m_mainWindow->senderIdentitiesModel(),
786 m_replyingToMessage, dummy));
787 } else {
788 m_markButton->hide();
789 m_replyModeButton->hide();
790 m_actionInReplyTo->setToolTip(QString());
791 m_actionStandalone->trigger();
794 int row = -1;
795 bool ok = Composer::Util::chooseSenderIdentityForReply(m_mainWindow->senderIdentitiesModel(), replyingToMessage, row);
796 if (ok) {
797 Q_ASSERT(row >= 0 && row < m_mainWindow->senderIdentitiesModel()->rowCount());
798 ui->sender->setCurrentIndex(row);
801 slotUpdateSignature();
804 /** @short Find out what type of recipient to use for the last row */
805 Composer::RecipientKind ComposeWidget::recipientKindForNextRow(const Composer::RecipientKind kind)
807 using namespace Imap::Mailbox;
808 switch (kind) {
809 case Composer::ADDRESS_TO:
810 // Heuristic: if the last one is "to", chances are that the next one shall not be "to" as well.
811 // Cc is reasonable here.
812 return Composer::ADDRESS_CC;
813 case Composer::ADDRESS_CC:
814 case Composer::ADDRESS_BCC:
815 // In any other case, it is probably better to just reuse the type of the last row
816 return kind;
817 case Composer::ADDRESS_FROM:
818 case Composer::ADDRESS_SENDER:
819 case Composer::ADDRESS_REPLY_TO:
820 // shall never be used here
821 Q_ASSERT(false);
822 return kind;
824 Q_ASSERT(false);
825 return Composer::ADDRESS_TO;
828 //BEGIN QFormLayout workarounds
830 /** First issue: QFormLayout messes up rows by never removing them
831 * ----------------------------------------------------------------
832 * As a result insertRow(int pos, .) does not pick the expected row, but usually minor
833 * (if you ever removed all items of a row in this layout)
835 * Solution: we count all rows non empty rows and when we have enough, return the row suitable for
836 * QFormLayout (which is usually behind the requested one)
838 static int actualRow(QFormLayout *form, int row)
840 for (int i = 0, c = 0; i < form->rowCount(); ++i) {
841 if (c == row) {
842 return i;
844 if (form->itemAt(i, QFormLayout::LabelRole) || form->itemAt(i, QFormLayout::FieldRole) ||
845 form->itemAt(i, QFormLayout::SpanningRole))
846 ++c;
848 return form->rowCount(); // append
851 /** Second (related) issue: QFormLayout messes the tab order
852 * ----------------------------------------------------------
853 * "Inserted" rows just get appended to the present ones and by this to the tab focus order
854 * It's therefore necessary to fix this forcing setTabOrder()
856 * Approach: traverse all rows until we have the widget that shall be inserted in tab order and
857 * return it's predecessor
860 static QWidget* formPredecessor(QFormLayout *form, QWidget *w)
862 QWidget *pred = 0;
863 QWidget *runner = 0;
864 QLayoutItem *item = 0;
865 for (int i = 0; i < form->rowCount(); ++i) {
866 if ((item = form->itemAt(i, QFormLayout::LabelRole))) {
867 runner = item->widget();
868 if (runner == w)
869 return pred;
870 else if (runner)
871 pred = runner;
873 if ((item = form->itemAt(i, QFormLayout::FieldRole))) {
874 runner = item->widget();
875 if (runner == w)
876 return pred;
877 else if (runner)
878 pred = runner;
880 if ((item = form->itemAt(i, QFormLayout::SpanningRole))) {
881 runner = item->widget();
882 if (runner == w)
883 return pred;
884 else if (runner)
885 pred = runner;
888 return pred;
891 //END QFormLayout workarounds
893 void ComposeWidget::calculateMaxVisibleRecipients()
895 const int oldMaxVisibleRecipients = m_maxVisibleRecipients;
896 int spacing, bottom;
897 ui->envelopeLayout->getContentsMargins(&spacing, &spacing, &spacing, &bottom);
898 // we abuse the fact that there's always an addressee and that they all look the same
899 QRect itemRects[2];
900 for (int i = 0; i < 2; ++i) {
901 if (QLayoutItem *li = ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE - i, QFormLayout::LabelRole)) {
902 itemRects[i] |= li->geometry();
904 if (QLayoutItem *li = ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE - i, QFormLayout::FieldRole)) {
905 itemRects[i] |= li->geometry();
907 if (QLayoutItem *li = ui->envelopeLayout->itemAt(OFFSET_OF_FIRST_ADDRESSEE - i, QFormLayout::SpanningRole)) {
908 itemRects[i] |= li->geometry();
911 int itemHeight = itemRects[0].height();
912 spacing = qMax(0, itemRects[0].top() - itemRects[1].bottom() - 1); // QFormLayout::[vertical]spacing() is useless ...
913 int firstTop = itemRects[0].top();
914 const int subjectHeight = ui->subject->height();
915 const int height = ui->verticalSplitter->sizes().at(0) - // entire splitter area
916 firstTop - // offset of first recipient
917 (subjectHeight + spacing) - // for the subject
918 bottom - // layout bottom padding
919 2; // extra pixels padding to detect that the user wants to shrink
920 if (itemHeight + spacing == 0) {
921 m_maxVisibleRecipients = MIN_MAX_VISIBLE_RECIPIENTS;
922 } else {
923 m_maxVisibleRecipients = height / (itemHeight + spacing);
925 if (m_maxVisibleRecipients < MIN_MAX_VISIBLE_RECIPIENTS)
926 m_maxVisibleRecipients = MIN_MAX_VISIBLE_RECIPIENTS; // allow up to 4 recipients w/o need for a sliding
927 if (oldMaxVisibleRecipients != m_maxVisibleRecipients) {
928 const int max = qMax(0, m_recipients.count() - m_maxVisibleRecipients);
929 int v = qRound(1.0f*(ui->recipientSlider->value()*m_maxVisibleRecipients)/oldMaxVisibleRecipients);
930 ui->recipientSlider->setMaximum(max);
931 ui->recipientSlider->setVisible(max > 0);
932 scrollRecipients(qMin(qMax(0, v), max));
936 void ComposeWidget::addRecipient(int position, Composer::RecipientKind kind, const QString &address)
938 QComboBox *combo = new QComboBox(this);
939 combo->addItem(tr("To"), Composer::ADDRESS_TO);
940 combo->addItem(tr("Cc"), Composer::ADDRESS_CC);
941 combo->addItem(tr("Bcc"), Composer::ADDRESS_BCC);
942 combo->setCurrentIndex(combo->findData(kind));
943 LineEdit *edit = new LineEdit(address, this);
944 slotCheckAddress(edit);
945 connect(edit, &QLineEdit::textChanged, this, &ComposeWidget::slotCheckAddressOfSender);
946 connect(edit, &QLineEdit::textChanged, this, &ComposeWidget::setMessageUpdated);
947 connect(edit, &QLineEdit::textEdited, this, &ComposeWidget::completeRecipients);
948 connect(edit, &QLineEdit::editingFinished, this, &ComposeWidget::collapseRecipients);
949 connect(edit, &QLineEdit::textChanged, m_recipientListUpdateTimer, static_cast<void (QTimer::*)()>(&QTimer::start));
950 connect(edit, &QLineEdit::textChanged, this, &ComposeWidget::markReplyModeHandpicked);
951 m_recipients.insert(position, Recipient(combo, edit));
952 ui->envelopeWidget->setUpdatesEnabled(false);
953 ui->envelopeLayout->insertRow(actualRow(ui->envelopeLayout, position + OFFSET_OF_FIRST_ADDRESSEE), combo, edit);
954 setTabOrder(formPredecessor(ui->envelopeLayout, combo), combo);
955 setTabOrder(combo, edit);
956 const int max = qMax(0, m_recipients.count() - m_maxVisibleRecipients);
957 ui->recipientSlider->setMaximum(max);
958 ui->recipientSlider->setVisible(max > 0);
959 if (ui->recipientSlider->isVisible()) {
960 const int v = ui->recipientSlider->value();
961 int keepInSight = ++position;
962 for (int i = 0; i < m_recipients.count(); ++i) {
963 if (m_recipients.at(i).first->hasFocus() || m_recipients.at(i).second->hasFocus()) {
964 keepInSight = i;
965 break;
968 if (qAbs(keepInSight - position) < m_maxVisibleRecipients)
969 ui->recipientSlider->setValue(position*max/m_recipients.count());
970 if (v == ui->recipientSlider->value()) // force scroll update
971 scrollRecipients(v);
973 ui->envelopeWidget->setUpdatesEnabled(true);
976 void ComposeWidget::slotCheckAddressOfSender()
978 QLineEdit *edit = qobject_cast<QLineEdit*>(sender());
979 Q_ASSERT(edit);
980 slotCheckAddress(edit);
983 void ComposeWidget::slotCheckAddress(QLineEdit *edit)
985 Imap::Message::MailAddress addr;
986 if (edit->text().isEmpty() || Imap::Message::MailAddress::fromPrettyString(addr, edit->text())) {
987 edit->setPalette(QPalette());
988 } else {
989 QPalette p;
990 p.setColor(QPalette::Base, UiUtils::tintColor(p.color(QPalette::Base), QColor(0xff, 0, 0, 0x20)));
991 edit->setPalette(p);
995 void ComposeWidget::removeRecipient(int pos)
997 // removing the widgets from the layout is important
998 // a) not doing so leaks (minor)
999 // b) deleteLater() crosses the evenchain and so our actualRow function would be tricked
1000 QWidget *formerFocus = QApplication::focusWidget();
1001 if (!formerFocus)
1002 formerFocus = m_lastFocusedRecipient;
1004 if (pos + 1 < m_recipients.count()) {
1005 if (m_recipients.at(pos).first == formerFocus) {
1006 m_recipients.at(pos + 1).first->setFocus();
1007 formerFocus = m_recipients.at(pos + 1).first;
1008 } else if (m_recipients.at(pos).second == formerFocus) {
1009 m_recipients.at(pos + 1).second->setFocus();
1010 formerFocus = m_recipients.at(pos + 1).second;
1012 } else if (m_recipients.at(pos).first == formerFocus || m_recipients.at(pos).second == formerFocus) {
1013 formerFocus = 0;
1016 ui->envelopeLayout->removeWidget(m_recipients.at(pos).first);
1017 ui->envelopeLayout->removeWidget(m_recipients.at(pos).second);
1018 m_recipients.at(pos).first->deleteLater();
1019 m_recipients.at(pos).second->deleteLater();
1020 m_recipients.removeAt(pos);
1021 const int max = qMax(0, m_recipients.count() - m_maxVisibleRecipients);
1022 ui->recipientSlider->setMaximum(max);
1023 ui->recipientSlider->setVisible(max > 0);
1024 if (formerFocus) {
1025 // skip event loop, remove might be triggered by imminent focus loss
1026 CALL_LATER_NOARG(formerFocus, setFocus);
1030 static inline Composer::RecipientKind currentRecipient(const QComboBox *box)
1032 return Composer::RecipientKind(box->itemData(box->currentIndex()).toInt());
1035 void ComposeWidget::updateRecipientList()
1037 // we ensure there's always one empty available
1038 bool haveEmpty = false;
1039 for (int i = 0; i < m_recipients.count(); ++i) {
1040 if (m_recipients.at(i).second->text().isEmpty()) {
1041 if (haveEmpty) {
1042 removeRecipient(i);
1044 haveEmpty = true;
1047 if (!haveEmpty) {
1048 addRecipient(m_recipients.count(),
1049 m_recipients.isEmpty() ?
1050 Composer::ADDRESS_TO :
1051 recipientKindForNextRow(currentRecipient(m_recipients.last().first)),
1052 QString());
1056 void ComposeWidget::handleFocusChange()
1058 // got explicit focus on other widget - don't restore former focused recipient on scrolling
1059 m_lastFocusedRecipient = QApplication::focusWidget();
1061 if (m_lastFocusedRecipient)
1062 QTimer::singleShot(150, this, SLOT(scrollToFocus())); // give user chance to notice the focus change disposition
1065 void ComposeWidget::scrollToFocus()
1067 if (!ui->recipientSlider->isVisible())
1068 return;
1070 QWidget *focus = QApplication::focusWidget();
1071 if (focus == ui->envelopeWidget)
1072 focus = m_lastFocusedRecipient;
1073 if (!focus)
1074 return;
1076 // if this is the first or last visible recipient, show one more (to hint there's more and allow tab progression)
1077 for (int i = 0, pos = 0; i < m_recipients.count(); ++i) {
1078 if (m_recipients.at(i).first->isVisible())
1079 ++pos;
1080 if (focus == m_recipients.at(i).first || focus == m_recipients.at(i).second) {
1081 if (pos > 1 && pos < m_maxVisibleRecipients) // prev & next are in sight
1082 break;
1083 if (pos == 1)
1084 ui->recipientSlider->setValue(i - 1); // scroll to prev
1085 else
1086 ui->recipientSlider->setValue(i + 2 - m_maxVisibleRecipients); // scroll to next
1087 break;
1090 if (focus == m_lastFocusedRecipient)
1091 focus->setFocus(); // in case we scrolled to m_lastFocusedRecipient
1094 void ComposeWidget::fadeIn(QWidget *w)
1096 QGraphicsOpacityEffect *effect = new QGraphicsOpacityEffect(w);
1097 w->setGraphicsEffect(effect);
1098 QPropertyAnimation *animation = new QPropertyAnimation(effect, "opacity", w);
1099 connect(animation, &QAbstractAnimation::finished, this, &ComposeWidget::slotFadeFinished);
1100 animation->setObjectName(trojita_opacityAnimation);
1101 animation->setDuration(333);
1102 animation->setStartValue(0.0);
1103 animation->setEndValue(1.0);
1104 animation->start(QAbstractAnimation::DeleteWhenStopped);
1107 void ComposeWidget::slotFadeFinished()
1109 Q_ASSERT(sender());
1110 QWidget *animatedEffectWidget = qobject_cast<QWidget*>(sender()->parent());
1111 Q_ASSERT(animatedEffectWidget);
1112 animatedEffectWidget->setGraphicsEffect(0); // deletes old one
1115 void ComposeWidget::scrollRecipients(int value)
1117 // ignore focus changes caused by "scrolling"
1118 disconnect(qApp, &QApplication::focusChanged, this, &ComposeWidget::handleFocusChange);
1120 QList<QWidget*> visibleWidgets;
1121 for (int i = 0; i < m_recipients.count(); ++i) {
1122 // remove all widgets from the form because of vspacing - causes spurious padding
1124 QWidget *toCC = m_recipients.at(i).first;
1125 QWidget *lineEdit = m_recipients.at(i).second;
1126 if (!m_lastFocusedRecipient) { // apply only _once_
1127 if (toCC->hasFocus())
1128 m_lastFocusedRecipient = toCC;
1129 else if (lineEdit->hasFocus())
1130 m_lastFocusedRecipient = lineEdit;
1132 if (toCC->isVisible())
1133 visibleWidgets << toCC;
1134 if (lineEdit->isVisible())
1135 visibleWidgets << lineEdit;
1136 ui->envelopeLayout->removeWidget(toCC);
1137 ui->envelopeLayout->removeWidget(lineEdit);
1138 toCC->hide();
1139 lineEdit->hide();
1142 const int begin = qMin(m_recipients.count(), value);
1143 const int end = qMin(m_recipients.count(), value + m_maxVisibleRecipients);
1144 for (int i = begin, j = 0; i < end; ++i, ++j) {
1145 const int pos = actualRow(ui->envelopeLayout, j + OFFSET_OF_FIRST_ADDRESSEE);
1146 QWidget *toCC = m_recipients.at(i).first;
1147 QWidget *lineEdit = m_recipients.at(i).second;
1148 ui->envelopeLayout->insertRow(pos, toCC, lineEdit);
1149 if (!visibleWidgets.contains(toCC))
1150 fadeIn(toCC);
1151 visibleWidgets.removeOne(toCC);
1152 if (!visibleWidgets.contains(lineEdit))
1153 fadeIn(lineEdit);
1154 visibleWidgets.removeOne(lineEdit);
1155 toCC->show();
1156 lineEdit->show();
1157 setTabOrder(formPredecessor(ui->envelopeLayout, toCC), toCC);
1158 setTabOrder(toCC, lineEdit);
1159 if (toCC == m_lastFocusedRecipient)
1160 toCC->setFocus();
1161 else if (lineEdit == m_lastFocusedRecipient)
1162 lineEdit->setFocus();
1165 if (m_lastFocusedRecipient && !m_lastFocusedRecipient->hasFocus() && QApplication::focusWidget())
1166 ui->envelopeWidget->setFocus();
1168 Q_FOREACH (QWidget *w, visibleWidgets) {
1169 // was visible, is no longer -> stop animation so it won't conflict later ones
1170 w->setGraphicsEffect(0); // deletes old one
1171 if (QPropertyAnimation *pa = w->findChild<QPropertyAnimation*>(trojita_opacityAnimation))
1172 pa->stop();
1174 connect(qApp, &QApplication::focusChanged, this, &ComposeWidget::handleFocusChange);
1177 void ComposeWidget::collapseRecipients()
1179 QLineEdit *edit = qobject_cast<QLineEdit*>(sender());
1180 Q_ASSERT(edit);
1181 if (edit->hasFocus() || !edit->text().isEmpty())
1182 return; // nothing to clean up
1184 // an empty recipient line just lost focus -> we "place it at the end", ie. simply remove it
1185 // and append a clone
1186 bool needEmpty = false;
1187 Composer::RecipientKind carriedKind = recipientKindForNextRow(Composer::ADDRESS_TO);
1188 for (int i = 0; i < m_recipients.count() - 1; ++i) { // sic! on the -1, no action if it trails anyway
1189 if (m_recipients.at(i).second == edit) {
1190 carriedKind = currentRecipient(m_recipients.last().first);
1191 removeRecipient(i);
1192 needEmpty = true;
1193 break;
1196 if (needEmpty)
1197 addRecipient(m_recipients.count(), carriedKind, QString());
1200 void ComposeWidget::gotError(const QString &error)
1202 QMessageBox::critical(this, tr("Failed to Send Mail"), error);
1203 setUiWidgetsEnabled(true);
1206 void ComposeWidget::sent()
1208 // FIXME: move back to the currently selected mailbox
1210 m_sentMail = true;
1211 QTimer::singleShot(0, this, SLOT(close()));
1214 bool ComposeWidget::parseRecipients(QList<QPair<Composer::RecipientKind, Imap::Message::MailAddress> > &results, QString &errorMessage)
1216 for (int i = 0; i < m_recipients.size(); ++i) {
1217 Composer::RecipientKind kind = currentRecipient(m_recipients.at(i).first);
1219 QString text = m_recipients.at(i).second->text();
1220 if (text.isEmpty())
1221 continue;
1222 Imap::Message::MailAddress addr;
1223 bool ok = Imap::Message::MailAddress::fromPrettyString(addr, text);
1224 if (ok) {
1225 // TODO: should we *really* learn every junk entered into a recipient field?
1226 // m_mainWindow->addressBook()->learn(addr);
1227 results << qMakePair(kind, addr);
1228 } else {
1229 errorMessage = tr("Can't parse \"%1\" as an e-mail address.").arg(text);
1230 return false;
1233 return true;
1236 void ComposeWidget::completeRecipients(const QString &text)
1238 if (text.isEmpty()) {
1239 if (m_completionPopup) {
1240 // if there's a popup close it and set back the receiver
1241 m_completionPopup->close();
1242 m_completionReceiver = 0;
1244 return; // we do not suggest "nothing"
1246 Q_ASSERT(sender());
1247 QLineEdit *toEdit = qobject_cast<QLineEdit*>(sender());
1248 Q_ASSERT(toEdit);
1250 Plugins::AddressbookJob *firstJob = m_firstCompletionRequests.take(toEdit);
1251 Plugins::AddressbookJob *secondJob = m_secondCompletionRequests.take(toEdit);
1253 // if two jobs are running, first was started before second so first should finish earlier
1254 // stop second job
1255 if (firstJob && secondJob) {
1256 disconnect(secondJob, nullptr, this, nullptr);
1257 secondJob->stop();
1258 secondJob->deleteLater();
1259 secondJob = 0;
1261 // now at most one job is running
1263 Plugins::AddressbookPlugin *addressbook = m_mainWindow->pluginManager()->addressbook();
1264 if (!addressbook || !(addressbook->features() & Plugins::AddressbookPlugin::FeatureCompletion))
1265 return;
1267 auto newJob = addressbook->requestCompletion(text, QStringList(), m_completionCount);
1269 if (!newJob)
1270 return;
1272 if (secondJob) {
1273 // if only second job is running move second to first and push new as second
1274 firstJob = secondJob;
1275 secondJob = newJob;
1276 } else if (firstJob) {
1277 // if only first job is running push new job as second
1278 secondJob = newJob;
1279 } else {
1280 // if no jobs is running push new job as first
1281 firstJob = newJob;
1284 if (firstJob)
1285 m_firstCompletionRequests.insert(toEdit, firstJob);
1287 if (secondJob)
1288 m_secondCompletionRequests.insert(toEdit, secondJob);
1290 connect(newJob, &Plugins::AddressbookCompletionJob::completionAvailable, this, &ComposeWidget::onCompletionAvailable);
1291 connect(newJob, &Plugins::AddressbookCompletionJob::error, this, &ComposeWidget::onCompletionFailed);
1293 newJob->setAutoDelete(true);
1294 newJob->start();
1297 void ComposeWidget::onCompletionFailed(Plugins::AddressbookJob::Error error)
1299 Q_UNUSED(error);
1300 onCompletionAvailable(Plugins::NameEmailList());
1303 void ComposeWidget::onCompletionAvailable(const Plugins::NameEmailList &completion)
1305 Plugins::AddressbookJob *job = qobject_cast<Plugins::AddressbookJob *>(sender());
1306 Q_ASSERT(job);
1307 QLineEdit *toEdit = m_firstCompletionRequests.key(job);
1309 if (!toEdit)
1310 toEdit = m_secondCompletionRequests.key(job);
1312 if (!toEdit)
1313 return;
1315 // jobs are removed from QMap below
1316 Plugins::AddressbookJob *firstJob = m_firstCompletionRequests.value(toEdit);
1317 Plugins::AddressbookJob *secondJob = m_secondCompletionRequests.value(toEdit);
1319 if (job == secondJob) {
1320 // second job finished before first and first was started before second
1321 // so stop first because it has old data
1322 if (firstJob) {
1323 disconnect(firstJob, nullptr, this, nullptr);
1324 firstJob->stop();
1325 firstJob->deleteLater();
1326 firstJob = nullptr;
1328 m_firstCompletionRequests.remove(toEdit);
1329 m_secondCompletionRequests.remove(toEdit);
1330 } else if (job == firstJob) {
1331 // first job finished, but if second is still running it will have new data, so do not stop it
1332 m_firstCompletionRequests.remove(toEdit);
1335 QStringList contacts;
1337 for (int i = 0; i < completion.size(); ++i) {
1338 const Plugins::NameEmail &item = completion.at(i);
1339 contacts << Imap::Message::MailAddress::fromNameAndMail(item.name, item.email).asPrettyString();
1342 if (contacts.isEmpty() && m_completionPopup) {
1343 m_completionPopup->close();
1344 m_completionReceiver = 0;
1345 } else {
1346 m_completionReceiver = toEdit;
1347 m_completionPopup->setUpdatesEnabled(false);
1348 m_completionPopup->clear();
1349 Q_FOREACH(const QString &s, contacts)
1350 m_completionPopup->addAction(s);
1351 if (m_completionPopup->isHidden())
1352 m_completionPopup->popup(toEdit->mapToGlobal(QPoint(0, toEdit->height())));
1353 m_completionPopup->setUpdatesEnabled(true);
1357 void ComposeWidget::completeRecipient(QAction *act)
1359 if (act->text().isEmpty())
1360 return;
1361 m_completionReceiver->setText(act->text());
1362 if (m_completionPopup) {
1363 m_completionPopup->close();
1364 m_completionReceiver = 0;
1368 bool ComposeWidget::eventFilter(QObject *o, QEvent *e)
1370 if (o == m_completionPopup) {
1371 if (!m_completionPopup->isVisible())
1372 return false;
1374 if (e->type() == QEvent::KeyPress || e->type() == QEvent::KeyRelease) {
1375 QKeyEvent *ke = static_cast<QKeyEvent*>(e);
1376 if (!( ke->key() == Qt::Key_Up || ke->key() == Qt::Key_Down || // Navigation
1377 ke->key() == Qt::Key_Escape || // "escape"
1378 ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter)) { // selection
1379 Q_ASSERT(m_completionReceiver);
1380 QCoreApplication::sendEvent(m_completionReceiver, e);
1381 return true;
1384 return false;
1387 if (o == ui->envelopeWidget) {
1388 if (e->type() == QEvent::Wheel) {
1389 int v = ui->recipientSlider->value();
1390 if (static_cast<QWheelEvent*>(e)->delta() > 0)
1391 --v;
1392 else
1393 ++v;
1394 // just QApplication::sendEvent(ui->recipientSlider, e) will cause a recursion if
1395 // ui->recipientSlider ignores the event (eg. because it would lead to an invalid value)
1396 // since ui->recipientSlider is child of ui->envelopeWidget
1397 // my guts tell me to not send events to children if it can be avoided, but its just a gut feeling
1398 ui->recipientSlider->setValue(v);
1399 e->accept();
1400 return true;
1402 if (e->type() == QEvent::KeyPress && ui->envelopeWidget->hasFocus()) {
1403 scrollToFocus();
1404 QWidget *focus = QApplication::focusWidget();
1405 if (focus && focus != ui->envelopeWidget) {
1406 int key = static_cast<QKeyEvent*>(e)->key();
1407 if (!(key == Qt::Key_Tab || key == Qt::Key_Backtab)) // those alter the focus again
1408 QApplication::sendEvent(focus, e);
1410 return true;
1412 if (e->type() == QEvent::Resize) {
1413 QResizeEvent *re = static_cast<QResizeEvent*>(e);
1414 if (re->size().height() != re->oldSize().height())
1415 calculateMaxVisibleRecipients();
1416 return false;
1418 return false;
1421 return false;
1425 void ComposeWidget::slotAskForFileAttachment()
1427 static QDir directory = QDir::home();
1428 QString fileName = QFileDialog::getOpenFileName(this, tr("Attach File..."), directory.absolutePath(), QString(), 0,
1429 QFileDialog::DontResolveSymlinks);
1430 if (!fileName.isEmpty()) {
1431 directory = QFileInfo(fileName).absoluteDir();
1432 m_submission->composer()->addFileAttachment(fileName);
1436 void ComposeWidget::slotAttachFiles(QList<QUrl> urls)
1438 foreach (const QUrl &url, urls) {
1439 if (url.isLocalFile()) {
1440 m_submission->composer()->addFileAttachment(url.path());
1445 void ComposeWidget::slotUpdateSignature()
1447 InhibitComposerDirtying inhibitor(this);
1448 QAbstractProxyModel *proxy = qobject_cast<QAbstractProxyModel*>(ui->sender->model());
1449 Q_ASSERT(proxy);
1450 QModelIndex proxyIndex = ui->sender->model()->index(ui->sender->currentIndex(), 0, ui->sender->rootModelIndex());
1452 if (!proxyIndex.isValid()) {
1453 // This happens when the settings dialog gets closed and the SenderIdentitiesModel reloads data from the on-disk cache
1454 return;
1457 QString newSignature = proxy->mapToSource(proxyIndex).sibling(proxyIndex.row(),
1458 Composer::SenderIdentitiesModel::COLUMN_SIGNATURE)
1459 .data().toString();
1461 Composer::Util::replaceSignature(ui->mailText->document(), newSignature);
1464 /** @short Massage the list of recipients so that they match the desired type of reply
1466 In case of an error, the original list of recipients is left as is.
1468 bool ComposeWidget::setReplyMode(const Composer::ReplyMode mode)
1470 if (!m_replyingToMessage.isValid())
1471 return false;
1473 // Determine the new list of recipients
1474 Composer::RecipientList list;
1475 if (!Composer::Util::replyRecipientList(mode, m_mainWindow->senderIdentitiesModel(),
1476 m_replyingToMessage, list)) {
1477 return false;
1480 while (!m_recipients.isEmpty())
1481 removeRecipient(0);
1483 Q_FOREACH(Composer::RecipientList::value_type recipient, list) {
1484 if (!recipient.second.hasUsefulDisplayName())
1485 recipient.second.name.clear();
1486 addRecipient(m_recipients.size(), recipient.first, recipient.second.asPrettyString());
1489 updateRecipientList();
1491 switch (mode) {
1492 case Composer::REPLY_PRIVATE:
1493 m_actionReplyModePrivate->setChecked(true);
1494 break;
1495 case Composer::REPLY_ALL_BUT_ME:
1496 m_actionReplyModeAllButMe->setChecked(true);
1497 break;
1498 case Composer::REPLY_ALL:
1499 m_actionReplyModeAll->setChecked(true);
1500 break;
1501 case Composer::REPLY_LIST:
1502 m_actionReplyModeList->setChecked(true);
1503 break;
1506 m_replyModeButton->setText(m_replyModeActions->checkedAction()->text());
1507 m_replyModeButton->setIcon(m_replyModeActions->checkedAction()->icon());
1509 ui->mailText->setFocus();
1511 return true;
1514 /** local draft serializaton:
1515 * Version (int)
1516 * Whether this draft was stored explicitly (bool)
1517 * The sender (QString)
1518 * Amount of recipients (int)
1519 * n * (RecipientKind ("int") + recipient (QString))
1520 * Subject (QString)
1521 * The message text (QString)
1524 void ComposeWidget::saveDraft(const QString &path)
1526 static const int trojitaDraftVersion = 3;
1527 QFile file(path);
1528 if (!file.open(QIODevice::WriteOnly))
1529 return; // TODO: error message?
1530 QDataStream stream(&file);
1531 stream.setVersion(QDataStream::Qt_4_6);
1532 stream << trojitaDraftVersion << m_explicitDraft << ui->sender->currentText();
1533 stream << m_recipients.count();
1534 for (int i = 0; i < m_recipients.count(); ++i) {
1535 stream << m_recipients.at(i).first->itemData(m_recipients.at(i).first->currentIndex()).toInt();
1536 stream << m_recipients.at(i).second->text();
1538 stream << m_submission->composer()->timestamp() << m_inReplyTo << m_references;
1539 stream << m_actionInReplyTo->isChecked();
1540 stream << ui->subject->text();
1541 stream << ui->mailText->toPlainText();
1542 // we spare attachments
1543 // a) serializing isn't an option, they could be HUUUGE
1544 // b) storing urls only works for urls
1545 // c) the data behind the url or the url validity might have changed
1546 // d) nasty part is writing mails - DnD a file into it is not a problem
1547 file.close();
1548 file.setPermissions(QFile::ReadOwner|QFile::WriteOwner);
1552 * When loading a draft we omit the present autostorage (content is replaced anyway) and make
1553 * the loaded path the autosave path, so all further automatic storage goes into the present
1554 * draft file
1557 void ComposeWidget::loadDraft(const QString &path)
1559 QFile file(path);
1560 if (!file.open(QIODevice::ReadOnly))
1561 return;
1563 if (m_autoSavePath != path) {
1564 QFile::remove(m_autoSavePath);
1565 m_autoSavePath = path;
1568 QDataStream stream(&file);
1569 stream.setVersion(QDataStream::Qt_4_6);
1570 QString string;
1571 int version, recipientCount;
1572 stream >> version;
1573 stream >> m_explicitDraft;
1574 stream >> string >> recipientCount; // sender / amount of recipients
1575 int senderIndex = ui->sender->findText(string);
1576 if (senderIndex != -1) {
1577 ui->sender->setCurrentIndex(senderIndex);
1578 } else {
1579 ui->sender->setEditText(string);
1581 for (int i = 0; i < recipientCount; ++i) {
1582 int kind;
1583 stream >> kind >> string;
1584 if (!string.isEmpty())
1585 addRecipient(i, static_cast<Composer::RecipientKind>(kind), string);
1587 if (version >= 2) {
1588 QDateTime timestamp;
1589 stream >> timestamp >> m_inReplyTo >> m_references;
1590 m_submission->composer()->setTimestamp(timestamp);
1591 if (!m_inReplyTo.isEmpty()) {
1592 m_markButton->show();
1593 // FIXME: in-reply-to's validitiy isn't the best check for showing or not showing the reply mode.
1594 // For eg: consider cases of mailto, forward, where valid in-reply-to won't mean choice of reply modes.
1595 m_replyModeButton->show();
1597 m_actionReplyModeAll->setEnabled(false);
1598 m_actionReplyModeAllButMe->setEnabled(false);
1599 m_actionReplyModeList->setEnabled(false);
1600 m_actionReplyModePrivate->setEnabled(false);
1601 markReplyModeHandpicked();
1603 // We do not have the message index at this point, but we can at least show the Message-Id here
1604 QStringList inReplyTo;
1605 Q_FOREACH(auto item, m_inReplyTo) {
1606 // There's no HTML escaping to worry about
1607 inReplyTo << QLatin1Char('<') + QString::fromUtf8(item.constData()) + QLatin1Char('>');
1609 m_actionInReplyTo->setToolTip(tr("This mail will be marked as a response<hr/>%1").arg(
1610 inReplyTo.join(tr("<br/>")).toHtmlEscaped()
1612 if (version == 2) {
1613 // it is always marked as a reply in v2
1614 m_actionInReplyTo->trigger();
1618 if (version >= 3) {
1619 bool replyChecked;
1620 stream >> replyChecked;
1621 // Got to use trigger() so that the default action of the QToolButton is updated
1622 if (replyChecked) {
1623 m_actionInReplyTo->trigger();
1624 } else {
1625 m_actionStandalone->trigger();
1628 stream >> string;
1629 ui->subject->setText(string);
1630 stream >> string;
1631 ui->mailText->setPlainText(string);
1632 m_messageUpdated = false; // this is now the most up-to-date one
1633 file.close();
1636 void ComposeWidget::autoSaveDraft()
1638 if (m_messageUpdated) {
1639 m_messageUpdated = false;
1640 saveDraft(m_autoSavePath);
1644 void ComposeWidget::setMessageUpdated()
1646 m_messageEverEdited = m_messageUpdated = true;
1649 void ComposeWidget::updateWindowTitle()
1651 if (ui->subject->text().isEmpty()) {
1652 setWindowTitle(tr("Compose Mail"));
1653 } else {
1654 setWindowTitle(tr("%1 - Compose Mail").arg(ui->subject->text()));
1658 void ComposeWidget::toggleReplyMarking()
1660 (m_actionInReplyTo->isChecked() ? m_actionStandalone : m_actionInReplyTo)->trigger();
1663 void ComposeWidget::updateReplyMarkingAction()
1665 auto action = m_markAsReply->checkedAction();
1666 m_actionToggleMarking->setText(action->text());
1667 m_actionToggleMarking->setIcon(action->icon());
1668 m_actionToggleMarking->setToolTip(action->toolTip());