[Room][UserList] Introduce style delegate for user list

- Allow users to set a card name and parameters as their background banner
- Allow mods to white/blacklist cards
- Allow toggling back to the old display style

Took 7 minutes

Took 28 seconds

Took 2 minutes

Took 2 minutes
This commit is contained in:
Lukas Brübach 2026-06-07 10:13:07 +02:00
parent bdb0f12f66
commit aff93a4435
35 changed files with 1977 additions and 26 deletions

View file

@ -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

View file

@ -370,6 +370,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();
@ -1037,6 +1038,13 @@ void SettingsCache::setRewindBufferingMs(int _rewindBufferingMs)
settings->setValue("replay/rewindBufferingMs", rewindBufferingMs);
}
void SettingsCache::setStyleUserList(QT_STATE_CHANGED_T _styleUserList)
{
styleUserList = static_cast<bool>(_styleUserList);
settings->setValue("appearance/styleUserList", styleUserList);
emit styleUserListChanged();
}
void SettingsCache::setChatMention(QT_STATE_CHANGED_T _chatMention)
{
chatMention = static_cast<bool>(_chatMention);

View file

@ -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();
@ -283,6 +284,7 @@ private:
bool autoRotateSidewaysLayoutCards;
bool openDeckInNewTab;
int rewindBufferingMs;
bool styleUserList;
bool chatMention;
bool chatMentionCompleter;
QString chatMentionColor;
@ -736,6 +738,10 @@ public:
{
return rewindBufferingMs;
}
[[nodiscard]] bool getStyleUserList() const
{
return styleUserList;
}
[[nodiscard]] bool getChatMention() const
{
return chatMention;
@ -1106,6 +1112,7 @@ public slots:
void setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards);
void setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab);
void setRewindBufferingMs(int _rewindBufferingMs);
void setStyleUserList(Qt::CheckState _styleUserList);
void setChatMention(QT_STATE_CHANGED_T _chatMention);
void setChatMentionCompleter(QT_STATE_CHANGED_T _chatMentionCompleter);
void setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground);

View file

@ -0,0 +1,48 @@
#include "user_avatar_provider.h"
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
UserAvatarProvider::UserAvatarProvider(AbstractClient *client, QObject *parent) : QObject(parent), client(client)
{
}
const QMap<QString, QPixmap> &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<const uchar *>(bmp.data()), static_cast<uint>(bmp.size()))) {
avatarCache.insert(userName, avatar);
} else {
avatarCache.insert(userName, QPixmap());
}
emit avatarUpdated(userName);
});
client->sendCommand(pend);
}

View file

@ -0,0 +1,30 @@
#ifndef COCKATRICE_USER_AVATAR_PROVIDER_H
#define COCKATRICE_USER_AVATAR_PROVIDER_H
#include <QMap>
#include <QObject>
#include <QPixmap>
#include <QSet>
class AbstractClient;
class UserAvatarProvider : public QObject
{
Q_OBJECT
public:
explicit UserAvatarProvider(AbstractClient *client, QObject *parent = nullptr);
void requestAvatar(const QString &userName);
const QMap<QString, QPixmap> &cache() const;
signals:
void avatarUpdated(const QString &userName);
private:
AbstractClient *client;
QMap<QString, QPixmap> avatarCache;
QSet<QString> pending;
};
#endif // COCKATRICE_USER_AVATAR_PROVIDER_H

View file

@ -0,0 +1,143 @@
#include "user_card_art_provider.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include <QPointer>
#include <libcockatrice/card/database/card_database_manager.h>
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<QString, QPixmap> &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;
}
if (queue.isEmpty()) {
return;
}
const QString key = queue.dequeue();
const QStringList parts = key.split(u'|');
if (parts.size() != 2) {
pending.remove(key);
processQueue();
return;
}
const QString userName = parts.at(0);
const QString cardName = parts.at(1);
ExactCard card = CardDatabaseManager::query()->getCard({cardName});
if (!card) {
pending.remove(key);
processQueue();
return;
}
QPixmap fullRes;
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
// If already available, store immediately
if (!fullRes.isNull()) {
insertIntoCache(key, cropCardArt(fullRes));
pending.remove(key);
emit cardArtUpdated(userName);
processQueue();
return;
}
// Otherwise wait for async load
QPointer<UserCardArtProvider> self(this);
connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [self, key, userName, card]() mutable {
if (!self) {
return;
}
disconnect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, self, nullptr);
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);
self->processQueue();
});
}

View file

@ -0,0 +1,39 @@
#ifndef COCKATRICE_USER_CARD_ART_PROVIDER_H
#define COCKATRICE_USER_CARD_ART_PROVIDER_H
#include <QMap>
#include <QObject>
#include <QPixmap>
#include <QQueue>
#include <QSet>
class UserCardArtProvider : public QObject
{
Q_OBJECT
public:
explicit UserCardArtProvider(QObject *parent = nullptr);
void requestCardArt(const QString &userName, const QString &cardName);
const QMap<QString, QPixmap> &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<QString> cacheInsertionOrder; // FIFO eviction
QMap<QString, QPixmap> cardArtCache;
QSet<QString> pending;
QQueue<QString> queue;
void processQueue();
void insertIntoCache(const QString &key, const QPixmap &pixmap);
};
#endif // COCKATRICE_USER_CARD_ART_PROVIDER_H

View file

@ -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 <QCompleter>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QRegularExpression>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
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<void (QCompleter::*)(const QString &)>(&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);
}

View file

@ -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 <QDialog>
#include <QPixmap>
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 &params);
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

View file

@ -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 <QDateTime>
#include <QGridLayout>
@ -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()) {

View file

@ -11,6 +11,7 @@
#include <QLabel>
#include <QPushButton>
#include <QWidget>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/utility/days_years_between.h>
@ -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;
@ -47,6 +50,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);

View file

@ -0,0 +1,342 @@
#include "user_list_painter.h"
#include "../../interface/pixel_map_generator.h"
#include <QAbstractScrollArea>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <QStyle>
#include <QStyleOptionViewItem>
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<const QAbstractScrollArea *>(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<QString, QPixmap> *cardArtCache,
const CardArtParams &params,
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<QString, QPixmap> *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::Badge> UserListPainter::buildBadges(const UserLevelFlags &userLevel, const QString &privLevel)
{
QList<Badge> 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<Badge> &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<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *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<Badge> badges = buildBadges(userLevel, privLevel);
drawBadges(painter, option, rect, cardRight, badges, online);
painter->restore();
}

View file

@ -0,0 +1,86 @@
#ifndef COCKATRICE_USER_LIST_PAINTER_H
#define COCKATRICE_USER_LIST_PAINTER_H
#include "user_level.h"
#include <QColor>
#include <QList>
#include <QMap>
#include <QPixmap>
#include <QRect>
#include <QSize>
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<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap);
static QSize sizeHint();
static void drawCardArt(QPainter *painter,
const QRect &rect,
int cardRight,
const QString &userName,
const QMap<QString, QPixmap> *cardArtCache,
const CardArtParams &params,
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<QString, QPixmap> *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<Badge> buildBadges(const UserLevelFlags &userLevel, const QString &privLevel);
static void drawBadges(QPainter *painter,
const QStyleOptionViewItem &option,
const QRect &rect,
int cardRight,
const QList<Badge> &badges,
bool online);
};
#endif // COCKATRICE_USER_LIST_PAINTER_H

View file

@ -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 <QApplication>
#include <QCheckBox>
@ -15,13 +18,18 @@
#include <QLineEdit>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QWidget>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/trice_limits.h>
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<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *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<ServerInfo_User>(), 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,38 @@ 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);
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 +505,34 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
retranslateUi();
}
void UserListWidget::bind(UserListManager *mgr)
{
manager = mgr;
connect(manager, &UserListManager::listsChanged, this, &UserListWidget::rebuild);
rebuild();
}
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::retranslateUi()
{
userContextMenu->retranslateUi();
@ -461,9 +553,59 @@ void UserListWidget::retranslateUi()
updateCount();
}
void UserListWidget::rebuild()
{
userTree->clear();
users.clear();
cardArtParamsMap.clear();
onlineCount = 0;
if (!manager) {
return;
}
const QMap<QString, ServerInfo_User> *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 +617,27 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
++onlineCount;
}
updateCount();
avatarProvider->requestAvatar(userName);
}
item->setOnline(online);
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 +681,5 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index
void UserListWidget::sortItems()
{
userTree->sortItems(1, Qt::AscendingOrder);
userTree->sortItems(0, Qt::AscendingOrder);
}

View file

@ -7,9 +7,16 @@
#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_list_manager.h"
#include "user_list_painter.h"
#include <QComboBox>
#include <QDialog>
#include <QGroupBox>
#include <QQueue>
#include <QStyledItemDelegate>
#include <QTextEdit>
#include <QTreeWidgetItem>
@ -94,12 +101,21 @@ public:
class UserListItemDelegate : public QStyledItemDelegate
{
const QMap<QString, QPixmap> *avatarCache;
const QMap<QString, QPixmap> *cardArtCache;
const QMap<QString, CardArtParams> *cardArtParamsMap;
public:
explicit UserListItemDelegate(QObject *const parent);
explicit UserListItemDelegate(QObject *const parent,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *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 +147,11 @@ public:
};
private:
UserListManager *manager = nullptr;
UserAvatarProvider *avatarProvider = nullptr;
UserCardArtProvider *cardArtProvider = nullptr;
QMap<QString, CardArtParams> cardArtParamsMap;
QMap<QString, UserListTWI *> users;
TabSupervisor *tabSupervisor;
AbstractClient *client;
@ -155,7 +176,10 @@ public:
AbstractClient *_client,
UserListType _type,
QWidget *parent = nullptr);
void bind(UserListManager *mgr);
void applyDisplayMode();
void retranslateUi();
void rebuild();
void processUserInfo(const ServerInfo_User &user, bool online);
bool deleteUser(const QString &userName);
void setUserOnline(const QString &userName, bool online);

View file

@ -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"));

View file

@ -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;

View file

@ -0,0 +1,246 @@
#include "tab_card_art_rules.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QCompleter>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pending_command.h>
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<int>(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<int>(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<void (QCompleter::*)(const QString &)>(&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();
}

View file

@ -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 <QAbstractTableModel>
#include <QComboBox>
#include <QLineEdit>
#include <QPushButton>
#include <QTableView>
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<Entry> 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

View file

@ -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);

View file

@ -24,6 +24,7 @@
#include <QProxyStyle>
#include <QTabWidget>
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<int, TabRoom *> roomTabs;
QMap<int, TabGame *> 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);
@ -197,6 +200,8 @@ private slots:
void openTabDeckStorage();
void openTabReplays();
void openTabAdmin();
void actTabCardArtRules(bool checked);
void openTabCardArtRules();
void openTabLog();
void updateCurrent(int index);

View file

@ -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);

View file

@ -64,6 +64,7 @@ public:
QString &clientid,
QString &clientVersion,
QString &connectionType);
void broadcastUserInfoUpdate(Server_ProtocolHandler *source);
const QMap<int, Server_Room *> &getRooms()
{

View file

@ -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

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,18 @@
ALTER TABLE `cockatrice_users` ADD COLUMN `card_art_params` TEXT DEFAULT NULL;
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;

View file

@ -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;

View file

@ -7,6 +7,8 @@
#include <QChar>
#include <QDateTime>
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QSqlError>
#include <QSqlQuery>
@ -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)) {

View file

@ -10,7 +10,7 @@
#include <server.h>
#include <server_database_interface.h>
#define DATABASE_SCHEMA_VERSION 34
#define DATABASE_SCHEMA_VERSION 35
class Servatrice;

View file

@ -31,6 +31,8 @@
#include <QDateTime>
#include <QDebug>
#include <QHostAddress>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QRegularExpression>
#include <QSqlError>
@ -59,8 +61,10 @@
#include <libcockatrice/protocol/pb/event_replay_added.pb.h>
#include <libcockatrice/protocol/pb/event_server_identification.pb.h>
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
#include <libcockatrice/protocol/pb/event_user_joined.pb.h>
#include <libcockatrice/protocol/pb/event_user_message.pb.h>
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
@ -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,146 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm
return Response::RespOk;
}
bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName)
{
QSqlQuery *q =
sqlInterface->prepareQuery("SELECT mode FROM cockatrice_card_art_name_rules WHERE card_name = :name");
q->bindValue(":name", cardName);
if (!sqlInterface->execSqlQuery(q)) {
return true; // fail-open to avoid breaking server
}
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.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());
QSqlQuery *q = sqlInterface->prepareQuery("INSERT INTO cockatrice_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 &)
{
QSqlQuery *q = sqlInterface->prepareQuery("DELETE FROM cockatrice_card_art_name_rules WHERE card_name=:name");
q->bindValue(":name", QString::fromStdString(cmd.card_name()));
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 cockatrice_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 */)
{

View file

@ -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);