chat_room.cpp 19 KB

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