chat_room.cpp 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. // SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project
  2. // SPDX-License-Identifier: GPL-2.0-or-later
  3. #include <array>
  4. #include <future>
  5. #include <QColor>
  6. #include <QDesktopServices>
  7. #include <QFutureWatcher>
  8. #include <QImage>
  9. #include <QList>
  10. #include <QLocale>
  11. #include <QMenu>
  12. #include <QMessageBox>
  13. #include <QMetaType>
  14. #include <QTime>
  15. #include <QUrl>
  16. #include <QtConcurrent/QtConcurrentRun>
  17. #include "common/logging/log.h"
  18. #include "network/announce_multiplayer_session.h"
  19. #include "ui_chat_room.h"
  20. #include "yuzu/game_list_p.h"
  21. #include "yuzu/multiplayer/chat_room.h"
  22. #include "yuzu/multiplayer/message.h"
  23. #ifdef ENABLE_WEB_SERVICE
  24. #include "web_service/web_backend.h"
  25. #endif
  26. class ChatMessage {
  27. public:
  28. explicit ChatMessage(const Network::ChatEntry& chat, Network::RoomNetwork& room_network,
  29. QTime ts = {}) {
  30. /// Convert the time to their default locale defined format
  31. QLocale locale;
  32. timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat);
  33. nickname = QString::fromStdString(chat.nickname);
  34. username = QString::fromStdString(chat.username);
  35. message = QString::fromStdString(chat.message);
  36. // Check for user pings
  37. QString cur_nickname, cur_username;
  38. if (auto room = room_network.GetRoomMember().lock()) {
  39. cur_nickname = QString::fromStdString(room->GetNickname());
  40. cur_username = QString::fromStdString(room->GetUsername());
  41. }
  42. // Handle pings at the beginning and end of message
  43. QString fixed_message = QStringLiteral(" %1 ").arg(message);
  44. if (fixed_message.contains(QStringLiteral(" @%1 ").arg(cur_nickname)) ||
  45. (!cur_username.isEmpty() &&
  46. fixed_message.contains(QStringLiteral(" @%1 ").arg(cur_username)))) {
  47. contains_ping = true;
  48. } else {
  49. contains_ping = false;
  50. }
  51. }
  52. bool ContainsPing() const {
  53. return contains_ping;
  54. }
  55. /// Format the message using the players color
  56. QString GetPlayerChatMessage(u16 player) const {
  57. const bool is_dark_theme = QIcon::themeName().contains(QStringLiteral("dark")) ||
  58. QIcon::themeName().contains(QStringLiteral("midnight"));
  59. auto color =
  60. is_dark_theme ? player_color_dark[player % 16] : player_color_default[player % 16];
  61. QString name;
  62. if (username.isEmpty() || username == nickname) {
  63. name = nickname;
  64. } else {
  65. name = QStringLiteral("%1 (%2)").arg(nickname, username);
  66. }
  67. QString style, text_color;
  68. if (ContainsPing()) {
  69. // Add a background color to these messages
  70. style = QStringLiteral("background-color: %1").arg(QString::fromStdString(ping_color));
  71. // Add a font color
  72. text_color = QStringLiteral("color='#000000'");
  73. }
  74. return QStringLiteral("[%1] <font color='%2'>&lt;%3&gt;</font> <font style='%4' "
  75. "%5>%6</font>")
  76. .arg(timestamp, QString::fromStdString(color), name.toHtmlEscaped(), style, text_color,
  77. message.toHtmlEscaped());
  78. }
  79. private:
  80. static constexpr std::array<const char*, 16> player_color_default = {
  81. {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222",
  82. "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "#FFFF00"}};
  83. static constexpr std::array<const char*, 16> player_color_dark = {
  84. {"#559AD1", "#4EC9A8", "#D69D85", "#C6C923", "#B975B5", "#D81F1F", "#7EAE39", "#4F8733",
  85. "#F7CD8A", "#6FCACF", "#CE4897", "#8A2BE2", "#D2691E", "#9ACD32", "#FF7F50", "#152ccd"}};
  86. static constexpr char ping_color[] = "#FFFF00";
  87. QString timestamp;
  88. QString nickname;
  89. QString username;
  90. QString message;
  91. bool contains_ping;
  92. };
  93. class StatusMessage {
  94. public:
  95. explicit StatusMessage(const QString& msg, QTime ts = {}) {
  96. /// Convert the time to their default locale defined format
  97. QLocale locale;
  98. timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat);
  99. message = msg;
  100. }
  101. QString GetSystemChatMessage() const {
  102. return QStringLiteral("[%1] <font color='%2'>* %3</font>")
  103. .arg(timestamp, QString::fromStdString(system_color), message);
  104. }
  105. private:
  106. static constexpr const char system_color[] = "#FF8C00";
  107. QString timestamp;
  108. QString message;
  109. };
  110. class PlayerListItem : public QStandardItem {
  111. public:
  112. static const int NicknameRole = Qt::UserRole + 1;
  113. static const int UsernameRole = Qt::UserRole + 2;
  114. static const int AvatarUrlRole = Qt::UserRole + 3;
  115. static const int GameNameRole = Qt::UserRole + 4;
  116. static const int GameVersionRole = Qt::UserRole + 5;
  117. PlayerListItem() = default;
  118. explicit PlayerListItem(const std::string& nickname, const std::string& username,
  119. const std::string& avatar_url,
  120. const AnnounceMultiplayerRoom::GameInfo& game_info) {
  121. setEditable(false);
  122. setData(QString::fromStdString(nickname), NicknameRole);
  123. setData(QString::fromStdString(username), UsernameRole);
  124. setData(QString::fromStdString(avatar_url), AvatarUrlRole);
  125. if (game_info.name.empty()) {
  126. setData(QObject::tr("Not playing a game"), GameNameRole);
  127. } else {
  128. setData(QString::fromStdString(game_info.name), GameNameRole);
  129. }
  130. setData(QString::fromStdString(game_info.version), GameVersionRole);
  131. }
  132. QVariant data(int role) const override {
  133. if (role != Qt::DisplayRole) {
  134. return QStandardItem::data(role);
  135. }
  136. QString name;
  137. const QString nickname = data(NicknameRole).toString();
  138. const QString username = data(UsernameRole).toString();
  139. if (username.isEmpty() || username == nickname) {
  140. name = nickname;
  141. } else {
  142. name = QStringLiteral("%1 (%2)").arg(nickname, username);
  143. }
  144. const QString version = data(GameVersionRole).toString();
  145. QString version_string;
  146. if (!version.isEmpty()) {
  147. version_string = QStringLiteral("(%1)").arg(version);
  148. }
  149. return QStringLiteral("%1\n %2 %3")
  150. .arg(name, data(GameNameRole).toString(), version_string);
  151. }
  152. };
  153. ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique<Ui::ChatRoom>()) {
  154. ui->setupUi(this);
  155. // set the item_model for player_view
  156. player_list = new QStandardItemModel(ui->player_view);
  157. ui->player_view->setModel(player_list);
  158. ui->player_view->setContextMenuPolicy(Qt::CustomContextMenu);
  159. // set a header to make it look better though there is only one column
  160. player_list->insertColumns(0, 1);
  161. player_list->setHeaderData(0, Qt::Horizontal, tr("Members"));
  162. ui->chat_history->document()->setMaximumBlockCount(max_chat_lines);
  163. auto font = ui->chat_history->font();
  164. font.setPointSizeF(10);
  165. ui->chat_history->setFont(font);
  166. // register the network structs to use in slots and signals
  167. qRegisterMetaType<Network::ChatEntry>();
  168. qRegisterMetaType<Network::StatusMessageEntry>();
  169. qRegisterMetaType<Network::RoomInformation>();
  170. qRegisterMetaType<Network::RoomMember::State>();
  171. // Connect all the widgets to the appropriate events
  172. connect(ui->player_view, &QTreeView::customContextMenuRequested, this,
  173. &ChatRoom::PopupContextMenu);
  174. connect(ui->chat_message, &QLineEdit::returnPressed, this, &ChatRoom::OnSendChat);
  175. connect(ui->chat_message, &QLineEdit::textChanged, this, &ChatRoom::OnChatTextChanged);
  176. connect(ui->send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat);
  177. }
  178. ChatRoom::~ChatRoom() = default;
  179. void ChatRoom::Initialize(Network::RoomNetwork* room_network_) {
  180. room_network = room_network_;
  181. // setup the callbacks for network updates
  182. if (auto member = room_network->GetRoomMember().lock()) {
  183. member->BindOnChatMessageRecieved(
  184. [this](const Network::ChatEntry& chat) { emit ChatReceived(chat); });
  185. member->BindOnStatusMessageReceived(
  186. [this](const Network::StatusMessageEntry& status_message) {
  187. emit StatusMessageReceived(status_message);
  188. });
  189. connect(this, &ChatRoom::ChatReceived, this, &ChatRoom::OnChatReceive);
  190. connect(this, &ChatRoom::StatusMessageReceived, this, &ChatRoom::OnStatusMessageReceive);
  191. }
  192. }
  193. void ChatRoom::SetModPerms(bool is_mod) {
  194. has_mod_perms = is_mod;
  195. }
  196. void ChatRoom::RetranslateUi() {
  197. ui->retranslateUi(this);
  198. }
  199. void ChatRoom::Clear() {
  200. ui->chat_history->clear();
  201. block_list.clear();
  202. }
  203. void ChatRoom::AppendStatusMessage(const QString& msg) {
  204. ui->chat_history->append(StatusMessage(msg).GetSystemChatMessage());
  205. }
  206. void ChatRoom::AppendChatMessage(const QString& msg) {
  207. ui->chat_history->append(msg);
  208. }
  209. void ChatRoom::SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname) {
  210. if (auto room = room_network->GetRoomMember().lock()) {
  211. auto members = room->GetMemberInformation();
  212. auto it = std::find_if(members.begin(), members.end(),
  213. [&nickname](const Network::RoomMember::MemberInformation& member) {
  214. return member.nickname == nickname;
  215. });
  216. if (it == members.end()) {
  217. NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::NO_SUCH_USER);
  218. return;
  219. }
  220. room->SendModerationRequest(type, nickname);
  221. }
  222. }
  223. bool ChatRoom::ValidateMessage(const std::string& msg) {
  224. return !msg.empty();
  225. }
  226. void ChatRoom::OnRoomUpdate(const Network::RoomInformation& info) {
  227. // TODO(B3N30): change title
  228. if (auto room_member = room_network->GetRoomMember().lock()) {
  229. SetPlayerList(room_member->GetMemberInformation());
  230. }
  231. }
  232. void ChatRoom::Disable() {
  233. ui->send_message->setDisabled(true);
  234. ui->chat_message->setDisabled(true);
  235. }
  236. void ChatRoom::Enable() {
  237. ui->send_message->setEnabled(true);
  238. ui->chat_message->setEnabled(true);
  239. }
  240. void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) {
  241. if (!ValidateMessage(chat.message)) {
  242. return;
  243. }
  244. if (auto room = room_network->GetRoomMember().lock()) {
  245. // get the id of the player
  246. auto members = room->GetMemberInformation();
  247. auto it = std::find_if(members.begin(), members.end(),
  248. [&chat](const Network::RoomMember::MemberInformation& member) {
  249. return member.nickname == chat.nickname &&
  250. member.username == chat.username;
  251. });
  252. if (it == members.end()) {
  253. LOG_INFO(Network, "Chat message received from unknown player. Ignoring it.");
  254. return;
  255. }
  256. if (block_list.count(chat.nickname)) {
  257. LOG_INFO(Network, "Chat message received from blocked player {}. Ignoring it.",
  258. chat.nickname);
  259. return;
  260. }
  261. auto player = std::distance(members.begin(), it);
  262. ChatMessage m(chat, *room_network);
  263. if (m.ContainsPing()) {
  264. emit UserPinged();
  265. }
  266. AppendChatMessage(m.GetPlayerChatMessage(player));
  267. }
  268. }
  269. void ChatRoom::OnStatusMessageReceive(const Network::StatusMessageEntry& status_message) {
  270. QString name;
  271. if (status_message.username.empty() || status_message.username == status_message.nickname) {
  272. name = QString::fromStdString(status_message.nickname);
  273. } else {
  274. name = QStringLiteral("%1 (%2)").arg(QString::fromStdString(status_message.nickname),
  275. QString::fromStdString(status_message.username));
  276. }
  277. QString message;
  278. switch (status_message.type) {
  279. case Network::IdMemberJoin:
  280. message = tr("%1 has joined").arg(name);
  281. break;
  282. case Network::IdMemberLeave:
  283. message = tr("%1 has left").arg(name);
  284. break;
  285. case Network::IdMemberKicked:
  286. message = tr("%1 has been kicked").arg(name);
  287. break;
  288. case Network::IdMemberBanned:
  289. message = tr("%1 has been banned").arg(name);
  290. break;
  291. case Network::IdAddressUnbanned:
  292. message = tr("%1 has been unbanned").arg(name);
  293. break;
  294. }
  295. if (!message.isEmpty())
  296. AppendStatusMessage(message);
  297. }
  298. void ChatRoom::OnSendChat() {
  299. if (auto room_member = room_network->GetRoomMember().lock()) {
  300. if (!room_member->IsConnected()) {
  301. return;
  302. }
  303. auto message = ui->chat_message->text().toStdString();
  304. if (!ValidateMessage(message)) {
  305. return;
  306. }
  307. auto nick = room_member->GetNickname();
  308. auto username = room_member->GetUsername();
  309. Network::ChatEntry chat{nick, username, message};
  310. auto members = room_member->GetMemberInformation();
  311. auto it = std::find_if(members.begin(), members.end(),
  312. [&chat](const Network::RoomMember::MemberInformation& member) {
  313. return member.nickname == chat.nickname &&
  314. member.username == chat.username;
  315. });
  316. if (it == members.end()) {
  317. LOG_INFO(Network, "Cannot find self in the player list when sending a message.");
  318. }
  319. auto player = std::distance(members.begin(), it);
  320. ChatMessage m(chat, *room_network);
  321. room_member->SendChatMessage(message);
  322. AppendChatMessage(m.GetPlayerChatMessage(player));
  323. ui->chat_message->clear();
  324. }
  325. }
  326. void ChatRoom::UpdateIconDisplay() {
  327. for (int row = 0; row < player_list->invisibleRootItem()->rowCount(); ++row) {
  328. QStandardItem* item = player_list->invisibleRootItem()->child(row);
  329. const std::string avatar_url =
  330. item->data(PlayerListItem::AvatarUrlRole).toString().toStdString();
  331. if (icon_cache.count(avatar_url)) {
  332. item->setData(icon_cache.at(avatar_url), Qt::DecorationRole);
  333. } else {
  334. item->setData(QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48),
  335. Qt::DecorationRole);
  336. }
  337. }
  338. }
  339. void ChatRoom::SetPlayerList(const Network::RoomMember::MemberList& member_list) {
  340. // TODO(B3N30): Remember which row is selected
  341. player_list->removeRows(0, player_list->rowCount());
  342. for (const auto& member : member_list) {
  343. if (member.nickname.empty())
  344. continue;
  345. QStandardItem* name_item = new PlayerListItem(member.nickname, member.username,
  346. member.avatar_url, member.game_info);
  347. #ifdef ENABLE_WEB_SERVICE
  348. if (!icon_cache.count(member.avatar_url) && !member.avatar_url.empty()) {
  349. // Start a request to get the member's avatar
  350. const QUrl url(QString::fromStdString(member.avatar_url));
  351. QFuture<std::string> future = QtConcurrent::run([url] {
  352. WebService::Client client(
  353. QStringLiteral("%1://%2").arg(url.scheme(), url.host()).toStdString(), "", "");
  354. auto result = client.GetImage(url.path().toStdString(), true);
  355. if (result.returned_data.empty()) {
  356. LOG_ERROR(WebService, "Failed to get avatar");
  357. }
  358. return result.returned_data;
  359. });
  360. auto* future_watcher = new QFutureWatcher<std::string>(this);
  361. connect(future_watcher, &QFutureWatcher<std::string>::finished, this,
  362. [this, future_watcher, avatar_url = member.avatar_url] {
  363. const std::string result = future_watcher->result();
  364. if (result.empty())
  365. return;
  366. QPixmap pixmap;
  367. if (!pixmap.loadFromData(reinterpret_cast<const u8*>(result.data()),
  368. static_cast<uint>(result.size())))
  369. return;
  370. icon_cache[avatar_url] =
  371. pixmap.scaled(48, 48, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
  372. // Update all the displayed icons with the new icon_cache
  373. UpdateIconDisplay();
  374. });
  375. future_watcher->setFuture(future);
  376. }
  377. #endif
  378. player_list->invisibleRootItem()->appendRow(name_item);
  379. }
  380. UpdateIconDisplay();
  381. // TODO(B3N30): Restore row selection
  382. }
  383. void ChatRoom::OnChatTextChanged() {
  384. if (ui->chat_message->text().length() > static_cast<int>(Network::MaxMessageSize))
  385. ui->chat_message->setText(
  386. ui->chat_message->text().left(static_cast<int>(Network::MaxMessageSize)));
  387. }
  388. void ChatRoom::PopupContextMenu(const QPoint& menu_location) {
  389. QModelIndex item = ui->player_view->indexAt(menu_location);
  390. if (!item.isValid())
  391. return;
  392. std::string nickname =
  393. player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString();
  394. QMenu context_menu;
  395. QString username = player_list->item(item.row())->data(PlayerListItem::UsernameRole).toString();
  396. if (!username.isEmpty()) {
  397. QAction* view_profile_action = context_menu.addAction(tr("View Profile"));
  398. connect(view_profile_action, &QAction::triggered, [username] {
  399. QDesktopServices::openUrl(
  400. QUrl(QStringLiteral("https://community.citra-emu.org/u/%1").arg(username)));
  401. });
  402. }
  403. std::string cur_nickname;
  404. if (auto room = room_network->GetRoomMember().lock()) {
  405. cur_nickname = room->GetNickname();
  406. }
  407. if (nickname != cur_nickname) { // You can't block yourself
  408. QAction* block_action = context_menu.addAction(tr("Block Player"));
  409. block_action->setCheckable(true);
  410. block_action->setChecked(block_list.count(nickname) > 0);
  411. connect(block_action, &QAction::triggered, [this, nickname] {
  412. if (block_list.count(nickname)) {
  413. block_list.erase(nickname);
  414. } else {
  415. QMessageBox::StandardButton result = QMessageBox::question(
  416. this, tr("Block Player"),
  417. tr("When you block a player, you will no longer receive chat messages from "
  418. "them.<br><br>Are you sure you would like to block %1?")
  419. .arg(QString::fromStdString(nickname)),
  420. QMessageBox::Yes | QMessageBox::No);
  421. if (result == QMessageBox::Yes)
  422. block_list.emplace(nickname);
  423. }
  424. });
  425. }
  426. if (has_mod_perms && nickname != cur_nickname) { // You can't kick or ban yourself
  427. context_menu.addSeparator();
  428. QAction* kick_action = context_menu.addAction(tr("Kick"));
  429. QAction* ban_action = context_menu.addAction(tr("Ban"));
  430. connect(kick_action, &QAction::triggered, [this, nickname] {
  431. QMessageBox::StandardButton result =
  432. QMessageBox::question(this, tr("Kick Player"),
  433. tr("Are you sure you would like to <b>kick</b> %1?")
  434. .arg(QString::fromStdString(nickname)),
  435. QMessageBox::Yes | QMessageBox::No);
  436. if (result == QMessageBox::Yes)
  437. SendModerationRequest(Network::IdModKick, nickname);
  438. });
  439. connect(ban_action, &QAction::triggered, [this, nickname] {
  440. QMessageBox::StandardButton result = QMessageBox::question(
  441. this, tr("Ban Player"),
  442. tr("Are you sure you would like to <b>kick and ban</b> %1?\n\nThis would "
  443. "ban both their forum username and their IP address.")
  444. .arg(QString::fromStdString(nickname)),
  445. QMessageBox::Yes | QMessageBox::No);
  446. if (result == QMessageBox::Yes)
  447. SendModerationRequest(Network::IdModBan, nickname);
  448. });
  449. }
  450. context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location));
  451. }