diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bd99d08bf..18679664b 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -236,10 +236,14 @@ set(cockatrice_SOURCES src/interface/widgets/server/handle_public_servers.cpp src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp + src/interface/widgets/server/user/user_avatar_provider.cpp + src/interface/widgets/server/user/user_card_art_provider.cpp + src/interface/widgets/server/user/user_card_settings_dialog.cpp src/interface/widgets/server/user/user_context_menu.cpp src/interface/widgets/server/user/user_info_box.cpp src/interface/widgets/server/user/user_info_connection.cpp src/interface/widgets/server/user/user_list_manager.cpp + src/interface/widgets/server/user/user_list_painter.cpp src/interface/widgets/server/user/user_list_widget.cpp src/interface/widgets/settings_page/appearance_settings_page.cpp src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -327,6 +331,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp + src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp src/interface/widgets/tabs/tab_game.cpp @@ -349,6 +354,8 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h + src/interface/widgets/server/user/user_info_popup.cpp + src/interface/widgets/server/user/user_info_popup.h ) add_subdirectory(sounds) diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index cc34e1707..28e5eb187 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -371,6 +371,7 @@ SettingsCache::SettingsCache() openDeckInNewTab = settings->value("editor/openDeckInNewTab", false).toBool(); rewindBufferingMs = settings->value("replay/rewindBufferingMs", 200).toInt(); + styleUserList = settings->value("appearance/styleUserList", true).toBool(); chatMention = settings->value("chat/mention", true).toBool(); chatMentionCompleter = settings->value("chat/mentioncompleter", true).toBool(); chatMentionForeground = settings->value("chat/mentionforeground", true).toBool(); @@ -1045,6 +1046,13 @@ void SettingsCache::setRewindBufferingMs(int _rewindBufferingMs) settings->setValue("replay/rewindBufferingMs", rewindBufferingMs); } +void SettingsCache::setStyleUserList(QT_STATE_CHANGED_T _styleUserList) +{ + styleUserList = static_cast(_styleUserList); + settings->setValue("appearance/styleUserList", styleUserList); + emit styleUserListChanged(); +} + void SettingsCache::setChatMention(QT_STATE_CHANGED_T _chatMention) { chatMention = static_cast(_chatMention); diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index a166917c1..5a5e0c546 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -190,6 +190,7 @@ signals: void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod); void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme); void masterVolumeChanged(int value); + void styleUserListChanged(); void chatMentionCompleterChanged(); void downloadSpoilerTimeIndexChanged(); void downloadSpoilerStatusChanged(); @@ -284,6 +285,7 @@ private: bool autoRotateSidewaysLayoutCards; bool openDeckInNewTab; int rewindBufferingMs; + bool styleUserList; bool chatMention; bool chatMentionCompleter; QString chatMentionColor; @@ -738,6 +740,10 @@ public: { return rewindBufferingMs; } + [[nodiscard]] bool getStyleUserList() const + { + return styleUserList; + } [[nodiscard]] bool getChatMention() const { return chatMention; @@ -1113,6 +1119,7 @@ public slots: void setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards); void setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab); void setRewindBufferingMs(int _rewindBufferingMs); + void setStyleUserList(QT_STATE_CHANGED_T _styleUserList); void setChatMention(QT_STATE_CHANGED_T _chatMention); void setChatMentionCompleter(QT_STATE_CHANGED_T _chatMentionCompleter); void setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground); diff --git a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp new file mode 100644 index 000000000..c115caa47 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp @@ -0,0 +1,48 @@ +#include "user_avatar_provider.h" + +#include +#include +#include + +UserAvatarProvider::UserAvatarProvider(AbstractClient *client, QObject *parent) : QObject(parent), client(client) +{ +} + +const QMap &UserAvatarProvider::cache() const +{ + return avatarCache; +} + +void UserAvatarProvider::requestAvatar(const QString &userName) +{ + if (avatarCache.contains(userName) || pending.contains(userName)) { + return; + } + + pending.insert(userName); + + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &r) { + pending.remove(userName); + + const auto &response = r.GetExtension(Response_GetUserInfo::ext); + const auto &user = response.user_info(); + const std::string &bmp = user.avatar_bmp(); + + QPixmap avatar; + if (!bmp.empty() && + avatar.loadFromData(reinterpret_cast(bmp.data()), static_cast(bmp.size()))) { + avatarCache.insert(userName, avatar); + } else { + avatarCache.insert(userName, QPixmap()); + } + + emit avatarUpdated(userName); + }); + + client->sendCommand(pend); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h new file mode 100644 index 000000000..44491e544 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h @@ -0,0 +1,30 @@ +#ifndef COCKATRICE_USER_AVATAR_PROVIDER_H +#define COCKATRICE_USER_AVATAR_PROVIDER_H + +#include +#include +#include +#include + +class AbstractClient; + +class UserAvatarProvider : public QObject +{ + Q_OBJECT + +public: + explicit UserAvatarProvider(AbstractClient *client, QObject *parent = nullptr); + + void requestAvatar(const QString &userName); + const QMap &cache() const; + +signals: + void avatarUpdated(const QString &userName); + +private: + AbstractClient *client; + QMap avatarCache; + QSet pending; +}; + +#endif // COCKATRICE_USER_AVATAR_PROVIDER_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp new file mode 100644 index 000000000..70a56375e --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -0,0 +1,146 @@ +#include "user_card_art_provider.h" + +#include "../../../card_picture_loader/card_picture_loader.h" + +#include +#include + +static QString makeKey(const QString &user, const QString &card) +{ + return user + u'|' + card; +} + +UserCardArtProvider::UserCardArtProvider(QObject *parent) : QObject(parent) +{ + dbReady = (CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok); + + if (!dbReady) { + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, + &UserCardArtProvider::onDatabaseReady); + } +} + +void UserCardArtProvider::onDatabaseReady() +{ + dbReady = true; + processQueue(); +} + +const QMap &UserCardArtProvider::cache() const +{ + return cardArtCache; +} + +void UserCardArtProvider::requestCardArt(const QString &userName, const QString &cardName) +{ + if (cardName.isEmpty()) { + return; + } + + const QString key = makeKey(userName, cardName); + + if (cardArtCache.contains(key) || pending.contains(key)) { + return; + } + + pending.insert(key); + queue.enqueue(key); + + processQueue(); +} + +QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes) +{ + const QSize sz = fullRes.size(); + const int marginX = sz.width() * 0.07; + const int topMargin = sz.height() * 0.11; + const int bottomMargin = sz.height() * 0.45; + + const QRect foilRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); + + return fullRes.copy(foilRect.intersected(fullRes.rect())); +} + +void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) +{ + if (!cardArtCache.contains(key)) { + cacheInsertionOrder.append(key); + while (cacheInsertionOrder.size() > MaxCacheEntries) { + const QString evicted = cacheInsertionOrder.takeFirst(); + cardArtCache.remove(evicted); + } + } + cardArtCache.insert(key, pixmap); +} + +void UserCardArtProvider::processQueue() +{ + if (!dbReady) { + return; + } + + while (!queue.isEmpty()) { + const QString key = queue.dequeue(); + + const QStringList parts = key.split(u'|'); + if (parts.size() != 2) { + pending.remove(key); + continue; + } + + const QString userName = parts.at(0); + const QString cardName = parts.at(1); + + ExactCard card = CardDatabaseManager::query()->getCard({cardName}); + + if (!card) { + pending.remove(key); + continue; + } + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + // Synchronous hit (already loaded/on disk) + if (!fullRes.isNull()) { + insertIntoCache(key, cropCardArt(fullRes)); + pending.remove(key); + + emit cardArtUpdated(userName); + continue; + } + + // Async load required. + QPointer self(this); + + auto conn = std::make_shared(); + + *conn = connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, + [self, key, userName, card, conn]() mutable { + if (!self) { + return; + } + + QObject::disconnect(*conn); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (!fullRes.isNull()) { + self->insertIntoCache(key, self->cropCardArt(fullRes)); + } else { + self->insertIntoCache(key, QPixmap()); + } + + self->pending.remove(key); + + emit self->cardArtUpdated(userName); + + // Resume processing remaining queued items. + self->processQueue(); + }); + + // Stop here. We'll continue when the async load finishes. + return; + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h new file mode 100644 index 000000000..a3ab874b7 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -0,0 +1,39 @@ +#ifndef COCKATRICE_USER_CARD_ART_PROVIDER_H +#define COCKATRICE_USER_CARD_ART_PROVIDER_H + +#include +#include +#include +#include +#include + +class UserCardArtProvider : public QObject +{ + Q_OBJECT + +public: + explicit UserCardArtProvider(QObject *parent = nullptr); + + void requestCardArt(const QString &userName, const QString &cardName); + const QMap &cache() const; + static QPixmap cropCardArt(const QPixmap &fullRes); + +signals: + void cardArtUpdated(const QString &userName); + +public slots: + void onDatabaseReady(); + +private: + bool dbReady = false; + static constexpr int MaxCacheEntries = 300; + QList cacheInsertionOrder; // FIFO eviction + QMap cardArtCache; + QSet pending; + QQueue queue; + + void processQueue(); + void insertIntoCache(const QString &key, const QPixmap &pixmap); +}; + +#endif // COCKATRICE_USER_CARD_ART_PROVIDER_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp new file mode 100644 index 000000000..335ee097e --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -0,0 +1,264 @@ +#include "user_card_settings_dialog.h" + +#include "../../../card_picture_loader/card_picture_loader.h" +#include "card/card_completer_proxy_model.h" +#include "card/card_search_model.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "user_card_art_provider.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CardArtPreviewWidget::CardArtPreviewWidget(QWidget *parent) : QWidget(parent) +{ + setMinimumSize(400, 72); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap) +{ + sourcePixmap = pixmap; + update(); +} + +void CardArtPreviewWidget::setParams(const CardArtParams &p) +{ + params = p; + update(); +} + +void CardArtPreviewWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + + const QColor accentColor(100, 116, 139); + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + bg.setColorAt(0, accentColor.darker(320)); + bg.setColorAt(1, QColor(18, 22, 30)); + painter.setPen(Qt::NoPen); + painter.setBrush(bg); + painter.drawRoundedRect(cardRect, 6, 6); + painter.setBrush(accentColor); + painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + + if (sourcePixmap.isNull()) { + painter.setPen(QColor(150, 150, 150)); + painter.drawText(rect, Qt::AlignCenter, tr("No card selected")); + return; + } + + UserListPainter::drawCardArt(&painter, rect, rect.right() - 4, + QString(), // userName not needed for override path + nullptr, // no cache + params, + &sourcePixmap // πŸ‘ˆ direct pixmap + ); + + // Avatar placeholder so the left-margin interaction is visible + const int avatarX = rect.left() + 14; + const int avatarY = rect.top() + (rect.height() - 36) / 2; + const QRect avatarRect(avatarX, avatarY, 36, 36); + + QPainterPath clip; + clip.addEllipse(avatarRect); + painter.save(); + painter.setClipPath(clip); + painter.setBrush(accentColor.darker(200)); + painter.setPen(Qt::NoPen); + painter.drawEllipse(avatarRect); + painter.restore(); + + painter.setPen(QPen(QColor(70, 80, 95), 2)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(avatarRect.adjusted(-1, -1, 1, 1)); +} + +UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initial, QWidget *parent) + : QDialog(parent), currentParams(initial) +{ + setWindowTitle(tr("Card Art Settings")); + setMinimumWidth(500); + setupUi(); + + // Seed UI from initial params + if (!initial.cardName.isEmpty()) { + searchBar->setText(initial.cardName); + onCardNameChanged(initial.cardName); + } + marginLSpin->setValue(initial.marginPctL); + marginRSpin->setValue(initial.marginPctR); + verticalOffsetSpin->setValue(initial.verticalOffset); + zoomSpin->setValue(initial.zoom); +} + +CardArtParams UserCardArtSettingsDialog::params() const +{ + return currentParams; +} + +QDoubleSpinBox *UserCardArtSettingsDialog::makeSpinBox(double min, double max, double value, double step) +{ + auto *spin = new QDoubleSpinBox; + spin->setRange(min, max); + spin->setSingleStep(step); + spin->setDecimals(3); + spin->setValue(value); + return spin; +} + +void UserCardArtSettingsDialog::initializeSearchBar() +{ + searchBar = new QLineEdit; + searchBar->setPlaceholderText(tr("Type a card name...")); + + cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); + cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); + searchModel = new CardSearchModel(cardDatabaseDisplayModel, this); + + proxyModel = new CardCompleterProxyModel(this); + proxyModel->setSourceModel(searchModel); + proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + proxyModel->setFilterRole(Qt::DisplayRole); + + completer = new QCompleter(proxyModel, this); + completer->setCompletionRole(Qt::DisplayRole); + completer->setCompletionMode(QCompleter::PopupCompletion); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setFilterMode(Qt::MatchContains); + completer->setMaxVisibleItems(15); + searchBar->setCompleter(completer); + + connect(searchBar, &QLineEdit::textEdited, searchModel, &CardSearchModel::updateSearchResults); + connect(searchBar, &QLineEdit::textEdited, this, [this](const QString &text) { + const QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; + proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); + if (!text.isEmpty()) { + completer->complete(); + } + }); + + connect(completer, static_cast(&QCompleter::activated), this, + [this](const QString &completion) { + if (searchBar->text() != completion) { + searchBar->setText(completion); + searchBar->setCursorPosition(searchBar->text().length()); + } + onCardNameChanged(completion); + }); + + // Also trigger a load when the user hits Return on a typed name + connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); }); +} + +void UserCardArtSettingsDialog::setupUi() +{ + initializeSearchBar(); + + marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); + marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); + verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); + zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05); + + auto *form = new QFormLayout; + form->addRow(tr("Card name:"), searchBar); + form->addRow(tr("Left margin (%):"), marginLSpin); + form->addRow(tr("Right margin (%):"), marginRSpin); + form->addRow(tr("Vertical offset:"), verticalOffsetSpin); + form->addRow(tr("Zoom:"), zoomSpin); + + auto *controlsGroup = new QGroupBox(tr("Parameters")); + controlsGroup->setLayout(form); + + preview = new CardArtPreviewWidget; + + auto *previewLayout = new QVBoxLayout; + previewLayout->addWidget(preview); + auto *previewGroup = new QGroupBox(tr("Preview")); + previewGroup->setLayout(previewLayout); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + auto *removeBtn = new QPushButton(tr("Remove Banner Card")); + buttons->addButton(removeBtn, QDialogButtonBox::ResetRole); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(removeBtn, &QPushButton::clicked, this, [this]() { + currentParams = CardArtParams{}; // empty cardName signals removal + accept(); + }); + + auto *root = new QVBoxLayout; + root->addWidget(controlsGroup); + root->addWidget(previewGroup); + root->addWidget(buttons); + setLayout(root); + + connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); +} + +void UserCardArtSettingsDialog::onCardNameChanged(const QString &name) +{ + if (name.isEmpty()) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + return; + } + + const ExactCard card = CardDatabaseManager::query()->getCard({name}); + if (!card) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + return; + } + + currentParams.cardName = name; + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this, card](const PrintingInfo &) { + disconnect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, nullptr); + QPixmap loaded; + CardPictureLoader::getPixmap(loaded, card, QSize(745, 1040)); + currentPixmap = UserCardArtProvider::cropCardArt(loaded); + preview->setPixmap(currentPixmap); + }); + return; + } + + currentPixmap = UserCardArtProvider::cropCardArt(fullRes); + preview->setPixmap(currentPixmap); +} + +void UserCardArtSettingsDialog::onParamChanged() +{ + currentParams.marginPctL = marginLSpin->value(); + currentParams.marginPctR = marginRSpin->value(); + currentParams.verticalOffset = verticalOffsetSpin->value(); + currentParams.zoom = zoomSpin->value(); + preview->setParams(currentParams); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h new file mode 100644 index 000000000..cac26c919 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h @@ -0,0 +1,70 @@ +#ifndef COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H +#define COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H + +#include "user_list_painter.h" + +#include +#include + +class QCompleter; +class QLineEdit; +class QDoubleSpinBox; +class CardDatabaseModel; +class CardDatabaseDisplayModel; +class CardSearchModel; +class CardCompleterProxyModel; + +class CardArtPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit CardArtPreviewWidget(QWidget *parent = nullptr); + + void setPixmap(const QPixmap &pixmap); + void setParams(const CardArtParams ¶ms); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + QPixmap sourcePixmap; + CardArtParams params; +}; + +class UserCardArtSettingsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit UserCardArtSettingsDialog(const CardArtParams &initial = {}, QWidget *parent = nullptr); + + CardArtParams params() const; + +private slots: + void onCardNameChanged(const QString &name); + void onParamChanged(); + +private: + void setupUi(); + void initializeSearchBar(); + QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); + + QLineEdit *searchBar; + QCompleter *completer; + CardDatabaseModel *cardDatabaseModel; + CardDatabaseDisplayModel *cardDatabaseDisplayModel; + CardSearchModel *searchModel; + CardCompleterProxyModel *proxyModel; + + QDoubleSpinBox *marginLSpin; + QDoubleSpinBox *marginRSpin; + QDoubleSpinBox *verticalOffsetSpin; + QDoubleSpinBox *zoomSpin; + CardArtPreviewWidget *preview; + + QPixmap currentPixmap; + CardArtParams currentParams; +}; + +#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index faa96fa1f..11fd02d80 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -542,3 +542,113 @@ void UserContextMenu::showContextMenu(const QPoint &pos, delete menu; } + +void UserContextMenu::execChat(const QString &userName) +{ + emit openMessageDialog(userName, true); +} + +void UserContextMenu::execDetails(const QString &userName) +{ + auto *w = new UserInfoBox(client, false, static_cast(parent()), + Qt::Dialog | Qt::WindowTitleHint | Qt::CustomizeWindowHint | Qt::WindowCloseButtonHint); + w->setAttribute(Qt::WA_DeleteOnClose); + w->updateInfo(userName); +} + +void UserContextMenu::execShowGames(const QString &userName) +{ + Command_GetGamesOfUser cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::gamesOfUserReceived); + client->sendCommand(pend); +} + +void UserContextMenu::execAddToBuddy(const QString &userName) +{ + Command_AddToList cmd; + cmd.set_list("buddy"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execRemoveFromBuddy(const QString &userName) +{ + Command_RemoveFromList cmd; + cmd.set_list("buddy"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execAddToIgnore(const QString &userName) +{ + Command_AddToList cmd; + cmd.set_list("ignore"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execRemoveFromIgnore(const QString &userName) +{ + Command_RemoveFromList cmd; + cmd.set_list("ignore"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execBan(const QString &userName) +{ + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUser_processUserInfoResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execWarn(const QString &userName) +{ + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUser_processUserInfoResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execBanHistory(const QString &userName) +{ + Command_GetBanHistory cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUserHistory_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execWarnHistory(const QString &userName) +{ + Command_GetWarnHistory cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUserHistory_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdminNotes(const QString &userName) +{ + Command_GetAdminNotes cmd; + cmd.set_user_name(userName.toStdString()); + auto *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::getAdminNotes_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod, bool shouldBeJudge) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_mod(shouldBeMod); + cmd.set_should_be_judge(shouldBeJudge); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index b0ff89816..28173bfbc 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -74,6 +74,27 @@ public: int playerId, const QString &deckHash, ChatView *chatView = nullptr); + + const UserListProxy *getUserListProxy() const + { + return userListProxy; + } + + // Individual action entry points β€” used by UserInfoPopup to trigger + // actions without re-running the full context menu flow. + void execChat(const QString &userName); + void execDetails(const QString &userName); + void execShowGames(const QString &userName); + void execAddToBuddy(const QString &userName); + void execRemoveFromBuddy(const QString &userName); + void execAddToIgnore(const QString &userName); + void execRemoveFromIgnore(const QString &userName); + void execBan(const QString &userName); + void execWarn(const QString &userName); + void execBanHistory(const QString &userName); + void execWarnHistory(const QString &userName); + void execAdminNotes(const QString &userName); + void execAdjustMod(const QString &userName, bool shouldBeMod, bool shouldBeJudge); }; #endif diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index e41ae6e75..e6cf38787 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -5,6 +5,7 @@ #include "../../interface/widgets/dialogs/dlg_edit_password.h" #include "../../interface/widgets/dialogs/dlg_edit_user.h" #include "../../interface/widgets/utility/get_text_with_max.h" +#include "user_card_settings_dialog.h" #include #include @@ -61,11 +62,13 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren buttonsLayout->addWidget(&editButton); buttonsLayout->addWidget(&passwordButton); buttonsLayout->addWidget(&avatarButton); + buttonsLayout->addWidget(&bannerCardButton); mainLayout->addLayout(buttonsLayout, 7, 0, 1, 3); connect(&editButton, &QPushButton::clicked, this, &UserInfoBox::actEdit); connect(&passwordButton, &QPushButton::clicked, this, &UserInfoBox::actPassword); connect(&avatarButton, &QPushButton::clicked, this, &UserInfoBox::actAvatar); + connect(&bannerCardButton, &QPushButton::clicked, this, &UserInfoBox::actBannerCard); } setWindowTitle(tr("User Information")); @@ -83,11 +86,15 @@ void UserInfoBox::retranslateUi() editButton.setText(tr("Edit")); passwordButton.setText(tr("Change password")); avatarButton.setText(tr("Change avatar")); + bannerCardButton.setText(tr("Edit Banner Card")); } void UserInfoBox::updateInfo(const ServerInfo_User &user) { - userLevel = UserLevelFlags(user.user_level()); + currentUserInfo = user; + hasUserInfo = true; + + const UserLevelFlags userLevel(user.user_level()); pawnColors = user.pawn_colors(); privLevel = QString::fromStdString(user.privlevel()); @@ -306,6 +313,48 @@ void UserInfoBox::actAvatar() client->sendCommand(pend); } +void UserInfoBox::actBannerCard() +{ + CardArtParams initial; + if (hasUserInfo && currentUserInfo.has_card_art_params()) { + const auto &cap = currentUserInfo.card_art_params(); + initial.cardName = QString::fromStdString(cap.card_name()); + initial.marginPctL = cap.margin_pct_l(); + initial.marginPctR = cap.margin_pct_r(); + initial.verticalOffset = cap.vertical_offset(); + initial.zoom = cap.zoom(); + } + + UserCardArtSettingsDialog dlg(initial, this); + if (dlg.exec() != QDialog::Accepted) { + return; + } + + const CardArtParams p = dlg.params(); + + Command_SetCardArtParams cmd; + cmd.set_card_name(p.cardName.toStdString()); + if (!p.cardName.isEmpty()) { + cmd.set_margin_pct_l(p.marginPctL); + cmd.set_margin_pct_r(p.marginPctR); + cmd.set_vertical_offset(p.verticalOffset); + cmd.set_zoom(p.zoom); + } + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, [p, this](const Response &r) { + if (r.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), + tr("The selected card is blacklisted on this server or another error occurred.")); + } else { + updateInfo(nameLabel.text()); // re-fetch so currentUserInfo reflects the change + QMessageBox::information(this, tr("Information"), + p.cardName.isEmpty() ? tr("Banner card removed.") : tr("Banner card updated.")); + } + }); + client->sendCommand(pend); +} + void UserInfoBox::processEditResponse(const Response &r) { switch (r.response_code()) { diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.h b/cockatrice/src/interface/widgets/server/user/user_info_box.h index 055ac0096..955cb9d3d 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.h @@ -12,6 +12,7 @@ #include #include #include +#include #include class AbstractClient; @@ -25,9 +26,11 @@ private: bool editable; QLabel avatarPic, userLevelIcon, nameLabel, realNameLabel1, realNameLabel2, countryLabel1, countryLabel2, countryLabel3, userLevelLabel1, userLevelLabel2, accountAgeLabel1, accountAgeLabel2; - QPushButton editButton, passwordButton, avatarButton; + QPushButton editButton, passwordButton, avatarButton, bannerCardButton; QPixmap avatarPixmap; bool hasAvatar; + ServerInfo_User currentUserInfo; + bool hasUserInfo = false; UserLevelFlags userLevel; ServerInfo_User::PawnColorsOverride pawnColors; QString privLevel; @@ -37,6 +40,7 @@ private: public: UserInfoBox(AbstractClient *_client, bool editable, QWidget *parent = nullptr, Qt::WindowFlags flags = {}); void retranslateUi(); + private slots: void processResponse(const Response &r); void processEditResponse(const Response &r); @@ -47,6 +51,7 @@ private slots: void actEditInternal(const Response &r); void actPassword(); void actAvatar(); + void actBannerCard(); public slots: void updateInfo(const ServerInfo_User &user); void updateInfo(const QString &userName); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp new file mode 100644 index 000000000..fd62d5ddf --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -0,0 +1,656 @@ +#include "user_info_popup.h" + +#include "../../interface/pixel_map_generator.h" +#include "../../interface/widgets/tabs/tab_supervisor.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ── Compact game row delegate ───────────────────────────────────────────────── + +class PopupGameDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + QSize sizeHint(const QStyleOptionViewItem &, const QModelIndex &) const override + { + return QSize(0, 38); + } + + void paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + const QVariant var = index.data(PopupRoles::GameData); + if (!var.isValid()) { + QStyledItemDelegate::paint(p, option, index); + return; + } + + p->save(); + p->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing); + + const QRect rect = option.rect; + const ServerInfo_Game game = var.value(); + const bool selected = option.state & QStyle::State_Selected; + + p->fillRect(rect, selected ? QColor(35, 45, 62) : QColor(14, 18, 26)); + + // State colour dot + const QColor dot = game.started() ? QColor(239, 68, 68) + : (game.player_count() >= game.max_players()) ? QColor(249, 115, 22) + : game.with_password() ? QColor(59, 130, 246) + : QColor(34, 197, 94); + p->setPen(Qt::NoPen); + p->setBrush(dot); + p->drawEllipse(QRectF(rect.left() + 9, rect.top() + (rect.height() - 8) / 2.0, 8, 8)); + + // Game title (bold, elided) + QFont tf = option.font; + tf.setBold(true); + p->setFont(tf); + p->setPen(QColor(205, 215, 230)); + const int textX = rect.left() + 26; + const int countW = 52; + const int titleW = rect.width() - textX - countW - 6; + p->drawText(QRect(textX, rect.top(), titleW, rect.height()), Qt::AlignVCenter | Qt::AlignLeft, + QFontMetrics(tf).elidedText(QString::fromStdString(game.description()), Qt::ElideRight, titleW)); + + // Player count + const bool full = game.player_count() >= game.max_players(); + p->setFont(option.font); + p->setPen(full ? QColor(249, 115, 22) : QColor(110, 128, 150)); + p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()), + Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1/%2").arg(game.player_count()).arg(game.max_players())); + + // Row separator + p->setPen(QColor(24, 32, 44)); + p->drawLine(rect.bottomLeft(), rect.bottomRight()); + + p->restore(); + } +}; + +// ── UserInfoHeaderWidget ────────────────────────────────────────────────────── + +UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(HeaderHeight); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void UserInfoHeaderWidget::setUserData(const ServerInfo_User &user, + bool online, + const QPixmap &avatar, + const QPixmap &cardArt, + const CardArtParams ¶ms) +{ + m_user = user; + m_online = online; + m_avatar = avatar; + m_cardArt = cardArt; + m_params = params; + update(); +} + +void UserInfoHeaderWidget::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + const UserLevelFlags level(m_user.user_level()); + const QString userName = QString::fromStdString(m_user.name()); + const QString privLevel = QString::fromStdString(m_user.privlevel()); + + // Dark base + p.fillRect(rect, QColor(14, 18, 26)); + + // ── Card art background ─────────────────────────────────────────────────── + if (!m_cardArt.isNull()) { + const int w = rect.width(); + const int h = rect.height(); + const int mL = qRound(w * m_params.marginPctL); + const int mR = qRound(w * m_params.marginPctR); + const int dW = w - mL - mR; + + const double base = qMax(double(dW) / m_cardArt.width(), double(h) / m_cardArt.height()); + const double scale = base * m_params.zoom; + const int sW = qRound(m_cardArt.width() * scale); + const int sH = qRound(m_cardArt.height() * scale); + + const QPixmap scaled = m_cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const int srcX = (sW - dW) / 2; + const int srcY = qBound(0, qRound((sH - h) * m_params.verticalOffset), qMax(0, sH - h)); + + QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + { + QPainter mask(&img); + mask.setCompositionMode(QPainter::CompositionMode_DestinationIn); + QLinearGradient g(0, 0, img.width(), 0); + g.setColorAt(0.00, Qt::transparent); + g.setColorAt(0.18, Qt::white); + g.setColorAt(0.82, Qt::white); + g.setColorAt(1.00, Qt::transparent); + mask.fillRect(img.rect(), g); + } + p.setOpacity(0.48); + p.drawImage(mL, 0, img); + p.setOpacity(1.0); + } + + // Bottom gradient overlay so avatar and text are always legible + { + QLinearGradient ov(0, 0, 0, rect.height()); + ov.setColorAt(0.0, QColor(14, 18, 26, 0)); + ov.setColorAt(0.55, QColor(14, 18, 26, 110)); + ov.setColorAt(1.0, QColor(14, 18, 26, 230)); + p.fillRect(rect, ov); + } + + // ── Avatar ──────────────────────────────────────────────────────────────── + const QColor accent = [&]() -> QColor { + if (level.testFlag(ServerInfo_User::IsAdmin)) { + return QColor(245, 158, 11); + } + if (level.testFlag(ServerInfo_User::IsModerator)) { + return QColor(59, 130, 246); + } + if (level.testFlag(ServerInfo_User::IsJudge)) { + return QColor(168, 85, 247); + } + return QColor(100, 116, 139); + }(); + + const int ax = LeftPad; + const int ay = rect.height() - AvatarSize - 10; + const QRect ar(ax, ay, AvatarSize, AvatarSize); + + QPainterPath clip; + clip.addEllipse(ar); + p.save(); + p.setClipPath(clip); + + if (!m_avatar.isNull()) { + p.drawPixmap(ar, m_avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + } else { + p.setPen(Qt::NoPen); + p.setBrush(accent.darker(200)); + p.drawEllipse(ar); + const QPixmap pawn = + UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, m_user.pawn_colors(), false, privLevel); + p.drawPixmap(ar.center().x() - AvatarPawnSize / 2, ar.center().y() - AvatarPawnSize / 2, pawn); + } + p.restore(); + + // Status ring + p.setPen(QPen(m_online ? QColor(34, 197, 94) : QColor(70, 80, 95), 2.5)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25)); + + // ── Username + badge ────────────────────────────────────────────────────── + const int tx = ax + AvatarSize + AvatarToTextGap; + const int tw = rect.width() - tx - 8; + + QFont nf = font(); + nf.setBold(true); + nf.setPointSizeF(nf.pointSizeF() * 1.12); + p.setFont(nf); + p.setPen(m_online ? QColor(220, 228, 240) : QColor(90, 100, 115)); + p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft, + QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw)); + + // Level / priv badge + struct + { + QString text; + QColor color; + } badge; + if (level.testFlag(ServerInfo_User::IsAdmin)) { + badge = {"ADMIN", QColor(245, 158, 11)}; + } else if (level.testFlag(ServerInfo_User::IsModerator)) { + badge = {"MOD", QColor(59, 130, 246)}; + } else if (level.testFlag(ServerInfo_User::IsJudge)) { + badge = {"JUDGE", QColor(168, 85, 247)}; + } else if (privLevel == "VIP") { + badge = {"VIP", QColor(20, 184, 166)}; + } else if (privLevel == "DONATOR") { + badge = {"DONATOR", QColor(249, 115, 22)}; + } + + if (!badge.text.isEmpty()) { + QFont bf = font(); + bf.setPointSizeF(bf.pointSizeF() * 0.70); + bf.setBold(true); + p.setFont(bf); + const QFontMetrics bfm(bf); + const int bw = bfm.horizontalAdvance(badge.text) + 10; + const QRect br(tx, ay + AvatarSize / 2 + 6, bw, 15); + p.setPen(Qt::NoPen); + p.setBrush(badge.color.darker(160)); + p.drawRoundedRect(br, 3, 3); + p.setPen(badge.color.lighter(150)); + p.drawText(br, Qt::AlignCenter, badge.text); + } +} + +// ── UserInfoPopup ───────────────────────────────────────────────────────────── + +UserInfoPopup::UserInfoPopup(TabSupervisor *ts, + AbstractClient *client, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap, + QWidget *parent) + : QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), m_ts(ts), m_client(client), m_avatarCache(avatarCache), + m_cardArtCache(cardArtCache), m_cardArtParamsMap(cardArtParamsMap) +{ + setAttribute(Qt::WA_ShowWithoutActivating); + setFixedWidth(PopupWidth); + setFrameShape(QFrame::NoFrame); + buildUi(); +} + +void UserInfoPopup::buildUi() +{ + setStyleSheet(QStringLiteral("UserInfoPopup {" + " background:#0e1218;" + " border:1px solid #1e2838;" + " border-radius:8px;" + "}")); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + // Header + m_header = new UserInfoHeaderWidget(this); + root->addWidget(m_header); + + // Action area β€” rebuilt per user + m_actionArea = new QWidget(this); + m_actionArea->setStyleSheet(QStringLiteral("background:#0e1218;")); + root->addWidget(m_actionArea); + + // Thin separator + auto *sep = new QFrame(this); + sep->setFrameShape(QFrame::HLine); + sep->setStyleSheet(QStringLiteral("color:#1a2434; margin: 0 8px;")); + root->addWidget(sep); + + // Games header row + auto *gh = new QHBoxLayout; + gh->setContentsMargins(10, 4, 8, 2); + auto *gl = new QLabel(tr("Games"), this); + gl->setStyleSheet(QStringLiteral("color:#6882a0; font-size:11px; font-weight:bold; background:transparent;")); + gh->addWidget(gl); + gh->addStretch(); + m_refreshBtn = new QPushButton(QStringLiteral("↻"), this); + m_refreshBtn->setFixedSize(20, 20); + m_refreshBtn->setFlat(true); + m_refreshBtn->setStyleSheet( + QStringLiteral("QPushButton{color:#6882a0;border:none;font-size:14px;background:transparent;}" + "QPushButton:hover{color:white;}")); + connect(m_refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); + gh->addWidget(m_refreshBtn); + root->addLayout(gh); + + // Status label + m_gamesStatus = new QLabel(this); + m_gamesStatus->setAlignment(Qt::AlignCenter); + m_gamesStatus->setStyleSheet( + QStringLiteral("color:#3a4a5e; font-size:11px; padding:10px; background:transparent;")); + root->addWidget(m_gamesStatus); + + // Games list + m_gamesModel = new QStandardItemModel(this); + m_gamesView = new QListView(this); + m_gamesView->setModel(m_gamesModel); + m_gamesView->setItemDelegate(new PopupGameDelegate(m_gamesView)); + m_gamesView->setFrameShape(QFrame::NoFrame); + m_gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_gamesView->setMaximumHeight(220); + m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}" + "QListView::item:selected{background:#232e42;}")); + m_gamesView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu); + + root->addWidget(m_gamesView); + + // Close button β€” positioned absolutely in the top-right corner + m_closeBtn = new QPushButton(QStringLiteral("βœ•"), this); + m_closeBtn->setFixedSize(22, 22); + m_closeBtn->setFlat(true); + m_closeBtn->setStyleSheet(QStringLiteral("QPushButton{background:rgba(14,18,26,180);color:#607080;" + "border:none;border-radius:11px;font-size:10px;}" + "QPushButton:hover{color:white;background:rgba(200,50,50,200);}")); + connect(m_closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested); +} + +// ── Action button factory ───────────────────────────────────────────────────── + +static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p) +{ + auto *b = new QPushButton(label, p); + b->setToolTip(tip); + b->setFixedHeight(26); + b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + b->setStyleSheet(QStringLiteral("QPushButton{" + " background:#192030;color:#b8c8de;border:1px solid #263040;" + " border-radius:4px;font-size:11px;padding:0 4px;" + "}" + "QPushButton:hover{background:#223050;color:white;}" + "QPushButton:pressed{background:#162030;}" + "QPushButton:disabled{color:#384858;border-color:#192030;}")); + return b; +} + +void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) +{ + // Clear previous contents + delete m_actionArea->layout(); + const auto old = m_actionArea->findChildren(QString{}, Qt::FindDirectChildrenOnly); + for (auto *w : old) { + w->deleteLater(); + } + + const QString name = QString::fromStdString(userInfo.name()); + const auto ownLevel = UserLevelFlags(m_ts->getUserInfo()->user_level()); + const bool isSelf = (name == QString::fromStdString(m_ts->getUserInfo()->name())); + const bool isMod = ownLevel.testFlag(ServerInfo_User::IsModerator); + const bool isAdmin = ownLevel.testFlag(ServerInfo_User::IsAdmin); + const auto their = UserLevelFlags(userInfo.user_level()); + const bool isReg = their.testFlag(ServerInfo_User::IsRegistered); + + auto *grid = new QGridLayout(m_actionArea); + grid->setContentsMargins(8, 6, 8, 6); + grid->setSpacing(4); + + int row = 0, col = 0; + const int cols = 3; + auto add = [&](QPushButton *btn) { + grid->addWidget(btn, row, col); + if (++col == cols) { + col = 0; + ++row; + } + }; + + // ── Always visible ──────────────────────────────────────────────────────── + auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), m_actionArea); + chat->setEnabled(!isSelf && online); + connect(chat, &QPushButton::clicked, this, [this, name] { emit chatRequested(name); }); + add(chat); + + auto *prof = makeBtn(tr("Profile"), tr("View user profile"), m_actionArea); + connect(prof, &QPushButton::clicked, this, [this, name] { emit detailsRequested(name); }); + add(prof); + + auto *games = makeBtn(tr("Games"), tr("Show this user's games"), m_actionArea); + games->setEnabled(!isSelf && online); + connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); }); + add(games); + + // ── Buddy / ignore (registered users only) ──────────────────────────────── + if (!isSelf && isReg) { + if (isBuddy) { + auto *b = makeBtn(tr("βˆ’ Buddy"), tr("Remove from buddy list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit removeBuddyRequested(name); }); + add(b); + } else { + auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit addBuddyRequested(name); }); + add(b); + } + if (isIgnored) { + auto *b = makeBtn(tr("βˆ’ Ignore"), tr("Remove from ignore list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit removeIgnoreRequested(name); }); + add(b); + } else { + auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); }); + add(b); + } + } + + // ── Moderator actions ───────────────────────────────────────────────────── + if (!isSelf && (isMod || isAdmin)) { + if (col != 0) { + ++row; + col = 0; + } // start mod section on a fresh row + + auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), m_actionArea); + auto *warn = makeBtn(tr("Warn"), tr("Warn user"), m_actionArea); + auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), m_actionArea); + auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), m_actionArea); + connect(ban, &QPushButton::clicked, this, [this, name] { emit banRequested(name); }); + connect(warn, &QPushButton::clicked, this, [this, name] { emit warnRequested(name); }); + connect(bLog, &QPushButton::clicked, this, [this, name] { emit banHistoryRequested(name); }); + connect(wLog, &QPushButton::clicked, this, [this, name] { emit warnHistoryRequested(name); }); + add(ban); + add(warn); + add(bLog); + add(wLog); + } + + // ── Admin actions ───────────────────────────────────────────────────────── + if (!isSelf && isAdmin) { + auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), m_actionArea); + connect(notes, &QPushButton::clicked, this, [this, name] { emit adminNotesRequested(name); }); + add(notes); + + if (their.testFlag(ServerInfo_User::IsModerator)) { + auto *b = makeBtn(tr("βˆ’ Mod"), tr("Demote from moderator"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromModRequested(name); }); + add(b); + } else if (isReg) { + auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToModRequested(name); }); + add(b); + } + if (their.testFlag(ServerInfo_User::IsJudge)) { + auto *b = makeBtn(tr("βˆ’ Judge"), tr("Demote from judge"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromJudgeRequested(name); }); + add(b); + } else if (isReg) { + auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); }); + add(b); + } + } + + m_actionArea->adjustSize(); +} + +void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) +{ + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + adjustSize(); +} + +void UserInfoPopup::onGamesContextMenu(const QPoint &pos) +{ + const QModelIndex idx = m_gamesView->indexAt(pos); + if (!idx.isValid()) { + return; + } + + const QVariant var = idx.data(PopupRoles::GameData); + if (!var.isValid()) { + return; + } + const ServerInfo_Game game = var.value(); + + QMenu menu(this); + menu.setStyleSheet( + QStringLiteral("QMenu{background:#12182a;color:#c8d8ec;border:1px solid #1e2838;border-radius:4px;}" + "QMenu::item:selected{background:#223050;}")); + + const bool canJoin = !game.started() && game.player_count() < game.max_players(); + QAction *join = menu.addAction(tr("Join game")); + join->setEnabled(canJoin); + + QAction *spec = nullptr; + if (game.spectators_allowed()) { + spec = menu.addAction(tr("Spectate")); + } + + const QAction *chosen = menu.exec(m_gamesView->viewport()->mapToGlobal(pos)); + if (!chosen) { + return; + } + + if (chosen == join) { + emit joinGameRequested(game.game_id(), game.room_id(), false); + } else if (spec && chosen == spec) { + emit joinGameRequested(game.game_id(), game.room_id(), true); + } +} + +// ── showForUser ─────────────────────────────────────────────────────────────── + +void UserInfoPopup::showForUser(const QString &userName, + const ServerInfo_User &userInfo, + bool online, + bool isBuddy, + bool isIgnored) +{ + m_currentUser = userName; + m_currentUserInfo = userInfo; + m_currentOnline = online; + + // Header + const QPixmap avatar = m_avatarCache ? m_avatarCache->value(userName) : QPixmap{}; + const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(userName)) + ? m_cardArtParamsMap->value(userName) + : CardArtParams{}; + const QString artKey = userName + u'|' + params.cardName; + const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{}; + m_header->setUserData(userInfo, online, avatar, cardArt, params); + + // Actions + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + + // Games list reset + m_gamesModel->clear(); + m_gamesView->hide(); + m_gamesStatus->setText(tr("Loading games…")); + m_gamesStatus->show(); + + // Close button β€” top-right corner, above everything + m_closeBtn->move(PopupWidth - m_closeBtn->width() - 6, 6); + m_closeBtn->raise(); + + adjustSize(); + fetchGames(); +} + +// ── Games fetch ─────────────────────────────────────────────────────────────── + +void UserInfoPopup::fetchGames() +{ + if (!m_client || m_currentUser.isEmpty()) { + return; + } + + Command_GetGamesOfUser cmd; + cmd.set_user_name(m_currentUser.toStdString()); + + const QString snapshot = m_currentUser; + PendingCommand *pend = m_client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); + m_client->sendCommand(pend); +} + +void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) +{ + if (forUser != m_currentUser) { + return; // stale response β€” different user showing now + } + + m_gamesModel->clear(); + + if (r.response_code() != Response::RespOk) { + m_gamesStatus->setText(tr("Could not load games.")); + m_gamesStatus->show(); + m_gamesView->hide(); + return; + } + + const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); + if (resp.game_list_size() == 0) { + m_gamesStatus->setText(tr("No active games.")); + m_gamesStatus->show(); + m_gamesView->hide(); + return; + } + + for (int i = 0; i < resp.game_list_size(); ++i) { + auto *item = new QStandardItem; + item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); + item->setEditable(false); + m_gamesModel->appendRow(item); + } + + m_gamesStatus->hide(); + m_gamesView->show(); + + // Fit exactly to the number of visible rows, scroll when more than 5 + constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint + constexpr int maxRows = 5; + const int count = m_gamesModel->rowCount(); + const int visible = qMin(count, maxRows); + m_gamesView->setFixedHeight(visible * rowH + 2); + m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + + adjustSize(); +} + +void UserInfoPopup::refreshGames() +{ + m_gamesModel->clear(); + m_gamesView->hide(); + m_gamesStatus->setText(tr("Loading games…")); + m_gamesStatus->show(); + fetchGames(); +} + +// ── Mouse events ────────────────────────────────────────────────────────────── + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +void UserInfoPopup::enterEvent(QEnterEvent *e) +{ + QFrame::enterEvent(e); + emit mouseEnteredPopup(); +} +#else +void UserInfoPopup::enterEvent(QEvent *e) +{ + QFrame::enterEvent(e); + emit mouseEnteredPopup(); +} +#endif +void UserInfoPopup::leaveEvent(QEvent *e) +{ + QFrame::leaveEvent(e); + emit mouseLeftPopup(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h new file mode 100644 index 000000000..0e03147c4 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -0,0 +1,181 @@ +#ifndef COCKATRICE_USER_INFO_POPUP_H +#define COCKATRICE_USER_INFO_POPUP_H + +#include "../../interface/widgets/server/game_type_map.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class QLabel; +class QPushButton; +class TabSupervisor; + +// ── Roles ───────────────────────────────────────────────────────────────────── + +namespace PopupRoles +{ +constexpr int GameData = Qt::UserRole + 10; +} + +// ── Header widget ───────────────────────────────────────────────────────────── + +/** + * @class UserInfoHeaderWidget + * @brief Paints the enlarged banner card art + circular avatar section at the + * top of the UserInfoPopup. + * + * Layout mirrors UserListPainter but at a larger scale: the card art fills the + * full width as a semi-transparent background, a bottom gradient ensures the + * avatar and username text remain legible, and the status ring colour matches + * the UserListPainter convention. + */ +class UserInfoHeaderWidget : public QWidget +{ + Q_OBJECT + + static constexpr int HeaderHeight = 130; + static constexpr int AvatarSize = 68; + static constexpr int AvatarPawnSize = 46; + static constexpr int LeftPad = 14; + static constexpr int AvatarToTextGap = 10; + +public: + explicit UserInfoHeaderWidget(QWidget *parent = nullptr); + + void setUserData(const ServerInfo_User &user, + bool online, + const QPixmap &avatar, + const QPixmap &cardArt, + const CardArtParams ¶ms); + +protected: + void paintEvent(QPaintEvent *e) override; + +private: + ServerInfo_User m_user; + bool m_online = false; + QPixmap m_avatar; + QPixmap m_cardArt; + CardArtParams m_params; +}; + +// ── Main popup ──────────────────────────────────────────────────────────────── + +/** + * @class UserInfoPopup + * @brief Floating panel showing an enlarged user card, quick action buttons, + * and a live scrollable games list. + * + * Lifecycle (mirrors DeckEditorDeckDockWidget): + * - showForUser() β€” populate, position externally, call show() + * - mouseEnteredPopup / mouseLeftPopup β€” caller manages hide timer + * - closeRequested() β€” emitted by the internal close button + * + * The popup is a Qt::Tool frameless child so windowOpacity animations and + * move() in screen coordinates work identically to CardInfoPictureEnlargedWidget. + * + * Action signals map 1-to-1 to UserContextMenu::exec*() methods so all action + * logic stays in one place. + */ +class UserInfoPopup : public QFrame +{ + Q_OBJECT + + static constexpr int PopupWidth = 316; + +public: + explicit UserInfoPopup(TabSupervisor *tabSupervisor, + AbstractClient *client, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap, + QWidget *parent); + + /** + * Populate the popup for @p userName and kick off a game list fetch. + * Call show() / move() externally after this. + */ + void + showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + void fetchGames(); + + [[nodiscard]] QString currentUser() const + { + return m_currentUser; + } + + /** Called when buddy/ignore status changes externally while popup is open. */ + void updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + +signals: + void mouseEnteredPopup(); + void mouseLeftPopup(); + void closeRequested(); + + /** Emitted when the user requests joining or spectating a game in the list. */ + void joinGameRequested(int gameId, int roomId, bool asSpectator); + + // ── Action signals β€” connect to UserContextMenu::exec*() ────────────────── + void chatRequested(const QString &userName); + void detailsRequested(const QString &userName); + void showGamesRequested(const QString &userName); + void addBuddyRequested(const QString &userName); + void removeBuddyRequested(const QString &userName); + void addIgnoreRequested(const QString &userName); + void removeIgnoreRequested(const QString &userName); + void banRequested(const QString &userName); + void warnRequested(const QString &userName); + void banHistoryRequested(const QString &userName); + void warnHistoryRequested(const QString &userName); + void adminNotesRequested(const QString &userName); + void promoteToModRequested(const QString &userName); + void demoteFromModRequested(const QString &userName); + void promoteToJudgeRequested(const QString &userName); + void demoteFromJudgeRequested(const QString &userName); + +protected: +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + void enterEvent(QEnterEvent *e) override; +#else + void enterEvent(QEvent *e) override; +#endif + void leaveEvent(QEvent *e) override; + +private slots: + void refreshGames(); + void onGamesReceived(const Response &r, const QString &forUser); + void onGamesContextMenu(const QPoint &pos); + +private: + void buildUi(); + void rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + + TabSupervisor *m_ts; + AbstractClient *m_client; + const QMap *m_avatarCache; + const QMap *m_cardArtCache; + const QMap *m_cardArtParamsMap; + + QString m_currentUser; + ServerInfo_User m_currentUserInfo; + bool m_currentOnline = false; + + UserInfoHeaderWidget *m_header; + QWidget *m_actionArea; ///< rebuilt per user + QListView *m_gamesView; + QStandardItemModel *m_gamesModel; + QLabel *m_gamesStatus; + QPushButton *m_closeBtn; + QPushButton *m_refreshBtn; +}; + +#endif // COCKATRICE_USER_INFO_POPUP_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp b/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp index 4bc2c84d6..216420006 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp @@ -42,6 +42,9 @@ void UserListManager::handleDisconnect() delete ownUserInfo; ownUserInfo = nullptr; + + // Full rebuild β€” all lists are gone + emit listReset(); } void UserListManager::setOwnUserInfo(const ServerInfo_User &userInfo) @@ -63,74 +66,77 @@ void UserListManager::processListUsersResponse(const Response &response) const int userListSize = resp.user_list_size(); for (int i = 0; i < userListSize; ++i) { const ServerInfo_User &info = resp.user_list(i); - const QString &userName = QString::fromStdString(info.name()); - onlineUsers.insert(userName, info); + onlineUsers.insert(QString::fromStdString(info.name()), info); } + + // Bulk load complete β€” widgets rebuild once from the now-populated map + emit listReset(); } void UserListManager::processUserJoinedEvent(const Event_UserJoined &event) { const auto &info = event.user_info(); - const QString &userName = QString::fromStdString(info.name()); - onlineUsers.insert(userName, info); + const QString name = QString::fromStdString(info.name()); + onlineUsers.insert(name, info); + + emit userJoinedOnline(info); } void UserListManager::processUserLeftEvent(const Event_UserLeft &event) { - const auto &userName = QString::fromStdString(event.name()); - onlineUsers.remove(userName); + const QString name = QString::fromStdString(event.name()); + onlineUsers.remove(name); + + emit userLeftOnline(name); } void UserListManager::buddyListReceived(const QList &_buddyList) { for (const auto &user : _buddyList) { - const auto &userName = QString::fromStdString(user.name()); - buddyUsers.insert(userName, user); + buddyUsers.insert(QString::fromStdString(user.name()), user); } + + // Bulk load β€” one reset covers all newly added entries + emit listReset(); } void UserListManager::ignoreListReceived(const QList &_ignoreList) { for (const auto &user : _ignoreList) { - const auto &userName = QString::fromStdString(user.name()); - ignoredUsers.insert(userName, user); + ignoredUsers.insert(QString::fromStdString(user.name()), user); } + + // Bulk load β€” one reset covers all newly added entries + emit listReset(); } void UserListManager::processAddToListEvent(const Event_AddToList &event) { const auto &user = event.user_info(); - const auto &userName = QString::fromStdString(user.name()); + const QString userName = QString::fromStdString(user.name()); + const QString listType = QString::fromStdString(event.list_name()); - const auto &userListType = QString::fromStdString(event.list_name()); - - QMap *userMap; - if (userListType == "buddy") { - userMap = &buddyUsers; - } else if (userListType == "ignore") { - userMap = &ignoredUsers; - } else { - return; + if (listType == "buddy") { + buddyUsers.insert(userName, user); + emit addedToBuddyList(user); + } else if (listType == "ignore") { + ignoredUsers.insert(userName, user); + emit addedToIgnoreList(user); } - - userMap->insert(userName, user); } void UserListManager::processRemoveFromListEvent(const Event_RemoveFromList &event) { - const auto &userListType = QString::fromStdString(event.list_name()); - const auto &userName = QString::fromStdString(event.user_name()); + const QString listType = QString::fromStdString(event.list_name()); + const QString userName = QString::fromStdString(event.user_name()); - QMap *userMap; - if (userListType == "buddy") { - userMap = &buddyUsers; - } else if (userListType == "ignore") { - userMap = &ignoredUsers; - } else { - return; + if (listType == "buddy") { + buddyUsers.remove(userName); + emit removedFromBuddyList(userName); + } else if (listType == "ignore") { + ignoredUsers.remove(userName); + emit removedFromIgnoreList(userName); } - - userMap->remove(userName); } bool UserListManager::isOwnUserRegistered() const @@ -155,16 +161,9 @@ bool UserListManager::isUserIgnored(const QString &userName) const const ServerInfo_User *UserListManager::getOnlineUser(const QString &userName) const { - const QString &userNameToMatchLower = userName.toLower(); - - const auto it = - std::find_if(onlineUsers.begin(), onlineUsers.end(), [&userNameToMatchLower](const ServerInfo_User &user) { - return userNameToMatchLower == QString::fromStdString(user.name()).toLower(); - }); - - if (it != onlineUsers.end()) { - return &*it; - } - - return nullptr; + const QString lower = userName.toLower(); + const auto it = std::find_if(onlineUsers.begin(), onlineUsers.end(), [&lower](const ServerInfo_User &user) { + return lower == QString::fromStdString(user.name()).toLower(); + }); + return it != onlineUsers.end() ? &*it : nullptr; } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_manager.h b/cockatrice/src/interface/widgets/server/user/user_list_manager.h index f09284bd0..6238f0799 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_manager.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_manager.h @@ -47,15 +47,17 @@ public: explicit UserListManager(AbstractClient *_client, QObject *parent = nullptr); ~UserListManager() override; - [[nodiscard]] QMap getAllUsersList() const + [[nodiscard]] const QMap &getAllUsersList() const { return onlineUsers; } - [[nodiscard]] QMap getBuddyList() const + + [[nodiscard]] const QMap &getBuddyList() const { return buddyUsers; } - [[nodiscard]] QMap getIgnoreList() const + + [[nodiscard]] const QMap &getIgnoreList() const { return ignoredUsers; } @@ -71,8 +73,26 @@ public slots: void handleDisconnect(); signals: - void userLeft(const QString &userName); - void userJoined(const ServerInfo_User &userInfo); + /** + * The entire list needs to be rebuilt from scratch. + * Fired on disconnect, reconnect, and initial bulk loads + * (Command_ListUsers response, initial buddy/ignore lists). + */ + void listReset(); + + // ── Online user presence ────────────────────────────────────────────────── + /** A user came online (or joined the room). Full ServerInfo_User available. */ + void userJoinedOnline(const ServerInfo_User &user); + /** A user went offline (or left the room). */ + void userLeftOnline(const QString &userName); + + // ── Buddy list mutations (individual, post-login) ───────────────────────── + void addedToBuddyList(const ServerInfo_User &user); + void removedFromBuddyList(const QString &userName); + + // ── Ignore list mutations (individual, post-login) ──────────────────────── + void addedToIgnoreList(const ServerInfo_User &user); + void removedFromIgnoreList(const QString &userName); }; #endif // COCKATRICE_USER_LIST_MANAGER_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp new file mode 100644 index 000000000..b5541b692 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -0,0 +1,342 @@ +#include "user_list_painter.h" + +#include "../../interface/pixel_map_generator.h" + +#include +#include +#include +#include +#include +#include + +static constexpr int RowHeight = 72; +static constexpr int AvatarSize = 36; +static constexpr int LeftPadding = 14; +static constexpr int TextSpacing = 10; + +QSize UserListPainter::sizeHint() +{ + return QSize(0, RowHeight); +} + +QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool online) +{ + QColor accentColor; + + if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { + accentColor = QColor(245, 158, 11); + } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { + accentColor = QColor(59, 130, 246); + } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { + accentColor = QColor(168, 85, 247); + } else { + accentColor = QColor(100, 116, 139); + } + + if (!online) { + accentColor = accentColor.darker(160); + } + + return accentColor; +} + +int UserListPainter::getCardRight(const QStyleOptionViewItem &option, const QRect &rect) +{ + int scrollBarWidth = 0; + + if (const auto *scrollArea = qobject_cast(option.widget)) { + const QScrollBar *sb = scrollArea->verticalScrollBar(); + if (sb && sb->isVisible()) { + scrollBarWidth = sb->width(); + } + } + + const int viewportRight = option.widget ? option.widget->width() - scrollBarWidth : rect.right(); + + return qMin(rect.right(), viewportRight - 4); +} + +void UserListPainter::drawBackground(QPainter *painter, + const QRectF &cardRect, + const QColor &accentColor, + bool selected) +{ + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320)); + bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30)); + + painter->setPen(Qt::NoPen); + painter->setBrush(bg); + painter->drawRoundedRect(cardRect, 6, 6); + + painter->setBrush(accentColor); + painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); +} + +static QString makeKey(const QString &user, const QString &card) +{ + return user + u'|' + card; +} + +void UserListPainter::drawCardArt(QPainter *painter, + const QRect &rect, + int cardRight, + const QString &userName, + const QMap *cardArtCache, + const CardArtParams ¶ms, + const QPixmap *overridePixmap = nullptr) +{ + QPixmap art; + + if (overridePixmap && !overridePixmap->isNull()) { + art = *overridePixmap; + } else { + if (!cardArtCache) { + return; + } + + const QString key = makeKey(userName, params.cardName); + + if (!cardArtCache->contains(key)) { + return; + } + + art = cardArtCache->value(key); + } + + if (art.isNull()) { + return; + } + + const int cardH = rect.height() - 4; + const int totalW = cardRight - rect.left(); + const int marginL = qRound(totalW * params.marginPctL); + const int marginR = qRound(totalW * params.marginPctR); + const int drawW = totalW - marginL - marginR; + + const double basescale = qMax(double(drawW) / art.width(), double(cardH) / art.height()); + const double scale = basescale * params.zoom; + + const int scaledW = qRound(art.width() * scale); + const int scaledH = qRound(art.height() * scale); + + const QPixmap scaled = art.scaled(scaledW, scaledH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + const int srcX = (scaledW - drawW) / 2; + const int srcY = qRound((scaledH - cardH) * params.verticalOffset); + + // Clamp srcY so we never copy outside the pixmap bounds + const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH)); + + QImage img = + scaled.copy(srcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + + { + QPainter mask(&img); + mask.setCompositionMode(QPainter::CompositionMode_DestinationIn); + + QLinearGradient grad(0, 0, img.width(), 0); + grad.setColorAt(0.00, Qt::transparent); + grad.setColorAt(0.22, Qt::white); + grad.setColorAt(0.78, Qt::white); + grad.setColorAt(1.00, Qt::transparent); + + mask.fillRect(img.rect(), grad); + } + + painter->setOpacity(0.55); + painter->drawImage(rect.left() + marginL, rect.top() + 2, img); + painter->setOpacity(1.0); +} + +QRect UserListPainter::getAvatarRect(const QRect &rect) +{ + const int avatarX = rect.left() + LeftPadding; + const int avatarY = rect.top() + (rect.height() - AvatarSize) / 2; + return QRect(avatarX, avatarY, AvatarSize, AvatarSize); +} + +void UserListPainter::drawAvatar(QPainter *painter, + const QRect &avatarRect, + const QString &userName, + const QColor &accentColor, + const UserLevelFlags &userLevel, + const ServerInfo_User &userInfo, + const QString &privLevel, + const QMap *avatarCache) +{ + QPainterPath clipPath; + clipPath.addEllipse(avatarRect); + + painter->save(); + painter->setClipPath(clipPath); + + bool drewAvatar = false; + + if (avatarCache && avatarCache->contains(userName)) { + const QPixmap &avatar = avatarCache->value(userName); + if (!avatar.isNull()) { + painter->drawPixmap( + avatarRect, avatar.scaled(avatarRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + drewAvatar = true; + } + } + + if (!drewAvatar) { + painter->setBrush(accentColor.darker(200)); + painter->setPen(Qt::NoPen); + painter->drawEllipse(avatarRect); + + const QPixmap pawn = + UserLevelPixmapGenerator::generatePixmap(24, userLevel, userInfo.pawn_colors(), false, privLevel); + + painter->drawPixmap(avatarRect.center().x() - 12, avatarRect.center().y() - 12, pawn); + } + + painter->restore(); +} + +void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online) +{ + const QColor statusColor = online ? QColor(34, 197, 94) : QColor(70, 80, 95); + + painter->setPen(QPen(statusColor, 2)); + painter->setBrush(Qt::NoBrush); + painter->drawEllipse(avatarRect.adjusted(-1, -1, 1, 1)); +} + +void UserListPainter::drawUserName(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + int textX, + const QString &userName, + bool online, + bool selected) +{ + QFont nameFont = option.font; + nameFont.setBold(true); + painter->setFont(nameFont); + + const QRect nameRect(textX, rect.top() + 8, cardRight - textX - 10, 20); + const QString elidedName = QFontMetrics(nameFont).elidedText(userName, Qt::ElideRight, cardRight - textX - 10); + + painter->setPen(QColor(0, 0, 0, 200)); + painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName); + + painter->setPen(online ? (selected ? Qt::white : QColor(226, 232, 240)) : QColor(90, 100, 115)); + painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, elidedName); +} + +void UserListPainter::drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo) +{ + const QPixmap flag = CountryPixmapGenerator::generatePixmap(13, QString::fromStdString(userInfo.country())); + if (!flag.isNull()) { + painter->drawPixmap(textX, rect.top() + 46, flag); + } +} + +QList UserListPainter::buildBadges(const UserLevelFlags &userLevel, const QString &privLevel) +{ + QList badges; + + if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { + badges << Badge{"ADMIN", QColor(245, 158, 11)}; + } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { + badges << Badge{"MOD", QColor(59, 130, 246)}; + } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { + badges << Badge{"JUDGE", QColor(168, 85, 247)}; + } + + if (privLevel == "VIP") { + badges << Badge{"VIP", QColor(20, 184, 166)}; + } else if (privLevel == "DONATOR") { + badges << Badge{"DONATOR", QColor(249, 115, 22)}; + } + + return badges; +} + +void UserListPainter::drawBadges(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + const QList &badges, + bool online) +{ + if (badges.isEmpty()) { + return; + } + + QFont badgeFont = option.font; + badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.68); + badgeFont.setBold(true); + painter->setFont(badgeFont); + + QFontMetrics fm(badgeFont); + + int totalBadgeW = 0; + for (const Badge &b : badges) { + totalBadgeW += fm.horizontalAdvance(b.text) + 8 + 4; + } + totalBadgeW -= 4; + + int bx = cardRight - 6 - totalBadgeW; + + for (const Badge &b : badges) { + const QColor col = online ? b.color : b.color.darker(180); + const int bw = fm.horizontalAdvance(b.text) + 8; + const QRect br(bx, rect.top() + 44, bw, 13); + + painter->setPen(Qt::NoPen); + painter->setBrush(col.darker(online ? 160 : 220)); + painter->drawRoundedRect(br, 3, 3); + + painter->setPen(col.lighter(online ? 160 : 100)); + painter->drawText(br, Qt::AlignCenter, b.text); + + bx += bw + 4; + } +} + +void UserListPainter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index, + const ServerInfo_User &userInfo, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap) +{ + painter->save(); + painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = option.rect; + const bool online = index.data(Qt::UserRole + 1).toBool(); + const bool selected = option.state & QStyle::State_Selected; + const UserLevelFlags userLevel(userInfo.user_level()); + const QString userName = QString::fromStdString(userInfo.name()); + const QString privLevel = QString::fromStdString(userInfo.privlevel()); + const QColor accentColor = getAccentColor(userLevel, online); + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + const int cardRight = getCardRight(option, rect); + + const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(userName)) + ? cardArtParamsMap->value(userName) + : CardArtParams{}; + + drawBackground(painter, cardRect, accentColor, selected); + drawCardArt(painter, rect, cardRight, userName, cardArtCache, params); + + const QRect avatarRect = getAvatarRect(rect); + drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache); + drawStatusRing(painter, avatarRect, online); + + const int textX = avatarRect.right() + TextSpacing; + drawUserName(painter, option, rect, cardRight, textX, userName, online, selected); + drawCountryFlag(painter, rect, textX, userInfo); + + const QList badges = buildBadges(userLevel, privLevel); + drawBadges(painter, option, rect, cardRight, badges, online); + + painter->restore(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.h b/cockatrice/src/interface/widgets/server/user/user_list_painter.h new file mode 100644 index 000000000..95486b75e --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.h @@ -0,0 +1,86 @@ +#ifndef COCKATRICE_USER_LIST_PAINTER_H +#define COCKATRICE_USER_LIST_PAINTER_H + +#include "user_level.h" + +#include +#include +#include +#include +#include +#include + +class QPainter; +class QModelIndex; +class QStyleOptionViewItem; +class ServerInfo_User; + +struct CardArtParams +{ + QString cardName = ""; + double marginPctL = 0.33; + double marginPctR = 0.02; + double verticalOffset = 0.35; + double zoom = 1.0; +}; + +class UserListPainter +{ +public: + static void paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index, + const ServerInfo_User &userInfo, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap); + + static QSize sizeHint(); + + static void drawCardArt(QPainter *painter, + const QRect &rect, + int cardRight, + const QString &userName, + const QMap *cardArtCache, + const CardArtParams ¶ms, + const QPixmap *overridePixmap); + +private: + struct Badge + { + QString text; + QColor color; + }; + + static QColor getAccentColor(const UserLevelFlags &userLevel, bool online); + static int getCardRight(const QStyleOptionViewItem &option, const QRect &rect); + static void drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, bool selected); + static QRect getAvatarRect(const QRect &rect); + static void drawAvatar(QPainter *painter, + const QRect &avatarRect, + const QString &userName, + const QColor &accentColor, + const UserLevelFlags &userLevel, + const ServerInfo_User &userInfo, + const QString &privLevel, + const QMap *avatarCache); + static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online); + static void drawUserName(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + int textX, + const QString &userName, + bool online, + bool selected); + static void drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo); + static QList buildBadges(const UserLevelFlags &userLevel, const QString &privLevel); + static void drawBadges(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + const QList &badges, + bool online); +}; + +#endif // COCKATRICE_USER_LIST_PAINTER_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 11c9b60eb..ed06ea941 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,10 +1,13 @@ #include "user_list_widget.h" +#include "../../../../client/settings/cache_settings.h" +#include "../../../card_picture_loader/card_picture_loader.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/widgets/tabs/tab_account.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../game_selector.h" #include "user_context_menu.h" +#include "user_list_painter.h" #include #include @@ -15,13 +18,18 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include +#include +#include #include BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent) @@ -308,7 +316,18 @@ QString AdminNotesDialog::getNotes() const return notes->toPlainText(); } -UserListItemDelegate::UserListItemDelegate(QObject *const parent) : QStyledItemDelegate(parent) +namespace UserListRoles +{ +constexpr int Online = Qt::UserRole + 1; +constexpr int UserInfo = Qt::UserRole + 2; +} // namespace UserListRoles + +UserListItemDelegate::UserListItemDelegate(QObject *const parent, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap) + : QStyledItemDelegate(parent), avatarCache(avatarCache), cardArtCache(cardArtCache), + cardArtParamsMap(cardArtParamsMap) { } @@ -331,6 +350,32 @@ bool UserListItemDelegate::editorEvent(QEvent *event, return QStyledItemDelegate::editorEvent(event, model, option, index); } +QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (!SettingsCache::instance().getStyleUserList()) { + return QStyledItemDelegate::sizeHint(option, index); + } + return UserListPainter::sizeHint(); +} + +void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (!SettingsCache::instance().getStyleUserList()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + const QVariant var = index.data(UserListRoles::UserInfo); + + if (!var.isValid()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + UserListPainter::paint(painter, option, index, var.value(), avatarCache, cardArtCache, + cardArtParamsMap); +} + UserListTWI::UserListTWI(const ServerInfo_User &_userInfo) : QTreeWidgetItem(Type) { setUserInfo(_userInfo); @@ -347,11 +392,12 @@ void UserListTWI::setUserInfo(const ServerInfo_User &_userInfo) setData(2, Qt::UserRole, QString::fromStdString(userInfo.name())); setData(2, Qt::DisplayRole, QString::fromStdString(userInfo.name())); setData(3, Qt::InitialSortOrderRole, QString::fromStdString(userInfo.privlevel())); + setData(0, UserListRoles::UserInfo, QVariant::fromValue(userInfo)); } void UserListTWI::setOnline(bool online) { - setData(0, Qt::UserRole + 1, online); + setData(0, UserListRoles::Online, online); setData(2, Qt::ForegroundRole, online ? qApp->palette().brush(QPalette::WindowText) : QBrush(Qt::gray)); } @@ -370,8 +416,8 @@ void UserListTWI::setOnline(bool online) bool UserListTWI::operator<(const QTreeWidgetItem &other) const { // Sort by online/offline - if (data(0, Qt::UserRole + 1) != other.data(0, Qt::UserRole + 1)) { - return data(0, Qt::UserRole + 1).toBool(); + if (data(0, UserListRoles::Online) != other.data(0, UserListRoles::Online)) { + return data(0, UserListRoles::Online).toBool(); } const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt()); @@ -418,20 +464,100 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0) { - itemDelegate = new UserListItemDelegate(this); + avatarProvider = new UserAvatarProvider(client, this); + cardArtProvider = new UserCardArtProvider(this); + + itemDelegate = + new UserListItemDelegate(this, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap); + userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog); userTree = new QTreeWidget; - userTree->setColumnCount(3); - userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + userTree->setColumnCount(4); // 0=display, 1=flag(hidden), 2=name(hidden), 3=privlevel(hidden) + userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); userTree->header()->setMinimumSectionSize(0); userTree->setHeaderHidden(true); userTree->setRootIsDecorated(false); userTree->setIconSize(QSize(20, 18)); userTree->setItemDelegate(itemDelegate); userTree->setAlternatingRowColors(true); + userTree->hideColumn(1); + userTree->hideColumn(2); + userTree->hideColumn(3); connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked); + userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + userTree->header()->setStretchLastSection(true); + + // ── Hover popup ─────────────────────────────────────────────────────────── + m_userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), + &cardArtProvider->cache(), &cardArtParamsMap, + window()); // parented to main window so it floats above siblings + + m_userInfoPopup->hide(); + m_userInfoPopup->setWindowOpacity(0.0); + m_userInfoPopup->installEventFilter(this); + + connectPopupSignals(); + + m_showPopupTimer = new QTimer(this); + m_showPopupTimer->setSingleShot(true); + m_showPopupTimer->setInterval(280); + connect(m_showPopupTimer, &QTimer::timeout, this, [this] { + if (!m_hoveredUser.isEmpty()) { + showPopupForUser(m_hoveredUser); + } + }); + + m_hidePopupTimer = new QTimer(this); + m_hidePopupTimer->setSingleShot(true); + m_hidePopupTimer->setInterval(160); + connect(m_hidePopupTimer, &QTimer::timeout, this, [this] { + if (!m_popupPinned && !m_userInfoPopup->underMouse() && !userTree->underMouse()) { + hidePopup(); + } + }); + + userTree->setMouseTracking(true); + userTree->viewport()->setMouseTracking(true); + userTree->viewport()->installEventFilter(this); + + // Pin on item click + connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + if (!SettingsCache::instance().getStyleUserList()) { + return; + } + const QString name = static_cast(item)->getUserInfo().name().c_str(); + m_popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(name); + m_popupPinned = true; // pin after showing + }); + + connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &sel, const QItemSelection &) { + // if (m_rebuildingTree) return; + if (sel.isEmpty() && m_popupPinned) { + m_popupPinned = false; + hidePopup(); + } + }); + + // Hide popup when list scrolls (reference row has moved) + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { + m_showPopupTimer->stop(); + hidePopup(true); + }); + + // Forward join requests from popup upward + connect(m_userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + + connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, + [this](const QString &) { userTree->viewport()->update(); }); + connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, + [this](const QString &) { userTree->viewport()->update(); }); + + connect(&SettingsCache::instance(), &SettingsCache::styleUserListChanged, this, &UserListWidget::applyDisplayMode); + applyDisplayMode(); QVBoxLayout *vbox = new QVBoxLayout; vbox->addWidget(userTree); @@ -441,6 +567,280 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, retranslateUi(); } +void UserListWidget::bind(UserListManager *mgr) +{ + manager = mgr; + + // ── Full rebuild: disconnect / reconnect / bulk initial load ────────────── + connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild); + + // ── Online users list (AllUsersList / RoomList) ─────────────────────────── + if (type == AllUsersList || type == RoomList) { + connect(manager, &UserListManager::userJoinedOnline, this, + [this](const ServerInfo_User &user) { processUserInfo(user, true); }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); + } + + // ── Buddy list ──────────────────────────────────────────────────────────── + if (type == BuddyList) { + connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromBuddyList, this, + [this](const QString &name) { deleteUser(name); }); + // Track online presence changes for buddies already in the tree + connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + if (users.contains(name)) { + users[name]->setUserInfo(user); + setUserOnline(name, true); + } + }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { + if (users.contains(name)) { + setUserOnline(name, false); + } + }); + } + + // ── Ignore list ─────────────────────────────────────────────────────────── + if (type == IgnoreList) { + connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromIgnoreList, this, + [this](const QString &name) { deleteUser(name); }); + } + + // ── Popup button refresh ────────────────────────────────────────────────── + // Any buddy/ignore mutation while the popup is open refreshes its buttons + auto refreshIfPopupOpen = [this](const QString &name) { + if (m_userInfoPopup && m_userInfoPopup->isVisible() && m_userInfoPopup->currentUser() == name) { + refreshPopupButtons(name); + } + }; + auto refreshCurrentPopup = [refreshIfPopupOpen](const ServerInfo_User &u) { + refreshIfPopupOpen(QString::fromStdString(u.name())); + }; + + connect(manager, &UserListManager::addedToBuddyList, this, refreshCurrentPopup); + connect(manager, &UserListManager::removedFromBuddyList, this, refreshIfPopupOpen); + connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup); + connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen); + connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup); + connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen); + + rebuild(); +} + +void UserListWidget::refreshPopupButtons(const QString &userName) +{ + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + const UserListProxy *proxy = tabSupervisor->getUserListManager(); + const bool online = item->data(0, UserListRoles::Online).toBool(); + const bool isBuddy = proxy->isUserBuddy(userName); + const bool isIgn = proxy->isUserIgnored(userName); + + m_userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); + positionPopup(userName); // height may have changed β€” reposition +} + +void UserListWidget::hideEvent(QHideEvent *e) +{ + QGroupBox::hideEvent(e); + m_showPopupTimer->stop(); + m_hidePopupTimer->stop(); + hidePopup(true); +} + +void UserListWidget::applyDisplayMode() +{ + const bool styled = SettingsCache::instance().getStyleUserList(); + + if (styled) { + userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + userTree->hideColumn(1); + userTree->hideColumn(2); + userTree->hideColumn(3); + } else { + userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + userTree->showColumn(1); + userTree->showColumn(2); + userTree->hideColumn(3); + } + + userTree->viewport()->update(); +} + +void UserListWidget::connectPopupSignals() +{ + connect(m_userInfoPopup, &UserInfoPopup::closeRequested, this, [this] { + m_popupPinned = false; + hidePopup(true); + }); + connect(m_userInfoPopup, &UserInfoPopup::mouseEnteredPopup, m_hidePopupTimer, &QTimer::stop); + connect(m_userInfoPopup, &UserInfoPopup::mouseLeftPopup, this, [this] { + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + }); + + // Wire all action signals to UserContextMenu::exec*() + connect(m_userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); + connect(m_userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); + connect(m_userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); + connect(m_userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); + connect(m_userInfoPopup, &UserInfoPopup::removeBuddyRequested, userContextMenu, + &UserContextMenu::execRemoveFromBuddy); + connect(m_userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore); + connect(m_userInfoPopup, &UserInfoPopup::removeIgnoreRequested, userContextMenu, + &UserContextMenu::execRemoveFromIgnore); + connect(m_userInfoPopup, &UserInfoPopup::banRequested, userContextMenu, &UserContextMenu::execBan); + connect(m_userInfoPopup, &UserInfoPopup::warnRequested, userContextMenu, &UserContextMenu::execWarn); + connect(m_userInfoPopup, &UserInfoPopup::banHistoryRequested, userContextMenu, &UserContextMenu::execBanHistory); + connect(m_userInfoPopup, &UserInfoPopup::warnHistoryRequested, userContextMenu, &UserContextMenu::execWarnHistory); + connect(m_userInfoPopup, &UserInfoPopup::adminNotesRequested, userContextMenu, &UserContextMenu::execAdminNotes); + connect(m_userInfoPopup, &UserInfoPopup::promoteToModRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, true, false); }); + connect(m_userInfoPopup, &UserInfoPopup::demoteFromModRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, false); }); + connect(m_userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, true); }); + connect(m_userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, false); }); +} + +bool UserListWidget::eventFilter(QObject *obj, QEvent *event) +{ + if (obj == userTree->viewport()) { + if (event->type() == QEvent::MouseMove) { + if (!SettingsCache::instance().getStyleUserList()) { + return QGroupBox::eventFilter(obj, event); + } + auto *me = static_cast(event); + auto *twi = static_cast(userTree->itemAt(me->pos())); + const QString hovName = twi ? QString::fromStdString(twi->getUserInfo().name()) : QString{}; + + if (hovName != m_hoveredUser) { + m_hoveredUser = hovName; + if (!hovName.isEmpty()) { + m_hidePopupTimer->stop(); + if (!m_popupPinned) { + m_showPopupTimer->start(); + } + } else { + m_showPopupTimer->stop(); + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + } + } + } else if (event->type() == QEvent::Leave) { + m_hoveredUser.clear(); + m_showPopupTimer->stop(); + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + } + } + + return QGroupBox::eventFilter(obj, event); +} + +void UserListWidget::showPopupForUser(const QString &userName) +{ + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + const ServerInfo_User &info = item->getUserInfo(); + const bool online = item->data(0, UserListRoles::Online).toBool(); + const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName); + const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName); + + m_userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); + + positionPopup(userName); + + m_userInfoPopup->show(); + m_userInfoPopup->raise(); + + // Fade in + m_userInfoPopup->setWindowOpacity(0.0); + auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + fade->setDuration(120); + fade->setStartValue(0.0); + fade->setEndValue(1.0); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + +void UserListWidget::positionPopup(const QString &userName) +{ + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + QWidget *vp = userTree->viewport(); + const QRect itemR = userTree->visualItemRect(item); + const QPoint itemBR = vp->mapToGlobal(itemR.bottomRight()); + const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); + const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight()); + + // Force a fresh size calculation so popH is accurate + m_userInfoPopup->adjustSize(); + const int popW = m_userInfoPopup->width(); + const int popH = m_userInfoPopup->height(); + const int margin = 12; + + const QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); + + // ── X: left of the list if there's room, otherwise right ───────────────── + int x = (vpTL.x() >= popW + margin) ? vpTL.x() - popW - margin : vpTR.x() + margin; + x = qBound(screen.left() + margin, x, screen.right() - popW - margin); + + // ── Y: bottom of popup aligns with bottom of hovered row, grows upward ─── + int y = itemBR.y() - popH; + + // Clamp: never above the screen top + y = qMax(y, screen.top() + margin); + + // Clamp: never below the screen bottom (e.g. if the popup is taller + // than the space above the row, let it spill downward rather than clip) + y = qMin(y, screen.bottom() - popH - margin); + + m_userInfoPopup->move(x, y); +} + +void UserListWidget::hidePopup(bool immediate) +{ + m_showPopupTimer->stop(); + m_hidePopupTimer->stop(); + if (!m_userInfoPopup->isVisible()) { + return; + } + + if (immediate) { + m_userInfoPopup->hide(); + return; + } + + // Fade out + auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + fade->setDuration(100); + fade->setStartValue(m_userInfoPopup->windowOpacity()); + fade->setEndValue(0.0); + connect(fade, &QPropertyAnimation::finished, m_userInfoPopup, &QWidget::hide); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + void UserListWidget::retranslateUi() { userContextMenu->retranslateUi(); @@ -461,9 +861,59 @@ void UserListWidget::retranslateUi() updateCount(); } +void UserListWidget::rebuild() +{ + userTree->clear(); + users.clear(); + cardArtParamsMap.clear(); + onlineCount = 0; + + if (!manager) { + return; + } + + const QMap *source = nullptr; + + switch (type) { + case AllUsersList: + case RoomList: + source = &manager->getAllUsersList(); + break; + case BuddyList: + source = &manager->getBuddyList(); + break; + case IgnoreList: + source = &manager->getIgnoreList(); + break; + } + + for (auto it = source->cbegin(); it != source->cend(); ++it) { + processUserInfo(it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + + sortItems(); +} + void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) { const QString userName = QString::fromStdString(user.name()); + + // Always update params from the latest ServerInfo_User, whether the + // item is new or existing, so a live server-push refreshes the rendering. + if (user.has_card_art_params()) { + const auto &cap = user.card_art_params(); + CardArtParams params; + params.cardName = QString::fromStdString(cap.card_name()); + params.marginPctL = cap.margin_pct_l(); + params.marginPctR = cap.margin_pct_r(); + params.verticalOffset = cap.vertical_offset(); + params.zoom = cap.zoom(); + cardArtParamsMap.insert(userName, params); + cardArtProvider->requestCardArt(userName, params.cardName); + } else { + cardArtParamsMap.remove(userName); // clear stale params on removal + } + UserListTWI *item = users.value(userName); if (item) { item->setUserInfo(user); @@ -475,25 +925,28 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) ++onlineCount; } updateCount(); + avatarProvider->requestAvatar(userName); } item->setOnline(online); + sortItems(); + userTree->viewport()->update(); } bool UserListWidget::deleteUser(const QString &userName) { UserListTWI *twi = users.value(userName); - if (twi) { - users.remove(userName); - userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); - if (twi->data(0, Qt::UserRole + 1).toBool()) { - --onlineCount; - } - delete twi; - updateCount(); - return true; + if (!twi) { + return false; } - return false; + users.remove(userName); + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); + if (twi->data(0, Qt::UserRole + 1).toBool()) { + --onlineCount; + } + delete twi; + updateCount(); + return true; } void UserListWidget::setUserOnline(const QString &userName, bool online) @@ -537,5 +990,5 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index void UserListWidget::sortItems() { - userTree->sortItems(1, Qt::AscendingOrder); + userTree->sortItems(0, Qt::AscendingOrder); } diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 5a8c00d10..d70cdfbbd 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -7,9 +7,17 @@ #ifndef USERLIST_H #define USERLIST_H +#include "../../cards/card_info_picture_art_crop_widget.h" +#include "user_avatar_provider.h" +#include "user_card_art_provider.h" +#include "user_info_popup.h" +#include "user_list_manager.h" +#include "user_list_painter.h" + #include #include #include +#include #include #include #include @@ -94,12 +102,21 @@ public: class UserListItemDelegate : public QStyledItemDelegate { + const QMap *avatarCache; + const QMap *cardArtCache; + const QMap *cardArtParamsMap; + public: - explicit UserListItemDelegate(QObject *const parent); + explicit UserListItemDelegate(QObject *const parent, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap); bool editorEvent(QEvent *event, QAbstractItemModel *model, const QStyleOptionViewItem &option, const QModelIndex &index) override; + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; }; class UserListTWI : public QTreeWidgetItem @@ -131,6 +148,22 @@ public: }; private: + UserListManager *manager = nullptr; + UserAvatarProvider *avatarProvider = nullptr; + UserCardArtProvider *cardArtProvider = nullptr; + QMap cardArtParamsMap; + // ── Hover popup ─────────────────────────────────────────────────────────── + UserInfoPopup *m_userInfoPopup = nullptr; + QTimer *m_showPopupTimer = nullptr; + QTimer *m_hidePopupTimer = nullptr; + QString m_hoveredUser; + bool m_popupPinned = false; + + void showPopupForUser(const QString &userName); + void hidePopup(bool immediate = false); + void positionPopup(const QString &userName); + void connectPopupSignals(); + QMap users; TabSupervisor *tabSupervisor; AbstractClient *client; @@ -141,6 +174,7 @@ private: int onlineCount; QString titleStr; void updateCount(); + void refreshPopupButtons(const QString &userName); private slots: void userClicked(QTreeWidgetItem *item, int column); signals: @@ -149,13 +183,18 @@ signals: void removeBuddy(const QString &userName); void addIgnore(const QString &userName); void removeIgnore(const QString &userName); + void joinGameRequested(int gameId, int roomId, bool asSpectator); public: UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, QWidget *parent = nullptr); + void bind(UserListManager *mgr); + void applyDisplayMode(); + bool eventFilter(QObject *obj, QEvent *event) override; void retranslateUi(); + void rebuild(); void processUserInfo(const ServerInfo_User &user, bool online); bool deleteUser(const QString &userName); void setUserOnline(const QString &userName, bool online); @@ -165,6 +204,9 @@ public: } void showContextMenu(const QPoint &pos, const QModelIndex &index); void sortItems(); + +protected: + void hideEvent(QHideEvent *e) override; }; #endif diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index 2d32f3ce1..e00484ebf 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -111,6 +111,15 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabGroupBox = new QGroupBox; homeTabGroupBox->setLayout(homeTabGrid); + styleUserListCheckBox.setChecked(settings.getStyleUserList()); + connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setStyleUserList); + + auto stylingTabGrid = new QGridLayout; + stylingTabGrid->addWidget(&styleUserListCheckBox, 0, 0, 1, 2); + + stylingGroupBox = new QGroupBox; + stylingGroupBox->setLayout(stylingTabGrid); + // Menu settings showShortcutsCheckBox.setChecked(settings.getShowShortcuts()); connect(&showShortcutsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::showShortcutsChanged); @@ -284,6 +293,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(themeGroupBox); mainLayout->addWidget(homeTabGroupBox); + mainLayout->addWidget(stylingGroupBox); mainLayout->addWidget(menuGroupBox); mainLayout->addWidget(printingsGroupBox); mainLayout->addWidget(cardsGroupBox); @@ -398,6 +408,9 @@ void AppearanceSettingsPage::retranslateUi() homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled")); homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right")); + stylingGroupBox->setTitle(tr("Styling settings")); + styleUserListCheckBox.setText(tr("Style user list")); + menuGroupBox->setTitle(tr("Menu settings")); showShortcutsCheckBox.setText(tr("Show keyboard shortcuts in right-click menus")); showGameSelectorFilterToolbarCheckBox.setText(tr("Show game filter toolbar above list in room tab")); diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 9ed27be4d..e223d70f8 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -37,6 +37,7 @@ private: QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; + QCheckBox styleUserListCheckBox; QLabel minPlayersForMultiColumnLayoutLabel; QLabel maxFontSizeForCardsLabel; QCheckBox showShortcutsCheckBox; @@ -58,6 +59,7 @@ private: QCheckBox invertVerticalCoordinateCheckBox; QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; + QGroupBox *stylingGroupBox; QGroupBox *menuGroupBox; QGroupBox *printingsGroupBox; QGroupBox *cardsGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp new file mode 100644 index 000000000..6e8ab752a --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -0,0 +1,246 @@ +#include "tab_card_art_rules.h" + +#include "libcockatrice/card/database/card_database_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +CardArtRulesModel::CardArtRulesModel(AbstractClient *client, QObject *parent) + : QAbstractTableModel(parent), client(client) +{ +} + +int CardArtRulesModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return static_cast(entries.size()); +} + +int CardArtRulesModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return 3; +} + +QVariant CardArtRulesModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) { + return {}; + } + + const auto &e = entries.at(index.row()); + + if (role == Qt::DisplayRole) { + switch (index.column()) { + case 0: + return e.cardName; + case 1: + return e.mode; + case 2: + return e.reason; + } + } + + return {}; +} + +QVariant CardArtRulesModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { + return {}; + } + + switch (section) { + case 0: + return tr("Card"); + case 1: + return tr("Mode"); + case 2: + return tr("Reason"); + default: + return {}; + } +} + +void CardArtRulesModel::refresh() +{ + Command_ListCardArtRules cmd; + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + + connect(pend, &PendingCommand::finished, this, &CardArtRulesModel::onRefreshFinished); + + client->sendCommand(pend); +} + +void CardArtRulesModel::clear() +{ + beginResetModel(); + entries.clear(); + endResetModel(); +} + +QString CardArtRulesModel::cardAt(int row) const +{ + if (row < 0 || row >= static_cast(entries.size())) { + return {}; + } + + return entries[row].cardName; +} + +void CardArtRulesModel::onRefreshFinished(const Response &r) +{ + if (r.response_code() != Response::RespOk) { + return; + } + + const auto &resp = r.GetExtension(Response_ListCardArtRules::ext); + + beginResetModel(); + entries.clear(); + + for (const auto &e : resp.entries()) { + entries.push_back({QString::fromStdString(e.card_name()), QString::fromStdString(e.mode()), + QString::fromStdString(e.reason())}); + } + + endResetModel(); +} + +TabCardArtRules::TabCardArtRules(TabSupervisor *parent, AbstractClient *_client) : Tab(parent), client(_client) +{ + setupUi(); + refresh(); +} + +void TabCardArtRules::setupUi() +{ + auto *central = new QWidget(this); + + initSearchBar(); + + modeBox = new QComboBox; + reasonEdit = new QLineEdit; + + addBtn = new QPushButton; + removeBtn = new QPushButton; + refreshBtn = new QPushButton; + + modeBox->addItems({"ALLOW", "DENY"}); + + tableModel = new CardArtRulesModel(client, this); + + table = new QTableView; + table->setModel(tableModel); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + + auto *form = new QFormLayout; + form->addRow(tr("Card:"), searchEdit); + form->addRow(tr("Mode:"), modeBox); + form->addRow(tr("Reason:"), reasonEdit); + + auto *buttons = new QHBoxLayout; + buttons->addWidget(addBtn); + buttons->addWidget(removeBtn); + buttons->addWidget(refreshBtn); + + auto *layout = new QVBoxLayout; + layout->addLayout(form); + layout->addLayout(buttons); + layout->addWidget(table); + + central->setLayout(layout); + setCentralWidget(central); + + connect(addBtn, &QPushButton::clicked, this, &TabCardArtRules::addRule); + + connect(removeBtn, &QPushButton::clicked, this, &TabCardArtRules::removeSelected); + + connect(refreshBtn, &QPushButton::clicked, this, &TabCardArtRules::refresh); + + retranslateUi(); +} + +void TabCardArtRules::initSearchBar() +{ + searchEdit = new QLineEdit; + searchEdit->setPlaceholderText(tr("Type a card name...")); + + cardDbModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDbDisplayModel = new CardDatabaseDisplayModel(this); + cardDbDisplayModel->setSourceModel(cardDbModel); + cardSearchModel = new CardSearchModel(cardDbDisplayModel, this); + + cardProxyModel = new CardCompleterProxyModel(this); + cardProxyModel->setSourceModel(cardSearchModel); + cardProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + + searchCompleter = new QCompleter(cardProxyModel, this); + searchCompleter->setCompletionRole(Qt::DisplayRole); + searchCompleter->setCompletionMode(QCompleter::PopupCompletion); + searchCompleter->setCaseSensitivity(Qt::CaseInsensitive); + searchCompleter->setFilterMode(Qt::MatchContains); + searchCompleter->setMaxVisibleItems(15); + searchEdit->setCompleter(searchCompleter); + + connect(searchEdit, &QLineEdit::textEdited, cardSearchModel, &CardSearchModel::updateSearchResults); + connect(searchEdit, &QLineEdit::textEdited, this, [this](const QString &text) { + const QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; + cardProxyModel->setFilterRegularExpression( + QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); + if (!text.isEmpty()) { + searchCompleter->complete(); + } + }); + connect(searchCompleter, static_cast(&QCompleter::activated), this, + [this](const QString &name) { searchEdit->setText(name); }); +} + +void TabCardArtRules::retranslateUi() +{ + addBtn->setText(tr("Add rule")); + removeBtn->setText(tr("Remove rule")); + refreshBtn->setText(tr("Refresh")); +} + +void TabCardArtRules::refresh() +{ + tableModel->refresh(); +} + +void TabCardArtRules::addRule() +{ + Command_AddCardArtRule cmd; + cmd.set_card_name(searchEdit->text().toStdString()); + cmd.set_mode(modeBox->currentText().toStdString()); + cmd.set_reason(reasonEdit->text().toStdString()); + + client->sendCommand(client->prepareModeratorCommand(cmd)); + + refresh(); +} + +void TabCardArtRules::removeSelected() +{ + QModelIndex idx = table->currentIndex(); + if (!idx.isValid()) { + return; + } + + Command_RemoveCardArtRule cmd; + cmd.set_card_name(tableModel->cardAt(idx.row()).toStdString()); + + client->sendCommand(client->prepareModeratorCommand(cmd)); + + refresh(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h new file mode 100644 index 000000000..a47f1267d --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h @@ -0,0 +1,89 @@ +#ifndef COCKATRICE_DLG_CARD_ART_RULES_H +#define COCKATRICE_DLG_CARD_ART_RULES_H + +#include "card/card_search_model.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include + +class AbstractClient; + +class CardArtRulesModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + struct Entry + { + QString cardName; + QString mode; + QString reason; + }; + + explicit CardArtRulesModel(AbstractClient *client, QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + void refresh(); + void clear(); + + QString cardAt(int row) const; + +private slots: + void onRefreshFinished(const Response &r); + +private: + AbstractClient *client; + std::vector entries; +}; + +class TabCardArtRules : public Tab +{ + Q_OBJECT + +public: + TabCardArtRules(TabSupervisor *parent, AbstractClient *client); + + QString getTabText() const override + { + return tr("Card Art Rules"); + } + void retranslateUi() override; + +private: + void setupUi(); + +private slots: + void addRule(); + void removeSelected(); + void refresh(); + +private: + AbstractClient *client; + + QLineEdit *searchEdit; + void initSearchBar(); + QCompleter *searchCompleter; + CardDatabaseModel *cardDbModel; + CardDatabaseDisplayModel *cardDbDisplayModel; + CardSearchModel *cardSearchModel; + CardCompleterProxyModel *cardProxyModel; + QComboBox *modeBox; + QLineEdit *reasonEdit; + + QPushButton *addBtn; + QPushButton *removeBtn; + QPushButton *refreshBtn; + + QTableView *table; + CardArtRulesModel *tableModel; +}; + +#endif // COCKATRICE_DLG_CARD_ART_RULES_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 424742e9b..c7495da5a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -49,10 +49,25 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, QMap tempMap; tempMap.insert(info.room_id(), gameTypes); gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); + + auto *tabs = new QTabWidget(this); + + friendsList = new UserListWidget(tabSupervisor, client, UserListWidget::BuddyList); + friendsList->bind(tabSupervisor->getUserListManager()); userList = new UserListWidget(tabSupervisor, client, UserListWidget::RoomList); + userList->bind(tabSupervisor->getUserListManager()); + ignoreList = new UserListWidget(tabSupervisor, client, UserListWidget::IgnoreList); + ignoreList->bind(tabSupervisor->getUserListManager()); + + connect(friendsList, SIGNAL(openMessageDialog(const QString &, bool)), this, + SIGNAL(openMessageDialog(const QString &, bool))); connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this, SIGNAL(openMessageDialog(const QString &, bool))); + tabs->addTab(friendsList, tr("Friends")); + tabs->addTab(userList, tr("Online")); + tabs->addTab(ignoreList, tr("Ignored")); + chatView = new ChatView(tabSupervisor, nullptr, true, this); connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); @@ -101,7 +116,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, auto *hbox = new QHBoxLayout; hbox->addWidget(splitter, 3); - hbox->addWidget(userList, 1); + hbox->addWidget(tabs, 1); aLeaveRoom = new QAction(this); connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest); @@ -112,10 +127,8 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, const int userListSize = info.user_list_size(); for (int i = 0; i < userListSize; ++i) { - userList->processUserInfo(info.user_list(i), true); autocompleteUserList.append("@" + QString::fromStdString(info.user_list(i).name())); } - userList->sortItems(); const int gameListSize = info.game_list_size(); for (int i = 0; i < gameListSize; ++i) { @@ -269,8 +282,6 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event) void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event) { - userList->processUserInfo(event.user_info(), true); - userList->sortItems(); if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) { autocompleteUserList << "@" + QString::fromStdString(event.user_info().name()); sayEdit->setCompletionList(autocompleteUserList); @@ -279,7 +290,6 @@ void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event) void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event) { - userList->deleteUser(QString::fromStdString(event.name())); autocompleteUserList.removeOne("@" + QString::fromStdString(event.name())); sayEdit->setCompletionList(autocompleteUserList); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index eeb5a9e14..d669b6107 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -56,7 +56,9 @@ private: QMap gameTypes; GameSelector *gameSelector; + UserListWidget *friendsList; UserListWidget *userList; + UserListWidget *ignoreList; const UserListProxy *userListProxy; ChatView *chatView; QLabel *sayLabel; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index e7075f78f..52309d94b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -9,6 +9,7 @@ #include "api/edhrec/tab_edhrec_main.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" #include "tab_game.h" @@ -179,6 +180,10 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * aTabAdmin->setCheckable(true); connect(aTabAdmin, &QAction::triggered, this, &TabSupervisor::actTabAdmin); + aTabCardArtRules = new QAction(this); + aTabCardArtRules->setCheckable(true); + connect(aTabCardArtRules, &QAction::triggered, this, &TabSupervisor::actTabCardArtRules); + aTabLog = new QAction(this); aTabLog->setCheckable(true); connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog); @@ -435,6 +440,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) tabsMenu->addSeparator(); tabsMenu->addAction(aTabAdmin); tabsMenu->addAction(aTabLog); + tabsMenu->addAction(aTabCardArtRules); if (SettingsCache::instance().getTabAdminOpen()) { openTabAdmin(); @@ -442,6 +448,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().getTabLogOpen()) { openTabLog(); } + openTabCardArtRules(); } retranslateUi(); @@ -681,6 +688,30 @@ void TabSupervisor::openTabAdmin() aTabAdmin->setChecked(true); } +void TabSupervisor::actTabCardArtRules(bool checked) +{ + if (checked && !tabCardArtRules) { + openTabCardArtRules(); + setCurrentWidget(tabCardArtRules); + } else if (!checked && tabCardArtRules) { + tabCardArtRules->closeRequest(); + } +} + +void TabSupervisor::openTabCardArtRules() +{ + tabCardArtRules = new TabCardArtRules(this, client); + + myAddTab(tabCardArtRules, aTabCardArtRules); + + connect(tabCardArtRules, &QObject::destroyed, this, [this] { + tabCardArtRules = nullptr; + aTabCardArtRules->setChecked(false); + }); + + aTabCardArtRules->setChecked(true); +} + void TabSupervisor::actTabLog(bool checked) { SettingsCache::instance().setTabLogOpen(checked); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index e77fb4f7b..3eac144b7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -24,6 +24,7 @@ #include #include +class TabCardArtRules; inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor"); class UserListManager; @@ -103,6 +104,7 @@ private: TabDeckStorage *tabDeckStorage; TabReplays *tabReplays; TabAdmin *tabAdmin; + TabCardArtRules *tabCardArtRules; TabLog *tabLog; QMap roomTabs; QMap gameTabs; @@ -112,7 +114,8 @@ private: bool isLocalGame; QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage, - *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, *aTabLog; + *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, + *aTabCardArtRules, *aTabLog; int myAddTab(Tab *tab, QAction *manager = nullptr); void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager); @@ -145,7 +148,7 @@ public: return userInfo; } [[nodiscard]] AbstractClient *getClient() const; - [[nodiscard]] const UserListManager *getUserListManager() const + [[nodiscard]] UserListManager *getUserListManager() const { return userListManager; } @@ -197,6 +200,8 @@ private slots: void openTabDeckStorage(); void openTabReplays(); void openTabAdmin(); + void actTabCardArtRules(bool checked); + void openTabCardArtRules(); void openTabLog(); void updateCurrent(int index); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 3da9ddc73..7abccfca8 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -190,6 +190,25 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session, return authState; } +void Server::broadcastUserInfoUpdate(Server_ProtocolHandler *source) +{ + Event_UserJoined event; + event.mutable_user_info()->CopyFrom(source->copyUserInfo(false)); + + SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event); + + clientsLock.lockForRead(); + for (auto &client : clients) { + if (client->getAcceptsUserListChanges()) { + client->sendProtocolItem(*se); + } + } + clientsLock.unlock(); + + sendIsl_SessionEvent(*se); + delete se; +} + void Server::addPersistentPlayer(const QString &userName, int roomId, int gameId, int playerId) { QWriteLocker locker(&persistentPlayersLock); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index ab57fac4e..2fca46593 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -64,6 +64,7 @@ public: QString &clientid, QString &clientVersion, QString &connectionType); + void broadcastUserInfoUpdate(Server_ProtocolHandler *source); const QMap &getRooms() { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index b4c7b6ac8..20a4cb08d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -122,6 +122,7 @@ set(PROTO_FILES response_activate.proto response_adjust_mod.proto response_ban_history.proto + response_card_art_rule_entry.proto response_deck_download.proto response_deck_list.proto response_deck_upload.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 9d01b51d2..c10b9de22 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -11,6 +11,9 @@ message ModeratorCommand { FORCE_ACTIVATE_USER = 1007; GET_ADMIN_NOTES = 1008; UPDATE_ADMIN_NOTES = 1009; + ADD_CARD_ART_RULE = 1010; + REMOVE_CARD_ART_RULE = 1011; + LIST_CARD_ART_RULES = 1012; } extensions 100 to max; } @@ -106,3 +109,27 @@ message Command_UpdateAdminNotes { optional string user_name = 1; optional string notes = 2; } + +message Command_AddCardArtRule { + extend ModeratorCommand { + optional Command_AddCardArtRule ext = 1010; + } + + optional string card_name = 1; + optional string mode = 2; // "ALLOW" or "DENY" + optional string reason = 3; +} + +message Command_RemoveCardArtRule { + extend ModeratorCommand { + optional Command_RemoveCardArtRule ext = 1011; + } + + optional string card_name = 1; +} + +message Command_ListCardArtRules { + extend ModeratorCommand { + optional Command_ListCardArtRules ext = 1012; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index dece8ae17..e719f3e92 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -68,6 +68,7 @@ message Response { REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_GET_CODE = 1102; + CARD_ART_RULE_LIST = 1200; } required uint64 cmd_id = 1; optional ResponseCode response_code = 2; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto new file mode 100644 index 000000000..b13c79742 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto @@ -0,0 +1,15 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_CardArtRuleEntry { + optional string card_name = 1; + optional string mode = 2; + optional string reason = 3; +} + +message Response_ListCardArtRules { + extend Response { + optional Response_ListCardArtRules ext = 1200; + } + repeated Response_CardArtRuleEntry entries = 1; +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto index 10add611f..4bbbe46bb 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto @@ -1,4 +1,5 @@ syntax = "proto2"; + message ServerInfo_User { enum UserLevelFlag { IsNothing = 0; @@ -12,6 +13,13 @@ message ServerInfo_User { optional string left_side = 1; optional string right_side = 2; }; + message CardArtParams { + optional string card_name = 1; + optional double margin_pct_l = 2 [default = 0.33]; + optional double margin_pct_r = 3 [default = 0.02]; + optional double vertical_offset = 4 [default = 0.35]; + optional double zoom = 5 [default = 1.0]; + }; optional string name = 1; optional uint32 user_level = 2; @@ -28,4 +36,5 @@ message ServerInfo_User { optional string clientid = 13; optional string privlevel = 14; optional PawnColorsOverride pawn_colors = 15; -} + optional CardArtParams card_art_params = 16; +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index cecf87370..ff5dbff32 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -27,6 +27,7 @@ message SessionCommand { FORGOT_PASSWORD_RESET = 1022; FORGOT_PASSWORD_CHALLENGE = 1023; REQUEST_PASSWORD_SALT = 1024; + SET_CARD_ART_PARAMS = 1025; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; @@ -205,3 +206,14 @@ message Command_RequestPasswordSalt { } required string user_name = 1; } + +message Command_SetCardArtParams { + extend SessionCommand { + optional Command_SetCardArtParams ext = 1025; + } + optional string card_name = 1; + optional double margin_pct_l = 2; + optional double margin_pct_r = 3; + optional double vertical_offset = 4; + optional double zoom = 5; +} diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index c0c7d8cc9..df29d6c9f 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -17,6 +17,7 @@ set(UTILITY_HEADERS libcockatrice/utility/passwordhasher.h libcockatrice/utility/trice_limits.h libcockatrice/utility/zone_names.h + libcockatrice/utility/days_years_between.h ) add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) diff --git a/libcockatrice_utility/libcockatrice/utility/days_years_between.h b/libcockatrice_utility/libcockatrice/utility/days_years_between.h index c0f5da23a..4b3b5bc0c 100644 --- a/libcockatrice_utility/libcockatrice/utility/days_years_between.h +++ b/libcockatrice_utility/libcockatrice/utility/days_years_between.h @@ -1,3 +1,6 @@ +#ifndef COCKATRICE_DAYS_YEARS_BETWEEN_H +#define COCKATRICE_DAYS_YEARS_BETWEEN_H + #include inline static QPair getDaysAndYearsBetween(const QDate &then, const QDate &now) @@ -6,3 +9,5 @@ inline static QPair getDaysAndYearsBetween(const QDate &then, const QD int days = then.addYears(years).daysTo(now); return {days, years}; } + +#endif // COCKATRICE_DAYS_YEARS_BETWEEN_H diff --git a/servatrice/migrations/servatrice_0034_to_0035.sql b/servatrice/migrations/servatrice_0034_to_0035.sql new file mode 100644 index 000000000..83502a949 --- /dev/null +++ b/servatrice/migrations/servatrice_0034_to_0035.sql @@ -0,0 +1,18 @@ +ALTER TABLE `cockatrice_users` ADD COLUMN `card_art_params` TEXT DEFAULT NULL, ALGORITHM=INSTANT; + +CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `card_name` varchar(255) NOT NULL, + `mode` enum('ALLOW','DENY') NOT NULL, + `reason` varchar(255) DEFAULT NULL, + `created_by` int(7) unsigned DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_card_name` (`card_name`), + KEY `idx_mode` (`mode`), + FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; + +UPDATE cockatrice_schema_version SET version=35 WHERE version=34; diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index fa644dbc0..badd82d6d 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(34); +INSERT INTO cockatrice_schema_version VALUES(35); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `passwordLastChangedDate` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', `leftPawnColorOverride` varchar(255), `rightPawnColorOverride` varchar(255), + `card_art_params` TEXT DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`), KEY `token` (`token`), @@ -300,3 +301,18 @@ CREATE TABLE IF NOT EXISTS `cockatrice_audit` ( PRIMARY KEY (`id`), KEY `user_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `card_name` varchar(255) NOT NULL, + `mode` enum('ALLOW','DENY') NOT NULL, + `reason` varchar(255) DEFAULT NULL, + `created_by` int(7) unsigned DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_card_name` (`card_name`), + KEY `idx_mode` (`mode`), + FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 73643825e..92ff80755 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -681,6 +683,30 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer if (!clientid.isEmpty()) { result.set_clientid(clientid.toStdString()); } + + const QString cardArtParamsJson = query->value(12).toString(); + if (!cardArtParamsJson.isEmpty()) { + const QJsonDocument doc = QJsonDocument::fromJson(cardArtParamsJson.toUtf8()); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + auto *cap = result.mutable_card_art_params(); + if (obj.contains("card_name")) { + cap->set_card_name(obj["card_name"].toString().toStdString()); + } + if (obj.contains("marginPctL")) { + cap->set_margin_pct_l(obj["marginPctL"].toDouble(0.33)); + } + if (obj.contains("marginPctR")) { + cap->set_margin_pct_r(obj["marginPctR"].toDouble(0.02)); + } + if (obj.contains("verticalOffset")) { + cap->set_vertical_offset(obj["verticalOffset"].toDouble(0.35)); + } + if (obj.contains("zoom")) { + cap->set_zoom(obj["zoom"].toDouble(1.0)); + } + } + } } return result; } @@ -698,7 +724,7 @@ ServerInfo_User Servatrice_DatabaseInterface::getUserData(const QString &name, b QSqlQuery *query = prepareQuery("select id, name, admin, country, privlevel, leftPawnColorOverride, " "rightPawnColorOverride, realname, avatar_bmp, registrationDate, " - "email, clientid from {prefix}_users where " + "email, clientid, card_art_params from {prefix}_users where " "name = :name and active = 1"); query->bindValue(":name", name); if (!execSqlQuery(query)) { diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 68080404c..1e3501ec7 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -10,7 +10,7 @@ #include #include -#define DATABASE_SCHEMA_VERSION 34 +#define DATABASE_SCHEMA_VERSION 35 class Servatrice; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index bc90a3ef1..f9d276941 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -31,6 +31,8 @@ #include #include #include +#include +#include #include #include #include @@ -59,8 +61,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -212,6 +216,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdAccountEdit(cmd.GetExtension(Command_AccountEdit::ext), rc); case SessionCommand::ACCOUNT_IMAGE: return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc); + case SessionCommand::SET_CARD_ART_PARAMS: + return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: @@ -247,6 +253,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdGetAdminNotes(cmd.GetExtension(Command_GetAdminNotes::ext), rc); case ModeratorCommand::UPDATE_ADMIN_NOTES: return cmdUpdateAdminNotes(cmd.GetExtension(Command_UpdateAdminNotes::ext), rc); + case ModeratorCommand::ADD_CARD_ART_RULE: + return cmdAddCardArtRule(cmd.GetExtension((Command_AddCardArtRule::ext)), rc); + case ModeratorCommand::REMOVE_CARD_ART_RULE: + return cmdRemoveCardArtRule(cmd.GetExtension((Command_RemoveCardArtRule::ext)), rc); + case ModeratorCommand::LIST_CARD_ART_RULES: + return cmdListCardArtRules(cmd.GetExtension((Command_ListCardArtRules::ext)), rc); default: return Response::RespFunctionNotAllowed; } @@ -1565,6 +1577,161 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm return Response::RespOk; } +bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName) +{ + QSqlQuery *q = sqlInterface->prepareQuery("SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name"); + + q->bindValue(":name", cardName); + + if (!sqlInterface->execSqlQuery(q)) { + qWarning() << "Card art rule lookup failed; failing open for" << cardName; + return true; + } + + if (!q->next()) { + return true; // default allow + } + + return q->value(0).toString() != "DENY"; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd, + ResponseContainer & /* rc */) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + const QString cardName = QString::fromStdString(cmd.card_name()); + + if (cardName.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + + if (cardName.isEmpty()) { + // Removal path + QSqlQuery *q = sqlInterface->prepareQuery("UPDATE {prefix}_users SET card_art_params = NULL WHERE id = :id"); + q->bindValue(":id", userInfo->id()); + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + userInfo->clear_card_art_params(); + server->broadcastUserInfoUpdate(this); + return Response::RespOk; + } + + if (!isCardNameAllowed(cardName)) { + return Response::RespFunctionNotAllowed; + } + + // Clamp everything to sane ranges server-side so a malicious client + // can't store garbage that breaks other clients' rendering. + const double marginPctL = qBound(0.0, cmd.margin_pct_l(), 0.95); + const double marginPctR = qBound(0.0, cmd.margin_pct_r(), 0.95); + const double verticalOffset = qBound(0.0, cmd.vertical_offset(), 1.0); + const double zoom = qBound(0.1, cmd.zoom(), 4.0); + + QJsonObject obj; + obj["card_name"] = cardName; + obj["marginPctL"] = marginPctL; + obj["marginPctR"] = marginPctR; + obj["verticalOffset"] = verticalOffset; + obj["zoom"] = zoom; + const QString json = QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + + QSqlQuery *query = sqlInterface->prepareQuery("update {prefix}_users set card_art_params=:params where id=:id"); + query->bindValue(":params", json); + query->bindValue(":id", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + // Keep the in-memory userInfo in sync + auto *cap = userInfo->mutable_card_art_params(); + cap->set_card_name(cmd.card_name()); + cap->set_margin_pct_l(marginPctL); + cap->set_margin_pct_r(marginPctR); + cap->set_vertical_offset(verticalOffset); + cap->set_zoom(zoom); + + const QString name = QString::fromStdString(userInfo->name()); + server->broadcastUserInfoUpdate(this); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdAddCardArtRule(const Command_AddCardArtRule &cmd, + ResponseContainer &) +{ + const QString cardName = QString::fromStdString(cmd.card_name()); + const QString mode = QString::fromStdString(cmd.mode()); + + if (mode != "ALLOW" && mode != "DENY") { + return Response::RespInvalidData; + } + if (cardName.isEmpty() || cardName.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + + QSqlQuery *q = sqlInterface->prepareQuery("INSERT INTO {prefix}_card_art_name_rules " + "(card_name, mode, reason, created_by) " + "VALUES (:name, :mode, :reason, :uid) " + "ON DUPLICATE KEY UPDATE mode=:mode2, reason=:reason2"); + + q->bindValue(":name", cardName); + q->bindValue(":mode", mode); + q->bindValue(":mode2", mode); + q->bindValue(":reason", QString::fromStdString(cmd.reason())); + q->bindValue(":reason2", QString::fromStdString(cmd.reason())); + q->bindValue(":uid", userInfo->id()); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, + ResponseContainer &) +{ + auto cardName = QString::fromStdString(cmd.card_name()); + if (cardName.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + QSqlQuery *q = sqlInterface->prepareQuery("DELETE FROM {prefix}_card_art_name_rules WHERE card_name=:name"); + + q->bindValue(":name", cardName); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdListCardArtRules(const Command_ListCardArtRules &, + ResponseContainer &rc) +{ + QSqlQuery *q = sqlInterface->prepareQuery("SELECT card_name, mode, reason FROM {prefix}_card_art_name_rules"); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + auto *re = new Response_ListCardArtRules; + + while (q->next()) { + auto *entry = re->add_entries(); + entry->set_card_name(q->value(0).toString().toStdString()); + entry->set_mode(q->value(1).toString().toStdString()); + entry->set_reason(q->value(2).toString().toStdString()); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const Command_AccountPassword &cmd, ResponseContainer & /* rc */) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index e10aa0dde..c0732ccd9 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -129,6 +129,11 @@ private: Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); + bool isCardNameAllowed(const QString &cardName); + Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &); + Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &); + Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &); + Response::ResponseCode cmdListCardArtRules(const Command_ListCardArtRules &, ResponseContainer &rc); Response::ResponseCode cmdAccountPassword(const Command_AccountPassword &cmd, ResponseContainer &rc); Response::ResponseCode cmdGrantReplayAccess(const Command_GrantReplayAccess &cmd, ResponseContainer &rc); Response::ResponseCode cmdForceActivateUser(const Command_ForceActivateUser &cmd, ResponseContainer &rc);