From 1f7503c537f8c2d3575f4b17489bf7b048a1e998 Mon Sep 17 00:00:00 2001 From: Collecting Date: Sun, 11 Jan 2026 20:19:17 +0000 Subject: [PATCH 1/2] feat(multiplayer): Chatroom QoL Changes & Additions Signed-off-by: Collecting --- src/citron/multiplayer/chat_room.h | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/citron/multiplayer/chat_room.h b/src/citron/multiplayer/chat_room.h index c6277cdf7..2b34db41c 100644 --- a/src/citron/multiplayer/chat_room.h +++ b/src/citron/multiplayer/chat_room.h @@ -4,6 +4,8 @@ #pragma once +#include // time tracking +#include // storing timestamps #include #include #include @@ -49,6 +51,8 @@ public slots: void OnSendChat(); void OnChatTextChanged(); void PopupContextMenu(const QPoint& menu_location); + void OnChatContextMenu(const QPoint& menu_location); + void OnPlayerDoubleClicked(const QModelIndex& index); void Disable(); void Enable(); void UpdateTheme(); @@ -69,7 +73,13 @@ private: std::unique_ptr ui; std::unordered_set block_list; std::unordered_map icon_cache; - Network::RoomNetwork* room_network = nullptr; // Initialize to nullptr + std::unordered_map color_overrides; + bool chat_muted = false; + bool show_timestamps = true; + Network::RoomNetwork* room_network = nullptr; + std::vector sent_message_timestamps; + static constexpr size_t MAX_MESSAGES_PER_INTERVAL = 3; + static constexpr std::chrono::seconds THROTTLE_INTERVAL{5}; }; Q_DECLARE_METATYPE(Network::ChatEntry); From 2332e23a9c0c80397b93eab08612a72d6d8f0a83 Mon Sep 17 00:00:00 2001 From: Collecting Date: Sun, 11 Jan 2026 20:20:33 +0000 Subject: [PATCH 2/2] feat(multiplayer): Chatroom QoL Changes & Additions Signed-off-by: Collecting --- src/citron/multiplayer/chat_room.cpp | 372 +++++++++++++++++---------- 1 file changed, 240 insertions(+), 132 deletions(-) diff --git a/src/citron/multiplayer/chat_room.cpp b/src/citron/multiplayer/chat_room.cpp index 324034e7b..8fea9f445 100644 --- a/src/citron/multiplayer/chat_room.cpp +++ b/src/citron/multiplayer/chat_room.cpp @@ -1,9 +1,11 @@ // SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include #include #include +#include #include #include #include @@ -12,9 +14,13 @@ #include #include #include +#include #include #include #include +#include +#include +#include #include "common/logging/log.h" #include "network/announce_multiplayer_session.h" #include "ui_chat_room.h" @@ -62,11 +68,17 @@ public: } /// Format the message using the players color - QString GetPlayerChatMessage(u16 player) const { + QString GetPlayerChatMessage(u16 player, bool show_timestamps, const std::string& override_color = "") 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]; + QIcon::themeName().contains(QStringLiteral("midnight")); + + std::string color; + if (!override_color.empty()) { + color = override_color; + } else { + color = is_dark_theme ? player_color_dark[player % 16] : player_color_default[player % 16]; + } + QString name; if (username.isEmpty() || username == nickname) { name = nickname; @@ -82,10 +94,11 @@ public: text_color = QStringLiteral("color='#000000'"); } - return QStringLiteral("[%1] <%3> %6") - .arg(timestamp, QString::fromStdString(color), name.toHtmlEscaped(), style, text_color, - message.toHtmlEscaped()); + QString time_str = show_timestamps ? QStringLiteral("[%1] ").arg(timestamp) : QStringLiteral(""); + return QStringLiteral("%1<%3> %6") + .arg(time_str, QString::fromStdString(color), name.toHtmlEscaped(), style, text_color, + message.toHtmlEscaped()); } private: @@ -113,9 +126,10 @@ public: message = msg; } - QString GetSystemChatMessage() const { - return QStringLiteral("[%1] * %3") - .arg(timestamp, QString::fromStdString(system_color), message); + QString GetSystemChatMessage(bool show_timestamps) const { + QString time_str = show_timestamps ? QStringLiteral("[%1] ").arg(timestamp) : QStringLiteral(""); + return QStringLiteral("%1* %3") + .arg(time_str, QString::fromStdString(system_color), message); } private: @@ -131,6 +145,7 @@ public: static const int AvatarUrlRole = Qt::UserRole + 3; static const int GameNameRole = Qt::UserRole + 4; static const int GameVersionRole = Qt::UserRole + 5; + static const int StatusDotRole = Qt::UserRole + 6; PlayerListItem() = default; explicit PlayerListItem(const std::string& nickname, const std::string& username, @@ -160,46 +175,99 @@ public: } 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); + .arg(name, data(GameNameRole).toString(), version_string); } }; ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique()) { ui->setupUi(this); - // set the item_model for player_view + QToolButton* emoji_button = new QToolButton(this); + emoji_button->setText(QStringLiteral("๐Ÿ˜€")); + emoji_button->setPopupMode(QToolButton::InstantPopup); + emoji_button->setAutoRaise(true); + emoji_button->setFixedSize(30, 30); + // Hide the arrow indicator and remove padding to ensure the emoji is dead-center + emoji_button->setStyleSheet(QStringLiteral("QToolButton::menu-indicator { image: none; } QToolButton { padding: 0px; }")); + + ui->horizontalLayout_3->insertWidget(1, emoji_button); + + QMenu* emoji_menu = new QMenu(this); + + QStringList emojis = { + QStringLiteral("๐Ÿ˜€"), QStringLiteral("๐Ÿ˜‚"), QStringLiteral("๐Ÿคฃ"), QStringLiteral("๐Ÿ˜Š"), QStringLiteral("๐Ÿ˜Ž"), + QStringLiteral("๐Ÿค”"), QStringLiteral("๐Ÿคจ"), QStringLiteral("๐Ÿ˜ญ"), QStringLiteral("๐Ÿ˜ฎ"), QStringLiteral("๐Ÿ’€"), + QStringLiteral("๐Ÿ‘"), QStringLiteral("๐Ÿ‘Ž"), QStringLiteral("๐Ÿ”ฅ"), QStringLiteral("โœจ"), QStringLiteral("โค๏ธ"), + QStringLiteral("๐ŸŽ‰"), QStringLiteral("๐Ÿ’ฏ"), QStringLiteral("๐Ÿš€"), QStringLiteral("๐ŸŽฎ"), QStringLiteral("๐Ÿ•น๏ธ"), + QStringLiteral("๐Ÿ‘พ"), QStringLiteral("๐Ÿ„"), QStringLiteral("โญ๏ธ"), QStringLiteral("โš”๏ธ"), QStringLiteral("๐Ÿ›ก๏ธ") + }; + + // Create a container widget for the grid + QWidget* grid_container = new QWidget(emoji_menu); + QGridLayout* grid_layout = new QGridLayout(grid_container); + grid_layout->setSpacing(2); + grid_layout->setContentsMargins(5, 5, 5, 5); + + const int max_columns = 5; + + for (int i = 0; i < emojis.size(); ++i) { + const QString emoji = emojis[i]; + QToolButton* btn = new QToolButton(grid_container); + btn->setText(emoji); + btn->setFixedSize(34, 30); + btn->setAutoRaise(true); + + connect(btn, &QToolButton::clicked, [this, emoji, emoji_menu]() { + ui->chat_message->insert(emoji); + ui->chat_message->setFocus(); + emoji_menu->close(); // Close the menu after picking + }); + + // Add to grid: row = i / columns, col = i % columns + grid_layout->addWidget(btn, i / max_columns, i % max_columns); + } + + // Use QWidgetAction to "stuff" the grid into the QMenu + QWidgetAction* action = new QWidgetAction(emoji_menu); + action->setDefaultWidget(grid_container); + emoji_menu->addAction(action); + + emoji_button->setMenu(emoji_menu); 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); + ui->chat_history->setContextMenuPolicy(Qt::CustomContextMenu); auto font = ui->chat_history->font(); font.setPointSizeF(10); ui->chat_history->setFont(font); - // register the network structs to use in slots and signals qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); - // Connect all the widgets to the appropriate events connect(ui->player_view, &QTreeView::customContextMenuRequested, this, &ChatRoom::PopupContextMenu); + connect(ui->chat_history, &QTextEdit::customContextMenuRequested, this, + &ChatRoom::OnChatContextMenu); 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); + connect(ui->player_view, &QTreeView::doubleClicked, this, &ChatRoom::OnPlayerDoubleClicked); UpdateTheme(); } @@ -208,7 +276,6 @@ ChatRoom::~ChatRoom() = default; void ChatRoom::Initialize(Network::RoomNetwork* room_network_) { room_network = room_network_; - // setup the callbacks for network updates if (auto member = room_network->GetRoomMember().lock()) { member->BindOnChatMessageReceived( [this](const Network::ChatEntry& chat) { emit ChatReceived(chat); }); @@ -223,17 +290,9 @@ void ChatRoom::Initialize(Network::RoomNetwork* room_network_) { void ChatRoom::Shutdown() { if (room_network) { - // Disconnect the signals that were connected in Initialize. - // It's safe to call disconnect even if the connection doesn't exist. disconnect(this, &ChatRoom::ChatReceived, this, &ChatRoom::OnChatReceive); disconnect(this, &ChatRoom::StatusMessageReceived, this, &ChatRoom::OnStatusMessageReceive); - - // NOTE: The Bind... functions do not have a direct unbind. The intended way - // to stop them is to let the 'member' object be destroyed or to stop calling them - // from the network backend. Since we are disconnecting from the room, this is safe. - // The important part is disconnecting the Qt signals. - - room_network = nullptr; // Clear the pointer to prevent further use. + room_network = nullptr; } } @@ -251,10 +310,12 @@ void ChatRoom::Clear() { } void ChatRoom::AppendStatusMessage(const QString& msg) { - ui->chat_history->append(StatusMessage(msg).GetSystemChatMessage()); + if (chat_muted) return; + ui->chat_history->append(StatusMessage(msg).GetSystemChatMessage(show_timestamps)); } void ChatRoom::AppendChatMessage(const QString& msg) { + if (chat_muted) return; ui->chat_history->append(msg); } @@ -278,7 +339,6 @@ bool ChatRoom::ValidateMessage(const std::string& msg) { } void ChatRoom::OnRoomUpdate(const Network::RoomInformation& info) { - // TODO(B3N30): change title if (auto room_member = room_network->GetRoomMember().lock()) { SetPlayerList(room_member->GetMemberInformation()); } @@ -299,7 +359,6 @@ void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) { 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) { @@ -320,7 +379,13 @@ void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) { if (m.ContainsPing()) { emit UserPinged(); } - AppendChatMessage(m.GetPlayerChatMessage(player)); + + std::string override_color = ""; + if (color_overrides.count(chat.nickname)) { + override_color = color_overrides[chat.nickname]; + } + + AppendChatMessage(m.GetPlayerChatMessage(static_cast(player), show_timestamps, override_color)); } } @@ -359,6 +424,20 @@ void ChatRoom::OnSendChat() { if (!room_member->IsConnected()) { return; } + + auto now = std::chrono::steady_clock::now(); + sent_message_timestamps.erase( + std::remove_if(sent_message_timestamps.begin(), sent_message_timestamps.end(), + [now](const auto& ts) { + return (now - ts) > THROTTLE_INTERVAL; + }), + sent_message_timestamps.end()); + + if (sent_message_timestamps.size() >= MAX_MESSAGES_PER_INTERVAL) { + AppendStatusMessage(tr("Spam detected. Please don't send more than 3 messages per every 5 seconds.")); + return; + } + auto message = ui->chat_message->text().toStdString(); if (!ValidateMessage(message)) { return; @@ -378,8 +457,16 @@ void ChatRoom::OnSendChat() { } auto player = std::distance(members.begin(), it); ChatMessage m(chat, *room_network); + room_member->SendChatMessage(message); - AppendChatMessage(m.GetPlayerChatMessage(player)); + sent_message_timestamps.push_back(now); + + std::string override_color = ""; + if (color_overrides.count(nick)) { + override_color = color_overrides[nick]; + } + + AppendChatMessage(m.GetPlayerChatMessage(static_cast(player), show_timestamps, override_color)); ui->chat_message->clear(); } } @@ -387,62 +474,105 @@ void ChatRoom::OnSendChat() { 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(); + const std::string avatar_url = item->data(PlayerListItem::AvatarUrlRole).toString().toStdString(); + + QPixmap pixmap; if (icon_cache.count(avatar_url)) { - item->setData(icon_cache.at(avatar_url), Qt::DecorationRole); + pixmap = icon_cache.at(avatar_url); } else { - item->setData(QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48), - Qt::DecorationRole); + pixmap = QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48); } + + QPixmap canvas = pixmap.copy(); + QPainter painter(&canvas); + painter.setRenderHint(QPainter::Antialiasing); + + QString dot_type = item->data(PlayerListItem::StatusDotRole).toString(); + QColor dot_color; + if (dot_type == QStringLiteral("๐ŸŸข")) dot_color = Qt::green; + else if (dot_type == QStringLiteral("๐ŸŸก")) dot_color = Qt::yellow; + else dot_color = Qt::gray; + + // Draw a small "outline" circle + painter.setBrush(QColor(30, 30, 30)); + painter.setPen(Qt::NoPen); + painter.drawEllipse(32, 32, 14, 14); + + // Draw the actual status dot + painter.setBrush(dot_color); + painter.drawEllipse(34, 34, 10, 10); + + painter.end(); + + // Set the final icon + item->setData(canvas, 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()); + + // 1. Find the local player's game info to use as a baseline + AnnounceMultiplayerRoom::GameInfo local_game_info; + if (room_network) { + if (auto room_member = room_network->GetRoomMember().lock()) { + std::string my_nick = room_member->GetNickname(); + for (const auto& m : member_list) { + if (m.nickname == my_nick) { + local_game_info = m.game_info; + break; + } + } + } + } + + // 2. Create the list items 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); + // Determine the Status Dot logic + QString status_dot = QStringLiteral("โšช"); + if (!member.game_info.name.empty() && !local_game_info.name.empty()) { + if (member.game_info.name == local_game_info.name) { + if (member.game_info.version == local_game_info.version) { + status_dot = QStringLiteral("๐ŸŸข"); + } else { + status_dot = QStringLiteral("๐ŸŸก"); + } + } + } + name_item->setData(status_dot, PlayerListItem::StatusDotRole); + #ifdef ENABLE_WEB_SERVICE if (!icon_cache.count(member.avatar_url) && !member.avatar_url.empty()) { - // Start a request to get the member's avatar const QUrl url(QString::fromStdString(member.avatar_url)); QFuture future = QtConcurrent::run([url] { WebService::Client client( QStringLiteral("%1://%2").arg(url.scheme(), url.host()).toStdString(), "", ""); auto result = client.GetImage(url.path().toStdString(), true); - if (result.returned_data.empty()) { - LOG_ERROR(WebService, "Failed to get avatar"); - } return result.returned_data; }); auto* future_watcher = new QFutureWatcher(this); connect(future_watcher, &QFutureWatcher::finished, this, [this, future_watcher, avatar_url = member.avatar_url] { const std::string result = future_watcher->result(); - if (result.empty()) - return; + if (result.empty()) return; QPixmap pixmap; - if (!pixmap.loadFromData(reinterpret_cast(result.data()), - static_cast(result.size()))) - return; - icon_cache[avatar_url] = - pixmap.scaled(48, 48, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - // Update all the displayed icons with the new icon_cache + if (!pixmap.loadFromData(reinterpret_cast(result.data()), static_cast(result.size()))) return; + icon_cache[avatar_url] = pixmap.scaled(48, 48, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); UpdateIconDisplay(); + future_watcher->deleteLater(); }); future_watcher->setFuture(future); } #endif - player_list->invisibleRootItem()->appendRow(name_item); } UpdateIconDisplay(); - // TODO(B3N30): Restore row selection } void ChatRoom::OnChatTextChanged() { @@ -453,20 +583,24 @@ void ChatRoom::OnChatTextChanged() { 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(); + if (!item.isValid()) return; + std::string nickname = player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString(); QMenu context_menu; + QAction* color_action = context_menu.addAction(tr("Set Name Color")); + connect(color_action, &QAction::triggered, [this, nickname] { + QColor color = QColorDialog::getColor(Qt::white, this, tr("Select Color for %1").arg(QString::fromStdString(nickname))); + if (color.isValid()) { + color_overrides[nickname] = color.name().toStdString(); + } + }); + 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))); + QDesktopServices::openUrl(QUrl(QStringLiteral("https://community.citra-emu.org/u/%1").arg(username))); }); } @@ -475,9 +609,8 @@ void ChatRoom::PopupContextMenu(const QPoint& menu_location) { cur_nickname = room->GetNickname(); } - if (nickname != cur_nickname) { // You can't block yourself + if (nickname != cur_nickname) { QAction* block_action = context_menu.addAction(tr("Block Player")); - block_action->setCheckable(true); block_action->setChecked(block_list.count(nickname) > 0); @@ -487,41 +620,19 @@ void ChatRoom::PopupContextMenu(const QPoint& menu_location) { } else { QMessageBox::StandardButton result = QMessageBox::question( this, tr("Block Player"), - tr("When you block a player, you will no longer receive chat messages from " - "them.

Are you sure you would like to block %1?") - .arg(QString::fromStdString(nickname)), + tr("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 (result == QMessageBox::Yes) block_list.emplace(nickname); } }); } - if (has_mod_perms && nickname != cur_nickname) { // You can't kick or ban yourself + if (has_mod_perms && nickname != cur_nickname) { context_menu.addSeparator(); - QAction* kick_action = context_menu.addAction(tr("Kick")); QAction* ban_action = context_menu.addAction(tr("Ban")); - - connect(kick_action, &QAction::triggered, [this, nickname] { - QMessageBox::StandardButton result = - QMessageBox::question(this, tr("Kick Player"), - tr("Are you sure you would like to kick %1?") - .arg(QString::fromStdString(nickname)), - QMessageBox::Yes | QMessageBox::No); - if (result == QMessageBox::Yes) - SendModerationRequest(Network::IdModKick, nickname); - }); - connect(ban_action, &QAction::triggered, [this, nickname] { - QMessageBox::StandardButton result = QMessageBox::question( - this, tr("Ban Player"), - tr("Are you sure you would like to kick and ban %1?\n\nThis would " - "ban both their forum username and their IP address.") - .arg(QString::fromStdString(nickname)), - QMessageBox::Yes | QMessageBox::No); - if (result == QMessageBox::Yes) - SendModerationRequest(Network::IdModBan, nickname); - }); + connect(kick_action, &QAction::triggered, [this, nickname] { SendModerationRequest(Network::IdModKick, nickname); }); + connect(ban_action, &QAction::triggered, [this, nickname] { SendModerationRequest(Network::IdModBan, nickname); }); } context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location)); @@ -530,57 +641,54 @@ void ChatRoom::PopupContextMenu(const QPoint& menu_location) { void ChatRoom::UpdateTheme() { QString style_sheet; const QString accent_color = Theme::GetAccentColor(); - if (UISettings::IsDarkTheme()) { style_sheet = QStringLiteral(R"( - QListView, QTextEdit, QLineEdit { - background-color: #252525; - color: #E0E0E0; - border: 1px solid #4A4A4A; - border-radius: 4px; - } - QListView::item:selected { - background-color: %1; - } - QPushButton { - background-color: #3E3E3E; - color: #E0E0E0; - border: 1px solid #5A5A5A; - padding: 5px; - border-radius: 4px; - } - QPushButton:hover { - background-color: #4A4A4A; - } - QPushButton:pressed { - background-color: #555555; - } + QListView, QTextEdit, QLineEdit { background-color: #252525; color: #E0E0E0; border: 1px solid #4A4A4A; border-radius: 4px; } + QListView::item:selected { background-color: %1; } + QPushButton { background-color: #3E3E3E; color: #E0E0E0; border: 1px solid #5A5A5A; padding: 5px; border-radius: 4px; } )").arg(accent_color); } else { style_sheet = QStringLiteral(R"( - QListView, QTextEdit, QLineEdit { - background-color: #FFFFFF; - color: #000000; - border: 1px solid #CFCFCF; - border-radius: 4px; - } - QListView::item:selected { - background-color: %1; - } - QPushButton { - background-color: #F0F0F0; - color: #000000; - border: 1px solid #BDBDBD; - padding: 5px; - border-radius: 4px; - } - QPushButton:hover { - background-color: #E0E0E0; - } - QPushButton:pressed { - background-color: #D0D0D0; - } + QListView, QTextEdit, QLineEdit { background-color: #FFFFFF; color: #000000; border: 1px solid #CFCFCF; border-radius: 4px; } + QListView::item:selected { background-color: %1; } + QPushButton { background-color: #F0F0F0; color: #000000; border: 1px solid #BDBDBD; padding: 5px; border-radius: 4px; } )").arg(accent_color); } this->setStyleSheet(style_sheet); } + +void ChatRoom::OnChatContextMenu(const QPoint& menu_location) { + QMenu* context_menu = ui->chat_history->createStandardContextMenu(menu_location); + context_menu->addSeparator(); + QAction* clear_action = context_menu->addAction(tr("Clear Chat History")); + connect(clear_action, &QAction::triggered, this, &ChatRoom::Clear); + + QAction* mute_action = context_menu->addAction(tr("Hide Future Messages")); + mute_action->setCheckable(true); + mute_action->setChecked(chat_muted); + connect(mute_action, &QAction::triggered, [this](bool checked) { + this->chat_muted = checked; + if (checked) { + ui->chat_history->clear(); + ui->chat_history->append(tr("* Chat Paused. Right-click to resume.")); + } + }); + + QAction* time_action = context_menu->addAction(tr("Show Timestamps")); + time_action->setCheckable(true); + time_action->setChecked(show_timestamps); + connect(time_action, &QAction::triggered, [this](bool checked) { show_timestamps = checked; }); + + context_menu->exec(ui->chat_history->viewport()->mapToGlobal(menu_location)); + delete context_menu; +} + +void ChatRoom::OnPlayerDoubleClicked(const QModelIndex& index) { + QString nickname = player_list->data(index, PlayerListItem::NicknameRole).toString(); + if (!nickname.isEmpty()) { + QString currentText = ui->chat_message->text(); + if (!currentText.isEmpty() && !currentText.endsWith(QStringLiteral(" "))) currentText += QStringLiteral(" "); + ui->chat_message->setText(currentText + QStringLiteral("@%1 ").arg(nickname)); + ui->chat_message->setFocus(); + } +}