diff options
author | Zephyron <zephyron@citron-emu.org> | 2024-12-31 16:19:25 +1000 |
---|---|---|
committer | Zephyron <zephyron@citron-emu.org> | 2024-12-31 16:19:25 +1000 |
commit | 9427e27e24a7135880ee2881c3c44988e174b41a (patch) | |
tree | 83f0062a35be144f6b162eaa823c5b3c7620146e /src/citron/multiplayer | |
parent | b35ae725d20960411e8588b11c12a2d55f86c9d0 (diff) |
chore: update project branding to citron
Diffstat (limited to 'src/citron/multiplayer')
24 files changed, 3664 insertions, 0 deletions
diff --git a/src/citron/multiplayer/chat_room.cpp b/src/citron/multiplayer/chat_room.cpp new file mode 100644 index 000000000..4463616b4 --- /dev/null +++ b/src/citron/multiplayer/chat_room.cpp @@ -0,0 +1,508 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <array> +#include <future> +#include <QColor> +#include <QDesktopServices> +#include <QFutureWatcher> +#include <QImage> +#include <QList> +#include <QLocale> +#include <QMenu> +#include <QMessageBox> +#include <QMetaType> +#include <QTime> +#include <QUrl> +#include <QtConcurrent/QtConcurrentRun> +#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] <font color='%2'><%3></font> <font style='%4' " + "%5>%6</font>") + .arg(timestamp, QString::fromStdString(color), name.toHtmlEscaped(), style, text_color, + message.toHtmlEscaped()); + } + +private: + static constexpr std::array<const char*, 16> player_color_default = { + {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", + "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "#FFFF00"}}; + static constexpr std::array<const char*, 16> 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] <font color='%2'>* %3</font>") + .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::ChatRoom>()) { + 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<Network::ChatEntry>(); + qRegisterMetaType<Network::StatusMessageEntry>(); + qRegisterMetaType<Network::RoomInformation>(); + qRegisterMetaType<Network::RoomMember::State>(); + + // 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<std::string> 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<std::string>(this); + connect(future_watcher, &QFutureWatcher<std::string>::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<const u8*>(result.data()), + static_cast<uint>(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<int>(Network::MaxMessageSize)) + ui->chat_message->setText( + ui->chat_message->text().left(static_cast<int>(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.<br><br>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 <b>kick</b> %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 <b>kick and ban</b> %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)); +} diff --git a/src/citron/multiplayer/chat_room.h b/src/citron/multiplayer/chat_room.h new file mode 100644 index 000000000..dd71ea4cd --- /dev/null +++ b/src/citron/multiplayer/chat_room.h @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <memory> +#include <unordered_map> +#include <unordered_set> +#include <QDialog> +#include <QSortFilterProxyModel> +#include <QStandardItemModel> +#include <QVariant> +#include "network/network.h" + +namespace Ui { +class ChatRoom; +} + +namespace Core { +class AnnounceMultiplayerSession; +} + +class ConnectionError; +class ComboBoxProxyModel; + +class ChatMessage; + +class ChatRoom : public QWidget { + Q_OBJECT + +public: + explicit ChatRoom(QWidget* parent); + void Initialize(Network::RoomNetwork* room_network); + void RetranslateUi(); + void SetPlayerList(const Network::RoomMember::MemberList& member_list); + void Clear(); + void AppendStatusMessage(const QString& msg); + ~ChatRoom(); + + void SetModPerms(bool is_mod); + void UpdateIconDisplay(); + +public slots: + void OnRoomUpdate(const Network::RoomInformation& info); + void OnChatReceive(const Network::ChatEntry&); + void OnStatusMessageReceive(const Network::StatusMessageEntry&); + void OnSendChat(); + void OnChatTextChanged(); + void PopupContextMenu(const QPoint& menu_location); + void Disable(); + void Enable(); + +signals: + void ChatReceived(const Network::ChatEntry&); + void StatusMessageReceived(const Network::StatusMessageEntry&); + void UserPinged(); + +private: + static constexpr u32 max_chat_lines = 1000; + void AppendChatMessage(const QString&); + bool ValidateMessage(const std::string&); + void SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname); + + bool has_mod_perms = false; + QStandardItemModel* player_list; + std::unique_ptr<Ui::ChatRoom> ui; + std::unordered_set<std::string> block_list; + std::unordered_map<std::string, QPixmap> icon_cache; + Network::RoomNetwork* room_network; +}; + +Q_DECLARE_METATYPE(Network::ChatEntry); +Q_DECLARE_METATYPE(Network::StatusMessageEntry); +Q_DECLARE_METATYPE(Network::RoomInformation); +Q_DECLARE_METATYPE(Network::RoomMember::State); +Q_DECLARE_METATYPE(Network::RoomMember::Error); diff --git a/src/citron/multiplayer/chat_room.ui b/src/citron/multiplayer/chat_room.ui new file mode 100644 index 000000000..f2b31b5da --- /dev/null +++ b/src/citron/multiplayer/chat_room.ui @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ChatRoom</class> + <widget class="QWidget" name="ChatRoom"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>807</width> + <height>432</height> + </rect> + </property> + <property name="windowTitle"> + <string>Room Window</string> + </property> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QTreeView" name="player_view"/> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <item> + <widget class="QTextEdit" name="chat_history"> + <property name="undoRedoEnabled"> + <bool>false</bool> + </property> + <property name="readOnly"> + <bool>true</bool> + </property> + <property name="textInteractionFlags"> + <set>Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLineEdit" name="chat_message"> + <property name="placeholderText"> + <string>Send Chat Message</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="send_message"> + <property name="text"> + <string>Send Message</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/citron/multiplayer/client_room.cpp b/src/citron/multiplayer/client_room.cpp new file mode 100644 index 000000000..caf34a414 --- /dev/null +++ b/src/citron/multiplayer/client_room.cpp @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <future> +#include <QColor> +#include <QImage> +#include <QList> +#include <QLocale> +#include <QMetaType> +#include <QTime> +#include <QtConcurrent/QtConcurrentRun> +#include "common/logging/log.h" +#include "network/announce_multiplayer_session.h" +#include "ui_client_room.h" +#include "yuzu/game_list_p.h" +#include "yuzu/multiplayer/client_room.h" +#include "yuzu/multiplayer/message.h" +#include "yuzu/multiplayer/moderation_dialog.h" +#include "yuzu/multiplayer/state.h" + +ClientRoomWindow::ClientRoomWindow(QWidget* parent, Network::RoomNetwork& room_network_) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique<Ui::ClientRoom>()), room_network{room_network_} { + ui->setupUi(this); + ui->chat->Initialize(&room_network); + + // setup the callbacks for network updates + if (auto member = room_network.GetRoomMember().lock()) { + member->BindOnRoomInformationChanged( + [this](const Network::RoomInformation& info) { emit RoomInformationChanged(info); }); + member->BindOnStateChanged( + [this](const Network::RoomMember::State& state) { emit StateChanged(state); }); + + connect(this, &ClientRoomWindow::RoomInformationChanged, this, + &ClientRoomWindow::OnRoomUpdate); + connect(this, &ClientRoomWindow::StateChanged, this, &::ClientRoomWindow::OnStateChange); + // Update the state + OnStateChange(member->GetState()); + } else { + // TODO (jroweboy) network was not initialized? + } + + connect(ui->disconnect, &QPushButton::clicked, this, &ClientRoomWindow::Disconnect); + ui->disconnect->setDefault(false); + ui->disconnect->setAutoDefault(false); + connect(ui->moderation, &QPushButton::clicked, [this] { + ModerationDialog dialog(room_network, this); + dialog.exec(); + }); + ui->moderation->setDefault(false); + ui->moderation->setAutoDefault(false); + connect(ui->chat, &ChatRoom::UserPinged, this, &ClientRoomWindow::ShowNotification); + UpdateView(); +} + +ClientRoomWindow::~ClientRoomWindow() = default; + +void ClientRoomWindow::SetModPerms(bool is_mod) { + ui->chat->SetModPerms(is_mod); + ui->moderation->setVisible(is_mod); + ui->moderation->setDefault(false); + ui->moderation->setAutoDefault(false); +} + +void ClientRoomWindow::RetranslateUi() { + ui->retranslateUi(this); + ui->chat->RetranslateUi(); +} + +void ClientRoomWindow::OnRoomUpdate(const Network::RoomInformation& info) { + UpdateView(); +} + +void ClientRoomWindow::OnStateChange(const Network::RoomMember::State& state) { + if (state == Network::RoomMember::State::Joined || + state == Network::RoomMember::State::Moderator) { + ui->chat->Clear(); + ui->chat->AppendStatusMessage(tr("Connected")); + SetModPerms(state == Network::RoomMember::State::Moderator); + } + UpdateView(); +} + +void ClientRoomWindow::Disconnect() { + auto parent = static_cast<MultiplayerState*>(parentWidget()); + if (parent->OnCloseRoom()) { + ui->chat->AppendStatusMessage(tr("Disconnected")); + close(); + } +} + +void ClientRoomWindow::UpdateView() { + if (auto member = room_network.GetRoomMember().lock()) { + if (member->IsConnected()) { + ui->chat->Enable(); + ui->disconnect->setEnabled(true); + auto memberlist = member->GetMemberInformation(); + ui->chat->SetPlayerList(memberlist); + const auto information = member->GetRoomInformation(); + setWindowTitle(QString(tr("%1 - %2 (%3/%4 members) - connected")) + .arg(QString::fromStdString(information.name)) + .arg(QString::fromStdString(information.preferred_game.name)) + .arg(memberlist.size()) + .arg(information.member_slots)); + ui->description->setText(QString::fromStdString(information.description)); + return; + } + } + // TODO(B3N30): can't get RoomMember*, show error and close window + close(); +} + +void ClientRoomWindow::UpdateIconDisplay() { + ui->chat->UpdateIconDisplay(); +} diff --git a/src/citron/multiplayer/client_room.h b/src/citron/multiplayer/client_room.h new file mode 100644 index 000000000..f338e3c59 --- /dev/null +++ b/src/citron/multiplayer/client_room.h @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "yuzu/multiplayer/chat_room.h" + +namespace Ui { +class ClientRoom; +} + +class ClientRoomWindow : public QDialog { + Q_OBJECT + +public: + explicit ClientRoomWindow(QWidget* parent, Network::RoomNetwork& room_network_); + ~ClientRoomWindow(); + + void RetranslateUi(); + void UpdateIconDisplay(); + +public slots: + void OnRoomUpdate(const Network::RoomInformation&); + void OnStateChange(const Network::RoomMember::State&); + +signals: + void RoomInformationChanged(const Network::RoomInformation&); + void StateChanged(const Network::RoomMember::State&); + void ShowNotification(); + +private: + void Disconnect(); + void UpdateView(); + void SetModPerms(bool is_mod); + + QStandardItemModel* player_list; + std::unique_ptr<Ui::ClientRoom> ui; + Network::RoomNetwork& room_network; +}; diff --git a/src/citron/multiplayer/client_room.ui b/src/citron/multiplayer/client_room.ui new file mode 100644 index 000000000..97e88b502 --- /dev/null +++ b/src/citron/multiplayer/client_room.ui @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ClientRoom</class> + <widget class="QWidget" name="ClientRoom"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>807</width> + <height>432</height> + </rect> + </property> + <property name="windowTitle"> + <string>Room Window</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <property name="rightMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="description"> + <property name="text"> + <string>Room Description</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="moderation"> + <property name="text"> + <string>Moderation...</string> + </property> + <property name="visible"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="disconnect"> + <property name="text"> + <string>Leave Room</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="ChatRoom" name="chat" native="true"/> + </item> + </layout> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>ChatRoom</class> + <extends>QWidget</extends> + <header>multiplayer/chat_room.h</header> + <container>1</container> + </customwidget> + </customwidgets> + <resources/> + <connections/> +</ui> diff --git a/src/citron/multiplayer/direct_connect.cpp b/src/citron/multiplayer/direct_connect.cpp new file mode 100644 index 000000000..74da97e21 --- /dev/null +++ b/src/citron/multiplayer/direct_connect.cpp @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <QComboBox> +#include <QFuture> +#include <QIntValidator> +#include <QRegularExpressionValidator> +#include <QString> +#include <QtConcurrent/QtConcurrentRun> +#include "common/settings.h" +#include "core/core.h" +#include "core/internal_network/network_interface.h" +#include "network/network.h" +#include "ui_direct_connect.h" +#include "yuzu/main.h" +#include "yuzu/multiplayer/client_room.h" +#include "yuzu/multiplayer/direct_connect.h" +#include "yuzu/multiplayer/message.h" +#include "yuzu/multiplayer/state.h" +#include "yuzu/multiplayer/validation.h" +#include "yuzu/uisettings.h" + +enum class ConnectionType : u8 { TraversalServer, IP }; + +DirectConnectWindow::DirectConnectWindow(Core::System& system_, QWidget* parent) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique<Ui::DirectConnect>()), system{system_}, room_network{ + system.GetRoomNetwork()} { + + ui->setupUi(this); + + // setup the watcher for background connections + watcher = new QFutureWatcher<void>; + connect(watcher, &QFutureWatcher<void>::finished, this, &DirectConnectWindow::OnConnection); + + ui->nickname->setValidator(validation.GetNickname()); + ui->nickname->setText( + QString::fromStdString(UISettings::values.multiplayer_nickname.GetValue())); + if (ui->nickname->text().isEmpty() && !Settings::values.yuzu_username.GetValue().empty()) { + // Use yuzu Web Service user name as nickname by default + ui->nickname->setText(QString::fromStdString(Settings::values.yuzu_username.GetValue())); + } + ui->ip->setValidator(validation.GetIP()); + ui->ip->setText(QString::fromStdString(UISettings::values.multiplayer_ip.GetValue())); + ui->port->setValidator(validation.GetPort()); + ui->port->setText(QString::number(UISettings::values.multiplayer_port.GetValue())); + + // TODO(jroweboy): Show or hide the connection options based on the current value of the combo + // box. Add this back in when the traversal server support is added. + connect(ui->connect, &QPushButton::clicked, this, &DirectConnectWindow::Connect); +} + +DirectConnectWindow::~DirectConnectWindow() = default; + +void DirectConnectWindow::RetranslateUi() { + ui->retranslateUi(this); +} + +void DirectConnectWindow::Connect() { + if (!Network::GetSelectedNetworkInterface()) { + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::NO_INTERFACE_SELECTED); + return; + } + if (!ui->nickname->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::USERNAME_NOT_VALID); + return; + } + if (system.IsPoweredOn()) { + if (!NetworkMessage::WarnGameRunning()) { + return; + } + } + if (const auto member = room_network.GetRoomMember().lock()) { + // Prevent the user from trying to join a room while they are already joining. + if (member->GetState() == Network::RoomMember::State::Joining) { + return; + } else if (member->IsConnected()) { + // And ask if they want to leave the room if they are already in one. + if (!NetworkMessage::WarnDisconnect()) { + return; + } + } + } + if (!ui->ip->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::IP_ADDRESS_NOT_VALID); + return; + } + if (!ui->port->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::PORT_NOT_VALID); + return; + } + + // Store settings + UISettings::values.multiplayer_nickname = ui->nickname->text().toStdString(); + UISettings::values.multiplayer_ip = ui->ip->text().toStdString(); + if (!ui->port->text().isEmpty()) { + UISettings::values.multiplayer_port = ui->port->text().toInt(); + } else { + UISettings::values.multiplayer_port = UISettings::values.multiplayer_port.GetDefault(); + } + + emit SaveConfig(); + + // attempt to connect in a different thread + QFuture<void> f = QtConcurrent::run([&] { + if (auto room_member = room_network.GetRoomMember().lock()) { + auto port = UISettings::values.multiplayer_port.GetValue(); + room_member->Join(ui->nickname->text().toStdString(), + ui->ip->text().toStdString().c_str(), port, 0, Network::NoPreferredIP, + ui->password->text().toStdString().c_str()); + } + }); + watcher->setFuture(f); + // and disable widgets and display a connecting while we wait + BeginConnecting(); +} + +void DirectConnectWindow::BeginConnecting() { + ui->connect->setEnabled(false); + ui->connect->setText(tr("Connecting")); +} + +void DirectConnectWindow::EndConnecting() { + ui->connect->setEnabled(true); + ui->connect->setText(tr("Connect")); +} + +void DirectConnectWindow::OnConnection() { + EndConnecting(); + + if (auto room_member = room_network.GetRoomMember().lock()) { + if (room_member->IsConnected()) { + close(); + } + } +} diff --git a/src/citron/multiplayer/direct_connect.h b/src/citron/multiplayer/direct_connect.h new file mode 100644 index 000000000..b8f66cfb2 --- /dev/null +++ b/src/citron/multiplayer/direct_connect.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <memory> +#include <QDialog> +#include <QFutureWatcher> +#include "yuzu/multiplayer/validation.h" + +namespace Ui { +class DirectConnect; +} + +namespace Core { +class System; +} + +class DirectConnectWindow : public QDialog { + Q_OBJECT + +public: + explicit DirectConnectWindow(Core::System& system_, QWidget* parent = nullptr); + ~DirectConnectWindow(); + + void RetranslateUi(); + +signals: + /** + * Signalled by this widget when it is closing itself and destroying any state such as + * connections that it might have. + */ + void Closed(); + void SaveConfig(); + +private slots: + void OnConnection(); + +private: + void Connect(); + void BeginConnecting(); + void EndConnecting(); + + QFutureWatcher<void>* watcher; + std::unique_ptr<Ui::DirectConnect> ui; + Validation validation; + Core::System& system; + Network::RoomNetwork& room_network; +}; diff --git a/src/citron/multiplayer/direct_connect.ui b/src/citron/multiplayer/direct_connect.ui new file mode 100644 index 000000000..0dd4e6829 --- /dev/null +++ b/src/citron/multiplayer/direct_connect.ui @@ -0,0 +1,165 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>DirectConnect</class> + <widget class="QWidget" name="DirectConnect"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>455</width> + <height>161</height> + </rect> + </property> + <property name="windowTitle"> + <string>Direct Connect</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <item> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <property name="spacing"> + <number>0</number> + </property> + <property name="leftMargin"> + <number>0</number> + </property> + <item> + <widget class="QWidget" name="ip_container" native="true"> + <layout class="QHBoxLayout" name="ip_layout"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Server Address</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="ip"> + <property name="toolTip"> + <string><html><head/><body><p>Server address of the host</p></body></html></string> + </property> + <property name="maxLength"> + <number>253</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="text"> + <string>Port</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="port"> + <property name="toolTip"> + <string><html><head/><body><p>Port number the host is listening on</p></body></html></string> + </property> + <property name="maxLength"> + <number>5</number> + </property> + <property name="placeholderText"> + <string notr="true" extracomment="placeholder string that tells user default port">24872</string> + </property> + <property name="maximumSize"> + <size> + <width>65</width> + <height>50</height> + </size> + </property> + </widget> + </item> + </layout> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QLabel" name="label_5"> + <property name="text"> + <string>Nickname</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="nickname"> + <property name="maxLength"> + <number>20</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Password</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="password"/> + </item> + </layout> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="connect"> + <property name="text"> + <string>Connect</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/citron/multiplayer/host_room.cpp b/src/citron/multiplayer/host_room.cpp new file mode 100644 index 000000000..ef364ee43 --- /dev/null +++ b/src/citron/multiplayer/host_room.cpp @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <future> +#include <QColor> +#include <QImage> +#include <QList> +#include <QLocale> +#include <QMessageBox> +#include <QMetaType> +#include <QTime> +#include <QtConcurrent/QtConcurrentRun> +#include "common/logging/log.h" +#include "common/settings.h" +#include "core/core.h" +#include "core/internal_network/network_interface.h" +#include "network/announce_multiplayer_session.h" +#include "ui_host_room.h" +#include "yuzu/game_list_p.h" +#include "yuzu/main.h" +#include "yuzu/multiplayer/host_room.h" +#include "yuzu/multiplayer/message.h" +#include "yuzu/multiplayer/state.h" +#include "yuzu/multiplayer/validation.h" +#include "yuzu/uisettings.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/verify_user_jwt.h" +#endif + +HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list, + std::shared_ptr<Core::AnnounceMultiplayerSession> session, + Core::System& system_) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique<Ui::HostRoom>()), + announce_multiplayer_session(session), system{system_}, room_network{ + system.GetRoomNetwork()} { + ui->setupUi(this); + + // set up validation for all of the fields + ui->room_name->setValidator(validation.GetRoomName()); + ui->username->setValidator(validation.GetNickname()); + ui->port->setValidator(validation.GetPort()); + ui->port->setPlaceholderText(QString::number(Network::DefaultRoomPort)); + + // Create a proxy to the game list to display the list of preferred games + game_list = new QStandardItemModel; + UpdateGameList(list); + + proxy = new ComboBoxProxyModel; + proxy->setSourceModel(game_list); + proxy->sort(0, Qt::AscendingOrder); + ui->game_list->setModel(proxy); + + // Connect all the widgets to the appropriate events + connect(ui->host, &QPushButton::clicked, this, &HostRoomWindow::Host); + + // Restore the settings: + ui->username->setText( + QString::fromStdString(UISettings::values.multiplayer_room_nickname.GetValue())); + if (ui->username->text().isEmpty() && !Settings::values.yuzu_username.GetValue().empty()) { + // Use yuzu Web Service user name as nickname by default + ui->username->setText(QString::fromStdString(Settings::values.yuzu_username.GetValue())); + } + ui->room_name->setText( + QString::fromStdString(UISettings::values.multiplayer_room_name.GetValue())); + ui->port->setText(QString::number(UISettings::values.multiplayer_room_port.GetValue())); + ui->max_player->setValue(UISettings::values.multiplayer_max_player.GetValue()); + int index = UISettings::values.multiplayer_host_type.GetValue(); + if (index < ui->host_type->count()) { + ui->host_type->setCurrentIndex(index); + } + index = ui->game_list->findData(UISettings::values.multiplayer_game_id.GetValue(), + GameListItemPath::ProgramIdRole); + if (index != -1) { + ui->game_list->setCurrentIndex(index); + } + ui->room_description->setText( + QString::fromStdString(UISettings::values.multiplayer_room_description.GetValue())); +} + +HostRoomWindow::~HostRoomWindow() = default; + +void HostRoomWindow::UpdateGameList(QStandardItemModel* list) { + game_list->clear(); + for (int i = 0; i < list->rowCount(); i++) { + auto parent = list->item(i, 0); + for (int j = 0; j < parent->rowCount(); j++) { + game_list->appendRow(parent->child(j)->clone()); + } + } +} + +void HostRoomWindow::RetranslateUi() { + ui->retranslateUi(this); +} + +std::unique_ptr<Network::VerifyUser::Backend> HostRoomWindow::CreateVerifyBackend( + bool use_validation) const { + std::unique_ptr<Network::VerifyUser::Backend> verify_backend; + if (use_validation) { +#ifdef ENABLE_WEB_SERVICE + verify_backend = + std::make_unique<WebService::VerifyUserJWT>(Settings::values.web_api_url.GetValue()); +#else + verify_backend = std::make_unique<Network::VerifyUser::NullBackend>(); +#endif + } else { + verify_backend = std::make_unique<Network::VerifyUser::NullBackend>(); + } + return verify_backend; +} + +void HostRoomWindow::Host() { + if (!Network::GetSelectedNetworkInterface()) { + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::NO_INTERFACE_SELECTED); + return; + } + if (!ui->username->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::USERNAME_NOT_VALID); + return; + } + if (!ui->room_name->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::ROOMNAME_NOT_VALID); + return; + } + if (!ui->port->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::PORT_NOT_VALID); + return; + } + if (ui->game_list->currentIndex() == -1) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::GAME_NOT_SELECTED); + return; + } + if (system.IsPoweredOn()) { + if (!NetworkMessage::WarnGameRunning()) { + return; + } + } + if (auto member = room_network.GetRoomMember().lock()) { + if (member->GetState() == Network::RoomMember::State::Joining) { + return; + } else if (member->IsConnected()) { + auto parent = static_cast<MultiplayerState*>(parentWidget()); + if (!parent->OnCloseRoom()) { + close(); + return; + } + } + ui->host->setDisabled(true); + + const AnnounceMultiplayerRoom::GameInfo game{ + .name = ui->game_list->currentData(Qt::DisplayRole).toString().toStdString(), + .id = ui->game_list->currentData(GameListItemPath::ProgramIdRole).toULongLong(), + }; + const auto port = + ui->port->isModified() ? ui->port->text().toInt() : Network::DefaultRoomPort; + const auto password = ui->password->text().toStdString(); + const bool is_public = ui->host_type->currentIndex() == 0; + Network::Room::BanList ban_list{}; + if (ui->load_ban_list->isChecked()) { + ban_list = UISettings::values.multiplayer_ban_list; + } + if (auto room = room_network.GetRoom().lock()) { + const bool created = + room->Create(ui->room_name->text().toStdString(), + ui->room_description->toPlainText().toStdString(), "", port, password, + ui->max_player->value(), Settings::values.yuzu_username.GetValue(), + game, CreateVerifyBackend(is_public), ban_list); + if (!created) { + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::COULD_NOT_CREATE_ROOM); + LOG_ERROR(Network, "Could not create room!"); + ui->host->setEnabled(true); + return; + } + } + // Start the announce session if they chose Public + if (is_public) { + if (auto session = announce_multiplayer_session.lock()) { + // Register the room first to ensure verify_uid is present when we connect + WebService::WebResult result = session->Register(); + if (result.result_code != WebService::WebResult::Code::Success) { + QMessageBox::warning( + this, tr("Error"), + tr("Failed to announce the room to the public lobby. In order to host a " + "room publicly, you must have a valid yuzu account configured in " + "Emulation -> Configure -> Web. If you do not want to publish a room in " + "the public lobby, then select Unlisted instead.\nDebug Message: ") + + QString::fromStdString(result.result_string), + QMessageBox::Ok); + ui->host->setEnabled(true); + if (auto room = room_network.GetRoom().lock()) { + room->Destroy(); + } + return; + } + session->Start(); + } else { + LOG_ERROR(Network, "Starting announce session failed"); + } + } + std::string token; +#ifdef ENABLE_WEB_SERVICE + if (is_public) { + WebService::Client client(Settings::values.web_api_url.GetValue(), + Settings::values.yuzu_username.GetValue(), + Settings::values.yuzu_token.GetValue()); + if (auto room = room_network.GetRoom().lock()) { + token = client.GetExternalJWT(room->GetVerifyUID()).returned_data; + } + if (token.empty()) { + LOG_ERROR(WebService, "Could not get external JWT, verification may fail"); + } else { + LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); + } + } +#endif + // TODO: Check what to do with this + member->Join(ui->username->text().toStdString(), "127.0.0.1", port, 0, + Network::NoPreferredIP, password, token); + + // Store settings + UISettings::values.multiplayer_room_nickname = ui->username->text().toStdString(); + UISettings::values.multiplayer_room_name = ui->room_name->text().toStdString(); + UISettings::values.multiplayer_game_id = + ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong(); + UISettings::values.multiplayer_max_player = ui->max_player->value(); + + UISettings::values.multiplayer_host_type = ui->host_type->currentIndex(); + if (ui->port->isModified() && !ui->port->text().isEmpty()) { + UISettings::values.multiplayer_room_port = ui->port->text().toInt(); + } else { + UISettings::values.multiplayer_room_port = Network::DefaultRoomPort; + } + UISettings::values.multiplayer_room_description = + ui->room_description->toPlainText().toStdString(); + ui->host->setEnabled(true); + emit SaveConfig(); + close(); + } +} + +QVariant ComboBoxProxyModel::data(const QModelIndex& idx, int role) const { + if (role != Qt::DisplayRole) { + auto val = QSortFilterProxyModel::data(idx, role); + // If its the icon, shrink it to 16x16 + if (role == Qt::DecorationRole) + val = val.value<QImage>().scaled(16, 16, Qt::KeepAspectRatio); + return val; + } + std::string filename; + Common::SplitPath( + QSortFilterProxyModel::data(idx, GameListItemPath::FullPathRole).toString().toStdString(), + nullptr, &filename, nullptr); + QString title = QSortFilterProxyModel::data(idx, GameListItemPath::TitleRole).toString(); + return title.isEmpty() ? QString::fromStdString(filename) : title; +} + +bool ComboBoxProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const { + auto leftData = left.data(GameListItemPath::TitleRole).toString(); + auto rightData = right.data(GameListItemPath::TitleRole).toString(); + return leftData.compare(rightData) < 0; +} diff --git a/src/citron/multiplayer/host_room.h b/src/citron/multiplayer/host_room.h new file mode 100644 index 000000000..ae816e2e0 --- /dev/null +++ b/src/citron/multiplayer/host_room.h @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <memory> +#include <QDialog> +#include <QSortFilterProxyModel> +#include <QStandardItemModel> +#include <QVariant> +#include "network/network.h" +#include "yuzu/multiplayer/chat_room.h" +#include "yuzu/multiplayer/validation.h" + +namespace Ui { +class HostRoom; +} + +namespace Core { +class System; +class AnnounceMultiplayerSession; +} // namespace Core + +class ConnectionError; +class ComboBoxProxyModel; + +class ChatMessage; + +namespace Network::VerifyUser { +class Backend; +}; + +class HostRoomWindow : public QDialog { + Q_OBJECT + +public: + explicit HostRoomWindow(QWidget* parent, QStandardItemModel* list, + std::shared_ptr<Core::AnnounceMultiplayerSession> session, + Core::System& system_); + ~HostRoomWindow(); + + /** + * Updates the dialog with a new game list model. + * This model should be the original model of the game list. + */ + void UpdateGameList(QStandardItemModel* list); + void RetranslateUi(); + +signals: + void SaveConfig(); + +private: + void Host(); + std::unique_ptr<Network::VerifyUser::Backend> CreateVerifyBackend(bool use_validation) const; + + std::unique_ptr<Ui::HostRoom> ui; + std::weak_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session; + QStandardItemModel* game_list; + ComboBoxProxyModel* proxy; + Validation validation; + Core::System& system; + Network::RoomNetwork& room_network; +}; + +/** + * Proxy Model for the game list combo box so we can reuse the game list model while still + * displaying the fields slightly differently + */ +class ComboBoxProxyModel : public QSortFilterProxyModel { + Q_OBJECT + +public: + int columnCount(const QModelIndex& idx) const override { + return 1; + } + + QVariant data(const QModelIndex& idx, int role) const override; + + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; +}; diff --git a/src/citron/multiplayer/host_room.ui b/src/citron/multiplayer/host_room.ui new file mode 100644 index 000000000..d54cf49c6 --- /dev/null +++ b/src/citron/multiplayer/host_room.ui @@ -0,0 +1,207 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>HostRoom</class> + <widget class="QWidget" name="HostRoom"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>607</width> + <height>211</height> + </rect> + </property> + <property name="windowTitle"> + <string>Create Room</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <item> + <widget class="QWidget" name="settings" native="true"> + <layout class="QHBoxLayout"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <item> + <layout class="QFormLayout" name="formLayout_2"> + <property name="labelAlignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + <item row="0" column="0"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Room Name</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QLineEdit" name="room_name"> + <property name="maxLength"> + <number>50</number> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="label_3"> + <property name="text"> + <string>Preferred Game</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QComboBox" name="game_list"/> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Max Players</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QSpinBox" name="max_player"> + <property name="minimum"> + <number>2</number> + </property> + <property name="maximum"> + <number>16</number> + </property> + <property name="value"> + <number>8</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QFormLayout" name="formLayout"> + <property name="labelAlignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + <item row="0" column="1"> + <widget class="QLineEdit" name="username"/> + </item> + <item row="0" column="0"> + <widget class="QLabel" name="label_6"> + <property name="text"> + <string>Username</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QLineEdit" name="password"> + <property name="echoMode"> + <enum>QLineEdit::PasswordEchoOnEdit</enum> + </property> + <property name="placeholderText"> + <string>(Leave blank for open game)</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QLineEdit" name="port"> + <property name="inputMethodHints"> + <set>Qt::ImhDigitsOnly</set> + </property> + <property name="maxLength"> + <number>5</number> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="label_5"> + <property name="text"> + <string>Password</string> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="label_4"> + <property name="text"> + <string>Port</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLabel" name="label_7"> + <property name="text"> + <string>Room Description</string> + </property> + </widget> + </item> + <item> + <widget class="QTextEdit" name="room_description"/> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout"> + <item> + <widget class="QCheckBox" name="load_ban_list"> + <property name="text"> + <string>Load Previous Ban List</string> + </property> + <property name="checked"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <property name="rightMargin"> + <number>0</number> + </property> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QComboBox" name="host_type"> + <item> + <property name="text"> + <string>Public</string> + </property> + </item> + <item> + <property name="text"> + <string>Unlisted</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QPushButton" name="host"> + <property name="text"> + <string>Host Room</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/citron/multiplayer/lobby.cpp b/src/citron/multiplayer/lobby.cpp new file mode 100644 index 000000000..77ac84295 --- /dev/null +++ b/src/citron/multiplayer/lobby.cpp @@ -0,0 +1,439 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <QInputDialog> +#include <QList> +#include <QtConcurrent/QtConcurrentRun> +#include "common/logging/log.h" +#include "common/settings.h" +#include "core/core.h" +#include "core/hle/service/acc/profile_manager.h" +#include "core/internal_network/network_interface.h" +#include "network/network.h" +#include "ui_lobby.h" +#include "yuzu/game_list_p.h" +#include "yuzu/main.h" +#include "yuzu/multiplayer/client_room.h" +#include "yuzu/multiplayer/lobby.h" +#include "yuzu/multiplayer/lobby_p.h" +#include "yuzu/multiplayer/message.h" +#include "yuzu/multiplayer/state.h" +#include "yuzu/multiplayer/validation.h" +#include "yuzu/uisettings.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/web_backend.h" +#endif + +Lobby::Lobby(QWidget* parent, QStandardItemModel* list, + std::shared_ptr<Core::AnnounceMultiplayerSession> session, Core::System& system_) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique<Ui::Lobby>()), + announce_multiplayer_session(session), system{system_}, room_network{ + system.GetRoomNetwork()} { + ui->setupUi(this); + + // setup the watcher for background connections + watcher = new QFutureWatcher<void>; + + model = new QStandardItemModel(ui->room_list); + + // Create a proxy to the game list to get the list of games owned + game_list = new QStandardItemModel; + UpdateGameList(list); + + proxy = new LobbyFilterProxyModel(this, game_list); + proxy->setSourceModel(model); + proxy->setDynamicSortFilter(true); + proxy->setFilterCaseSensitivity(Qt::CaseInsensitive); + proxy->setSortLocaleAware(true); + ui->room_list->setModel(proxy); + ui->room_list->header()->setSectionResizeMode(QHeaderView::Interactive); + ui->room_list->header()->stretchLastSection(); + ui->room_list->setAlternatingRowColors(true); + ui->room_list->setSelectionMode(QHeaderView::SingleSelection); + ui->room_list->setSelectionBehavior(QHeaderView::SelectRows); + ui->room_list->setVerticalScrollMode(QHeaderView::ScrollPerPixel); + ui->room_list->setHorizontalScrollMode(QHeaderView::ScrollPerPixel); + ui->room_list->setSortingEnabled(true); + ui->room_list->setEditTriggers(QHeaderView::NoEditTriggers); + ui->room_list->setExpandsOnDoubleClick(false); + ui->room_list->setContextMenuPolicy(Qt::CustomContextMenu); + + ui->nickname->setValidator(validation.GetNickname()); + ui->nickname->setText( + QString::fromStdString(UISettings::values.multiplayer_nickname.GetValue())); + + // Try find the best nickname by default + if (ui->nickname->text().isEmpty() || ui->nickname->text() == QStringLiteral("yuzu")) { + if (!Settings::values.yuzu_username.GetValue().empty()) { + ui->nickname->setText( + QString::fromStdString(Settings::values.yuzu_username.GetValue())); + } else if (!GetProfileUsername().empty()) { + ui->nickname->setText(QString::fromStdString(GetProfileUsername())); + } else { + ui->nickname->setText(QStringLiteral("yuzu")); + } + } + + // UI Buttons + connect(ui->refresh_list, &QPushButton::clicked, this, &Lobby::RefreshLobby); + connect(ui->search, &QLineEdit::textChanged, proxy, &LobbyFilterProxyModel::SetFilterSearch); + connect(ui->games_owned, &QCheckBox::toggled, proxy, &LobbyFilterProxyModel::SetFilterOwned); + connect(ui->hide_empty, &QCheckBox::toggled, proxy, &LobbyFilterProxyModel::SetFilterEmpty); + connect(ui->hide_full, &QCheckBox::toggled, proxy, &LobbyFilterProxyModel::SetFilterFull); + connect(ui->room_list, &QTreeView::doubleClicked, this, &Lobby::OnJoinRoom); + connect(ui->room_list, &QTreeView::clicked, this, &Lobby::OnExpandRoom); + + // Actions + connect(&room_list_watcher, &QFutureWatcher<AnnounceMultiplayerRoom::RoomList>::finished, this, + &Lobby::OnRefreshLobby); + + // Load persistent filters after events are connected to make sure they apply + ui->search->setText( + QString::fromStdString(UISettings::values.multiplayer_filter_text.GetValue())); + ui->games_owned->setChecked(UISettings::values.multiplayer_filter_games_owned.GetValue()); + ui->hide_empty->setChecked(UISettings::values.multiplayer_filter_hide_empty.GetValue()); + ui->hide_full->setChecked(UISettings::values.multiplayer_filter_hide_full.GetValue()); +} + +Lobby::~Lobby() = default; + +void Lobby::UpdateGameList(QStandardItemModel* list) { + game_list->clear(); + for (int i = 0; i < list->rowCount(); i++) { + auto parent = list->item(i, 0); + for (int j = 0; j < parent->rowCount(); j++) { + game_list->appendRow(parent->child(j)->clone()); + } + } + if (proxy) + proxy->UpdateGameList(game_list); + ui->room_list->sortByColumn(Column::GAME_NAME, Qt::AscendingOrder); +} + +void Lobby::RetranslateUi() { + ui->retranslateUi(this); +} + +QString Lobby::PasswordPrompt() { + bool ok; + const QString text = + QInputDialog::getText(this, tr("Password Required to Join"), tr("Password:"), + QLineEdit::Password, QString(), &ok); + return ok ? text : QString(); +} + +void Lobby::OnExpandRoom(const QModelIndex& index) { + QModelIndex member_index = proxy->index(index.row(), Column::MEMBER); + auto member_list = proxy->data(member_index, LobbyItemMemberList::MemberListRole).toList(); +} + +void Lobby::OnJoinRoom(const QModelIndex& source) { + if (!Network::GetSelectedNetworkInterface()) { + LOG_INFO(WebService, "Automatically selected network interface for room network."); + Network::SelectFirstNetworkInterface(); + } + + if (!Network::GetSelectedNetworkInterface()) { + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::NO_INTERFACE_SELECTED); + return; + } + + if (system.IsPoweredOn()) { + if (!NetworkMessage::WarnGameRunning()) { + return; + } + } + + if (const auto member = room_network.GetRoomMember().lock()) { + // Prevent the user from trying to join a room while they are already joining. + if (member->GetState() == Network::RoomMember::State::Joining) { + return; + } else if (member->IsConnected()) { + // And ask if they want to leave the room if they are already in one. + if (!NetworkMessage::WarnDisconnect()) { + return; + } + } + } + QModelIndex index = source; + // If the user double clicks on a child row (aka the player list) then use the parent instead + if (source.parent() != QModelIndex()) { + index = source.parent(); + } + if (!ui->nickname->hasAcceptableInput()) { + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::USERNAME_NOT_VALID); + return; + } + + // Get a password to pass if the room is password protected + QModelIndex password_index = proxy->index(index.row(), Column::ROOM_NAME); + bool has_password = proxy->data(password_index, LobbyItemName::PasswordRole).toBool(); + const std::string password = has_password ? PasswordPrompt().toStdString() : ""; + if (has_password && password.empty()) { + return; + } + + QModelIndex connection_index = proxy->index(index.row(), Column::HOST); + const std::string nickname = ui->nickname->text().toStdString(); + const std::string ip = + proxy->data(connection_index, LobbyItemHost::HostIPRole).toString().toStdString(); + int port = proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt(); + const std::string verify_uid = + proxy->data(connection_index, LobbyItemHost::HostVerifyUIDRole).toString().toStdString(); + + // attempt to connect in a different thread + QFuture<void> f = QtConcurrent::run([nickname, ip, port, password, verify_uid, this] { + std::string token; +#ifdef ENABLE_WEB_SERVICE + if (!Settings::values.yuzu_username.GetValue().empty() && + !Settings::values.yuzu_token.GetValue().empty()) { + WebService::Client client(Settings::values.web_api_url.GetValue(), + Settings::values.yuzu_username.GetValue(), + Settings::values.yuzu_token.GetValue()); + token = client.GetExternalJWT(verify_uid).returned_data; + if (token.empty()) { + LOG_ERROR(WebService, "Could not get external JWT, verification may fail"); + } else { + LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); + } + } +#endif + if (auto room_member = room_network.GetRoomMember().lock()) { + room_member->Join(nickname, ip.c_str(), port, 0, Network::NoPreferredIP, password, + token); + } + }); + watcher->setFuture(f); + + // TODO(jroweboy): disable widgets and display a connecting while we wait + + // Save settings + UISettings::values.multiplayer_nickname = ui->nickname->text().toStdString(); + UISettings::values.multiplayer_filter_text = ui->search->text().toStdString(); + UISettings::values.multiplayer_filter_games_owned = ui->games_owned->isChecked(); + UISettings::values.multiplayer_filter_hide_empty = ui->hide_empty->isChecked(); + UISettings::values.multiplayer_filter_hide_full = ui->hide_full->isChecked(); + UISettings::values.multiplayer_ip = + proxy->data(connection_index, LobbyItemHost::HostIPRole).value<QString>().toStdString(); + UISettings::values.multiplayer_port = + proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt(); + emit SaveConfig(); +} + +void Lobby::ResetModel() { + model->clear(); + model->insertColumns(0, Column::TOTAL); + model->setHeaderData(Column::MEMBER, Qt::Horizontal, tr("Players"), Qt::DisplayRole); + model->setHeaderData(Column::ROOM_NAME, Qt::Horizontal, tr("Room Name"), Qt::DisplayRole); + model->setHeaderData(Column::GAME_NAME, Qt::Horizontal, tr("Preferred Game"), Qt::DisplayRole); + model->setHeaderData(Column::HOST, Qt::Horizontal, tr("Host"), Qt::DisplayRole); +} + +void Lobby::RefreshLobby() { + if (auto session = announce_multiplayer_session.lock()) { + ResetModel(); + ui->refresh_list->setEnabled(false); + ui->refresh_list->setText(tr("Refreshing")); + room_list_watcher.setFuture( + QtConcurrent::run([session]() { return session->GetRoomList(); })); + } else { + // TODO(jroweboy): Display an error box about announce couldn't be started + } +} + +void Lobby::OnRefreshLobby() { + AnnounceMultiplayerRoom::RoomList new_room_list = room_list_watcher.result(); + for (auto room : new_room_list) { + // find the icon for the game if this person owns that game. + QPixmap smdh_icon; + for (int r = 0; r < game_list->rowCount(); ++r) { + auto index = game_list->index(r, 0); + auto game_id = game_list->data(index, GameListItemPath::ProgramIdRole).toULongLong(); + + if (game_id != 0 && room.information.preferred_game.id == game_id) { + smdh_icon = game_list->data(index, Qt::DecorationRole).value<QPixmap>(); + } + } + + QList<QVariant> members; + for (auto member : room.members) { + QVariant var; + var.setValue(LobbyMember{QString::fromStdString(member.username), + QString::fromStdString(member.nickname), member.game.id, + QString::fromStdString(member.game.name)}); + members.append(var); + } + + auto first_item = new LobbyItemGame( + room.information.preferred_game.id, + QString::fromStdString(room.information.preferred_game.name), smdh_icon); + auto row = QList<QStandardItem*>({ + first_item, + new LobbyItemName(room.has_password, QString::fromStdString(room.information.name)), + new LobbyItemMemberList(members, room.information.member_slots), + new LobbyItemHost(QString::fromStdString(room.information.host_username), + QString::fromStdString(room.ip), room.information.port, + QString::fromStdString(room.verify_uid)), + }); + model->appendRow(row); + // To make the rows expandable, add the member data as a child of the first column of the + // rows with people in them and have qt set them to colspan after the model is finished + // resetting + if (!room.information.description.empty()) { + first_item->appendRow( + new LobbyItemDescription(QString::fromStdString(room.information.description))); + } + if (!room.members.empty()) { + first_item->appendRow(new LobbyItemExpandedMemberList(members)); + } + } + + // Re-enable the refresh button and resize the columns + ui->refresh_list->setEnabled(true); + ui->refresh_list->setText(tr("Refresh List")); + ui->room_list->header()->stretchLastSection(); + for (int i = 0; i < Column::TOTAL - 1; ++i) { + ui->room_list->resizeColumnToContents(i); + } + + // Set the member list child items to span all columns + for (int i = 0; i < proxy->rowCount(); i++) { + auto parent = model->item(i, 0); + for (int j = 0; j < parent->rowCount(); j++) { + ui->room_list->setFirstColumnSpanned(j, proxy->index(i, 0), true); + } + } + + ui->room_list->sortByColumn(Column::GAME_NAME, Qt::AscendingOrder); +} + +std::string Lobby::GetProfileUsername() { + const auto& current_user = + system.GetProfileManager().GetUser(Settings::values.current_user.GetValue()); + Service::Account::ProfileBase profile{}; + + if (!current_user.has_value()) { + return ""; + } + + if (!system.GetProfileManager().GetProfileBase(*current_user, profile)) { + return ""; + } + + const auto text = Common::StringFromFixedZeroTerminatedBuffer( + reinterpret_cast<const char*>(profile.username.data()), profile.username.size()); + + return text; +} + +LobbyFilterProxyModel::LobbyFilterProxyModel(QWidget* parent, QStandardItemModel* list) + : QSortFilterProxyModel(parent), game_list(list) {} + +void LobbyFilterProxyModel::UpdateGameList(QStandardItemModel* list) { + game_list = list; +} + +bool LobbyFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const { + // Prioritize filters by fastest to compute + + // pass over any child rows (aka row that shows the players in the room) + if (sourceParent != QModelIndex()) { + return true; + } + + // filter by empty rooms + if (filter_empty) { + QModelIndex member_list = sourceModel()->index(sourceRow, Column::MEMBER, sourceParent); + int player_count = + sourceModel()->data(member_list, LobbyItemMemberList::MemberListRole).toList().size(); + if (player_count == 0) { + return false; + } + } + + // filter by filled rooms + if (filter_full) { + QModelIndex member_list = sourceModel()->index(sourceRow, Column::MEMBER, sourceParent); + int player_count = + sourceModel()->data(member_list, LobbyItemMemberList::MemberListRole).toList().size(); + int max_players = + sourceModel()->data(member_list, LobbyItemMemberList::MaxPlayerRole).toInt(); + if (player_count >= max_players) { + return false; + } + } + + // filter by search parameters + if (!filter_search.isEmpty()) { + QModelIndex game_name = sourceModel()->index(sourceRow, Column::GAME_NAME, sourceParent); + QModelIndex room_name = sourceModel()->index(sourceRow, Column::ROOM_NAME, sourceParent); + QModelIndex host_name = sourceModel()->index(sourceRow, Column::HOST, sourceParent); + bool preferred_game_match = sourceModel() + ->data(game_name, LobbyItemGame::GameNameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + bool room_name_match = sourceModel() + ->data(room_name, LobbyItemName::NameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + bool username_match = sourceModel() + ->data(host_name, LobbyItemHost::HostUsernameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + if (!preferred_game_match && !room_name_match && !username_match) { + return false; + } + } + + // filter by game owned + if (filter_owned) { + QModelIndex game_name = sourceModel()->index(sourceRow, Column::GAME_NAME, sourceParent); + QList<QModelIndex> owned_games; + for (int r = 0; r < game_list->rowCount(); ++r) { + owned_games.append(QModelIndex(game_list->index(r, 0))); + } + auto current_id = sourceModel()->data(game_name, LobbyItemGame::TitleIDRole).toLongLong(); + if (current_id == 0) { + // TODO(jroweboy): homebrew often doesn't have a game id and this hides them + return false; + } + bool owned = false; + for (const auto& game : owned_games) { + auto game_id = game_list->data(game, GameListItemPath::ProgramIdRole).toLongLong(); + if (current_id == game_id) { + owned = true; + } + } + if (!owned) { + return false; + } + } + + return true; +} + +void LobbyFilterProxyModel::sort(int column, Qt::SortOrder order) { + sourceModel()->sort(column, order); +} + +void LobbyFilterProxyModel::SetFilterOwned(bool filter) { + filter_owned = filter; + invalidate(); +} + +void LobbyFilterProxyModel::SetFilterEmpty(bool filter) { + filter_empty = filter; + invalidate(); +} + +void LobbyFilterProxyModel::SetFilterFull(bool filter) { + filter_full = filter; + invalidate(); +} + +void LobbyFilterProxyModel::SetFilterSearch(const QString& filter) { + filter_search = filter; + invalidate(); +} diff --git a/src/citron/multiplayer/lobby.h b/src/citron/multiplayer/lobby.h new file mode 100644 index 000000000..e78c9cae3 --- /dev/null +++ b/src/citron/multiplayer/lobby.h @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <memory> +#include <QDialog> +#include <QFutureWatcher> +#include <QSortFilterProxyModel> +#include <QStandardItemModel> +#include "common/announce_multiplayer_room.h" +#include "network/announce_multiplayer_session.h" +#include "network/network.h" +#include "yuzu/multiplayer/validation.h" + +namespace Ui { +class Lobby; +} + +class LobbyModel; +class LobbyFilterProxyModel; + +namespace Core { +class System; +} + +/** + * Listing of all public games pulled from services. The lobby should be simple enough for users to + * find the game they want to play, and join it. + */ +class Lobby : public QDialog { + Q_OBJECT + +public: + explicit Lobby(QWidget* parent, QStandardItemModel* list, + std::shared_ptr<Core::AnnounceMultiplayerSession> session, + Core::System& system_); + ~Lobby() override; + + /** + * Updates the lobby with a new game list model. + * This model should be the original model of the game list. + */ + void UpdateGameList(QStandardItemModel* list); + void RetranslateUi(); + +public slots: + /** + * Begin the process to pull the latest room list from web services. After the listing is + * returned from web services, `LobbyRefreshed` will be signalled + */ + void RefreshLobby(); + +private slots: + /** + * Pulls the list of rooms from network and fills out the lobby model with the results + */ + void OnRefreshLobby(); + + /** + * Handler for single clicking on a room in the list. Expands the treeitem to show player + * information for the people in the room + * + * index - The row of the proxy model that the user wants to join. + */ + void OnExpandRoom(const QModelIndex&); + + /** + * Handler for double clicking on a room in the list. Gathers the host ip and port and attempts + * to connect. Will also prompt for a password in case one is required. + * + * index - The row of the proxy model that the user wants to join. + */ + void OnJoinRoom(const QModelIndex&); + +signals: + void StateChanged(const Network::RoomMember::State&); + void SaveConfig(); + +private: + std::string GetProfileUsername(); + + /** + * Removes all entries in the Lobby before refreshing. + */ + void ResetModel(); + + /** + * Prompts for a password. Returns an empty QString if the user either did not provide a + * password or if the user closed the window. + */ + QString PasswordPrompt(); + + std::unique_ptr<Ui::Lobby> ui; + + QStandardItemModel* model{}; + QStandardItemModel* game_list{}; + LobbyFilterProxyModel* proxy{}; + + QFutureWatcher<AnnounceMultiplayerRoom::RoomList> room_list_watcher; + std::weak_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session; + QFutureWatcher<void>* watcher; + Validation validation; + Core::System& system; + Network::RoomNetwork& room_network; +}; + +/** + * Proxy Model for filtering the lobby + */ +class LobbyFilterProxyModel : public QSortFilterProxyModel { + Q_OBJECT; + +public: + explicit LobbyFilterProxyModel(QWidget* parent, QStandardItemModel* list); + + /** + * Updates the filter with a new game list model. + * This model should be the processed one created by the Lobby. + */ + void UpdateGameList(QStandardItemModel* list); + + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + void sort(int column, Qt::SortOrder order) override; + +public slots: + void SetFilterOwned(bool); + void SetFilterEmpty(bool); + void SetFilterFull(bool); + void SetFilterSearch(const QString&); + +private: + QStandardItemModel* game_list; + bool filter_owned = false; + bool filter_empty = false; + bool filter_full = false; + QString filter_search; +}; diff --git a/src/citron/multiplayer/lobby.ui b/src/citron/multiplayer/lobby.ui new file mode 100644 index 000000000..0ef0ef762 --- /dev/null +++ b/src/citron/multiplayer/lobby.ui @@ -0,0 +1,130 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Lobby</class> + <widget class="QWidget" name="Lobby"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>903</width> + <height>487</height> + </rect> + </property> + <property name="windowTitle"> + <string>Public Room Browser</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <property name="spacing"> + <number>3</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <property name="spacing"> + <number>6</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_5"> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Nickname</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="nickname"> + <property name="placeholderText"> + <string>Nickname</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Filters</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="search"> + <property name="placeholderText"> + <string>Search</string> + </property> + <property name="clearButtonEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="games_owned"> + <property name="text"> + <string>Games I Own</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="hide_empty"> + <property name="text"> + <string>Hide Empty Rooms</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="hide_full"> + <property name="text"> + <string>Hide Full Rooms</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="refresh_list"> + <property name="text"> + <string>Refresh Lobby</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <widget class="QTreeView" name="room_list"/> + </item> + <item> + <widget class="QWidget" name="widget" native="true"/> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/citron/multiplayer/lobby_p.h b/src/citron/multiplayer/lobby_p.h new file mode 100644 index 000000000..77ec1fcde --- /dev/null +++ b/src/citron/multiplayer/lobby_p.h @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <utility> +#include <QPixmap> +#include <QStandardItem> +#include <QStandardItemModel> +#include "common/common_types.h" + +namespace Column { +enum List { + GAME_NAME, + ROOM_NAME, + MEMBER, + HOST, + TOTAL, +}; +} + +class LobbyItem : public QStandardItem { +public: + LobbyItem() = default; + explicit LobbyItem(const QString& string) : QStandardItem(string) {} + virtual ~LobbyItem() override = default; +}; + +class LobbyItemName : public LobbyItem { +public: + static const int NameRole = Qt::UserRole + 1; + static const int PasswordRole = Qt::UserRole + 2; + + LobbyItemName() = default; + explicit LobbyItemName(bool has_password, QString name) : LobbyItem() { + setData(name, NameRole); + setData(has_password, PasswordRole); + } + + QVariant data(int role) const override { + if (role == Qt::DecorationRole) { + bool has_password = data(PasswordRole).toBool(); + return has_password ? QIcon::fromTheme(QStringLiteral("lock")).pixmap(16) : QIcon(); + } + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(NameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(NameRole).toString().localeAwareCompare(other.data(NameRole).toString()) < 0; + } +}; + +class LobbyItemDescription : public LobbyItem { +public: + static const int DescriptionRole = Qt::UserRole + 1; + + LobbyItemDescription() = default; + explicit LobbyItemDescription(QString description) { + setData(description, DescriptionRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + auto description = data(DescriptionRole).toString(); + description.prepend(QStringLiteral("Description: ")); + return description; + } + + bool operator<(const QStandardItem& other) const override { + return data(DescriptionRole) + .toString() + .localeAwareCompare(other.data(DescriptionRole).toString()) < 0; + } +}; + +class LobbyItemGame : public LobbyItem { +public: + static const int TitleIDRole = Qt::UserRole + 1; + static const int GameNameRole = Qt::UserRole + 2; + static const int GameIconRole = Qt::UserRole + 3; + + LobbyItemGame() = default; + explicit LobbyItemGame(u64 title_id, QString game_name, QPixmap smdh_icon) { + setData(static_cast<unsigned long long>(title_id), TitleIDRole); + setData(game_name, GameNameRole); + if (!smdh_icon.isNull()) { + setData(smdh_icon, GameIconRole); + } else { + setData(QIcon::fromTheme(QStringLiteral("chip")).pixmap(32), GameIconRole); + } + } + + QVariant data(int role) const override { + if (role == Qt::DecorationRole) { + auto val = data(GameIconRole); + if (val.isValid()) { + val = val.value<QPixmap>().scaled(32, 32, Qt::KeepAspectRatio, + Qt::TransformationMode::SmoothTransformation); + } else { + auto blank_image = QPixmap(32, 32); + blank_image.fill(Qt::black); + val = blank_image; + } + return val; + } else if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(GameNameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(GameNameRole) + .toString() + .localeAwareCompare(other.data(GameNameRole).toString()) < 0; + } +}; + +class LobbyItemHost : public LobbyItem { +public: + static const int HostUsernameRole = Qt::UserRole + 1; + static const int HostIPRole = Qt::UserRole + 2; + static const int HostPortRole = Qt::UserRole + 3; + static const int HostVerifyUIDRole = Qt::UserRole + 4; + + LobbyItemHost() = default; + explicit LobbyItemHost(QString username, QString ip, u16 port, QString verify_uid) { + setData(username, HostUsernameRole); + setData(ip, HostIPRole); + setData(port, HostPortRole); + setData(verify_uid, HostVerifyUIDRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(HostUsernameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(HostUsernameRole) + .toString() + .localeAwareCompare(other.data(HostUsernameRole).toString()) < 0; + } +}; + +class LobbyMember { +public: + LobbyMember() = default; + LobbyMember(const LobbyMember& other) = default; + explicit LobbyMember(QString username_, QString nickname_, u64 title_id_, QString game_name_) + : username(std::move(username_)), nickname(std::move(nickname_)), title_id(title_id_), + game_name(std::move(game_name_)) {} + ~LobbyMember() = default; + + QString GetName() const { + if (username.isEmpty() || username == nickname) { + return nickname; + } else { + return QStringLiteral("%1 (%2)").arg(nickname, username); + } + } + u64 GetTitleId() const { + return title_id; + } + QString GetGameName() const { + return game_name; + } + +private: + QString username; + QString nickname; + u64 title_id; + QString game_name; +}; + +Q_DECLARE_METATYPE(LobbyMember); + +class LobbyItemMemberList : public LobbyItem { +public: + static const int MemberListRole = Qt::UserRole + 1; + static const int MaxPlayerRole = Qt::UserRole + 2; + + LobbyItemMemberList() = default; + explicit LobbyItemMemberList(QList<QVariant> members, u32 max_players) { + setData(members, MemberListRole); + setData(max_players, MaxPlayerRole); + } + + QVariant data(int role) const override { + switch (role) { + case Qt::DisplayRole: { + auto members = data(MemberListRole).toList(); + return QStringLiteral("%1 / %2 ") + .arg(QString::number(members.size()), data(MaxPlayerRole).toString()); + } + case Qt::ForegroundRole: { + auto members = data(MemberListRole).toList(); + auto max_players = data(MaxPlayerRole).toInt(); + const QColor room_full_color(255, 48, 32); + const QColor room_almost_full_color(255, 140, 32); + const QColor room_has_players_color(32, 160, 32); + const QColor room_empty_color(128, 128, 128); + + if (members.size() >= max_players) { + return QBrush(room_full_color); + } else if (members.size() == (max_players - 1)) { + return QBrush(room_almost_full_color); + } else if (members.size() == 0) { + return QBrush(room_empty_color); + } else if (members.size() > 0 && members.size() < (max_players - 1)) { + return QBrush(room_has_players_color); + } + // FIXME: How to return a value that tells Qt not to modify the + // text color from the default (as if Qt::ForegroundRole wasn't overridden)? + return QBrush(nullptr); + } + default: + return LobbyItem::data(role); + } + } + + bool operator<(const QStandardItem& other) const override { + // sort by rooms that have the most players + int left_members = data(MemberListRole).toList().size(); + int right_members = other.data(MemberListRole).toList().size(); + return left_members < right_members; + } +}; + +/** + * Member information for when a lobby is expanded in the UI + */ +class LobbyItemExpandedMemberList : public LobbyItem { +public: + static const int MemberListRole = Qt::UserRole + 1; + + LobbyItemExpandedMemberList() = default; + explicit LobbyItemExpandedMemberList(QList<QVariant> members) { + setData(members, MemberListRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + auto members = data(MemberListRole).toList(); + QString out; + bool first = true; + for (const auto& member : members) { + if (!first) + out.append(QStringLiteral("\n")); + const auto& m = member.value<LobbyMember>(); + if (m.GetGameName().isEmpty()) { + out += QString(QObject::tr("%1 is not playing a game")).arg(m.GetName()); + } else { + out += QString(QObject::tr("%1 is playing %2")).arg(m.GetName(), m.GetGameName()); + } + first = false; + } + return out; + } +}; diff --git a/src/citron/multiplayer/message.cpp b/src/citron/multiplayer/message.cpp new file mode 100644 index 000000000..6d8f18274 --- /dev/null +++ b/src/citron/multiplayer/message.cpp @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <QMessageBox> +#include <QString> + +#include "yuzu/multiplayer/message.h" + +namespace NetworkMessage { +const ConnectionError ErrorManager::USERNAME_NOT_VALID( + QT_TR_NOOP("Username is not valid. Must be 4 to 20 alphanumeric characters.")); +const ConnectionError ErrorManager::ROOMNAME_NOT_VALID( + QT_TR_NOOP("Room name is not valid. Must be 4 to 20 alphanumeric characters.")); +const ConnectionError ErrorManager::USERNAME_NOT_VALID_SERVER( + QT_TR_NOOP("Username is already in use or not valid. Please choose another.")); +const ConnectionError ErrorManager::IP_ADDRESS_NOT_VALID( + QT_TR_NOOP("IP is not a valid IPv4 address.")); +const ConnectionError ErrorManager::PORT_NOT_VALID( + QT_TR_NOOP("Port must be a number between 0 to 65535.")); +const ConnectionError ErrorManager::GAME_NOT_SELECTED(QT_TR_NOOP( + "You must choose a Preferred Game to host a room. If you do not have any games in your game " + "list yet, add a game folder by clicking on the plus icon in the game list.")); +const ConnectionError ErrorManager::NO_INTERNET( + QT_TR_NOOP("Unable to find an internet connection. Check your internet settings.")); +const ConnectionError ErrorManager::UNABLE_TO_CONNECT( + QT_TR_NOOP("Unable to connect to the host. Verify that the connection settings are correct. If " + "you still cannot connect, contact the room host and verify that the host is " + "properly configured with the external port forwarded.")); +const ConnectionError ErrorManager::ROOM_IS_FULL( + QT_TR_NOOP("Unable to connect to the room because it is already full.")); +const ConnectionError ErrorManager::COULD_NOT_CREATE_ROOM( + QT_TR_NOOP("Creating a room failed. Please retry. Restarting yuzu might be necessary.")); +const ConnectionError ErrorManager::HOST_BANNED( + QT_TR_NOOP("The host of the room has banned you. Speak with the host to unban you " + "or try a different room.")); +const ConnectionError ErrorManager::WRONG_VERSION( + QT_TR_NOOP("Version mismatch! Please update to the latest version of yuzu. If the problem " + "persists, contact the room host and ask them to update the server.")); +const ConnectionError ErrorManager::WRONG_PASSWORD(QT_TR_NOOP("Incorrect password.")); +const ConnectionError ErrorManager::GENERIC_ERROR(QT_TR_NOOP( + "An unknown error occurred. If this error continues to occur, please open an issue")); +const ConnectionError ErrorManager::LOST_CONNECTION( + QT_TR_NOOP("Connection to room lost. Try to reconnect.")); +const ConnectionError ErrorManager::HOST_KICKED( + QT_TR_NOOP("You have been kicked by the room host.")); +const ConnectionError ErrorManager::IP_COLLISION( + QT_TR_NOOP("IP address is already in use. Please choose another.")); +const ConnectionError ErrorManager::PERMISSION_DENIED( + QT_TR_NOOP("You do not have enough permission to perform this action.")); +const ConnectionError ErrorManager::NO_SUCH_USER(QT_TR_NOOP( + "The user you are trying to kick/ban could not be found.\nThey may have left the room.")); +const ConnectionError ErrorManager::NO_INTERFACE_SELECTED(QT_TR_NOOP( + "No valid network interface is selected.\nPlease go to Configure -> System -> Network and " + "make a selection.")); + +static bool WarnMessage(const std::string& title, const std::string& text) { + return QMessageBox::Ok == QMessageBox::warning(nullptr, QObject::tr(title.c_str()), + QObject::tr(text.c_str()), + QMessageBox::Ok | QMessageBox::Cancel); +} + +void ErrorManager::ShowError(const ConnectionError& e) { + QMessageBox::critical(nullptr, tr("Error"), tr(e.GetString().c_str())); +} + +bool WarnGameRunning() { + return WarnMessage( + QT_TR_NOOP("Game already running"), + QT_TR_NOOP("Joining a room when the game is already running is discouraged " + "and can cause the room feature not to work correctly.\nProceed anyway?")); +} + +bool WarnCloseRoom() { + return WarnMessage( + QT_TR_NOOP("Leave Room"), + QT_TR_NOOP("You are about to close the room. Any network connections will be closed.")); +} + +bool WarnDisconnect() { + return WarnMessage( + QT_TR_NOOP("Disconnect"), + QT_TR_NOOP("You are about to leave the room. Any network connections will be closed.")); +} + +} // namespace NetworkMessage diff --git a/src/citron/multiplayer/message.h b/src/citron/multiplayer/message.h new file mode 100644 index 000000000..f038b9a1f --- /dev/null +++ b/src/citron/multiplayer/message.h @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <utility> + +namespace NetworkMessage { + +class ConnectionError { + +public: + explicit ConnectionError(std::string str) : err(std::move(str)) {} + const std::string& GetString() const { + return err; + } + +private: + std::string err; +}; + +class ErrorManager : QObject { + Q_OBJECT +public: + /// When the nickname is considered invalid by the client + static const ConnectionError USERNAME_NOT_VALID; + static const ConnectionError ROOMNAME_NOT_VALID; + /// When the nickname is considered invalid by the room server + static const ConnectionError USERNAME_NOT_VALID_SERVER; + static const ConnectionError IP_ADDRESS_NOT_VALID; + static const ConnectionError PORT_NOT_VALID; + static const ConnectionError GAME_NOT_SELECTED; + static const ConnectionError NO_INTERNET; + static const ConnectionError UNABLE_TO_CONNECT; + static const ConnectionError ROOM_IS_FULL; + static const ConnectionError COULD_NOT_CREATE_ROOM; + static const ConnectionError HOST_BANNED; + static const ConnectionError WRONG_VERSION; + static const ConnectionError WRONG_PASSWORD; + static const ConnectionError GENERIC_ERROR; + static const ConnectionError LOST_CONNECTION; + static const ConnectionError HOST_KICKED; + static const ConnectionError IP_COLLISION; + static const ConnectionError PERMISSION_DENIED; + static const ConnectionError NO_SUCH_USER; + static const ConnectionError NO_INTERFACE_SELECTED; + /** + * Shows a standard QMessageBox with a error message + */ + static void ShowError(const ConnectionError& e); +}; + +/** + * Show a standard QMessageBox with a warning message about joining a room when + * the game is already running + * return true if the user wants to close the network connection + */ +bool WarnGameRunning(); + +/** + * Show a standard QMessageBox with a warning message about leaving the room + * return true if the user wants to close the network connection + */ +bool WarnCloseRoom(); + +/** + * Show a standard QMessageBox with a warning message about disconnecting from the room + * return true if the user wants to disconnect + */ +bool WarnDisconnect(); + +} // namespace NetworkMessage diff --git a/src/citron/multiplayer/moderation_dialog.cpp b/src/citron/multiplayer/moderation_dialog.cpp new file mode 100644 index 000000000..c9b8ed397 --- /dev/null +++ b/src/citron/multiplayer/moderation_dialog.cpp @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <QStandardItem> +#include <QStandardItemModel> +#include "network/network.h" +#include "network/room_member.h" +#include "ui_moderation_dialog.h" +#include "yuzu/multiplayer/moderation_dialog.h" + +namespace Column { +enum { + SUBJECT, + TYPE, + COUNT, +}; +} + +ModerationDialog::ModerationDialog(Network::RoomNetwork& room_network_, QWidget* parent) + : QDialog(parent), ui(std::make_unique<Ui::ModerationDialog>()), room_network{room_network_} { + ui->setupUi(this); + + qRegisterMetaType<Network::Room::BanList>(); + + if (auto member = room_network.GetRoomMember().lock()) { + callback_handle_status_message = member->BindOnStatusMessageReceived( + [this](const Network::StatusMessageEntry& status_message) { + emit StatusMessageReceived(status_message); + }); + connect(this, &ModerationDialog::StatusMessageReceived, this, + &ModerationDialog::OnStatusMessageReceived); + callback_handle_ban_list = member->BindOnBanListReceived( + [this](const Network::Room::BanList& ban_list) { emit BanListReceived(ban_list); }); + connect(this, &ModerationDialog::BanListReceived, this, &ModerationDialog::PopulateBanList); + } + + // Initialize the UI + model = new QStandardItemModel(ui->ban_list_view); + model->insertColumns(0, Column::COUNT); + model->setHeaderData(Column::SUBJECT, Qt::Horizontal, tr("Subject")); + model->setHeaderData(Column::TYPE, Qt::Horizontal, tr("Type")); + + ui->ban_list_view->setModel(model); + + // Load the ban list in background + LoadBanList(); + + connect(ui->refresh, &QPushButton::clicked, this, [this] { LoadBanList(); }); + connect(ui->unban, &QPushButton::clicked, this, [this] { + auto index = ui->ban_list_view->currentIndex(); + SendUnbanRequest(model->item(index.row(), 0)->text()); + }); + connect(ui->ban_list_view, &QTreeView::clicked, [this] { ui->unban->setEnabled(true); }); +} + +ModerationDialog::~ModerationDialog() { + if (callback_handle_status_message) { + if (auto room = room_network.GetRoomMember().lock()) { + room->Unbind(callback_handle_status_message); + } + } + + if (callback_handle_ban_list) { + if (auto room = room_network.GetRoomMember().lock()) { + room->Unbind(callback_handle_ban_list); + } + } +} + +void ModerationDialog::LoadBanList() { + if (auto room = room_network.GetRoomMember().lock()) { + ui->refresh->setEnabled(false); + ui->refresh->setText(tr("Refreshing")); + ui->unban->setEnabled(false); + room->RequestBanList(); + } +} + +void ModerationDialog::PopulateBanList(const Network::Room::BanList& ban_list) { + model->removeRows(0, model->rowCount()); + for (const auto& username : ban_list.first) { + QStandardItem* subject_item = new QStandardItem(QString::fromStdString(username)); + QStandardItem* type_item = new QStandardItem(tr("Forum Username")); + model->invisibleRootItem()->appendRow({subject_item, type_item}); + } + for (const auto& ip : ban_list.second) { + QStandardItem* subject_item = new QStandardItem(QString::fromStdString(ip)); + QStandardItem* type_item = new QStandardItem(tr("IP Address")); + model->invisibleRootItem()->appendRow({subject_item, type_item}); + } + for (int i = 0; i < Column::COUNT - 1; ++i) { + ui->ban_list_view->resizeColumnToContents(i); + } + ui->refresh->setEnabled(true); + ui->refresh->setText(tr("Refresh")); + ui->unban->setEnabled(false); +} + +void ModerationDialog::SendUnbanRequest(const QString& subject) { + if (auto room = room_network.GetRoomMember().lock()) { + room->SendModerationRequest(Network::IdModUnban, subject.toStdString()); + } +} + +void ModerationDialog::OnStatusMessageReceived(const Network::StatusMessageEntry& status_message) { + if (status_message.type != Network::IdMemberBanned && + status_message.type != Network::IdAddressUnbanned) + return; + + // Update the ban list for ban/unban + LoadBanList(); +} diff --git a/src/citron/multiplayer/moderation_dialog.h b/src/citron/multiplayer/moderation_dialog.h new file mode 100644 index 000000000..e9e5daff7 --- /dev/null +++ b/src/citron/multiplayer/moderation_dialog.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <memory> +#include <optional> +#include <QDialog> +#include "network/room.h" +#include "network/room_member.h" + +namespace Ui { +class ModerationDialog; +} + +class QStandardItemModel; + +class ModerationDialog : public QDialog { + Q_OBJECT + +public: + explicit ModerationDialog(Network::RoomNetwork& room_network_, QWidget* parent = nullptr); + ~ModerationDialog(); + +signals: + void StatusMessageReceived(const Network::StatusMessageEntry&); + void BanListReceived(const Network::Room::BanList&); + +private: + void LoadBanList(); + void PopulateBanList(const Network::Room::BanList& ban_list); + void SendUnbanRequest(const QString& subject); + void OnStatusMessageReceived(const Network::StatusMessageEntry& status_message); + + std::unique_ptr<Ui::ModerationDialog> ui; + QStandardItemModel* model; + Network::RoomMember::CallbackHandle<Network::StatusMessageEntry> callback_handle_status_message; + Network::RoomMember::CallbackHandle<Network::Room::BanList> callback_handle_ban_list; + + Network::RoomNetwork& room_network; +}; + +Q_DECLARE_METATYPE(Network::Room::BanList); diff --git a/src/citron/multiplayer/moderation_dialog.ui b/src/citron/multiplayer/moderation_dialog.ui new file mode 100644 index 000000000..808d99414 --- /dev/null +++ b/src/citron/multiplayer/moderation_dialog.ui @@ -0,0 +1,84 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ModerationDialog</class> + <widget class="QDialog" name="ModerationDialog"> + <property name="windowTitle"> + <string>Moderation</string> + </property> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>500</width> + <height>300</height> + </rect> + </property> + <layout class="QVBoxLayout"> + <item> + <widget class="QGroupBox" name="ban_list_group_box"> + <property name="title"> + <string>Ban List</string> + </property> + <layout class="QVBoxLayout"> + <item> + <layout class="QHBoxLayout"> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="refresh"> + <property name="text"> + <string>Refreshing</string> + </property> + <property name="enabled"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="unban"> + <property name="text"> + <string>Unban</string> + </property> + <property name="enabled"> + <bool>false</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QTreeView" name="ban_list_view"/> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="standardButtons"> + <set>QDialogButtonBox::Ok</set> + </property> + </widget> + </item> + </layout> + </widget> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>accepted()</signal> + <receiver>ModerationDialog</receiver> + <slot>accept()</slot> + </connection> + </connections> + <resources/> +</ui> diff --git a/src/citron/multiplayer/state.cpp b/src/citron/multiplayer/state.cpp new file mode 100644 index 000000000..d82ca9aee --- /dev/null +++ b/src/citron/multiplayer/state.cpp @@ -0,0 +1,336 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <QAction> +#include <QApplication> +#include <QIcon> +#include <QMessageBox> +#include <QStandardItemModel> +#include "common/announce_multiplayer_room.h" +#include "common/logging/log.h" +#include "core/core.h" +#include "yuzu/game_list.h" +#include "yuzu/multiplayer/client_room.h" +#include "yuzu/multiplayer/direct_connect.h" +#include "yuzu/multiplayer/host_room.h" +#include "yuzu/multiplayer/lobby.h" +#include "yuzu/multiplayer/message.h" +#include "yuzu/multiplayer/state.h" +#include "yuzu/uisettings.h" +#include "yuzu/util/clickable_label.h" + +MultiplayerState::MultiplayerState(QWidget* parent, QStandardItemModel* game_list_model_, + QAction* leave_room_, QAction* show_room_, Core::System& system_) + : QWidget(parent), game_list_model(game_list_model_), leave_room(leave_room_), + show_room(show_room_), system{system_}, room_network{system.GetRoomNetwork()} { + if (auto member = room_network.GetRoomMember().lock()) { + // register the network structs to use in slots and signals + state_callback_handle = member->BindOnStateChanged( + [this](const Network::RoomMember::State& state) { emit NetworkStateChanged(state); }); + connect(this, &MultiplayerState::NetworkStateChanged, this, + &MultiplayerState::OnNetworkStateChanged); + error_callback_handle = member->BindOnError( + [this](const Network::RoomMember::Error& error) { emit NetworkError(error); }); + connect(this, &MultiplayerState::NetworkError, this, &MultiplayerState::OnNetworkError); + } + + qRegisterMetaType<Network::RoomMember::State>(); + qRegisterMetaType<Network::RoomMember::Error>(); + qRegisterMetaType<WebService::WebResult>(); + announce_multiplayer_session = std::make_shared<Core::AnnounceMultiplayerSession>(room_network); + announce_multiplayer_session->BindErrorCallback( + [this](const WebService::WebResult& result) { emit AnnounceFailed(result); }); + connect(this, &MultiplayerState::AnnounceFailed, this, &MultiplayerState::OnAnnounceFailed); + + status_text = new ClickableLabel(this); + status_icon = new ClickableLabel(this); + + connect(status_text, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); + connect(status_icon, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); + + connect(static_cast<QApplication*>(QApplication::instance()), &QApplication::focusChanged, this, + [this](QWidget* /*old*/, QWidget* now) { + if (client_room && client_room->isAncestorOf(now)) { + HideNotification(); + } + }); + + retranslateUi(); +} + +MultiplayerState::~MultiplayerState() = default; + +void MultiplayerState::Close() { + if (state_callback_handle) { + if (auto member = room_network.GetRoomMember().lock()) { + member->Unbind(state_callback_handle); + } + } + + if (error_callback_handle) { + if (auto member = room_network.GetRoomMember().lock()) { + member->Unbind(error_callback_handle); + } + } + if (host_room) { + host_room->close(); + } + if (direct_connect) { + direct_connect->close(); + } + if (client_room) { + client_room->close(); + } + if (lobby) { + lobby->close(); + } +} + +void MultiplayerState::retranslateUi() { + status_text->setToolTip(tr("Current connection status")); + + UpdateNotificationStatus(); + + if (lobby) { + lobby->RetranslateUi(); + } + if (host_room) { + host_room->RetranslateUi(); + } + if (client_room) { + client_room->RetranslateUi(); + } + if (direct_connect) { + direct_connect->RetranslateUi(); + } +} + +void MultiplayerState::SetNotificationStatus(NotificationStatus status) { + notification_status = status; + UpdateNotificationStatus(); +} + +void MultiplayerState::UpdateNotificationStatus() { + switch (notification_status) { + case NotificationStatus::Uninitialized: + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("disconnected")).pixmap(16)); + status_text->setText(tr("Not Connected. Click here to find a room!")); + leave_room->setEnabled(false); + show_room->setEnabled(false); + break; + case NotificationStatus::Disconnected: + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("disconnected")).pixmap(16)); + status_text->setText(tr("Not Connected")); + leave_room->setEnabled(false); + show_room->setEnabled(false); + break; + case NotificationStatus::Connected: + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("connected")).pixmap(16)); + status_text->setText(tr("Connected")); + leave_room->setEnabled(true); + show_room->setEnabled(true); + break; + case NotificationStatus::Notification: + status_icon->setPixmap( + QIcon::fromTheme(QStringLiteral("connected_notification")).pixmap(16)); + status_text->setText(tr("New Messages Received")); + leave_room->setEnabled(true); + show_room->setEnabled(true); + break; + } + + // Clean up status bar if game is running + if (system.IsPoweredOn()) { + status_text->clear(); + } +} + +void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& state) { + LOG_DEBUG(Frontend, "Network State: {}", Network::GetStateStr(state)); + if (state == Network::RoomMember::State::Joined || + state == Network::RoomMember::State::Moderator) { + + OnOpenNetworkRoom(); + SetNotificationStatus(NotificationStatus::Connected); + } else { + SetNotificationStatus(NotificationStatus::Disconnected); + } + + current_state = state; +} + +void MultiplayerState::OnNetworkError(const Network::RoomMember::Error& error) { + LOG_DEBUG(Frontend, "Network Error: {}", Network::GetErrorStr(error)); + switch (error) { + case Network::RoomMember::Error::LostConnection: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::LOST_CONNECTION); + break; + case Network::RoomMember::Error::HostKicked: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::HOST_KICKED); + break; + case Network::RoomMember::Error::CouldNotConnect: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::Error::NameCollision: + NetworkMessage::ErrorManager::ShowError( + NetworkMessage::ErrorManager::USERNAME_NOT_VALID_SERVER); + break; + case Network::RoomMember::Error::IpCollision: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::IP_COLLISION); + break; + case Network::RoomMember::Error::RoomIsFull: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::ROOM_IS_FULL); + break; + case Network::RoomMember::Error::WrongPassword: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::WRONG_PASSWORD); + break; + case Network::RoomMember::Error::WrongVersion: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::WRONG_VERSION); + break; + case Network::RoomMember::Error::HostBanned: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::HOST_BANNED); + break; + case Network::RoomMember::Error::UnknownError: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::Error::PermissionDenied: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::PERMISSION_DENIED); + break; + case Network::RoomMember::Error::NoSuchUser: + NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::NO_SUCH_USER); + break; + } +} + +void MultiplayerState::OnAnnounceFailed(const WebService::WebResult& result) { + announce_multiplayer_session->Stop(); + QMessageBox::warning(this, tr("Error"), + tr("Failed to update the room information. Please check your Internet " + "connection and try hosting the room again.\nDebug Message: ") + + QString::fromStdString(result.result_string), + QMessageBox::Ok); +} + +void MultiplayerState::OnSaveConfig() { + emit SaveConfig(); +} + +void MultiplayerState::UpdateThemedIcons() { + if (show_notification) { + status_icon->setPixmap( + QIcon::fromTheme(QStringLiteral("connected_notification")).pixmap(16)); + } else if (current_state == Network::RoomMember::State::Joined || + current_state == Network::RoomMember::State::Moderator) { + + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("connected")).pixmap(16)); + } else { + status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("disconnected")).pixmap(16)); + } + if (client_room) + client_room->UpdateIconDisplay(); +} + +static void BringWidgetToFront(QWidget* widget) { + widget->show(); + widget->activateWindow(); + widget->raise(); +} + +void MultiplayerState::OnViewLobby() { + if (lobby == nullptr) { + lobby = new Lobby(this, game_list_model, announce_multiplayer_session, system); + connect(lobby, &Lobby::SaveConfig, this, &MultiplayerState::OnSaveConfig); + } + lobby->RefreshLobby(); + BringWidgetToFront(lobby); +} + +void MultiplayerState::OnCreateRoom() { + if (host_room == nullptr) { + host_room = new HostRoomWindow(this, game_list_model, announce_multiplayer_session, system); + connect(host_room, &HostRoomWindow::SaveConfig, this, &MultiplayerState::OnSaveConfig); + } + BringWidgetToFront(host_room); +} + +bool MultiplayerState::OnCloseRoom() { + if (!NetworkMessage::WarnCloseRoom()) + return false; + if (auto room = room_network.GetRoom().lock()) { + // if you are in a room, leave it + if (auto member = room_network.GetRoomMember().lock()) { + member->Leave(); + LOG_DEBUG(Frontend, "Left the room (as a client)"); + } + + // if you are hosting a room, also stop hosting + if (room->GetState() != Network::Room::State::Open) { + return true; + } + // Save ban list + UISettings::values.multiplayer_ban_list = room->GetBanList(); + + room->Destroy(); + announce_multiplayer_session->Stop(); + LOG_DEBUG(Frontend, "Closed the room (as a server)"); + } + return true; +} + +void MultiplayerState::ShowNotification() { + if (client_room && client_room->isAncestorOf(QApplication::focusWidget())) + return; // Do not show notification if the chat window currently has focus + show_notification = true; + QApplication::alert(nullptr); + QApplication::beep(); + SetNotificationStatus(NotificationStatus::Notification); +} + +void MultiplayerState::HideNotification() { + show_notification = false; + SetNotificationStatus(NotificationStatus::Connected); +} + +void MultiplayerState::OnOpenNetworkRoom() { + if (auto member = room_network.GetRoomMember().lock()) { + if (member->IsConnected()) { + if (client_room == nullptr) { + client_room = new ClientRoomWindow(this, room_network); + connect(client_room, &ClientRoomWindow::ShowNotification, this, + &MultiplayerState::ShowNotification); + } + BringWidgetToFront(client_room); + return; + } + } + // If the user is not a member of a room, show the lobby instead. + // This is currently only used on the clickable label in the status bar + OnViewLobby(); +} + +void MultiplayerState::OnDirectConnectToRoom() { + if (direct_connect == nullptr) { + direct_connect = new DirectConnectWindow(system, this); + connect(direct_connect, &DirectConnectWindow::SaveConfig, this, + &MultiplayerState::OnSaveConfig); + } + BringWidgetToFront(direct_connect); +} + +bool MultiplayerState::IsHostingPublicRoom() const { + return announce_multiplayer_session->IsRunning(); +} + +void MultiplayerState::UpdateCredentials() { + announce_multiplayer_session->UpdateCredentials(); +} + +void MultiplayerState::UpdateGameList(QStandardItemModel* game_list) { + game_list_model = game_list; + if (lobby) { + lobby->UpdateGameList(game_list); + } + if (host_room) { + host_room->UpdateGameList(game_list); + } +} diff --git a/src/citron/multiplayer/state.h b/src/citron/multiplayer/state.h new file mode 100644 index 000000000..d6149838f --- /dev/null +++ b/src/citron/multiplayer/state.h @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <QWidget> +#include "network/announce_multiplayer_session.h" +#include "network/network.h" + +class QStandardItemModel; +class Lobby; +class HostRoomWindow; +class ClientRoomWindow; +class DirectConnectWindow; +class ClickableLabel; + +namespace Core { +class System; +} + +class MultiplayerState : public QWidget { + Q_OBJECT; + +public: + enum class NotificationStatus { + Uninitialized, + Disconnected, + Connected, + Notification, + }; + + explicit MultiplayerState(QWidget* parent, QStandardItemModel* game_list, QAction* leave_room, + QAction* show_room, Core::System& system_); + ~MultiplayerState(); + + /** + * Close all open multiplayer related dialogs + */ + void Close(); + + void SetNotificationStatus(NotificationStatus state); + + void UpdateNotificationStatus(); + + ClickableLabel* GetStatusText() const { + return status_text; + } + + ClickableLabel* GetStatusIcon() const { + return status_icon; + } + + void retranslateUi(); + + /** + * Whether a public room is being hosted or not. + * When this is true, Web Services configuration should be disabled. + */ + bool IsHostingPublicRoom() const; + + void UpdateCredentials(); + + /** + * Updates the multiplayer dialogs with a new game list model. + * This model should be the original model of the game list. + */ + void UpdateGameList(QStandardItemModel* game_list); + +public slots: + void OnNetworkStateChanged(const Network::RoomMember::State& state); + void OnNetworkError(const Network::RoomMember::Error& error); + void OnViewLobby(); + void OnCreateRoom(); + bool OnCloseRoom(); + void OnOpenNetworkRoom(); + void OnDirectConnectToRoom(); + void OnAnnounceFailed(const WebService::WebResult&); + void OnSaveConfig(); + void UpdateThemedIcons(); + void ShowNotification(); + void HideNotification(); + +signals: + void NetworkStateChanged(const Network::RoomMember::State&); + void NetworkError(const Network::RoomMember::Error&); + void AnnounceFailed(const WebService::WebResult&); + void SaveConfig(); + +private: + Lobby* lobby = nullptr; + HostRoomWindow* host_room = nullptr; + ClientRoomWindow* client_room = nullptr; + DirectConnectWindow* direct_connect = nullptr; + ClickableLabel* status_icon = nullptr; + ClickableLabel* status_text = nullptr; + QStandardItemModel* game_list_model = nullptr; + QAction* leave_room; + QAction* show_room; + std::shared_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session; + Network::RoomMember::State current_state = Network::RoomMember::State::Uninitialized; + NotificationStatus notification_status = NotificationStatus::Uninitialized; + bool has_mod_perms = false; + Network::RoomMember::CallbackHandle<Network::RoomMember::State> state_callback_handle; + Network::RoomMember::CallbackHandle<Network::RoomMember::Error> error_callback_handle; + + bool show_notification = false; + Core::System& system; + Network::RoomNetwork& room_network; +}; + +Q_DECLARE_METATYPE(WebService::WebResult); diff --git a/src/citron/multiplayer/validation.h b/src/citron/multiplayer/validation.h new file mode 100644 index 000000000..cbbe6757b --- /dev/null +++ b/src/citron/multiplayer/validation.h @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <QRegularExpression> +#include <QString> +#include <QValidator> + +class Validation { +public: + Validation() + : room_name(room_name_regex), nickname(nickname_regex), ip(ip_regex), port(0, UINT16_MAX) {} + + ~Validation() = default; + + const QValidator* GetRoomName() const { + return &room_name; + } + const QValidator* GetNickname() const { + return &nickname; + } + const QValidator* GetIP() const { + return &ip; + } + const QValidator* GetPort() const { + return &port; + } + +private: + /// room name can be alphanumeric and " " "_" "." and "-" and must have a size of 4-20 + QRegularExpression room_name_regex = + QRegularExpression(QStringLiteral("^[a-zA-Z0-9._ -]{4,20}")); + QRegularExpressionValidator room_name; + + /// nickname can be alphanumeric and " " "_" "." and "-" and must have a size of 4-20 + const QRegularExpression nickname_regex = + QRegularExpression(QStringLiteral("^[a-zA-Z0-9._ -]{4,20}")); + QRegularExpressionValidator nickname; + + /// ipv4 / ipv6 / hostnames + QRegularExpression ip_regex = QRegularExpression(QStringLiteral( + // IPv4 regex + "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|" + // IPv6 regex + "^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|" + "(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-" + "5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|" + "(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)" + "(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|" + "(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]" + "\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|" + "(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[" + "0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|" + "(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[" + "0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|" + "(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[" + "0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|" + "(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?" + "\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?$|" + // Hostname regex + "^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\\.)+[a-zA-Z]{2,}$")); + QRegularExpressionValidator ip; + + /// port must be between 0 and 65535 + QIntValidator port; +}; |