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>
26 #include <QDesktopWidget>
27 #include <QFileDialog>
28 #include <QGraphicsOpacityEffect>
31 #include <QMessageBox>
32 #include <QProgressDialog>
33 #include <QPropertyAnimation>
34 #include <QPushButton>
37 #include <QToolButton>
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"
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"
72 enum { OFFSET_OF_FIRST_ADDRESSEE
= 1, MIN_MAX_VISIBLE_RECIPIENTS
= 4 };
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
86 This guard object makes sure (via RAII) that these dirtifying events are ignored during its lifetime.
88 class InhibitComposerDirtying
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
;
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
),
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);
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());
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
);
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();
268 int sz
= ui
->mailText
->idealWidth();
269 ui
->mailText
->setMinimumSize(sz
, 1000*sz
/1618); // golden mean editor
271 ui
->mailText
->setMinimumSize(0, 0);
272 resize(size().boundedTo(qApp
->desktop()->availableGeometry().size()));
275 ComposeWidget::~ComposeWidget()
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
)
284 QMessageBox::critical(mainWindow
, tr("Error"), tr("Please set appropriate settings for outgoing messages."));
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()) {
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;
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) {
320 if (dx
>= 120) // give up
322 } else if (dx
< 0 || dy
< 0) {
337 /** @short Create a blank composer window */
338 ComposeWidget
*ComposeWidget::createBlank(MainWindow
*mainWindow
)
340 MSA::MSAFactory
*msaFactory
= mainWindow
->msaFactory();
344 ComposeWidget
*w
= new ComposeWidget(mainWindow
, msaFactory
);
345 w
->placeOnMainWindow();
350 /** @short Load a draft in composer window */
351 ComposeWidget
*ComposeWidget::createDraft(MainWindow
*mainWindow
, const QString
&path
)
353 MSA::MSAFactory
*msaFactory
= mainWindow
->msaFactory();
357 ComposeWidget
*w
= new ComposeWidget(mainWindow
, msaFactory
);
359 w
->placeOnMainWindow();
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();
371 ComposeWidget
*w
= new ComposeWidget(mainWindow
, msaFactory
);
372 InhibitComposerDirtying
inhibitor(w
);
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());
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();
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();
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
);
428 case Composer::REPLY_ALL
:
429 case Composer::REPLY_ALL_BUT_ME
:
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.");
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.");
441 QMessageBox::warning(w
, tr("Cannot Determine Recipients"), err
);
443 w
->placeOnMainWindow();
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();
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();
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
);
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
);
511 m_submission
->setPassword(password
);
516 Plugins::PasswordPlugin
*password
= m_mainWindow
->pluginManager()->password();
518 askPassword(user
, host
);
522 Plugins::PasswordJob
*job
= password
->requestPassword(QStringLiteral("account-0"), QStringLiteral("smtp"));
524 askPassword(user
, host
);
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
);
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
)
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(),
555 m_submission
->setPassword(password
);
557 m_submission
->cancelPassword();
560 void ComposeWidget::changeEvent(QEvent
*e
)
562 QWidget::changeEvent(e
);
564 case QEvent::LanguageChange
:
565 ui
->retranslateUi(this);
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
);
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"));
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
;
614 m_explicitDraft
= true;
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
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
));
641 if (recipients
.isEmpty()) {
642 gotError(tr("You haven't entered any recipients"));
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"));
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();
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());
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
)
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
);
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())
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(),
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);
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
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
));
788 m_markButton
->hide();
789 m_replyModeButton
->hide();
790 m_actionInReplyTo
->setToolTip(QString());
791 m_actionStandalone
->trigger();
795 bool ok
= Composer::Util::chooseSenderIdentityForReply(m_mainWindow
->senderIdentitiesModel(), replyingToMessage
, row
);
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
;
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
817 case Composer::ADDRESS_FROM
:
818 case Composer::ADDRESS_SENDER
:
819 case Composer::ADDRESS_REPLY_TO
:
820 // shall never be used here
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
) {
844 if (form
->itemAt(i
, QFormLayout::LabelRole
) || form
->itemAt(i
, QFormLayout::FieldRole
) ||
845 form
->itemAt(i
, QFormLayout::SpanningRole
))
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
)
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();
873 if ((item
= form
->itemAt(i
, QFormLayout::FieldRole
))) {
874 runner
= item
->widget();
880 if ((item
= form
->itemAt(i
, QFormLayout::SpanningRole
))) {
881 runner
= item
->widget();
891 //END QFormLayout workarounds
893 void ComposeWidget::calculateMaxVisibleRecipients()
895 const int oldMaxVisibleRecipients
= m_maxVisibleRecipients
;
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
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
;
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()) {
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
973 ui
->envelopeWidget
->setUpdatesEnabled(true);
976 void ComposeWidget::slotCheckAddressOfSender()
978 QLineEdit
*edit
= qobject_cast
<QLineEdit
*>(sender());
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());
990 p
.setColor(QPalette::Base
, UiUtils::tintColor(p
.color(QPalette::Base
), QColor(0xff, 0, 0, 0x20)));
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();
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
) {
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);
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()) {
1048 addRecipient(m_recipients
.count(),
1049 m_recipients
.isEmpty() ?
1050 Composer::ADDRESS_TO
:
1051 recipientKindForNextRow(currentRecipient(m_recipients
.last().first
)),
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())
1070 QWidget
*focus
= QApplication::focusWidget();
1071 if (focus
== ui
->envelopeWidget
)
1072 focus
= m_lastFocusedRecipient
;
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())
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
1084 ui
->recipientSlider
->setValue(i
- 1); // scroll to prev
1086 ui
->recipientSlider
->setValue(i
+ 2 - m_maxVisibleRecipients
); // scroll to next
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()
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
);
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
))
1151 visibleWidgets
.removeOne(toCC
);
1152 if (!visibleWidgets
.contains(lineEdit
))
1154 visibleWidgets
.removeOne(lineEdit
);
1157 setTabOrder(formPredecessor(ui
->envelopeLayout
, toCC
), toCC
);
1158 setTabOrder(toCC
, lineEdit
);
1159 if (toCC
== m_lastFocusedRecipient
)
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
))
1174 connect(qApp
, &QApplication::focusChanged
, this, &ComposeWidget::handleFocusChange
);
1177 void ComposeWidget::collapseRecipients()
1179 QLineEdit
*edit
= qobject_cast
<QLineEdit
*>(sender());
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
);
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
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();
1222 Imap::Message::MailAddress addr
;
1223 bool ok
= Imap::Message::MailAddress::fromPrettyString(addr
, text
);
1225 // TODO: should we *really* learn every junk entered into a recipient field?
1226 // m_mainWindow->addressBook()->learn(addr);
1227 results
<< qMakePair(kind
, addr
);
1229 errorMessage
= tr("Can't parse \"%1\" as an e-mail address.").arg(text
);
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"
1247 QLineEdit
*toEdit
= qobject_cast
<QLineEdit
*>(sender());
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
1255 if (firstJob
&& secondJob
) {
1256 disconnect(secondJob
, nullptr, this, nullptr);
1258 secondJob
->deleteLater();
1261 // now at most one job is running
1263 Plugins::AddressbookPlugin
*addressbook
= m_mainWindow
->pluginManager()->addressbook();
1264 if (!addressbook
|| !(addressbook
->features() & Plugins::AddressbookPlugin::FeatureCompletion
))
1267 auto newJob
= addressbook
->requestCompletion(text
, QStringList(), m_completionCount
);
1273 // if only second job is running move second to first and push new as second
1274 firstJob
= secondJob
;
1276 } else if (firstJob
) {
1277 // if only first job is running push new job as second
1280 // if no jobs is running push new job as first
1285 m_firstCompletionRequests
.insert(toEdit
, firstJob
);
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);
1297 void ComposeWidget::onCompletionFailed(Plugins::AddressbookJob::Error error
)
1300 onCompletionAvailable(Plugins::NameEmailList());
1303 void ComposeWidget::onCompletionAvailable(const Plugins::NameEmailList
&completion
)
1305 Plugins::AddressbookJob
*job
= qobject_cast
<Plugins::AddressbookJob
*>(sender());
1307 QLineEdit
*toEdit
= m_firstCompletionRequests
.key(job
);
1310 toEdit
= m_secondCompletionRequests
.key(job
);
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
1323 disconnect(firstJob
, nullptr, this, nullptr);
1325 firstJob
->deleteLater();
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;
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())
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())
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
);
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)
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
);
1402 if (e
->type() == QEvent::KeyPress
&& ui
->envelopeWidget
->hasFocus()) {
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
);
1412 if (e
->type() == QEvent::Resize
) {
1413 QResizeEvent
*re
= static_cast<QResizeEvent
*>(e
);
1414 if (re
->size().height() != re
->oldSize().height())
1415 calculateMaxVisibleRecipients();
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());
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
1457 QString newSignature
= proxy
->mapToSource(proxyIndex
).sibling(proxyIndex
.row(),
1458 Composer::SenderIdentitiesModel::COLUMN_SIGNATURE
)
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())
1473 // Determine the new list of recipients
1474 Composer::RecipientList list
;
1475 if (!Composer::Util::replyRecipientList(mode
, m_mainWindow
->senderIdentitiesModel(),
1476 m_replyingToMessage
, list
)) {
1480 while (!m_recipients
.isEmpty())
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();
1492 case Composer::REPLY_PRIVATE
:
1493 m_actionReplyModePrivate
->setChecked(true);
1495 case Composer::REPLY_ALL_BUT_ME
:
1496 m_actionReplyModeAllButMe
->setChecked(true);
1498 case Composer::REPLY_ALL
:
1499 m_actionReplyModeAll
->setChecked(true);
1501 case Composer::REPLY_LIST
:
1502 m_actionReplyModeList
->setChecked(true);
1506 m_replyModeButton
->setText(m_replyModeActions
->checkedAction()->text());
1507 m_replyModeButton
->setIcon(m_replyModeActions
->checkedAction()->icon());
1509 ui
->mailText
->setFocus();
1514 /** local draft serializaton:
1516 * Whether this draft was stored explicitly (bool)
1517 * The sender (QString)
1518 * Amount of recipients (int)
1519 * n * (RecipientKind ("int") + recipient (QString))
1521 * The message text (QString)
1524 void ComposeWidget::saveDraft(const QString
&path
)
1526 static const int trojitaDraftVersion
= 3;
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
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
1557 void ComposeWidget::loadDraft(const QString
&path
)
1560 if (!file
.open(QIODevice::ReadOnly
))
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
);
1571 int version
, recipientCount
;
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
);
1579 ui
->sender
->setEditText(string
);
1581 for (int i
= 0; i
< recipientCount
; ++i
) {
1583 stream
>> kind
>> string
;
1584 if (!string
.isEmpty())
1585 addRecipient(i
, static_cast<Composer::RecipientKind
>(kind
), string
);
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()
1613 // it is always marked as a reply in v2
1614 m_actionInReplyTo
->trigger();
1620 stream
>> replyChecked
;
1621 // Got to use trigger() so that the default action of the QToolButton is updated
1623 m_actionInReplyTo
->trigger();
1625 m_actionStandalone
->trigger();
1629 ui
->subject
->setText(string
);
1631 ui
->mailText
->setPlainText(string
);
1632 m_messageUpdated
= false; // this is now the most up-to-date one
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"));
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());