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