diff options
Diffstat (limited to 'src/yuzu/multiplayer')
24 files changed, 3413 insertions, 0 deletions
diff --git a/src/yuzu/multiplayer/chat_room.cpp b/src/yuzu/multiplayer/chat_room.cpp new file mode 100644 index 000000000..5837b36ab --- /dev/null +++ b/src/yuzu/multiplayer/chat_room.cpp @@ -0,0 +1,491 @@ +// 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 "core/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 { +        auto color = player_color[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 = { +        {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", +         "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "FFFF00"}}; +    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; + +    PlayerListItem() = default; +    explicit PlayerListItem(const std::string& nickname, const std::string& username, +                            const std::string& avatar_url, const std::string& game_name) { +        setEditable(false); +        setData(QString::fromStdString(nickname), NicknameRole); +        setData(QString::fromStdString(username), UsernameRole); +        setData(QString::fromStdString(avatar_url), AvatarUrlRole); +        if (game_name.empty()) { +            setData(QObject::tr("Not playing a game"), GameNameRole); +        } else { +            setData(QString::fromStdString(game_name), GameNameRole); +        } +    } + +    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); +        } +        return QStringLiteral("%1\n      %2").arg(name, data(GameNameRole).toString()); +    } +}; + +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); + +    // 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->BindOnChatMessageRecieved( +            [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 = room_network->GetRoomMember().lock()) { +        if (room->GetState() != Network::RoomMember::State::Joined && +            room->GetState() != Network::RoomMember::State::Moderator) { + +            return; +        } +        auto message = ui->chat_message->text().toStdString(); +        if (!ValidateMessage(message)) { +            return; +        } +        auto nick = room->GetNickname(); +        auto username = room->GetUsername(); +        Network::ChatEntry chat{nick, username, message}; + +        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, "Cannot find self in the player list when sending a message."); +        } +        auto player = std::distance(members.begin(), it); +        ChatMessage m(chat, *room_network); +        room->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.name); + +#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/yuzu/multiplayer/chat_room.h b/src/yuzu/multiplayer/chat_room.h new file mode 100644 index 000000000..01c70fad0 --- /dev/null +++ b/src/yuzu/multiplayer/chat_room.h @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <memory> +#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/yuzu/multiplayer/chat_room.ui b/src/yuzu/multiplayer/chat_room.ui new file mode 100644 index 000000000..f2b31b5da --- /dev/null +++ b/src/yuzu/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/yuzu/multiplayer/client_room.cpp b/src/yuzu/multiplayer/client_room.cpp new file mode 100644 index 000000000..a9859ed70 --- /dev/null +++ b/src/yuzu/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 "core/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 members) - connected")) +                               .arg(QString::fromStdString(information.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/yuzu/multiplayer/client_room.h b/src/yuzu/multiplayer/client_room.h new file mode 100644 index 000000000..f338e3c59 --- /dev/null +++ b/src/yuzu/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/yuzu/multiplayer/client_room.ui b/src/yuzu/multiplayer/client_room.ui new file mode 100644 index 000000000..97e88b502 --- /dev/null +++ b/src/yuzu/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/yuzu/multiplayer/direct_connect.cpp b/src/yuzu/multiplayer/direct_connect.cpp new file mode 100644 index 000000000..9000c4531 --- /dev/null +++ b/src/yuzu/multiplayer/direct_connect.cpp @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <QComboBox> +#include <QFuture> +#include <QIntValidator> +#include <QRegExpValidator> +#include <QString> +#include <QtConcurrent/QtConcurrentRun> +#include "common/settings.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(Network::RoomNetwork& room_network_, QWidget* parent) +    : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), +      ui(std::make_unique<Ui::DirectConnect>()), room_network{room_network_} { + +    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(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(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 (!ui->nickname->hasAcceptableInput()) { +        NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::USERNAME_NOT_VALID); +        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; +            } +        } +    } +    switch (static_cast<ConnectionType>(ui->connection_type->currentIndex())) { +    case ConnectionType::TraversalServer: +        break; +    case ConnectionType::IP: +        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; +        } +        break; +    } + +    // Store settings +    UISettings::values.multiplayer_nickname = ui->nickname->text(); +    UISettings::values.multiplayer_ip = ui->ip->text(); +    if (ui->port->isModified() && !ui->port->text().isEmpty()) { +        UISettings::values.multiplayer_port = ui->port->text().toInt(); +    } else { +        UISettings::values.multiplayer_port = UISettings::values.multiplayer_port.GetDefault(); +    } + +    // 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::NoPreferredMac, 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->GetState() == Network::RoomMember::State::Joined || +            room_member->GetState() == Network::RoomMember::State::Moderator) { + +            close(); +        } +    } +} diff --git a/src/yuzu/multiplayer/direct_connect.h b/src/yuzu/multiplayer/direct_connect.h new file mode 100644 index 000000000..4e1043053 --- /dev/null +++ b/src/yuzu/multiplayer/direct_connect.h @@ -0,0 +1,43 @@ +// 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; +} + +class DirectConnectWindow : public QDialog { +    Q_OBJECT + +public: +    explicit DirectConnectWindow(Network::RoomNetwork& room_network_, 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(); + +private slots: +    void OnConnection(); + +private: +    void Connect(); +    void BeginConnecting(); +    void EndConnecting(); + +    QFutureWatcher<void>* watcher; +    std::unique_ptr<Ui::DirectConnect> ui; +    Validation validation; +    Network::RoomNetwork& room_network; +}; diff --git a/src/yuzu/multiplayer/direct_connect.ui b/src/yuzu/multiplayer/direct_connect.ui new file mode 100644 index 000000000..681b6bf69 --- /dev/null +++ b/src/yuzu/multiplayer/direct_connect.ui @@ -0,0 +1,168 @@ +<?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="QComboBox" name="connection_type"> +           <item> +            <property name="text"> +             <string>IP Address</string> +            </property> +           </item> +          </widget> +         </item> +         <item> +          <widget class="QWidget" name="ip_container" native="true"> +           <layout class="QHBoxLayout" name="ip_layout"> +            <property name="leftMargin"> +             <number>5</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>IP</string> +              </property> +             </widget> +            </item> +            <item> +             <widget class="QLineEdit" name="ip"> +              <property name="toolTip"> +               <string><html><head/><body><p>IPv4 address of the host</p></body></html></string> +              </property> +              <property name="maxLength"> +               <number>16</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>24872</string> +              </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/yuzu/multiplayer/host_room.cpp b/src/yuzu/multiplayer/host_room.cpp new file mode 100644 index 000000000..cb9464b2b --- /dev/null +++ b/src/yuzu/multiplayer/host_room.cpp @@ -0,0 +1,246 @@ +// 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/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, +                               Network::RoomNetwork& room_network_) +    : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), +      ui(std::make_unique<Ui::HostRoom>()), +      announce_multiplayer_session(session), room_network{room_network_} { +    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(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(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(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 (!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 (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::NoPreferredMac, password, token); + +        // Store settings +        UISettings::values.multiplayer_room_nickname = ui->username->text(); +        UISettings::values.multiplayer_room_name = ui->room_name->text(); +        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(); +        ui->host->setEnabled(true); +        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/yuzu/multiplayer/host_room.h b/src/yuzu/multiplayer/host_room.h new file mode 100644 index 000000000..a968042d0 --- /dev/null +++ b/src/yuzu/multiplayer/host_room.h @@ -0,0 +1,75 @@ +// 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 AnnounceMultiplayerSession; +} + +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, +                            Network::RoomNetwork& room_network_); +    ~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(); + +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; +    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/yuzu/multiplayer/host_room.ui b/src/yuzu/multiplayer/host_room.ui new file mode 100644 index 000000000..d54cf49c6 --- /dev/null +++ b/src/yuzu/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/yuzu/multiplayer/lobby.cpp b/src/yuzu/multiplayer/lobby.cpp new file mode 100644 index 000000000..23c2f21ab --- /dev/null +++ b/src/yuzu/multiplayer/lobby.cpp @@ -0,0 +1,367 @@ +// 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 "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, +             Network::RoomNetwork& room_network_) +    : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), +      ui(std::make_unique<Ui::Lobby>()), +      announce_multiplayer_session(session), room_network{room_network_} { +    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(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 Buttons +    connect(ui->refresh_list, &QPushButton::clicked, this, &Lobby::RefreshLobby); +    connect(ui->games_owned, &QCheckBox::toggled, proxy, &LobbyFilterProxyModel::SetFilterOwned); +    connect(ui->hide_full, &QCheckBox::toggled, proxy, &LobbyFilterProxyModel::SetFilterFull); +    connect(ui->search, &QLineEdit::textChanged, proxy, &LobbyFilterProxyModel::SetFilterSearch); +    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); + +    // manually start a refresh when the window is opening +    // TODO(jroweboy): if this refresh is slow for people with bad internet, then don't do it as +    // part of the constructor, but offload the refresh until after the window shown. perhaps emit a +    // refreshroomlist signal from places that open the lobby +    RefreshLobby(); +} + +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); +} + +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 (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::NoPreferredMac, 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(); +    UISettings::values.multiplayer_ip = +        proxy->data(connection_index, LobbyItemHost::HostIPRole).toString(); +    UISettings::values.multiplayer_port = +        proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt(); +} + +void Lobby::ResetModel() { +    model->clear(); +    model->insertColumns(0, Column::TOTAL); +    model->setHeaderData(Column::EXPAND, Qt::Horizontal, QString(), 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); +    model->setHeaderData(Column::MEMBER, Qt::Horizontal, tr("Players"), 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 LobbyItem(); +        auto row = QList<QStandardItem*>({ +            first_item, +            new LobbyItemName(room.has_password, QString::fromStdString(room.information.name)), +            new LobbyItemGame(room.information.preferred_game.id, +                              QString::fromStdString(room.information.preferred_game.name), +                              smdh_icon), +            new LobbyItemHost(QString::fromStdString(room.information.host_username), +                              QString::fromStdString(room.ip), room.information.port, +                              QString::fromStdString(room.verify_uid)), +            new LobbyItemMemberList(members, room.information.member_slots), +        }); +        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)); +        } +    } + +    // Reenable 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); +        } +    } +} + +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 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::SetFilterFull(bool filter) { +    filter_full = filter; +    invalidate(); +} + +void LobbyFilterProxyModel::SetFilterSearch(const QString& filter) { +    filter_search = filter; +    invalidate(); +} diff --git a/src/yuzu/multiplayer/lobby.h b/src/yuzu/multiplayer/lobby.h new file mode 100644 index 000000000..82744ca94 --- /dev/null +++ b/src/yuzu/multiplayer/lobby.h @@ -0,0 +1,128 @@ +// 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 "core/announce_multiplayer_session.h" +#include "network/network.h" +#include "yuzu/multiplayer/validation.h" + +namespace Ui { +class Lobby; +} + +class LobbyModel; +class LobbyFilterProxyModel; + +/** + * 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, +                   Network::RoomNetwork& room_network_); +    ~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&); + +private: +    /** +     * 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; +    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 SetFilterFull(bool); +    void SetFilterSearch(const QString&); + +private: +    QStandardItemModel* game_list; +    bool filter_owned = false; +    bool filter_full = false; +    QString filter_search; +}; diff --git a/src/yuzu/multiplayer/lobby.ui b/src/yuzu/multiplayer/lobby.ui new file mode 100644 index 000000000..4c9901c9a --- /dev/null +++ b/src/yuzu/multiplayer/lobby.ui @@ -0,0 +1,123 @@ +<?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_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/yuzu/multiplayer/lobby_p.h b/src/yuzu/multiplayer/lobby_p.h new file mode 100644 index 000000000..8071cede4 --- /dev/null +++ b/src/yuzu/multiplayer/lobby_p.h @@ -0,0 +1,238 @@ +// 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 { +    EXPAND, +    ROOM_NAME, +    GAME_NAME, +    HOST, +    MEMBER, +    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); +        } +    } + +    QVariant data(int role) const override { +        if (role == Qt::DecorationRole) { +            auto val = data(GameIconRole); +            if (val.isValid()) { +                val = val.value<QPixmap>().scaled(16, 16, Qt::KeepAspectRatio); +            } +            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 { +        if (role != Qt::DisplayRole) { +            return LobbyItem::data(role); +        } +        auto members = data(MemberListRole).toList(); +        return QStringLiteral("%1 / %2").arg(QString::number(members.size()), +                                             data(MaxPlayerRole).toString()); +    } + +    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/yuzu/multiplayer/message.cpp b/src/yuzu/multiplayer/message.cpp new file mode 100644 index 000000000..76ec276ad --- /dev/null +++ b/src/yuzu/multiplayer/message.cpp @@ -0,0 +1,78 @@ +// 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::MAC_COLLISION( +    QT_TR_NOOP("MAC address is already in use. Please choose another.")); +const ConnectionError ErrorManager::CONSOLE_ID_COLLISION(QT_TR_NOOP( +    "Your Console ID conflicted with someone else's in the room.\n\nPlease go to Emulation " +    "> Configure > System to regenerate your Console ID.")); +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.")); + +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 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/yuzu/multiplayer/message.h b/src/yuzu/multiplayer/message.h new file mode 100644 index 000000000..eb5c8d1be --- /dev/null +++ b/src/yuzu/multiplayer/message.h @@ -0,0 +1,64 @@ +// 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 MAC_COLLISION; +    static const ConnectionError CONSOLE_ID_COLLISION; +    static const ConnectionError PERMISSION_DENIED; +    static const ConnectionError NO_SUCH_USER; +    /** +     *  Shows a standard QMessageBox with a error message +     */ +    static void ShowError(const ConnectionError& e); +}; +/** + * 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/yuzu/multiplayer/moderation_dialog.cpp b/src/yuzu/multiplayer/moderation_dialog.cpp new file mode 100644 index 000000000..c9b8ed397 --- /dev/null +++ b/src/yuzu/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/yuzu/multiplayer/moderation_dialog.h b/src/yuzu/multiplayer/moderation_dialog.h new file mode 100644 index 000000000..e9e5daff7 --- /dev/null +++ b/src/yuzu/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/yuzu/multiplayer/moderation_dialog.ui b/src/yuzu/multiplayer/moderation_dialog.ui new file mode 100644 index 000000000..808d99414 --- /dev/null +++ b/src/yuzu/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/yuzu/multiplayer/state.cpp b/src/yuzu/multiplayer/state.cpp new file mode 100644 index 000000000..4149b5232 --- /dev/null +++ b/src/yuzu/multiplayer/state.cpp @@ -0,0 +1,308 @@ +// 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 "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_, +                                   Network::RoomNetwork& room_network_) +    : QWidget(parent), game_list_model(game_list_model_), leave_room(leave_room_), +      show_room(show_room_), room_network{room_network_} { +    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); +    status_text->setToolTip(tr("Current connection status")); +    status_text->setText(tr("Not Connected. Click here to find a room!")); +    status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("disconnected")).pixmap(16)); + +    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(); +                } +            }); +} + +MultiplayerState::~MultiplayerState() { +    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); +        } +    } +} + +void MultiplayerState::Close() { +    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")); + +    if (current_state == Network::RoomMember::State::Uninitialized) { +        status_text->setText(tr("Not Connected. Click here to find a room!")); +    } else if (current_state == Network::RoomMember::State::Joined || +               current_state == Network::RoomMember::State::Moderator) { + +        status_text->setText(tr("Connected")); +    } else { +        status_text->setText(tr("Not Connected")); +    } + +    if (lobby) { +        lobby->RetranslateUi(); +    } +    if (host_room) { +        host_room->RetranslateUi(); +    } +    if (client_room) { +        client_room->RetranslateUi(); +    } +    if (direct_connect) { +        direct_connect->RetranslateUi(); +    } +} + +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(); +        status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("connected")).pixmap(16)); +        status_text->setText(tr("Connected")); +        leave_room->setEnabled(true); +        show_room->setEnabled(true); +    } else { +        status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("disconnected")).pixmap(16)); +        status_text->setText(tr("Not Connected")); +        leave_room->setEnabled(false); +        show_room->setEnabled(false); +    } + +    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::MacCollision: +        NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::MAC_COLLISION); +        break; +    case Network::RoomMember::Error::ConsoleIdCollision: +        NetworkMessage::ErrorManager::ShowError(NetworkMessage::ErrorManager::CONSOLE_ID_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::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, room_network); +    } +    BringWidgetToFront(lobby); +} + +void MultiplayerState::OnCreateRoom() { +    if (host_room == nullptr) { +        host_room = +            new HostRoomWindow(this, game_list_model, announce_multiplayer_session, room_network); +    } +    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 = std::move(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); +    status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("connected_notification")).pixmap(16)); +    status_text->setText(tr("New Messages Received")); +} + +void MultiplayerState::HideNotification() { +    show_notification = false; +    status_icon->setPixmap(QIcon::fromTheme(QStringLiteral("connected")).pixmap(16)); +    status_text->setText(tr("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(room_network, this); +    } +    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/yuzu/multiplayer/state.h b/src/yuzu/multiplayer/state.h new file mode 100644 index 000000000..9c60712d5 --- /dev/null +++ b/src/yuzu/multiplayer/state.h @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <QWidget> +#include "core/announce_multiplayer_session.h" +#include "network/network.h" + +class QStandardItemModel; +class Lobby; +class HostRoomWindow; +class ClientRoomWindow; +class DirectConnectWindow; +class ClickableLabel; + +class MultiplayerState : public QWidget { +    Q_OBJECT; + +public: +    explicit MultiplayerState(QWidget* parent, QStandardItemModel* game_list, QAction* leave_room, +                              QAction* show_room, Network::RoomNetwork& room_network_); +    ~MultiplayerState(); + +    /** +     * Close all open multiplayer related dialogs +     */ +    void Close(); + +    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 UpdateThemedIcons(); +    void ShowNotification(); +    void HideNotification(); + +signals: +    void NetworkStateChanged(const Network::RoomMember::State&); +    void NetworkError(const Network::RoomMember::Error&); +    void AnnounceFailed(const WebService::WebResult&); + +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; +    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; +    Network::RoomNetwork& room_network; +}; + +Q_DECLARE_METATYPE(WebService::WebResult); diff --git a/src/yuzu/multiplayer/validation.h b/src/yuzu/multiplayer/validation.h new file mode 100644 index 000000000..7d48e589d --- /dev/null +++ b/src/yuzu/multiplayer/validation.h @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <QRegExp> +#include <QString> +#include <QValidator> + +class Validation { +public: +    Validation() +        : room_name(room_name_regex), nickname(nickname_regex), ip(ip_regex), port(0, 65535) {} + +    ~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 +    QRegExp room_name_regex = QRegExp(QStringLiteral("^[a-zA-Z0-9._- ]{4,20}$")); +    QRegExpValidator room_name; + +    /// nickname can be alphanumeric and " " "_" "." and "-" and must have a size of 4-20 +    QRegExp nickname_regex = QRegExp(QStringLiteral("^[a-zA-Z0-9._- ]{4,20}$")); +    QRegExpValidator nickname; + +    /// ipv4 address only +    // TODO remove this when we support hostnames in direct connect +    QRegExp ip_regex = QRegExp(QStringLiteral( +        "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|" +        "2[0-4][0-9]|25[0-5])")); +    QRegExpValidator ip; + +    /// port must be between 0 and 65535 +    QIntValidator port; +};  | 
