1 /**************************************************************************
2  *                                                                        *
3  * Copyright (C) 2015 Felix Rohrbach <kde@fxrh.de>                        *
4  *                                                                        *
5  * This program is free software; you can redistribute it and/or          *
6  * modify it under the terms of the GNU General Public License            *
7  * as published by the Free Software Foundation; either version 3         *
8  * of the License, or (at your option) any later version.                 *
9  *                                                                        *
10  * This program is distributed in the hope that it will be useful,        *
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of         *
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the          *
13  * GNU General Public License for more details.                           *
14  *                                                                        *
15  * You should have received a copy of the GNU General Public License      *
16  * along with this program.  If not, see <http://www.gnu.org/licenses/>.  *
17  *                                                                        *
18  **************************************************************************/
19 
20 #include "chatroomwidget.h"
21 
22 #include <QtWidgets/QVBoxLayout>
23 #include <QtWidgets/QLabel>
24 #include <QtWidgets/QToolButton>
25 #include <QtWidgets/QAction>
26 #include <QtWidgets/QFileDialog>
27 #include <QtWidgets/QApplication>
28 #include <QtWidgets/QMenu>
29 #include <QtGui/QClipboard>
30 #include <QtGui/QTextCursor> // for last-minute message fixups before sending
31 #include <QtGui/QTextDocumentFragment> // to produce plain text from /html
32 #include <QtGui/QDesktopServices>
33 
34 #include <QtQml/QQmlContext>
35 #include <QtQml/QQmlEngine>
36 #ifdef DISABLE_QQUICKWIDGET
37 #include <QtQuick/QQuickView>
38 #else
39 #include <QtQuickWidgets/QQuickWidget>
40 #endif
41 #include <QtCore/QRegularExpression>
42 #include <QtCore/QStringBuilder>
43 #include <QtCore/QLocale>
44 #include <QtCore/QTemporaryFile>
45 #include <QtCore/QMimeData>
46 
47 #include <events/roommessageevent.h>
48 #include <events/roompowerlevelsevent.h>
49 #include <events/reactionevent.h>
50 #include <csapi/message_pagination.h>
51 #include <user.h>
52 #include <connection.h>
53 #include <uri.h>
54 #include <settings.h>
55 #include "models/messageeventmodel.h"
56 #include "imageprovider.h"
57 #include "chatedit.h"
58 #include "htmlfilter.h"
59 
60 static const auto DefaultPlaceholderText =
61         ChatRoomWidget::tr("Choose a room to send messages or enter a command...");
62 
ChatRoomWidget(QWidget * parent)63 ChatRoomWidget::ChatRoomWidget(QWidget* parent)
64     : QWidget(parent)
65     , m_messageModel(new MessageEventModel(this))
66     , m_currentRoom(nullptr)
67     , m_uiSettings("UI")
68     , indexToMaybeRead(-1)
69     , readMarkerOnScreen(false)
70 {
71     {
72         using namespace Quotient;
73         qmlRegisterUncreatableType<QuaternionRoom>("Quotient", 1, 0, "Room",
74             "Room objects can only be created by libQuotient");
75         qmlRegisterUncreatableType<User>("Quotient", 1, 0, "User",
76             "User objects can only be created by libQuotient");
77 #if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
78         qmlRegisterAnonymousType<GetRoomEventsJob>("Quotient", 1);
79         qmlRegisterAnonymousType<MessageEventModel>("Quotient", 1);
80 #else
81         qmlRegisterType<GetRoomEventsJob>();
82         qmlRegisterType<MessageEventModel>();
83 #endif
84         qRegisterMetaType<GetRoomEventsJob*>("GetRoomEventsJob*");
85         qRegisterMetaType<User*>("User*");
86         qmlRegisterType<Settings>("Quotient", 1, 0, "Settings");
87         qmlRegisterUncreatableType<RoomMessageEvent>("Quotient", 1, 0,
88             "RoomMessageEvent", "RoomMessageEvent is uncreatable");
89     }
90 
91     m_timelineWidget = new timelineWidget_t;
92     qDebug() << "Rendering QML with"
93              << timelineWidget_t::staticMetaObject.className();
94     auto* qmlContainer =
95 #ifdef DISABLE_QQUICKWIDGET
96             QWidget::createWindowContainer(m_timelineWidget, this);
97 #else
98             m_timelineWidget;
99 #endif // Use different objects but the same method with the same parameters
100     qmlContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
101 
102     m_timelineWidget->setResizeMode(timelineWidget_t::SizeRootObjectToView);
103 
104     m_imageProvider = new ImageProvider();
105     m_timelineWidget->engine()
106             ->addImageProvider(QStringLiteral("mtx"), m_imageProvider);
107 
108     auto* ctxt = m_timelineWidget->rootContext();
109     ctxt->setContextProperty(QStringLiteral("messageModel"), m_messageModel);
110     ctxt->setContextProperty(QStringLiteral("controller"), this);
111     ctxt->setContextProperty(QStringLiteral("debug"), QVariant(false));
112     ctxt->setContextProperty(QStringLiteral("room"), nullptr);
113 
114     m_timelineWidget->setSource(QUrl("qrc:///qml/Timeline.qml"));
115 
116     {
117         m_hudCaption = new QLabel();
118         m_hudCaption->setWordWrap(true);
119         auto f = m_hudCaption->font();
120         f.setItalic(true);
121         m_hudCaption->setFont(f);
122         m_hudCaption->setTextFormat(Qt::PlainText);
123     }
124 
125     auto attachButton = new QToolButton();
126     attachButton->setAutoRaise(true);
127     m_attachAction = new QAction(QIcon::fromTheme("mail-attachment"),
128                                  tr("Attach"), attachButton);
129     m_attachAction->setCheckable(true);
130     m_attachAction->setDisabled(true);
131     connect(m_attachAction, &QAction::triggered, this, [this] (bool checked) {
132         if (checked)
133         {
134             attachedFileName =
135                     QFileDialog::getOpenFileName(this, tr("Attach file"));
136         } else {
137             if (m_fileToAttach->isOpen())
138                 m_fileToAttach->remove();
139             attachedFileName.clear();
140         }
141 
142         if (!attachedFileName.isEmpty())
143         {
144             m_chatEdit->setPlaceholderText(
145                 tr("Add a message to the file or just push Enter"));
146             emit showStatusMessage(tr("Attaching %1").arg(attachedFileName));
147         } else {
148             m_attachAction->setChecked(false);
149             m_chatEdit->setPlaceholderText(DefaultPlaceholderText);
150             emit showStatusMessage(tr("Attaching cancelled"), 3000);
151         }
152     });
153     attachButton->setDefaultAction(m_attachAction);
154 
155     m_fileToAttach = new QTemporaryFile(this);
156 
157     m_chatEdit = new ChatEdit(this);
158     m_chatEdit->setPlaceholderText(DefaultPlaceholderText);
159     m_chatEdit->setAcceptRichText(true); // m_uiSettings.get("rich_text_editor", false);
160     m_chatEdit->setMaximumHeight(maximumChatEditHeight());
161     connect(m_chatEdit, &KChatEdit::returnPressed, this,
162             &ChatRoomWidget::sendInput);
163     connect(m_chatEdit, &KChatEdit::copyRequested, this, [=] {
164         QApplication::clipboard()->setText(
165             m_chatEdit->textCursor().hasSelection()
166                 ? m_chatEdit->textCursor().selectedText()
167                 : selectedText);
168     });
169     connect(m_chatEdit, &ChatEdit::insertFromMimeDataRequested,
170             this, [=] (const QMimeData* source) {
171         if (m_fileToAttach->isOpen() || m_currentRoom == nullptr)
172             return;
173         m_fileToAttach->open();
174 
175         qvariant_cast<QImage>(source->imageData()).save(m_fileToAttach, "PNG");
176 
177         attachedFileName = m_fileToAttach->fileName();
178         m_attachAction->setChecked(true);
179         m_chatEdit->setPlaceholderText(
180             tr("Add a message to the file or just push Enter"));
181         emit showStatusMessage(tr("Attaching an image from clipboard"));
182     });
183     connect(m_chatEdit, &ChatEdit::proposedCompletion, this,
184             [=](const QStringList& matches, int pos) {
185                 setHudCaption(
186                     tr("Next completion: %1")
187                         .arg(QStringList(matches.mid(pos, 5)).join(", ")));
188             });
189     connect(m_chatEdit, &ChatEdit::cancelledCompletion,
190             this, &ChatRoomWidget::typingChanged);
191 
192     {
193         QString styleSheet;
194         const auto& fontFamily =
195             m_uiSettings.get<QString>("Fonts/timeline_family");
196         if (!fontFamily.isEmpty())
197             styleSheet += "font-family: " + fontFamily + ";";
198         const auto& fontPointSize =
199             m_uiSettings.value("Fonts/timeline_pointSize");
200         if (fontPointSize.toReal() > 0.0)
201             styleSheet += "font-size: " + fontPointSize.toString() + "pt;";
202         if (!styleSheet.isEmpty())
203             setStyleSheet(styleSheet);
204     }
205 
206     auto* layout = new QVBoxLayout();
207     layout->addWidget(qmlContainer);
208     layout->addWidget(m_hudCaption);
209     {
210         auto inputLayout = new QHBoxLayout;
211         inputLayout->addWidget(attachButton);
212         inputLayout->addWidget(m_chatEdit);
213         layout->addLayout(inputLayout);
214     }
215     setLayout(layout);
216 }
217 
enableDebug()218 void ChatRoomWidget::enableDebug()
219 {
220     QQmlContext* ctxt = m_timelineWidget->rootContext();
221     ctxt->setContextProperty(QStringLiteral("debug"), true);
222 }
223 
setRoom(QuaternionRoom * room)224 void ChatRoomWidget::setRoom(QuaternionRoom* room)
225 {
226     if (m_currentRoom == room) {
227         focusInput();
228         return;
229     }
230 
231     if( m_currentRoom )
232     {
233         m_currentRoom->setDisplayed(false);
234         m_currentRoom->connection()->disconnect(this);
235         m_currentRoom->disconnect( this );
236     }
237     readMarkerOnScreen = false;
238     maybeReadTimer.stop();
239     indicesOnScreen.clear();
240     attachedFileName.clear();
241     m_attachAction->setChecked(false);
242     if (m_fileToAttach->isOpen())
243         m_fileToAttach->remove();
244 
245     m_currentRoom = room;
246     m_attachAction->setEnabled(m_currentRoom != nullptr);
247     m_chatEdit->switchContext(room);
248     if( m_currentRoom )
249     {
250         using namespace Quotient;
251         m_imageProvider->setConnection(room->connection());
252         focusInput();
253         connect( m_currentRoom, &Room::typingChanged,
254                  this, &ChatRoomWidget::typingChanged );
255         connect( m_currentRoom, &Room::readMarkerMoved, this, [this] {
256             const auto rm = m_currentRoom->readMarker();
257             readMarkerOnScreen =
258                 rm != m_currentRoom->timelineEdge() &&
259                 std::lower_bound( indicesOnScreen.cbegin(), indicesOnScreen.cend(),
260                                  rm->index() ) != indicesOnScreen.cend();
261             reStartShownTimer();
262             emit readMarkerMoved();
263         });
264         connect( m_currentRoom, &Room::encryption,
265                  this, &ChatRoomWidget::encryptionChanged);
266         connect(m_currentRoom->connection(), &Connection::loggedOut,
267                 this, [this]
268         {
269             qWarning() << "Logged out, escaping the room";
270             setRoom(nullptr);
271         });
272         m_currentRoom->setDisplayed(true);
273     } else
274         m_imageProvider->setConnection(nullptr);
275     m_timelineWidget->rootContext()
276             ->setContextProperty(QStringLiteral("room"), room);
277     typingChanged();
278     encryptionChanged();
279 
280     m_messageModel->changeRoom( m_currentRoom );
281 }
282 
spotlightEvent(QString eventId)283 void ChatRoomWidget::spotlightEvent(QString eventId)
284 {
285     auto index = m_messageModel->findRow(eventId);
286     if (index >= 0) {
287         emit scrollViewTo(index);
288         emit animateMessage(index);
289     } else
290         setHudCaption( tr("Referenced message not found") );
291 }
292 
typingChanged()293 void ChatRoomWidget::typingChanged()
294 {
295     if (!m_currentRoom || m_currentRoom->usersTyping().isEmpty())
296     {
297         m_hudCaption->clear();
298         return;
299     }
300     QStringList typingNames;
301     for(auto user: m_currentRoom->usersTyping())
302     {
303         typingNames << m_currentRoom->safeMemberName(user->id());
304     }
305     setHudCaption( tr("Currently typing: %1")
306                    .arg(typingNames.join(QStringLiteral(", "))) );
307 }
308 
encryptionChanged()309 void ChatRoomWidget::encryptionChanged()
310 {
311     m_chatEdit->setPlaceholderText(
312         m_currentRoom
313             ? m_currentRoom->usesEncryption()
314                 ? tr("Send a message (no end-to-end encryption support yet)...")
315                 : tr("Send a message (over %1) or enter a command...",
316                      "%1 is the protocol used by the server (usually HTTPS)")
317                   .arg(m_currentRoom->connection()->homeserver()
318                        .scheme().toUpper())
319             : DefaultPlaceholderText);
320 }
321 
setHudCaption(QString newCaption)322 void ChatRoomWidget::setHudCaption(QString newCaption)
323 {
324     m_hudCaption->setText(newCaption);
325 }
326 
insertMention(Quotient::User * user)327 void ChatRoomWidget::insertMention(Quotient::User* user)
328 {
329     Q_ASSERT(m_currentRoom != nullptr);
330     m_chatEdit->insertMention(
331         user->displayname(m_currentRoom),
332         Quotient::Uri(user->id()).toUrl(Quotient::Uri::MatrixToUri));
333     m_chatEdit->setFocus();
334 }
335 
focusInput()336 void ChatRoomWidget::focusInput()
337 {
338     m_chatEdit->setFocus();
339 }
340 
341 /**
342  * \brief Split the string into the specified number of parts
343  * The function takes \p s and splits it into \p maxParts parts using \p sep
344  * for the separator. Empty parts are skipped. If there are more than
345  * \p maxParts parts in the string, the last returned part includes
346  * the remainder of the string; if there are fewer parts, the missing parts
347  * are filled with empty strings.
348  * \return the vector of references to the original string, one reference for
349  * each part.
350  */
lazySplitRef(const QString & s,QChar sep,int maxParts)351 QVector<QString> lazySplitRef(const QString& s, QChar sep, int maxParts)
352 {
353     QVector<QString> parts { maxParts };
354     int pos = 0, nextPos = 0;
355     for (; maxParts > 1 && (nextPos = s.indexOf(sep, pos)) > -1; --maxParts)
356     {
357         parts[parts.size() - maxParts] = s.mid(pos, nextPos - pos);
358         while (s[++nextPos] == sep)
359             ;
360         pos = nextPos;
361     }
362     parts[parts.size() - maxParts] = s.mid(pos);
363     return parts;
364 }
365 
sendFile()366 void ChatRoomWidget::sendFile()
367 {
368     Q_ASSERT(m_currentRoom != nullptr);
369     const auto& description = m_chatEdit->toPlainText();
370     auto txnId = m_currentRoom->postFile(description.isEmpty()
371                                              ? QUrl(attachedFileName).fileName()
372                                              : description,
373                                          QUrl::fromLocalFile(attachedFileName));
374 
375     if (m_fileToAttach->isOpen())
376         m_fileToAttach->remove();
377     attachedFileName.clear();
378     m_attachAction->setChecked(false);
379     m_chatEdit->setPlaceholderText(DefaultPlaceholderText);
380 }
381 
382 #if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
sendMarkdown(QuaternionRoom * room,const QTextDocumentFragment & text)383 void sendMarkdown(QuaternionRoom* room, const QTextDocumentFragment& text)
384 {
385     room->postHtmlText(text.toPlainText(),
386                        HtmlFilter::qtToMatrix(text.toHtml(), room,
387                                               HtmlFilter::ConvertMarkdown));
388 }
389 #endif
390 
sendMessage()391 void ChatRoomWidget::sendMessage()
392 {
393     if (m_chatEdit->toPlainText().startsWith("//"))
394         QTextCursor(m_chatEdit->document()).deleteChar();
395 
396 #if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
397     if (m_uiSettings.get("auto_markdown", false)) {
398         sendMarkdown(m_currentRoom,
399                      QTextDocumentFragment(m_chatEdit->document()));
400         return;
401     }
402 #endif
403     const auto& plainText = m_chatEdit->toPlainText();
404     const auto& htmlText =
405         HtmlFilter::qtToMatrix(m_chatEdit->toHtml(), m_currentRoom);
406     Q_ASSERT(!plainText.isEmpty() && !htmlText.isEmpty());
407     m_currentRoom->postHtmlText(plainText, htmlText);
408 }
409 
410 static const auto NothingToSendMsg =
411     ChatRoomWidget::tr("There's nothing to send");
412 
sendCommand(const QStringRef & command,const QString & argString)413 QString ChatRoomWidget::sendCommand(const QStringRef& command,
414                                     const QString& argString)
415 {
416     static const auto ReFlags = QRegularExpression::DotMatchesEverythingOption
417                                 | QRegularExpression::DontCaptureOption;
418 
419     // FIXME: copy-paste from lib/util.cpp
420     static const auto ServerPartPattern =
421         QStringLiteral("(\\[[^]]+\\]|[-[:alnum:].]+)" // Either IPv6 address or
422                                                       // hostname/IPv4 address
423                        "(:\\d{1,5})?" // Optional port
424         );
425     static const auto UserIdPattern =
426         QString("@[-[:alnum:]._=/]+:" % ServerPartPattern);
427 
428     static const QRegularExpression
429         RoomIdRE { "^([#!][^:[:space:]]+):" % ServerPartPattern % '$', ReFlags },
430         UserIdRE { '^' % UserIdPattern % '$', ReFlags };
431     Q_ASSERT(RoomIdRE.isValid() && UserIdRE.isValid());
432 
433     // Commands available without a current room
434     if (command == "join")
435     {
436         if (!argString.contains(RoomIdRE))
437             return tr("/join argument doesn't look like a room ID or alias");
438         emit resourceRequested(argString, "join");
439         return {};
440     }
441     if (command == "quit")
442     {
443         qApp->closeAllWindows();
444         return {};
445     }
446     // --- Add more roomless commands here
447     if (!m_currentRoom)
448     {
449         return tr("There's no such /command outside of room.");
450     }
451 
452     // Commands available only in the room context
453     using namespace Quotient;
454     if (command == "leave" || command == "part")
455     {
456         if (!argString.isEmpty())
457             return tr("Sending a farewell message is not supported yet."
458                       " If you intended to leave another room, switch to it"
459                       " and type /leave there.");
460 
461         m_currentRoom->leaveRoom();
462         return {};
463     }
464     if (command == "forget")
465     {
466         if (argString.isEmpty())
467             return tr("/forget must be followed by the room id/alias,"
468                       " even for the current room");
469         if (!argString.contains(RoomIdRE))
470             return tr("%1 doesn't look like a room id or alias").arg(argString);
471 
472         // Forget the specified room using the current room's connection
473         m_currentRoom->connection()->forgetRoom(argString);
474         return {};
475     }
476     if (command == "invite")
477     {
478         if (argString.isEmpty())
479             return tr("/invite <memberId>");
480         if (!argString.contains(UserIdRE))
481             return tr("%1 doesn't look like a user ID").arg(argString);
482 
483         m_currentRoom->inviteToRoom(argString);
484         return {};
485     }
486     if (command == "kick" || command == "ban")
487     {
488         const auto args = lazySplitRef(argString, ' ', 2);
489         if (args.front().isEmpty())
490             return tr("/%1 <userId> <reason>").arg(command.toString());
491         if (!UserIdRE.match(args.front()).hasMatch())
492             return tr("%1 doesn't look like a user id")
493                     .arg(args.front());
494 
495         if (command == "ban")
496             m_currentRoom->ban(args.front(), args.back());
497         else {
498             auto* user = m_currentRoom->user(args.front());
499             if (m_currentRoom->memberJoinState(user) != JoinState::Join)
500                 return tr("%1 is not a member of this room")
501                         .arg(user->fullName(m_currentRoom));
502             m_currentRoom->kickMember(user->id(), args.back());
503         }
504         return {};
505     }
506     if (command == "unban")
507     {
508         if (argString.isEmpty())
509             return tr("/unban <userId>");
510         if (!argString.contains(UserIdRE))
511             return tr("/unban argument doesn't look like a user ID");
512 
513         m_currentRoom->unban(argString);
514         return {};
515     }
516     if (command == "ignore" || command == "unignore")
517     {
518         if (argString.isEmpty())
519             return tr("/ignore <userId>");
520         if (!argString.contains(UserIdRE))
521             return tr("/ignore argument doesn't look like a user ID");
522 
523         if (auto* user = m_currentRoom->user(argString))
524         {
525             if (command == "ignore")
526                 user->ignore();
527             else
528                 user->unmarkIgnore();
529             return {};
530         }
531         return tr("Couldn't find user %1 on the server").arg(argString);
532     }
533     using MsgType = RoomMessageEvent::MsgType;
534     if (command == "me")
535     {
536         if (argString.isEmpty())
537             return tr("/me needs an argument");
538         m_currentRoom->postMessage(argString, MsgType::Emote);
539         return {};
540     }
541     if (command == "notice")
542     {
543         if (argString.isEmpty())
544             return tr("/notice needs an argument");
545         m_currentRoom->postMessage(argString, MsgType::Notice);
546         return {};
547     }
548     if (command == "shrug") // Peeked at Discord
549     {
550         m_currentRoom->postPlainText((argString.isEmpty() ? "" : argString + " ") +
551                                      "¯\\_(ツ)_/¯");
552         return {};
553     }
554     if (command == "topic")
555     {
556         m_currentRoom->setTopic(argString);
557         return {};
558     }
559     if (command == "nick")
560     {
561         m_currentRoom->localUser()->rename(argString);
562         return {};
563     }
564     if (command == "roomnick")
565     {
566         m_currentRoom->localUser()->rename(argString, m_currentRoom);
567         return {};
568     }
569     if (command == "pm" || command == "msg")
570     {
571         const auto args = lazySplitRef(argString, ' ', 2);
572         if (args.front().isEmpty() || (args.back().isEmpty() && command == "msg"))
573             return tr("/%1 <memberId> <message>").arg(command.toString());
574         if (RoomIdRE.match(args.front()).hasMatch() && command == "msg")
575         {
576             if (auto* room = m_currentRoom->connection()->room(args.front()))
577             {
578                 room->postPlainText(args.back());
579                 return {};
580             }
581             return tr("%1 doesn't seem to have joined room %2")
582                     .arg(m_currentRoom->localUser()->id(), args.front());
583         }
584         if (UserIdRE.match(args.front()).hasMatch())
585         {
586             if (args.back().isEmpty())
587                 m_currentRoom->connection()->requestDirectChat(args.front());
588             else
589                 m_currentRoom->connection()->doInDirectChat(args.front(),
590                     [msg=args.back()] (Room* dc) { dc->postPlainText(msg); });
591             return {};
592         }
593 
594         return tr("%1 doesn't look like a user id or room alias")
595                 .arg(args.front());
596     }
597     if (command == "plain") {
598         static const auto CmdLen = QStringLiteral("/plain ").size();
599         const auto& plainMsg = m_chatEdit->toPlainText().mid(CmdLen);
600         if (plainMsg.isEmpty())
601             return NothingToSendMsg;
602         m_currentRoom->postPlainText(plainMsg);
603         return {};
604     }
605     if (command == "html")
606     {
607         // Assuming Matrix HTML, convert it to Qt and load to a fragment in
608         // order to produce a plain text version (maybe introduce
609         // filterMatrixHtmlToPlainText() one day instead...); then convert
610         // back to Matrix HTML to produce the (clean) rich text version
611         // of the message
612         const auto& [cleanQtHtml, errorPos, errorString] =
613             HtmlFilter::matrixToQt(argString, m_currentRoom, true);
614         if (errorPos != -1)
615             return tr("At pos %1: ").arg(errorPos) % errorString;
616 
617         const auto& fragment = QTextDocumentFragment::fromHtml(cleanQtHtml);
618         m_currentRoom->postHtmlText(fragment.toPlainText(),
619                                     HtmlFilter::qtToMatrix(fragment.toHtml(),
620                                                            m_currentRoom));
621         return {};
622     }
623     if (command == "md") {
624 #if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
625         // Select everything after /md and one whitespace character after it
626         // (leading whitespaces have meaning in Markdown)
627         QTextCursor c(m_chatEdit->document());
628         c.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, 4);
629         c.movePosition(QTextCursor::End, QTextCursor::KeepAnchor);
630         sendMarkdown(m_currentRoom, c.selection());
631         return {};
632 #else
633         return tr("Your build of Quaternion doesn't support Markdown");
634 #endif
635     }
636     if (command == "query" || command == "dc")
637     {
638         if (argString.isEmpty())
639             return tr("/%1 <memberId>").arg(command.toString());
640         if (!argString.contains(UserIdRE))
641             return tr("%1 doesn't look like a user id").arg(argString);
642 
643         m_currentRoom->connection()->requestDirectChat(argString);
644         return {};
645     }
646     // --- Add more room commands here
647     qDebug() << "Unknown command:" << command;
648     return tr("Unknown /command. Use // to send this line literally");
649 }
650 
sendInput()651 void ChatRoomWidget::sendInput()
652 {
653     if (!attachedFileName.isEmpty())
654         sendFile();
655     else {
656         const auto& text = m_chatEdit->toPlainText();
657         QString error;
658         if (text.isEmpty())
659             error = NothingToSendMsg;
660         else if (text.startsWith('/') && !text.midRef(1).startsWith('/')) {
661             QRegularExpression cmdSplit("(\\w+)(?:\\s+(.*))?");
662             const auto& blanksMatch = cmdSplit.match(text, 1);
663             error = sendCommand(blanksMatch.capturedRef(1),
664                                 blanksMatch.captured(2));
665         } else if (!m_currentRoom)
666             error = tr("You should select a room to send messages.");
667         else
668             sendMessage();
669         if (!error.isEmpty()) {
670             emit showStatusMessage(error, 5000);
671             return;
672         }
673     }
674 
675     m_chatEdit->saveInput();
676 }
677 
678 QVector<QPair<QString, QUrl>>
findCompletionMatches(const QString & pattern) const679 ChatRoomWidget::findCompletionMatches(const QString& pattern) const
680 {
681     QVector<QPair<QString, QUrl>> matches;
682     if (m_currentRoom)
683     {
684         for(auto user: m_currentRoom->users() )
685         {
686             using Quotient::Uri;
687             if (user->displayname(m_currentRoom)
688                     .startsWith(pattern, Qt::CaseInsensitive)
689                 || user->id().startsWith(pattern, Qt::CaseInsensitive))
690                 matches.push_back({ user->displayname(m_currentRoom),
691                                     Uri(user->id()).toUrl(Uri::MatrixToUri) });
692         }
693         std::sort(matches.begin(), matches.end(),
694             [] (const auto& p1, const auto& p2)
695                 { return p1.first.localeAwareCompare(p2.first) < 0; });
696     }
697     return matches;
698 }
699 
saveFileAs(QString eventId)700 void ChatRoomWidget::saveFileAs(QString eventId)
701 {
702     if (!m_currentRoom)
703     {
704         qWarning()
705             << "ChatRoomWidget::saveFileAs without an active room ignored";
706         return;
707     }
708     auto fileName = QFileDialog::getSaveFileName(
709                 this, tr("Save file as"),
710                 m_currentRoom->fileNameToDownload(eventId));
711     if (!fileName.isEmpty())
712         m_currentRoom->downloadFile(eventId, QUrl::fromLocalFile(fileName));
713 }
714 
onMessageShownChanged(const QString & eventId,bool shown)715 void ChatRoomWidget::onMessageShownChanged(const QString& eventId, bool shown)
716 {
717     if (!m_currentRoom || !m_currentRoom->displayed())
718         return;
719 
720     // A message can be auto-marked as read (as soon as the user is active), if:
721     // 0. The read marker exists and is on the screen
722     // 1. The message is shown on the screen now
723     // 2. It's been the bottommost message on the screen for the last 1 second
724     // 3. It's below the read marker
725 
726     const auto readMarker = m_currentRoom->readMarker();
727     if (readMarker != m_currentRoom->timelineEdge() &&
728             readMarker->event()->id() == eventId)
729     {
730         readMarkerOnScreen = shown;
731         if (shown)
732         {
733             qDebug() << "Read marker is on-screen, at" << *readMarker;
734             indexToMaybeRead = readMarker->index();
735             reStartShownTimer();
736         } else
737         {
738             qDebug() << "Read marker is off-screen";
739             qDebug() << "Bottommost shown message index was" << indexToMaybeRead;
740             maybeReadTimer.stop();
741         }
742     }
743 
744     const auto iter = m_currentRoom->findInTimeline(eventId);
745     if (iter == m_currentRoom->timelineEdge())
746     {
747         qWarning() << "Event" << eventId
748                    << "is not in the timeline (local echo?)";
749         return;
750     }
751     const auto timelineIndex = iter->index();
752     auto pos = std::lower_bound(indicesOnScreen.begin(), indicesOnScreen.end(),
753                                 timelineIndex);
754     if (shown)
755     {
756         if (pos == indicesOnScreen.end() || *pos != timelineIndex)
757         {
758             indicesOnScreen.insert(pos, timelineIndex);
759             if (timelineIndex == indicesOnScreen.back())
760                 reStartShownTimer();
761         }
762     } else
763     {
764         if (pos != indicesOnScreen.end() && *pos == timelineIndex)
765             if (indicesOnScreen.erase(pos) == indicesOnScreen.end())
766                 reStartShownTimer();
767     }
768 }
769 
quote(const QString & htmlText)770 void ChatRoomWidget::quote(const QString& htmlText)
771 {
772     const auto type = m_uiSettings.get<int>("quote_type");
773     const auto defaultStyle = QStringLiteral("> \\1\n");
774     const auto defaultRegex = QStringLiteral("(.+)(?:\n|$)");
775     auto style = m_uiSettings.get<QString>("quote_style");
776     auto regex = m_uiSettings.get<QString>("quote_regex");
777 
778     if (style.isEmpty())
779         style = defaultStyle;
780     if (regex.isEmpty())
781         regex = defaultRegex;
782 
783     QTextDocument document;
784     document.setHtml(htmlText);
785     QString sendString;
786 
787     switch (type)
788     {
789         case 0:
790             sendString = document.toPlainText()
791                 .replace(QRegularExpression(defaultRegex), defaultStyle);
792             break;
793         case 1:
794             sendString = document.toPlainText()
795                 .replace(QRegularExpression(regex), style);
796             break;
797         case 2:
798             sendString = QLocale().quoteString(document.toPlainText()) + "\n";
799             break;
800     }
801 
802     m_chatEdit->insertPlainText(sendString);
803 }
804 
showMenu(int index,const QString & hoveredLink,const QString & selectedText,bool showingDetails)805 void ChatRoomWidget::showMenu(int index, const QString& hoveredLink,
806                               const QString& selectedText, bool showingDetails)
807 {
808     const auto modelIndex = m_messageModel->index(index, 0);
809     const auto eventId = modelIndex.data(MessageEventModel::EventIdRole).toString();
810 
811     QMenu menu;
812 
813     const auto* plEvt =
814         m_currentRoom->getCurrentState<Quotient::RoomPowerLevelsEvent>();
815     const auto localUserId = m_currentRoom->localUser()->id();
816     const int userPl = plEvt->powerLevelForUser(localUserId);
817     const auto* modelUser =
818         modelIndex.data(MessageEventModel::AuthorRole).value<Quotient::User*>();
819     if (!plEvt || userPl >= plEvt->redact() || localUserId == modelUser->id()) {
820         menu.addAction(QIcon::fromTheme("edit-delete"), tr("Redact"), [=] {
821             m_currentRoom->redactEvent(eventId);
822         });
823     }
824     if (!selectedText.isEmpty())
825     {
826         menu.addAction(tr("Copy selected text to clipboard"), [=] {
827             QApplication::clipboard()->setText(selectedText);
828         });
829     }
830     if (!hoveredLink.isEmpty())
831     {
832         menu.addAction(tr("Copy link to clipboard"), [=] {
833             QApplication::clipboard()->setText(hoveredLink);
834         });
835     }
836     menu.addAction(QIcon::fromTheme("link"), tr("Copy permalink to clipboard"), [=] {
837         QApplication::clipboard()->setText("https://matrix.to/#/" +
838             m_currentRoom->id() + "/" + QUrl::toPercentEncoding(eventId));
839     });
840     menu.addAction(QIcon::fromTheme("format-text-blockquote"),
841                    tr("Quote", "a verb (do quote), not a noun (a quote)"), [=] {
842         emit quote(modelIndex.data().toString());
843     });
844     auto a = menu.addAction(QIcon::fromTheme("view-list-details"), tr("Show details"), [=] {
845         emit showDetails(index);
846     });
847     a->setCheckable(true);
848     a->setChecked(showingDetails);
849 
850     const auto eventType = modelIndex.data(MessageEventModel::EventTypeRole).toString();
851     if (eventType == "image" || eventType == "file")
852     {
853         const auto progressInfo = modelIndex.data(MessageEventModel::LongOperationRole)
854             .value<Quotient::FileTransferInfo>();
855         const bool downloaded = !progressInfo.isUpload && progressInfo.completed();
856 
857         menu.addSeparator();
858         menu.addAction(QIcon::fromTheme("document-open"), tr("Open externally"), [=] {
859             emit openExternally(index);
860         });
861         if (downloaded) {
862             menu.addAction(QIcon::fromTheme("folder-open"), tr("Open Folder"), [=] {
863                 QDesktopServices::openUrl(progressInfo.localDir);
864             });
865             if (eventType == "image")
866             {
867                 menu.addAction(tr("Copy image to clipboard"), [=] {
868                     QApplication::clipboard()->setImage(QImage(progressInfo.localPath.path()));
869                 });
870             }
871         }
872         else
873         {
874             menu.addAction(QIcon::fromTheme("edit-download"), tr("Download"), [=] {
875                 m_currentRoom->downloadFile(eventId);
876             });
877         }
878         menu.addAction(QIcon::fromTheme("document-save-as"), tr("Save file as..."), [=] {
879             saveFileAs(eventId);
880         });
881     }
882     menu.exec(QCursor::pos());
883 }
884 
reactionButtonClicked(const QString & eventId,const QString & key)885 void ChatRoomWidget::reactionButtonClicked(const QString& eventId, const QString& key)
886 {
887     using namespace Quotient;
888     const auto& annotations =
889         m_currentRoom->relatedEvents(eventId, EventRelation::Annotation());
890 
891     for (const auto& a : annotations) {
892         auto* e = eventCast<const ReactionEvent>(a);
893         if (e != nullptr && e->relation().key == key
894                 && a->senderId() == m_currentRoom->localUser()->id()) {
895             m_currentRoom->redactEvent(a->id());
896             return;
897         }
898     }
899 
900     m_currentRoom->postReaction(eventId, key);
901 }
902 
setGlobalSelectionBuffer(QString text)903 void ChatRoomWidget::setGlobalSelectionBuffer(QString text)
904 {
905     if (QApplication::clipboard()->supportsSelection())
906         QApplication::clipboard()->setText(text, QClipboard::Selection);
907 
908     selectedText = text;
909 }
910 
reStartShownTimer()911 void ChatRoomWidget::reStartShownTimer()
912 {
913     if (!readMarkerOnScreen || indicesOnScreen.empty() ||
914             indexToMaybeRead >= indicesOnScreen.back())
915         return;
916 
917     maybeReadTimer.start(m_uiSettings.get<int>("maybe_read_timer", 1000), this);
918     qDebug() << "Scheduled maybe-read message update:"
919              << indexToMaybeRead << "->" << indicesOnScreen.back();
920 }
921 
timerEvent(QTimerEvent * qte)922 void ChatRoomWidget::timerEvent(QTimerEvent* qte)
923 {
924     if (qte->timerId() != maybeReadTimer.timerId())
925     {
926         QWidget::timerEvent(qte);
927         return;
928     }
929     maybeReadTimer.stop();
930     // Only update the maybe-read message if we're tracking it
931     if (readMarkerOnScreen && !indicesOnScreen.empty()
932             && indexToMaybeRead < indicesOnScreen.back())
933     {
934         qDebug() << "Maybe-read message update:" << indexToMaybeRead
935                  << "->" << indicesOnScreen.back();
936         indexToMaybeRead = indicesOnScreen.back();
937         emit readMarkerCandidateMoved();
938     }
939 }
940 
resizeEvent(QResizeEvent *)941 void ChatRoomWidget::resizeEvent(QResizeEvent*)
942 {
943     m_chatEdit->setMaximumHeight(maximumChatEditHeight());
944 }
945 
keyPressEvent(QKeyEvent * event)946 void ChatRoomWidget::keyPressEvent(QKeyEvent* event)
947 {
948     // This only handles keypresses not handled by ChatEdit; in particular,
949     // this means that PageUp/PageDown below are actually Ctrl-PageUp/PageDown
950     switch(event->key()) {
951         case Qt::Key_PageUp:
952             emit pageUpPressed();
953             break;
954         case Qt::Key_PageDown:
955             emit pageDownPressed();
956             break;
957     }
958 }
959 
maximumChatEditHeight() const960 int ChatRoomWidget::maximumChatEditHeight() const
961 {
962     return maximumHeight() / 3;
963 }
964 
markShownAsRead()965 void ChatRoomWidget::markShownAsRead()
966 {
967     // FIXME: a case when a single message doesn't fit on the screen.
968     if (m_currentRoom && readMarkerOnScreen)
969     {
970         const auto iter = m_currentRoom->findInTimeline(indicesOnScreen.back());
971         Q_ASSERT( iter != m_currentRoom->timelineEdge() );
972         m_currentRoom->markMessagesAsRead((*iter)->id());
973     }
974 }
975 
pendingMarkRead() const976 bool ChatRoomWidget::pendingMarkRead() const
977 {
978     if (!readMarkerOnScreen || !m_currentRoom)
979         return false;
980 
981     const auto rm = m_currentRoom->readMarker();
982     return rm != m_currentRoom->timelineEdge() && rm->index() < indexToMaybeRead;
983 }
984 
fileDrop(const QString & url)985 void ChatRoomWidget::fileDrop(const QString& url)
986 {
987     attachedFileName = QUrl(url).path();
988     m_attachAction->setChecked(true);
989     m_chatEdit->setPlaceholderText(
990         tr("Add a message to the file or just push Enter"));
991     emit showStatusMessage(tr("Attaching %1").arg(attachedFileName));
992 }
993 
htmlDrop(const QString & html)994 void ChatRoomWidget::htmlDrop(const QString &html)
995 {
996     m_chatEdit->insertHtml(html);
997 }
998 
textDrop(const QString & text)999 void ChatRoomWidget::textDrop(const QString& text)
1000 {
1001     m_chatEdit->insertPlainText(text);
1002 }
1003 
getModifierKeys() const1004 Qt::KeyboardModifiers ChatRoomWidget::getModifierKeys() const
1005 {
1006     return QGuiApplication::keyboardModifiers();
1007 }
1008