// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "common/logging/log.h" #include "network/announce_multiplayer_session.h" #include "ui_chat_room.h" #include "yuzu/game_list_p.h" #include "yuzu/multiplayer/chat_room.h" #include "yuzu/multiplayer/message.h" #ifdef ENABLE_WEB_SERVICE #include "web_service/web_backend.h" #endif class ChatMessage { public: explicit ChatMessage(const Network::ChatEntry& chat, Network::RoomNetwork& room_network, QTime ts = {}) { /// Convert the time to their default locale defined format QLocale locale; timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat); nickname = QString::fromStdString(chat.nickname); username = QString::fromStdString(chat.username); message = QString::fromStdString(chat.message); // Check for user pings QString cur_nickname, cur_username; if (auto room = room_network.GetRoomMember().lock()) { cur_nickname = QString::fromStdString(room->GetNickname()); cur_username = QString::fromStdString(room->GetUsername()); } // Handle pings at the beginning and end of message QString fixed_message = QStringLiteral(" %1 ").arg(message); if (fixed_message.contains(QStringLiteral(" @%1 ").arg(cur_nickname)) || (!cur_username.isEmpty() && fixed_message.contains(QStringLiteral(" @%1 ").arg(cur_username)))) { contains_ping = true; } else { contains_ping = false; } } bool ContainsPing() const { return contains_ping; } /// Format the message using the players color QString GetPlayerChatMessage(u16 player) const { const bool is_dark_theme = QIcon::themeName().contains(QStringLiteral("dark")) || QIcon::themeName().contains(QStringLiteral("midnight")); auto color = is_dark_theme ? player_color_dark[player % 16] : player_color_default[player % 16]; QString name; if (username.isEmpty() || username == nickname) { name = nickname; } else { name = QStringLiteral("%1 (%2)").arg(nickname, username); } QString style, text_color; if (ContainsPing()) { // Add a background color to these messages style = QStringLiteral("background-color: %1").arg(QString::fromStdString(ping_color)); // Add a font color text_color = QStringLiteral("color='#000000'"); } return QStringLiteral("[%1] <%3> %6") .arg(timestamp, QString::fromStdString(color), name.toHtmlEscaped(), style, text_color, message.toHtmlEscaped()); } private: static constexpr std::array player_color_default = { {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "#FFFF00"}}; static constexpr std::array player_color_dark = { {"#559AD1", "#4EC9A8", "#D69D85", "#C6C923", "#B975B5", "#D81F1F", "#7EAE39", "#4F8733", "#F7CD8A", "#6FCACF", "#CE4897", "#8A2BE2", "#D2691E", "#9ACD32", "#FF7F50", "#152ccd"}}; static constexpr char ping_color[] = "#FFFF00"; QString timestamp; QString nickname; QString username; QString message; bool contains_ping; }; class StatusMessage { public: explicit StatusMessage(const QString& msg, QTime ts = {}) { /// Convert the time to their default locale defined format QLocale locale; timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat); message = msg; } QString GetSystemChatMessage() const { return QStringLiteral("[%1] * %3") .arg(timestamp, QString::fromStdString(system_color), message); } private: static constexpr const char system_color[] = "#FF8C00"; QString timestamp; QString message; }; class PlayerListItem : public QStandardItem { public: static const int NicknameRole = Qt::UserRole + 1; static const int UsernameRole = Qt::UserRole + 2; static const int AvatarUrlRole = Qt::UserRole + 3; static const int GameNameRole = Qt::UserRole + 4; static const int GameVersionRole = Qt::UserRole + 5; PlayerListItem() = default; explicit PlayerListItem(const std::string& nickname, const std::string& username, const std::string& avatar_url, const AnnounceMultiplayerRoom::GameInfo& game_info) { setEditable(false); setData(QString::fromStdString(nickname), NicknameRole); setData(QString::fromStdString(username), UsernameRole); setData(QString::fromStdString(avatar_url), AvatarUrlRole); if (game_info.name.empty()) { setData(QObject::tr("Not playing a game"), GameNameRole); } else { setData(QString::fromStdString(game_info.name), GameNameRole); } setData(QString::fromStdString(game_info.version), GameVersionRole); } QVariant data(int role) const override { if (role != Qt::DisplayRole) { return QStandardItem::data(role); } QString name; const QString nickname = data(NicknameRole).toString(); const QString username = data(UsernameRole).toString(); if (username.isEmpty() || username == nickname) { name = nickname; } else { name = QStringLiteral("%1 (%2)").arg(nickname, username); } const QString version = data(GameVersionRole).toString(); QString version_string; if (!version.isEmpty()) { version_string = QStringLiteral("(%1)").arg(version); } return QStringLiteral("%1\n %2 %3") .arg(name, data(GameNameRole).toString(), version_string); } }; ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique()) { ui->setupUi(this); // set the item_model for player_view player_list = new QStandardItemModel(ui->player_view); ui->player_view->setModel(player_list); ui->player_view->setContextMenuPolicy(Qt::CustomContextMenu); // set a header to make it look better though there is only one column player_list->insertColumns(0, 1); player_list->setHeaderData(0, Qt::Horizontal, tr("Members")); ui->chat_history->document()->setMaximumBlockCount(max_chat_lines); auto font = ui->chat_history->font(); font.setPointSizeF(10); ui->chat_history->setFont(font); // register the network structs to use in slots and signals qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); // Connect all the widgets to the appropriate events connect(ui->player_view, &QTreeView::customContextMenuRequested, this, &ChatRoom::PopupContextMenu); connect(ui->chat_message, &QLineEdit::returnPressed, this, &ChatRoom::OnSendChat); connect(ui->chat_message, &QLineEdit::textChanged, this, &ChatRoom::OnChatTextChanged); connect(ui->send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat); } ChatRoom::~ChatRoom() = default; void ChatRoom::Initialize(Network::RoomNetwork* room_network_) { room_network = room_network_; // setup the callbacks for network updates if (auto member = room_network->GetRoomMember().lock()) { member->BindOnChatMessageReceived( [this](const Network::ChatEntry& chat) { emit ChatReceived(chat); }); member->BindOnStatusMessageReceived( [this](const Network::StatusMessageEntry& status_message) { emit StatusMessageReceived(status_message); }); connect(this, &ChatRoom::ChatReceived, this, &ChatRoom::OnChatReceive); connect(this, &ChatRoom::StatusMessageReceived, this, &ChatRoom::OnStatusMessageReceive); } } void ChatRoom::SetModPerms(bool is_mod) { has_mod_perms = is_mod; } void ChatRoom::RetranslateUi() { ui->retranslateUi(this); } void ChatRoom::Clear() { ui->chat_history->clear(); block_list.clear(); } void ChatRoom::AppendStatusMessage(const QString& msg) { ui->chat_history->append(StatusMessage(msg).GetSystemChatMessage()); } void ChatRoom::AppendChatMessage(const QString& msg) { ui->chat_history->append(msg); } void ChatRoom::SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname) { if (auto room = room_network->GetRoomMember().lock()) { auto members = room->GetMemberInformation(); auto it = std::find_if(members.begin(), members.end(), [&nickname](const Network::RoomMember::MemberInformation& member) { return member.nickname == nickname; }); if (it == members.end()) { NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::NO_SUCH_USER); return; } room->SendModerationRequest(type, nickname); } } bool ChatRoom::ValidateMessage(const std::string& msg) { return !msg.empty(); } void ChatRoom::OnRoomUpdate(const Network::RoomInformation& info) { // TODO(B3N30): change title if (auto room_member = room_network->GetRoomMember().lock()) { SetPlayerList(room_member->GetMemberInformation()); } } void ChatRoom::Disable() { ui->send_message->setDisabled(true); ui->chat_message->setDisabled(true); } void ChatRoom::Enable() { ui->send_message->setEnabled(true); ui->chat_message->setEnabled(true); } void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) { if (!ValidateMessage(chat.message)) { return; } if (auto room = room_network->GetRoomMember().lock()) { // get the id of the player auto members = room->GetMemberInformation(); auto it = std::find_if(members.begin(), members.end(), [&chat](const Network::RoomMember::MemberInformation& member) { return member.nickname == chat.nickname && member.username == chat.username; }); if (it == members.end()) { LOG_INFO(Network, "Chat message received from unknown player. Ignoring it."); return; } if (block_list.count(chat.nickname)) { LOG_INFO(Network, "Chat message received from blocked player {}. Ignoring it.", chat.nickname); return; } auto player = std::distance(members.begin(), it); ChatMessage m(chat, *room_network); if (m.ContainsPing()) { emit UserPinged(); } AppendChatMessage(m.GetPlayerChatMessage(player)); } } void ChatRoom::OnStatusMessageReceive(const Network::StatusMessageEntry& status_message) { QString name; if (status_message.username.empty() || status_message.username == status_message.nickname) { name = QString::fromStdString(status_message.nickname); } else { name = QStringLiteral("%1 (%2)").arg(QString::fromStdString(status_message.nickname), QString::fromStdString(status_message.username)); } QString message; switch (status_message.type) { case Network::IdMemberJoin: message = tr("%1 has joined").arg(name); break; case Network::IdMemberLeave: message = tr("%1 has left").arg(name); break; case Network::IdMemberKicked: message = tr("%1 has been kicked").arg(name); break; case Network::IdMemberBanned: message = tr("%1 has been banned").arg(name); break; case Network::IdAddressUnbanned: message = tr("%1 has been unbanned").arg(name); break; } if (!message.isEmpty()) AppendStatusMessage(message); } void ChatRoom::OnSendChat() { if (auto room_member = room_network->GetRoomMember().lock()) { if (!room_member->IsConnected()) { return; } auto message = ui->chat_message->text().toStdString(); if (!ValidateMessage(message)) { return; } auto nick = room_member->GetNickname(); auto username = room_member->GetUsername(); Network::ChatEntry chat{nick, username, message}; auto members = room_member->GetMemberInformation(); auto it = std::find_if(members.begin(), members.end(), [&chat](const Network::RoomMember::MemberInformation& member) { return member.nickname == chat.nickname && member.username == chat.username; }); if (it == members.end()) { LOG_INFO(Network, "Cannot find self in the player list when sending a message."); } auto player = std::distance(members.begin(), it); ChatMessage m(chat, *room_network); room_member->SendChatMessage(message); AppendChatMessage(m.GetPlayerChatMessage(player)); ui->chat_message->clear(); } } void ChatRoom::UpdateIconDisplay() { for (int row = 0; row < player_list->invisibleRootItem()->rowCount(); ++row) { QStandardItem* item = player_list->invisibleRootItem()->child(row); const std::string avatar_url = item->data(PlayerListItem::AvatarUrlRole).toString().toStdString(); if (icon_cache.count(avatar_url)) { item->setData(icon_cache.at(avatar_url), Qt::DecorationRole); } else { item->setData(QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48), Qt::DecorationRole); } } } void ChatRoom::SetPlayerList(const Network::RoomMember::MemberList& member_list) { // TODO(B3N30): Remember which row is selected player_list->removeRows(0, player_list->rowCount()); for (const auto& member : member_list) { if (member.nickname.empty()) continue; QStandardItem* name_item = new PlayerListItem(member.nickname, member.username, member.avatar_url, member.game_info); #ifdef ENABLE_WEB_SERVICE if (!icon_cache.count(member.avatar_url) && !member.avatar_url.empty()) { // Start a request to get the member's avatar const QUrl url(QString::fromStdString(member.avatar_url)); QFuture future = QtConcurrent::run([url] { WebService::Client client( QStringLiteral("%1://%2").arg(url.scheme(), url.host()).toStdString(), "", ""); auto result = client.GetImage(url.path().toStdString(), true); if (result.returned_data.empty()) { LOG_ERROR(WebService, "Failed to get avatar"); } return result.returned_data; }); auto* future_watcher = new QFutureWatcher(this); connect(future_watcher, &QFutureWatcher::finished, this, [this, future_watcher, avatar_url = member.avatar_url] { const std::string result = future_watcher->result(); if (result.empty()) return; QPixmap pixmap; if (!pixmap.loadFromData(reinterpret_cast(result.data()), static_cast(result.size()))) return; icon_cache[avatar_url] = pixmap.scaled(48, 48, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); // Update all the displayed icons with the new icon_cache UpdateIconDisplay(); }); future_watcher->setFuture(future); } #endif player_list->invisibleRootItem()->appendRow(name_item); } UpdateIconDisplay(); // TODO(B3N30): Restore row selection } void ChatRoom::OnChatTextChanged() { if (ui->chat_message->text().length() > static_cast(Network::MaxMessageSize)) ui->chat_message->setText( ui->chat_message->text().left(static_cast(Network::MaxMessageSize))); } void ChatRoom::PopupContextMenu(const QPoint& menu_location) { QModelIndex item = ui->player_view->indexAt(menu_location); if (!item.isValid()) return; std::string nickname = player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString(); QMenu context_menu; QString username = player_list->item(item.row())->data(PlayerListItem::UsernameRole).toString(); if (!username.isEmpty()) { QAction* view_profile_action = context_menu.addAction(tr("View Profile")); connect(view_profile_action, &QAction::triggered, [username] { QDesktopServices::openUrl( QUrl(QStringLiteral("https://community.citra-emu.org/u/%1").arg(username))); }); } std::string cur_nickname; if (auto room = room_network->GetRoomMember().lock()) { cur_nickname = room->GetNickname(); } if (nickname != cur_nickname) { // You can't block yourself QAction* block_action = context_menu.addAction(tr("Block Player")); block_action->setCheckable(true); block_action->setChecked(block_list.count(nickname) > 0); connect(block_action, &QAction::triggered, [this, nickname] { if (block_list.count(nickname)) { block_list.erase(nickname); } else { QMessageBox::StandardButton result = QMessageBox::question( this, tr("Block Player"), tr("When you block a player, you will no longer receive chat messages from " "them.

Are you sure you would like to block %1?") .arg(QString::fromStdString(nickname)), QMessageBox::Yes | QMessageBox::No); if (result == QMessageBox::Yes) block_list.emplace(nickname); } }); } if (has_mod_perms && nickname != cur_nickname) { // You can't kick or ban yourself context_menu.addSeparator(); QAction* kick_action = context_menu.addAction(tr("Kick")); QAction* ban_action = context_menu.addAction(tr("Ban")); connect(kick_action, &QAction::triggered, [this, nickname] { QMessageBox::StandardButton result = QMessageBox::question(this, tr("Kick Player"), tr("Are you sure you would like to kick %1?") .arg(QString::fromStdString(nickname)), QMessageBox::Yes | QMessageBox::No); if (result == QMessageBox::Yes) SendModerationRequest(Network::IdModKick, nickname); }); connect(ban_action, &QAction::triggered, [this, nickname] { QMessageBox::StandardButton result = QMessageBox::question( this, tr("Ban Player"), tr("Are you sure you would like to kick and ban %1?\n\nThis would " "ban both their forum username and their IP address.") .arg(QString::fromStdString(nickname)), QMessageBox::Yes | QMessageBox::No); if (result == QMessageBox::Yes) SendModerationRequest(Network::IdModBan, nickname); }); } context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location)); }