eden-miror/src/yuzu/multiplayer/chat_room.cpp
crueter b9e052b3a7
[desktop] Basic grid view implementation (#3479)
Closes #3441

Basic impl of a grid view on the game list. The ideal solution here
would be to use QSortFilterProxyModel and abstract the game list model
out to a QStandardItemModel, but that is too much effort for me rn.
Adapted the "card" design from QML, can 1000% be improved but QPainter
is just such a pain to deal with. Implanting a Qt Quick scene into there
would legitimately be easier.

Anyways, margins and text sizes lgtm at all sizes, though please give
feedback on both that and the general card design.

Future TODOs:
- [ ] Auto size mode
- [ ] Refactor to use models

Signed-off-by: crueter <crueter@eden-emu.dev>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3479
2026-02-06 19:51:01 +01:00

508 lines
20 KiB
C++

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