1 /*
2    Drawpile - a collaborative drawing program.
3 
4    Copyright (C) 2007-2019 Calle Laakkonen
5 
6    Drawpile is free software: you can redistribute it and/or modify
7    it under the terms of the GNU General Public License as published by
8    the Free Software Foundation, either version 3 of the License, or
9    (at your option) any later version.
10 
11    Drawpile is distributed in the hope that it will be useful,
12    but WITHOUT ANY WARRANTY; without even the implied warranty of
13    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14    GNU General Public License for more details.
15 
16    You should have received a copy of the GNU General Public License
17    along with Drawpile.  If not, see <http://www.gnu.org/licenses/>.
18 */
19 
20 #include "chatlineedit.h"
21 #include "chatwidgetpinnedarea.h"
22 #include "chatwidget.h"
23 #include "utils/html.h"
24 #include "utils/funstuff.h"
25 #include "notifications.h"
26 
27 #include "../libshared/net/meta.h"
28 #include "canvas/userlist.h"
29 
30 #include <QResizeEvent>
31 #include <QTextBrowser>
32 #include <QVBoxLayout>
33 #include <QLabel>
34 #include <QDateTime>
35 #include <QTextBlock>
36 #include <QScrollBar>
37 #include <QTabBar>
38 #include <QIcon>
39 #include <QMenu>
40 #include <QSettings>
41 
42 #include <memory>
43 
44 namespace widgets {
45 
46 struct Chat {
47 	QTextDocument *doc;
48 	int lastAppendedId = 0;
49 	qint64 lastMessageTs = 0;
50 	int scrollPosition = 0;
51 
Chatwidgets::Chat52 	Chat() : doc(nullptr) { }
Chatwidgets::Chat53 	explicit Chat(QObject *parent)
54 		: doc(new QTextDocument(parent))
55 	{
56 		doc->setDefaultStyleSheet(
57 			".sep { background: #4d4d4d }"
58 			".notification { background: #232629 }"
59 			".message, .notification {"
60 				"color: #eff0f1;"
61 				"margin: 1px 0 1px 0"
62 			"}"
63 			".shout { background: #34292c }"
64 			".shout .tab { background: #da4453 }"
65 			".action { font-style: italic }"
66 			".username { font-weight: bold }"
67 			".trusted { color: #27ae60 }"
68 			".registered { color: #16a085 }"
69 			".op { color: #f47750 }"
70 			".mod { color: #ed1515 }"
71 			".timestamp { color: #8d8d8d }"
72 			"a:link { color: #1d99f3 }"
73 		);
74 	}
75 
76 	void appendSeparator(QTextCursor &cursor);
77 	void appendMessage(int userId, const QString &usernameSpan, const QString &message, bool shout);
78 	void appendMessageCompact(int userId, const QString &usernameSpan, const QString &message, bool shout);
79 	void appendAction(const QString &usernameSpan, const QString &message);
80 	void appendNotification(const QString &message);
81 };
82 
83 struct ChatWidget::Private {
Privatewidgets::ChatWidget::Private84 	Private(ChatWidget *parent) : chatbox(parent) { }
85 
86 	ChatWidget * const chatbox;
87 	QTextBrowser *view = nullptr;
88 	ChatLineEdit *myline = nullptr;
89 	ChatWidgetPinnedArea *pinned = nullptr;
90 	QTabBar *tabs = nullptr;
91 
92 	QList<int> announcedUsers;
93 	canvas::UserListModel *userlist = nullptr;
94 	QHash<int, Chat> chats;
95 
96 	int myId = 0;
97 	int currentChat = 0;
98 
99 	bool preserveChat = true;
100 	bool compactMode = false;
101 	bool isAttached = true;
102 
103 	QString usernameSpan(int userId);
104 
isAtEndwidgets::ChatWidget::Private105 	bool isAtEnd() const {
106 		return view->verticalScrollBar()->value() == view->verticalScrollBar()->maximum();
107 	}
108 
scrollToEndwidgets::ChatWidget::Private109 	void scrollToEnd(int ifCurrentId) {
110 		if(ifCurrentId == tabs->tabData(tabs->currentIndex()).toInt())
111 			view->verticalScrollBar()->setValue(view->verticalScrollBar()->maximum());
112 	}
113 
publicChatwidgets::ChatWidget::Private114 	inline Chat &publicChat()
115 	{
116 		Q_ASSERT(chats.contains(0));
117 		return chats[0];
118 	}
119 
120 	bool ensurePrivateChatExists(int userId, QObject *parent);
121 
122 	void updatePreserveModeUi();
123 };
124 
ChatWidget(QWidget * parent)125 ChatWidget::ChatWidget(QWidget *parent)
126 	: QWidget(parent), d(new Private(this))
127 {
128 	QVBoxLayout *layout = new QVBoxLayout(this);
129 
130 	layout->setSpacing(0);
131 	layout->setMargin(0);
132 
133 	d->tabs = new QTabBar(this);
134 	d->tabs->addTab(QString());
135 	d->tabs->setTabText(0, tr("Public"));
136 	d->tabs->setAutoHide(true);
137 	d->tabs->setDocumentMode(true);
138 	d->tabs->setTabsClosable(true);
139 	d->tabs->setMovable(true);
140 	d->tabs->setTabData(0, 0); // context id 0 is used for the public chat
141 
142 	// The public chat cannot be closed
143 	if(d->tabs->tabButton(0, QTabBar::LeftSide)) {
144 		d->tabs->tabButton(0, QTabBar::LeftSide)->deleteLater();
145 		d->tabs->setTabButton(0, QTabBar::LeftSide, nullptr);
146 	}
147 	if(d->tabs->tabButton(0, QTabBar::RightSide)) {
148 		d->tabs->tabButton(0, QTabBar::RightSide)->deleteLater();
149 		d->tabs->setTabButton(0, QTabBar::RightSide, nullptr);
150 	}
151 
152 	connect(d->tabs, &QTabBar::currentChanged, this, &ChatWidget::chatTabSelected);
153 	connect(d->tabs, &QTabBar::tabCloseRequested, this, &ChatWidget::chatTabClosed);
154 	layout->addWidget(d->tabs, 0);
155 
156 	d->pinned = new ChatWidgetPinnedArea(this);
157 	layout->addWidget(d->pinned, 0);
158 
159 	d->view = new QTextBrowser(this);
160 	d->view->setOpenExternalLinks(true);
161 
162 	d->view->setContextMenuPolicy(Qt::CustomContextMenu);
163 	connect(d->view, &QTextBrowser::customContextMenuRequested, this, &ChatWidget::showChatContextMenu);
164 
165 	layout->addWidget(d->view, 1);
166 
167 	d->myline = new ChatLineEdit(this);
168 	layout->addWidget(d->myline);
169 
170 	setLayout(layout);
171 
172 	connect(d->myline, &ChatLineEdit::returnPressed, this, &ChatWidget::sendMessage);
173 
174 	d->chats[0] = Chat(this);
175 	d->view->setDocument(d->chats[0].doc);
176 
177 	setPreserveMode(false);
178 
179 	d->compactMode = QSettings().value("history/compactchat").toBool();
180 }
181 
~ChatWidget()182 ChatWidget::~ChatWidget()
183 {
184 	delete d;
185 }
186 
setAttached(bool isAttached)187 void ChatWidget::setAttached(bool isAttached)
188 {
189 	d->isAttached = isAttached;
190 }
191 
updatePreserveModeUi()192 void ChatWidget::Private::updatePreserveModeUi()
193 {
194 	const bool preserve = preserveChat && currentChat == 0;
195 
196 	QString placeholder, color;
197 	if(preserve) {
198 		placeholder = tr("Chat (recorded)...");
199 		color = "#da4453";
200 	} else {
201 		placeholder = tr("Chat...");
202 		color = "#1d99f3";
203 	}
204 
205 	// Set placeholder text and window style based on the mode
206 	myline->setPlaceholderText(placeholder);
207 
208 	chatbox->setStyleSheet(
209 		QStringLiteral(
210 		"QTextEdit, QLineEdit {"
211 			"background-color: #232629;"
212 			"border: none;"
213 			"color: #eff0f1"
214 		"}"
215 		"QLineEdit {"
216 			"border-top: 1px solid %1;"
217 			"padding: 4px"
218 		"}"
219 		).arg(color)
220 	);
221 
222 }
setPreserveMode(bool preservechat)223 void ChatWidget::setPreserveMode(bool preservechat)
224 {
225 	d->preserveChat = preservechat;
226 	d->updatePreserveModeUi();
227 }
228 
loggedIn(int myId)229 void ChatWidget::loggedIn(int myId)
230 {
231 	d->myId = myId;
232 	d->announcedUsers.clear();
233 }
234 
focusInput()235 void ChatWidget::focusInput()
236 {
237 	d->myline->setFocus();
238 }
239 
setUserList(canvas::UserListModel * userlist)240 void ChatWidget::setUserList(canvas::UserListModel *userlist)
241 {
242 	 d->userlist = userlist;
243 }
244 
clear()245 void ChatWidget::clear()
246 {
247 	Chat &chat = d->chats[d->currentChat];
248 	chat.doc->clear();
249 	chat.lastAppendedId = 0;
250 
251 	// Re-add avatars
252 	if(d->userlist) {
253 		for(const auto &u : d->userlist->users()) {
254 			chat.doc->addResource(
255 				QTextDocument::ImageResource,
256 				QUrl(QStringLiteral("avatar://%1").arg(u.id)),
257 				u.avatar
258 			);
259 		}
260 	}
261 }
262 
ensurePrivateChatExists(int userId,QObject * parent)263 bool ChatWidget::Private::ensurePrivateChatExists(int userId, QObject *parent)
264 {
265 	if(userId < 1 || userId > 255) {
266 		qWarning("ChatWidget::openPrivateChat(%d): Invalid user ID", userId);
267 		return false;
268 	}
269 	if(userId == myId) {
270 		qWarning("ChatWidget::openPrivateChat(%d): this is me...", userId);
271 		return false;
272 	}
273 
274 	if(!chats.contains(userId)) {
275 		chats[userId] = Chat(parent);
276 		const int newTab = tabs->addTab(userlist->getUsername(userId));
277 		tabs->setTabData(newTab, userId);
278 
279 		chats[userId].doc->addResource(
280 			QTextDocument::ImageResource,
281 			QUrl(QStringLiteral("avatar://%1").arg(userId)),
282 			userlist->getUserById(userId).avatar
283 		);
284 		chats[userId].doc->addResource(
285 			QTextDocument::ImageResource,
286 			QUrl(QStringLiteral("avatar://%1").arg(myId)),
287 			userlist->getUserById(myId).avatar
288 		);
289 	}
290 
291 	return true;
292 }
293 
openPrivateChat(int userId)294 void ChatWidget::openPrivateChat(int userId)
295 {
296 	if(!d->ensurePrivateChatExists(userId, this))
297 		return;
298 
299 	for(int i=d->tabs->count()-1;i>=0;--i) {
300 		if(d->tabs->tabData(i).toInt() == userId) {
301 			d->tabs->setCurrentIndex(i);
302 			break;
303 		}
304 	}
305 }
306 
timestamp()307 static QString timestamp()
308 {
309 	return QStringLiteral("<span class=ts>%1</span>").arg(
310 		QDateTime::currentDateTime().toString("HH:mm")
311 	);
312 }
313 
usernameSpan(int userId)314 QString ChatWidget::Private::usernameSpan(int userId)
315 {
316 	const canvas::User user = userlist ? userlist->getUserById(userId) : canvas::User();
317 
318 	QString userclass;
319 	if(user.isMod)
320 		userclass = QStringLiteral("mod");
321 	else if(user.isOperator)
322 		userclass = QStringLiteral("op");
323 	else if(user.isTrusted)
324 		userclass = QStringLiteral("trusted");
325 	else if(user.isAuth)
326 		userclass = QStringLiteral("registered");
327 
328 	return QStringLiteral("<span class=\"username %1\">%2</span>").arg(
329 		userclass,
330 		user.name.isEmpty() ? QStringLiteral("<s>User #%1</s>").arg(userId) : user.name.toHtmlEscaped()
331 	);
332 }
333 
appendSeparator(QTextCursor & cursor)334 void Chat::appendSeparator(QTextCursor &cursor)
335 {
336 	cursor.insertHtml(QStringLiteral(
337 		"<table height=1 width=\"100%\" class=sep><tr><td></td></tr></table>"
338 		));
339 }
340 
appendMessageCompact(int userId,const QString & usernameSpan,const QString & message,bool shout)341 void Chat::appendMessageCompact(int userId, const QString &usernameSpan, const QString &message, bool shout)
342 {
343 	Q_UNUSED(userId);
344 
345 	lastAppendedId = -1;
346 
347 	QTextCursor cursor(doc);
348 
349 	cursor.movePosition(QTextCursor::End);
350 
351 	cursor.insertHtml(QStringLiteral(
352 		"<table width=\"100%\" class=\"message%1\">"
353 		"<tr>"
354 			"<td width=3 class=tab></td>"
355 			"<td>%2: %3</td>"
356 			"<td class=timestamp align=right>%4</td>"
357 		"</tr>"
358 		"</table>"
359 		).arg(
360 			shout ? QStringLiteral(" shout") : QString(),
361 			usernameSpan,
362 			message,
363 			timestamp()
364 		)
365 	);
366 }
367 
appendMessage(int userId,const QString & usernameSpan,const QString & message,bool shout)368 void Chat::appendMessage(int userId, const QString &usernameSpan, const QString &message, bool shout)
369 {
370 	QTextCursor cursor(doc);
371 	cursor.movePosition(QTextCursor::End);
372 
373 	const qint64 ts = QDateTime::currentMSecsSinceEpoch();
374 
375 	if(shout) {
376 		lastAppendedId = -2;
377 
378 	} else if(lastAppendedId != userId) {
379 		appendSeparator(cursor);
380 		lastAppendedId = userId;
381 
382 	} else if(ts - lastMessageTs < 60000) {
383 		QTextBlock b = doc->lastBlock().previous();
384 		cursor.setPosition(b.position() + b.length() - 1);
385 
386 		cursor.insertHtml(QStringLiteral("<br>"));
387 		cursor.insertHtml(message);
388 
389 		return;
390 	}
391 
392 	// We'll have to make do with a very limited subset of HTML and CSS:
393 	// http://doc.qt.io/qt-5/richtext-html-subset.html
394 	// Embedding a whole browser engine just to render the chat widget would
395 	// be excessive.
396 	cursor.insertHtml(QStringLiteral(
397 		"<table width=\"100%\" class=\"message%1\">"
398 		"<tr>"
399 			"<td width=3 rowspan=2 class=tab></td>"
400 			"<td width=40 rowspan=2><img src=\"avatar://%2\"></td>"
401 			"<td>%3</td>"
402 			"<td class=timestamp align=right>%4</td>"
403 		"</tr>"
404 		"<tr>"
405 			"<td colspan=2>%5</td>"
406 		"</tr>"
407 		"</table>"
408 		).arg(
409 			shout ? QStringLiteral(" shout") : QString(),
410 			QString::number(userId),
411 			usernameSpan,
412 			timestamp(),
413 			htmlutils::newlineToBr(message)
414 		)
415 	);
416 	lastMessageTs = ts;
417 }
418 
appendAction(const QString & usernameSpan,const QString & message)419 void Chat::appendAction(const QString &usernameSpan, const QString &message)
420 {
421 	QTextCursor cursor(doc);
422 	cursor.movePosition(QTextCursor::End);
423 
424 	if(lastAppendedId != -1) {
425 		appendSeparator(cursor);
426 		lastAppendedId = -1;
427 	}
428 	cursor.insertHtml(QStringLiteral(
429 		"<table width=\"100%\" class=message>"
430 		"<tr>"
431 			"<td width=3 class=tab></td>"
432 			"<td><span class=action>%1 %2</span></td>"
433 			"<td class=timestamp align=right>%3</td>"
434 		"</tr>"
435 		"</table>"
436 		).arg(
437 			usernameSpan,
438 			message,
439 			timestamp()
440 		)
441 	);
442 }
443 
appendNotification(const QString & message)444 void Chat::appendNotification(const QString &message)
445 {
446 	QTextCursor cursor(doc);
447 	cursor.movePosition(QTextCursor::End);
448 
449 	if(lastAppendedId != 0) {
450 		appendSeparator(cursor);
451 		lastAppendedId = 0;
452 	}
453 
454 	cursor.insertHtml(QStringLiteral(
455 		"<table width=\"100%\" class=notification><tr>"
456 			"<td width=3 class=tab></td>"
457 			"<td>%1</td>"
458 			"<td align=right class=timestamp>%2</td>"
459 		"</tr></table>"
460 		).arg(
461 			htmlutils::newlineToBr(message),
462 			timestamp()
463 		)
464 	);
465 }
466 
userJoined(int id,const QString & name)467 void ChatWidget::userJoined(int id, const QString &name)
468 {
469 	Q_UNUSED(name);
470 
471 	if(d->userlist) {
472 		d->chats[0].doc->addResource(
473 			QTextDocument::ImageResource,
474 			QUrl(QStringLiteral("avatar://%1").arg(id)),
475 			d->userlist->getUserById(id).avatar
476 		);
477 		if(d->chats.contains(id)) {
478 			d->chats[id].doc->addResource(
479 				QTextDocument::ImageResource,
480 				QUrl(QStringLiteral("avatar://%1").arg(id)),
481 				d->userlist->getUserById(id).avatar
482 			);
483 		}
484 
485 	} else {
486 		qWarning("User #%d logged in, but userlist object not assigned to ChatWidget!", id);
487 	}
488 
489 	// The server resends UserJoin messages during session reset.
490 	// We don't need to see the join messages again.
491 	if(d->announcedUsers.contains(id))
492 		return;
493 
494 	d->announcedUsers << id;
495 	const QString msg = tr("%1 joined the session").arg(d->usernameSpan(id));
496 	const bool wasAtEnd = d->isAtEnd();
497 
498 	d->publicChat().appendNotification(msg);
499 	if(wasAtEnd)
500 		d->scrollToEnd(0);
501 
502 	if(d->chats.contains(id)) {
503 		d->chats[id].appendNotification(msg);
504 		if(wasAtEnd)
505 			d->scrollToEnd(id);
506 	}
507 
508 	notification::playSound(notification::Event::LOGIN);
509 }
510 
userParted(int id)511 void ChatWidget::userParted(int id)
512 {
513 	QString msg = tr("%1 left the session").arg(d->usernameSpan(id));
514 	const bool wasAtEnd = d->isAtEnd();
515 
516 	d->publicChat().appendNotification(msg);
517 	if(wasAtEnd)
518 		d->scrollToEnd(0);
519 
520 	if(d->chats.contains(id)) {
521 		d->chats[id].appendNotification(msg);
522 		if(wasAtEnd)
523 			d->scrollToEnd(id);
524 	}
525 
526 	d->announcedUsers.removeAll(id);
527 
528 	notification::playSound(notification::Event::LOGOUT);
529 }
530 
kicked(const QString & kickedBy)531 void ChatWidget::kicked(const QString &kickedBy)
532 {
533 	const bool wasAtEnd = d->isAtEnd();
534 	d->publicChat().appendNotification(tr("You have been kicked by %1").arg(kickedBy.toHtmlEscaped()));
535 	if(wasAtEnd)
536 		d->scrollToEnd(0);
537 }
538 
receiveMessage(const protocol::MessagePtr & msg)539 void ChatWidget::receiveMessage(const protocol::MessagePtr &msg)
540 {
541 	const bool wasAtEnd = d->isAtEnd();
542 	int chatId = 0;
543 
544 	if(msg->type() == protocol::MSG_CHAT) {
545 		const protocol::Chat &chat = msg.cast<protocol::Chat>();
546 		const QString safetext = chat.message().toHtmlEscaped();
547 
548 		if(chat.isPin()) {
549 			d->pinned->setPinText(safetext);
550 		} else if(chat.isAction()) {
551 			d->publicChat().appendAction(d->usernameSpan(msg->contextId()), htmlutils::linkify(safetext));
552 
553 		} else {
554 			if(d->compactMode)
555 				d->publicChat().appendMessageCompact(msg->contextId(), d->usernameSpan(msg->contextId()), htmlutils::linkify(safetext), chat.isShout());
556 			else
557 				d->publicChat().appendMessage(msg->contextId(), d->usernameSpan(msg->contextId()), htmlutils::linkify(safetext), chat.isShout());
558 		}
559 
560 	} else if(msg->type() == protocol::MSG_PRIVATE_CHAT) {
561 		const protocol::PrivateChat &chat = msg.cast<protocol::PrivateChat>();
562 		const QString safetext = chat.message().toHtmlEscaped();
563 
564 		if(chat.target() != d->myId && chat.contextId() != d->myId) {
565 			qWarning("ChatWidget::recivePrivateMessage: message was targeted to user %d, but our ID is %d", chat.target(), d->myId);
566 			return;
567 		}
568 
569 		// The server echoes back the messages we send
570 		chatId = chat.target() == d->myId ? chat.contextId() : chat.target();
571 
572 		if(!d->ensurePrivateChatExists(chatId, this))
573 			return;
574 
575 		Chat &c = d->chats[chatId];
576 
577 		if(chat.isAction()) {
578 			c.appendAction(d->usernameSpan(msg->contextId()), htmlutils::linkify(safetext));
579 
580 		} else {
581 			if(d->compactMode)
582 				c.appendMessageCompact(msg->contextId(), d->usernameSpan(msg->contextId()), htmlutils::linkify(safetext), false);
583 			else
584 				c.appendMessage(msg->contextId(), d->usernameSpan(msg->contextId()), htmlutils::linkify(safetext), false);
585 		}
586 
587 	} else {
588 		qWarning("ChatWidget::receiveMessage: got wrong message type %s!", qPrintable(msg->messageName()));
589 		return;
590 	}
591 
592 	if(chatId != d->currentChat) {
593 		for(int i=0;i<d->tabs->count();++i) {
594 			if(d->tabs->tabData(i).toInt() == chatId) {
595 				d->tabs->setTabTextColor(i, QColor(218, 68, 83));
596 				break;
597 			}
598 		}
599 	}
600 
601 	if(!d->myline->hasFocus() || chatId != d->currentChat)
602 		notification::playSound(notification::Event::CHAT);
603 
604 	if(wasAtEnd)
605 		d->scrollToEnd(chatId);
606 }
607 
receiveMarker(int id,const QString & message)608 void ChatWidget::receiveMarker(int id, const QString &message)
609 {
610 	const bool wasAtEnd = d->isAtEnd();
611 
612 	d->publicChat().appendNotification(QStringLiteral(
613 		"<img src=\"theme:flag-red.svg\"> %1: %2"
614 		).arg(
615 			d->usernameSpan(id),
616 			htmlutils::linkify(message.toHtmlEscaped())
617 		)
618 	);
619 
620 	if(wasAtEnd)
621 		d->scrollToEnd(0);
622 }
623 
systemMessage(const QString & message,bool alert)624 void ChatWidget::systemMessage(const QString& message, bool alert)
625 {
626 	Q_UNUSED(alert);
627 	const bool wasAtEnd = d->isAtEnd();
628 	d->publicChat().appendNotification(message.toHtmlEscaped());
629 	if(wasAtEnd)
630 		d->scrollToEnd(0);
631 }
632 
sendMessage(const QString & msg)633 void ChatWidget::sendMessage(const QString &msg)
634 {
635 	if(msg.at(0) == '/') {
636 		// Special commands
637 
638 		int split = msg.indexOf(' ');
639 		if(split<0)
640 			split = msg.length();
641 
642 		const QString cmd = msg.mid(1, split-1).toLower();
643 		const QString params = msg.mid(split).trimmed();
644 
645 		if(cmd == "clear") {
646 			clear();
647 			return;
648 
649 		} else if(cmd.at(0)=='!' && d->currentChat == 0) {
650 			if(msg.length() > 2)
651 				emit message(protocol::Chat::announce(d->myId, msg.mid(2)));
652 			return;
653 
654 		} else if(cmd == "me") {
655 			if(!params.isEmpty()) {
656 				if(d->currentChat == 0)
657 					emit message(protocol::Chat::action(d->myId, params, !d->preserveChat));
658 				else
659 					emit message(protocol::PrivateChat::action(d->myId, d->currentChat, params));
660 			}
661 			return;
662 
663 		} else if(cmd == "pin" && d->currentChat == 0) {
664 			if(!params.isEmpty())
665 				emit message(protocol::Chat::pin(d->myId, params));
666 			return;
667 
668 		} else if(cmd == "unpin" && d->currentChat == 0) {
669 			emit message(protocol::Chat::pin(d->myId, QStringLiteral("-")));
670 			return;
671 
672 		} else if(cmd == "roll") {
673 			utils::DiceRoll result = utils::diceRoll(params.isEmpty() ? QStringLiteral("1d6") : params);
674 			if(result.number>0) {
675 				if(d->currentChat == 0)
676 					emit message(protocol::Chat::action(d->myId, "rolls " + result.toString(), !d->preserveChat));
677 				else
678 					emit message(protocol::PrivateChat::action(d->myId, d->currentChat, "rolls " + result.toString()));
679 			} else
680 				systemMessage(tr("Invalid dice roll description"));
681 			return;
682 
683 		} else if(cmd == "help") {
684 			const QString text = QStringLiteral(
685 				"Available client commands:\n"
686 				"/help - show this message\n"
687 				"/clear - clear chat window\n"
688 				"/! <text> - make an announcement (recorded in session history)\n"
689 				"/me <text> - send action type message\n"
690 				"/pin <text> - pin a message to the top of the chat box (Ops only)\n"
691 				"/unpin - remove pinned message\n"
692 				"/roll [AdX] - roll dice"
693 			);
694 			systemMessage(text);
695 			return;
696 		}
697 
698 	}
699 
700 	// A normal chat message
701 	if(d->currentChat == 0)
702 		emit message(protocol::Chat::regular(d->myId, msg, !d->preserveChat));
703 	else
704 		emit message(protocol::PrivateChat::regular(d->myId, d->currentChat, msg));
705 }
706 
chatTabSelected(int index)707 void ChatWidget::chatTabSelected(int index)
708 {
709 	d->chats[d->currentChat].scrollPosition = d->view->verticalScrollBar()->value();
710 
711 	const int id = d->tabs->tabData(index).toInt();
712 	Q_ASSERT(d->chats.contains(id));
713 	d->view->setDocument(d->chats[id].doc);
714 	d->view->verticalScrollBar()->setValue(d->chats[id].scrollPosition);
715 	d->tabs->setTabTextColor(index, QColor());
716 	d->currentChat = d->tabs->tabData(index).toInt();
717 	d->updatePreserveModeUi();
718 }
719 
chatTabClosed(int index)720 void ChatWidget::chatTabClosed(int index)
721 {
722 	const int id = d->tabs->tabData(index).toInt();
723 	Q_ASSERT(d->chats.contains(id));
724 	if(id == 0) {
725 		// Can't close the public chat
726 		return;
727 	}
728 
729 	d->tabs->removeTab(index);
730 
731 	delete d->chats[id].doc;
732 	d->chats.remove(id);
733 }
734 
showChatContextMenu(const QPoint & pos)735 void ChatWidget::showChatContextMenu(const QPoint &pos)
736 {
737 	auto menu = std::unique_ptr<QMenu>(d->view->createStandardContextMenu());
738 
739 	menu->addSeparator();
740 
741 	menu->addAction(tr("Clear"), this, &ChatWidget::clear);
742 
743 	auto compact = menu->addAction(tr("Compact mode"), this, &ChatWidget::setCompactMode);
744 	compact->setCheckable(true);
745 	compact->setChecked(d->compactMode);
746 
747 	if(d->isAttached) {
748 		menu->addAction(tr("Detach"), this, &ChatWidget::detachRequested);
749 	} else {
750 		QWidget *win = parentWidget();
751 		while(win->parent() != nullptr)
752 			win = win->parentWidget();
753 
754 		menu->addAction(tr("Attach"), win, &QWidget::close);
755 	}
756 
757 	menu->exec(d->view->mapToGlobal(pos));
758 }
759 
setCompactMode(bool compact)760 void ChatWidget::setCompactMode(bool compact)
761 {
762 	d->compactMode = compact;
763 	QSettings().setValue("history/compactchat", compact);
764 }
765 
766 }
767 
768