From 7b8f57613a3a54d534380d7e8589b71dd83b3c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 05:29:12 +0100 Subject: [PATCH 1/8] Fix remaining review threads on the report queue dashboard - Read the raw report status from Qt::UserRole in DlgMyReports, so regular users can comment on their own open reports again. - Exclude the server's depth-0 seed row from the related-alt count. - Dim only IP matches shared by more than the pair, not every IP match. - Activate alts rows on double-click or Enter instead of `activated`, which fires on a single click on single-click platforms. - Fold the reported-user bolding pass into the fill loop. --- cockatrice/CMakeLists.txt | 2 ++ .../widgets/dialogs/dlg_my_reports.cpp | 3 +- .../src/interface/widgets/tabs/tab_alts.cpp | 5 +-- .../interface/widgets/tabs/tab_moderation.cpp | 12 +++++-- .../src/interface/widgets/tabs/tab_report.cpp | 7 ++-- .../widgets/utility/table_activation.cpp | 28 ++++++++++++++++ .../widgets/utility/table_activation.h | 32 +++++++++++++++++++ 7 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 cockatrice/src/interface/widgets/utility/table_activation.cpp create mode 100644 cockatrice/src/interface/widgets/utility/table_activation.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 4707c3f32..bafee1b81 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -289,6 +289,8 @@ set(cockatrice_SOURCES src/interface/widgets/utility/get_text_with_max.cpp src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h + src/interface/widgets/utility/table_activation.cpp + src/interface/widgets/utility/table_activation.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 2a2758ba5..d5c4b62be 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -223,7 +223,8 @@ void DlgMyReports::onSelectionChanged() connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); client->sendCommand(pend); - QString status = table->item(row, COL_STATUS)->text(); + // The cell text is the translated label; the raw status lives in UserRole. + const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString(); bool canComment = (status == "open" || status == "assigned"); commentButton->setEnabled(canComment); commentInput->setEnabled(canComment); diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index ec32bf60a..da08a0b56 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -1,6 +1,7 @@ #include "tab_alts.h" #include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" @@ -135,7 +136,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); - connect(altsTable, &QTableWidget::activated, this, [this](const QModelIndex &index) { + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; } @@ -393,7 +394,7 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && - alt.shared_count() > 1) { + alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { if (auto *item = altsTable->item(row, col)) { item->setForeground(dimColor); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 747eb7600..8568b0d2e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,6 +1,7 @@ #include "tab_moderation.h" #include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" @@ -176,7 +177,7 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); - connect(altsTable, &QTableWidget::activated, this, [this](const QModelIndex &index) { + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; } @@ -456,7 +457,14 @@ void TabModeration::altsResponse(const Response &response) } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); - cachedAltsCount = resp.alts_size(); + // The server always includes the frontier user as a depth-0 seed row. + int relatedCount = 0; + for (int i = 0; i < resp.alts_size(); ++i) { + if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) { + ++relatedCount; + } + } + cachedAltsCount = relatedCount; applyAltsCount(); altsTable->setRowCount(resp.alts_size()); for (int i = 0; i < resp.alts_size(); ++i) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index ea2f47902..0dda1ed66 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -463,6 +463,9 @@ void TabReport::applyFilters() table->setSortingEnabled(false); table->setRowCount(filteredReports.size()); + QFont reportedFont = table->font(); + reportedFont.setBold(true); + for (int row = 0; row < filteredReports.size(); ++row) { const ServerInfo_Report &r = filteredReports[row]; @@ -476,11 +479,7 @@ void TabReport::applyFilters() table->setItem(row, COL_ROOM, new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); - } - QFont reportedFont = table->font(); - reportedFont.setBold(true); - for (int row = 0; row < filteredReports.size(); ++row) { if (auto *reportedItem = table->item(row, COL_REPORTED)) { reportedItem->setFont(reportedFont); } diff --git a/cockatrice/src/interface/widgets/utility/table_activation.cpp b/cockatrice/src/interface/widgets/utility/table_activation.cpp new file mode 100644 index 000000000..5633b9f58 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.cpp @@ -0,0 +1,28 @@ +#include "table_activation.h" + +#include +#include +#include + +namespace +{ +void addActivationShortcut(QTableWidget *table, + QObject *context, + int key, + const std::function &handler) +{ + auto *shortcut = new QShortcut(QKeySequence(key), table); + // The table's viewport takes focus, so match children as well. + shortcut->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); }); +} +} // namespace + +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler) +{ + QObject::connect(table, &QTableWidget::doubleClicked, context, handler); + addActivationShortcut(table, context, Qt::Key_Return, handler); + addActivationShortcut(table, context, Qt::Key_Enter, handler); +} diff --git a/cockatrice/src/interface/widgets/utility/table_activation.h b/cockatrice/src/interface/widgets/utility/table_activation.h new file mode 100644 index 000000000..f5038bdb0 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.h @@ -0,0 +1,32 @@ +/** + * @file table_activation.h + * @brief Row activation for tables that must not navigate on a single click. + */ + +#ifndef TABLE_ACTIVATION_H +#define TABLE_ACTIVATION_H + +#include + +class QModelIndex; +class QObject; +class QTableWidget; + +/** + * @brief Call @p handler on double-click or Enter, never on a single click. + * + * QAbstractItemView::activated fires on a single click on platforms where + * SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not + * use it. This connects doubleClicked and installs Return/Enter shortcuts that + * forward the current index. + * + * @param table The table whose rows activate. + * @param context Object that owns the connections; the handler is disconnected + * with it. + * @param handler Receives the activated row's index, which may be invalid. + */ +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler); + +#endif // TABLE_ACTIVATION_H From d7b27861841777875762677f3bfb7ecefa168eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 22:09:09 +0200 Subject: [PATCH 2/8] [Client] Alt badges, breadcrumb trail, investigate button AltTableDelegate paints palette-tinted match badges with paint-through selection, style warning/critical icons on nonzero counts, and font/elision handling. TabAlts gains a clickable breadcrumb trail, an Investigate-this-user button, and bold usernames. Async alts responses are guarded against stale investigations. --- cockatrice/CMakeLists.txt | 1 + .../widgets/tabs/alt_table_delegate.cpp | 132 ++++++++++++++++++ .../widgets/tabs/alt_table_delegate.h | 35 +++++ .../src/interface/widgets/tabs/tab_alts.cpp | 60 ++++++++ .../src/interface/widgets/tabs/tab_alts.h | 6 + 5 files changed, 234 insertions(+) create mode 100644 cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/alt_table_delegate.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bafee1b81..bfc9e040a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -378,6 +378,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp + src/interface/widgets/tabs/alt_table_delegate.cpp src/interface/widgets/tabs/report_status_delegate.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp new file mode 100644 index 000000000..aa9ce907c --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -0,0 +1,132 @@ +#include "alt_table_delegate.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +constexpr int PILL_PAD_X = 10; +constexpr int PILL_PAD_Y = 4; +constexpr int ICON_TEXT_GAP = 4; +} // namespace + +AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColumn, int sharedColumn, QObject *parent) + : QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn), + sharedColumn(sharedColumn) +{ +} + +void AltTableDelegate::setVpnThreshold(int threshold) +{ + vpnThreshold = threshold; +} + +void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + const QString text = index.data(Qt::DisplayRole).toString(); + const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + + auto paintPill = [&](QPalette::ColorRole tintRole, QPalette::ColorRole textRole, const QIcon &icon, + const QString &pillText) { + const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + QStyledItemDelegate::paint(painter, opt, index); + return; + } + if (!selected) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = opt.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QRect textRect = pillRect; + if (!icon.isNull()) { + const int iconSize = qMin(pillRect.height(), pillRect.width()); + if (iconSize > 0) { + const QRect iconRect(pillRect.left() + ICON_TEXT_GAP, pillRect.top(), iconSize, iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + } + const QString elided = opt.fontMetrics.elidedText(pillText, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignCenter, opt.palette, true, elided, textRole); + }; + + if (index.column() == viaColumn) { + bool sharedOk = false; + const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); + const bool dimmed = sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > 1; + const bool vpn = + sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > static_cast(vpnThreshold); + const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + if (text == QStringLiteral("email")) { + paintPill(QPalette::Link, emphasisRole, QIcon(), text); + return; + } + if (text.startsWith(QStringLiteral("clientid:"))) { + paintPill(QPalette::Highlight, emphasisRole, QIcon(), text); + return; + } + if (text.startsWith(QStringLiteral("ip:"))) { + if (vpn) { + paintPill(QPalette::Highlight, textRole, style->standardIcon(QStyle::SP_MessageBoxWarning), text); + return; + } + paintPill(QPalette::PlaceholderText, textRole, QIcon(), text); + return; + } + QStyledItemDelegate::paint(painter, opt, index); + return; + } + + if (index.column() == warnsColumn || index.column() == bansColumn) { + bool countOk = false; + const int count = text.toInt(&countOk); + if (countOk && count > 0 && !selected) { + const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical + : QStyle::SP_MessageBoxWarning); + const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP); + QRect textRect = opt.rect; + if (!icon.isNull() && iconSize > 0) { + const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize, + iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided, + emphasisRole); + return; + } + } + + QStyledItemDelegate::paint(painter, opt, index); +} + +QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + if (index.column() == viaColumn) { + return hint + QSize(PILL_PAD_X + 2 * ICON_TEXT_GAP, PILL_PAD_Y); + } + if (index.column() == warnsColumn || index.column() == bansColumn) { + return hint + QSize(2 * ICON_TEXT_GAP, 0); + } + return hint; +} diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h new file mode 100644 index 000000000..7d7af2928 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -0,0 +1,35 @@ +#ifndef ALT_TABLE_DELEGATE_H +#define ALT_TABLE_DELEGATE_H + +#include +#include + +/** + * Badge + icon painting for the User Alts table. The match-evidence column + * renders as a palette-tinted pill per match type (email / client id / IP), + * with the style's warning icon once an IP is shared by more than the VPN + * threshold. Warn/ban counts gain the style's warning/critical icons when + * nonzero. Selected rows and unknown content fall back to default painting. + */ +class AltTableDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + QObject *parent = nullptr); + void setVpnThreshold(int threshold); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + int viaColumn; + int warnsColumn; + int bansColumn; + int sharedColumn; + int vpnThreshold = 10; +}; + +#endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index da08a0b56..3d38a26ff 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -3,6 +3,7 @@ #include "../utility/epoch_format.h" #include "../utility/table_activation.h" #include "abstract_client.h" +#include "alt_table_delegate.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" @@ -102,6 +103,15 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack); statusLabel = new QLabel; + trailLabel = new QLabel; + trailLabel->setTextFormat(Qt::RichText); + trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); + trailLabel->setOpenExternalLinks(false); + connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { + if (!userName.isEmpty() && userName != currentUser) { + investigate(userName); + } + }); auto *topBar = new QHBoxLayout; topBar->addWidget(backButton); @@ -136,6 +146,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); + altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); + altsDelegate->setVpnThreshold(vpnSpin->value()); + connect(vpnSpin, &QSpinBox::valueChanged, altsDelegate, &AltTableDelegate::setVpnThreshold); + altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; @@ -174,6 +190,10 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q detailsLayout->addLayout(detailGrid); detailsLayout->addWidget(warnsTable, 1); detailsLayout->addWidget(bansTable, 1); + investigateSelectedButton = new QPushButton; + investigateSelectedButton->setEnabled(false); + connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected); + detailsLayout->addWidget(investigateSelectedButton); auto *contentSplitter = new QSplitter(Qt::Horizontal); contentSplitter->addWidget(altsTable); @@ -186,6 +206,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q mainLayout->addLayout(topBar); mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); + mainLayout->addWidget(trailLabel); mainLayout->addWidget(contentSplitter, 1); retranslateUi(); @@ -225,6 +246,8 @@ void TabAlts::retranslateUi() detailNameLabel->setText(tr("Name:")); detailCountsLabel->setText(tr("Counts:")); detailNotesLabel->setText(tr("Admin notes:")); + investigateSelectedButton->setText(tr("Investigate this user")); + investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation")); if (!lastAlts.isEmpty()) { renderTable(); @@ -262,16 +285,41 @@ void TabAlts::loadUser(const QString &userName) { currentUser = userName; searchEdit->setText(userName); + updateTrail(); investigateUser(); } +void TabAlts::updateTrail() +{ + QStringList trail = backStack; + trail << currentUser; + trail.removeAll(QString()); + QStringList links; + for (const QString &name : trail) { + const QString safeName = name.toHtmlEscaped(); + if (name == currentUser) { + links << QStringLiteral("") + safeName + QStringLiteral(""); + } else { + links << QStringLiteral("") + safeName + + QStringLiteral(""); + } + } + trailLabel->setText(links.join(QStringLiteral(" › "))); + trailLabel->setVisible(!links.isEmpty()); +} + void TabAlts::investigateUser() { const QString userName = searchEdit->text().simplified(); if (userName.isEmpty()) { return; } + if (userName != currentUser && !currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } currentUser = userName; + updateTrail(); lastAlts.clear(); selectedUser.clear(); clearDetails(); @@ -396,6 +444,9 @@ void TabAlts::renderTable() if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { + if (col == COL_ALTS_VIA) { + continue; + } if (auto *item = altsTable->item(row, col)) { item->setForeground(dimColor); } @@ -446,6 +497,13 @@ void TabAlts::onFilterChanged() renderTable(); } +void TabAlts::investigateSelected() +{ + if (!selectedUser.isEmpty() && selectedUser != currentUser) { + investigate(selectedUser); + } +} + void TabAlts::onAltSelected() { const auto selected = altsTable->selectedItems(); @@ -469,6 +527,7 @@ void TabAlts::requestDetails(const QString &userName) detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(userName != currentUser); Command_ReportUserInfo infoCmd; infoCmd.set_user_name(userName.toStdString()); @@ -508,6 +567,7 @@ void TabAlts::clearDetails() detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(false); } void TabAlts::userDetailsResponse(const Response &response) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 8c0086f25..4ba355447 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -10,6 +10,7 @@ #include class AbstractClient; +class AltTableDelegate; class QGroupBox; class QLabel; class QLineEdit; @@ -41,6 +42,7 @@ public: private slots: void investigateUser(); void goBack(); + void investigateSelected(); void altsResponse(const Response &response); void onFilterChanged(); void onAltSelected(); @@ -50,6 +52,7 @@ private slots: private: void loadUser(const QString &userName); + void updateTrail(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); void clearDetails(); @@ -67,8 +70,11 @@ private: QSpinBox *depthSpin; QPushButton *investigateButton; QPushButton *backButton; + QPushButton *investigateSelectedButton; + QLabel *trailLabel; QLabel *statusLabel; QTableWidget *altsTable; + AltTableDelegate *altsDelegate; QPushButton *emailChip; QPushButton *clientIdChip; From d09f4c5356b94174754b4e79522b676564f0eb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 12:21:16 +0100 Subject: [PATCH 3/8] [Client] Fix alt badge and trail review findings - Drop the dead VPN warning branch: rowVisible() already hides IP rows above the threshold, so setVpnThreshold(), its member and the spin-box connect never took effect. - Dim IP rows at shared_count > 2 instead of > 1: the count already includes the investigated account, so every IP row was dimmed. - Truncate backStack when a breadcrumb is clicked instead of pushing the current user again, so the trail keeps a single path and Back works. - Word-wrap the trail label so a long chain cannot widen the tab. - Extract the status/match pill painting into widgets/utility/pill_painter and use it from both delegates instead of duplicating it. --- cockatrice/CMakeLists.txt | 2 + .../widgets/tabs/alt_table_delegate.cpp | 71 +++++-------------- .../widgets/tabs/alt_table_delegate.h | 10 ++- .../widgets/tabs/report_status_delegate.cpp | 35 ++------- .../src/interface/widgets/tabs/tab_alts.cpp | 23 +++++- .../src/interface/widgets/tabs/tab_alts.h | 1 + .../widgets/utility/pill_painter.cpp | 41 +++++++++++ .../interface/widgets/utility/pill_painter.h | 29 ++++++++ 8 files changed, 120 insertions(+), 92 deletions(-) create mode 100644 cockatrice/src/interface/widgets/utility/pill_painter.cpp create mode 100644 cockatrice/src/interface/widgets/utility/pill_painter.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bfc9e040a..fc593058a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -287,6 +287,8 @@ set(cockatrice_SOURCES src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/pill_painter.cpp + src/interface/widgets/utility/pill_painter.h src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/table_activation.cpp diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp index aa9ce907c..3cc412a7e 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -1,5 +1,7 @@ #include "alt_table_delegate.h" +#include "../utility/pill_painter.h" + #include #include #include @@ -9,9 +11,6 @@ namespace { -constexpr qreal PILL_ALPHA = 0.30; -constexpr int PILL_PAD_X = 10; -constexpr int PILL_PAD_Y = 4; constexpr int ICON_TEXT_GAP = 4; } // namespace @@ -21,11 +20,6 @@ AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColum { } -void AltTableDelegate::setVpnThreshold(int threshold) -{ - vpnThreshold = threshold; -} - void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { const bool selected = (option.state & QStyle::State_Selected) != 0; @@ -37,58 +31,27 @@ void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opti const QString text = index.data(Qt::DisplayRole).toString(); const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; - auto paintPill = [&](QPalette::ColorRole tintRole, QPalette::ColorRole textRole, const QIcon &icon, - const QString &pillText) { - const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); - if (pillRect.width() <= 0 || pillRect.height() <= 0) { - QStyledItemDelegate::paint(painter, opt, index); - return; - } - if (!selected) { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - QColor pill = opt.palette.color(tintRole); - pill.setAlphaF(PILL_ALPHA); - painter->setPen(Qt::NoPen); - painter->setBrush(pill); - painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); - painter->restore(); - } - - QRect textRect = pillRect; - if (!icon.isNull()) { - const int iconSize = qMin(pillRect.height(), pillRect.width()); - if (iconSize > 0) { - const QRect iconRect(pillRect.left() + ICON_TEXT_GAP, pillRect.top(), iconSize, iconSize); - icon.paint(painter, iconRect); - textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); - } - } - const QString elided = opt.fontMetrics.elidedText(pillText, opt.textElideMode, textRect.width()); - style->drawItemText(painter, textRect, Qt::AlignCenter, opt.palette, true, elided, textRole); - }; - if (index.column() == viaColumn) { bool sharedOk = false; const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); - const bool dimmed = sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > 1; - const bool vpn = - sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > static_cast(vpnThreshold); + const bool ipMatch = text.startsWith(QStringLiteral("ip:")); + // shared_count for an IP match already includes the investigated account, + // so anything above 2 means the address is shared beyond that pair. + const bool dimmed = sharedOk && ipMatch && shared > 2; const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + + QPalette::ColorRole tintRole = QPalette::NoRole; if (text == QStringLiteral("email")) { - paintPill(QPalette::Link, emphasisRole, QIcon(), text); - return; + tintRole = QPalette::Link; + } else if (text.startsWith(QStringLiteral("clientid:"))) { + tintRole = QPalette::Highlight; + } else if (ipMatch) { + tintRole = QPalette::PlaceholderText; } - if (text.startsWith(QStringLiteral("clientid:"))) { - paintPill(QPalette::Highlight, emphasisRole, QIcon(), text); - return; - } - if (text.startsWith(QStringLiteral("ip:"))) { - if (vpn) { - paintPill(QPalette::Highlight, textRole, style->standardIcon(QStyle::SP_MessageBoxWarning), text); - return; + if (tintRole != QPalette::NoRole) { + if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); } - paintPill(QPalette::PlaceholderText, textRole, QIcon(), text); return; } QStyledItemDelegate::paint(painter, opt, index); @@ -123,7 +86,7 @@ QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QMode { const QSize hint = QStyledItemDelegate::sizeHint(option, index); if (index.column() == viaColumn) { - return hint + QSize(PILL_PAD_X + 2 * ICON_TEXT_GAP, PILL_PAD_Y); + return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y); } if (index.column() == warnsColumn || index.column() == bansColumn) { return hint + QSize(2 * ICON_TEXT_GAP, 0); diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h index 7d7af2928..73386b00d 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -6,10 +6,10 @@ /** * Badge + icon painting for the User Alts table. The match-evidence column - * renders as a palette-tinted pill per match type (email / client id / IP), - * with the style's warning icon once an IP is shared by more than the VPN - * threshold. Warn/ban counts gain the style's warning/critical icons when - * nonzero. Selected rows and unknown content fall back to default painting. + * renders as a palette-tinted pill per match type (email / client id / IP), and + * an IP shared by more than one other account is dimmed. Warn/ban counts gain + * the style's warning/critical icons when nonzero. Selected rows and unknown + * content fall back to default painting. */ class AltTableDelegate : public QStyledItemDelegate { @@ -20,7 +20,6 @@ public: int bansColumn, int sharedColumn, QObject *parent = nullptr); - void setVpnThreshold(int threshold); void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; @@ -29,7 +28,6 @@ private: int warnsColumn; int bansColumn; int sharedColumn; - int vpnThreshold = 10; }; #endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp index fe59702c4..62e427e0e 100644 --- a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -1,18 +1,13 @@ #include "report_status_delegate.h" +#include "../utility/pill_painter.h" + #include #include #include #include #include -namespace -{ -constexpr qreal PILL_ALPHA = 0.30; -constexpr int PILL_PAD_X = 10; -constexpr int PILL_PAD_Y = 4; -} // namespace - ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent) { } @@ -46,32 +41,14 @@ void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem & return; } - const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); - if (pillRect.width() <= 0 || pillRect.height() <= 0) { - QStyledItemDelegate::paint(painter, opt, index); - return; - } - - if (!selected) { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - QColor pill = opt.palette.color(tintRole); - pill.setAlphaF(PILL_ALPHA); - painter->setPen(Qt::NoPen); - painter->setBrush(pill); - painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); - painter->restore(); - } - - const QString elided = - opt.fontMetrics.elidedText(opt.text.isEmpty() ? status : opt.text, opt.textElideMode, pillRect.width()); const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText; - QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); - style->drawItemText(painter, pillRect, Qt::AlignCenter, opt.palette, true, elided, textRole); + if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) { + QStyledItemDelegate::paint(painter, opt, index); + } } QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { const QSize hint = QStyledItemDelegate::sizeHint(option, index); - return hint + QSize(PILL_PAD_X, PILL_PAD_Y); + return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 3d38a26ff..fb842122d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -107,9 +107,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q trailLabel->setTextFormat(Qt::RichText); trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); trailLabel->setOpenExternalLinks(false); + // The trail grows with every hop, so let it wrap instead of forcing the tab + // wider than the window. + trailLabel->setWordWrap(true); connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { if (!userName.isEmpty() && userName != currentUser) { - investigate(userName); + goToTrailEntry(userName); } }); @@ -147,8 +150,6 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); - altsDelegate->setVpnThreshold(vpnSpin->value()); - connect(vpnSpin, &QSpinBox::valueChanged, altsDelegate, &AltTableDelegate::setVpnThreshold); altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); @@ -271,6 +272,20 @@ void TabAlts::investigate(const QString &userName) loadUser(userName); } +void TabAlts::goToTrailEntry(const QString &userName) +{ + const int index = backStack.indexOf(userName); + if (index < 0) { + investigate(userName); + return; + } + // Clicking an earlier crumb truncates the history there instead of pushing + // another entry, so the trail keeps a single path and Back still works. + backStack.erase(backStack.begin() + index, backStack.end()); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(userName); +} + void TabAlts::goBack() { if (backStack.isEmpty()) { @@ -441,6 +456,8 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + // shared_count already includes the investigated account, so anything + // above 2 means the address is shared beyond that pair. if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 4ba355447..50aabf8f5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -52,6 +52,7 @@ private slots: private: void loadUser(const QString &userName); + void goToTrailEntry(const QString &userName); void updateTrail(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.cpp b/cockatrice/src/interface/widgets/utility/pill_painter.cpp new file mode 100644 index 000000000..6f317b951 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.cpp @@ -0,0 +1,41 @@ +#include "pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +} // namespace + +bool pill_painter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text) +{ + const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + return false; + } + + if ((option.state & QStyle::State_Selected) == 0) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = option.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style(); + const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width()); + style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole); + return true; +} diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.h b/cockatrice/src/interface/widgets/utility/pill_painter.h new file mode 100644 index 000000000..72e3fe0b4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.h @@ -0,0 +1,29 @@ +#ifndef PILL_PAINTER_H +#define PILL_PAINTER_H + +#include +#include + +class QPainter; +class QStyleOptionViewItem; + +namespace pill_painter +{ +constexpr int PAD_X = 10; +constexpr int PAD_Y = 4; + +/** + * Draws a translucent, palette-tinted rounded pill inside the option's rect and + * paints the text elided and centred on it, so badges follow the active theme. + * Returns false when the cell is too small to hold a pill, leaving the caller to + * fall back to default painting. + */ +bool paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text); + +} // namespace pill_painter + +#endif // PILL_PAINTER_H From c836d8b10dab0008feb5c29cf2b6539529f9de52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 2 Nov 2026 10:13:31 +0100 Subject: [PATCH 4/8] [Client] Route investigate() through the trail helper --- cockatrice/src/interface/widgets/tabs/tab_alts.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index fb842122d..833584a39 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -265,6 +265,13 @@ void TabAlts::investigate(const QString &userName) if (userName.isEmpty() || userName == currentUser) { return; } + // Alt matches are symmetric, so revisiting an earlier account is a normal + // double-click. Truncate there instead of pushing it again, so the trail + // stays a single path. + if (backStack.contains(userName)) { + goToTrailEntry(userName); + return; + } if (!currentUser.isEmpty()) { backStack << currentUser; backButton->setEnabled(true); From a82d8f30216e2fa70a1dd149de5375a513fe9e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 22:31:50 +0200 Subject: [PATCH 5/8] [Alts] Server-side search controls, IP counts, adjustable window Plumbs the match-type chips through to the server: include_email/include_clientid/include_ip skip disabled identifier types in both matching and frontier expansion, so excluded types (e.g. throwaway domains) never enter the graph at any depth. Session lookback becomes adjustable (session_months, clamped 1-24, default 6). Each alt row carries ip_count, the distinct recent session IP count shown in a new IPs column. All proto additions are optional with old defaults, so old clients/servers interoperate. --- .../src/interface/widgets/tabs/tab_alts.cpp | 52 ++++++-- .../src/interface/widgets/tabs/tab_alts.h | 5 +- .../protocol/pb/moderator_commands.proto | 4 + .../protocol/pb/serverinfo_user_alt.proto | 1 + .../src/servatrice_database_interface.cpp | 114 +++++++++++++----- .../src/servatrice_database_interface.h | 7 +- servatrice/src/serversocketinterface.cpp | 4 +- 7 files changed, 142 insertions(+), 45 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 833584a39..b13f67de4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -42,14 +42,15 @@ constexpr int COL_ALTS_USER = 0; constexpr int COL_ALTS_VIA = 1; constexpr int COL_ALTS_DEPTH = 2; constexpr int COL_ALTS_SHARED = 3; -constexpr int COL_ALTS_EMAIL = 4; -constexpr int COL_ALTS_CLIENTID = 5; -constexpr int COL_ALTS_REGISTERED = 6; -constexpr int COL_ALTS_LAST_LOGIN = 7; -constexpr int COL_ALTS_WARNS = 8; -constexpr int COL_ALTS_BANS = 9; -constexpr int COL_ALTS_ACTIVE = 10; -constexpr int COL_ALTS_COUNT = 11; +constexpr int COL_ALTS_IPS = 4; +constexpr int COL_ALTS_EMAIL = 5; +constexpr int COL_ALTS_CLIENTID = 6; +constexpr int COL_ALTS_REGISTERED = 7; +constexpr int COL_ALTS_LAST_LOGIN = 8; +constexpr int COL_ALTS_WARNS = 9; +constexpr int COL_ALTS_BANS = 10; +constexpr int COL_ALTS_ACTIVE = 11; +constexpr int COL_ALTS_COUNT = 12; constexpr int COL_WARNS_TIME = 0; constexpr int COL_WARNS_MODERATOR = 1; @@ -94,6 +95,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q depthSpin->setMinimum(1); depthSpin->setMaximum(MAX_ALT_DEPTH); depthSpin->setValue(1); + connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); investigateButton = new QPushButton; connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); @@ -126,7 +128,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q auto *chip = new QPushButton(text); chip->setCheckable(true); chip->setChecked(true); - connect(chip, &QPushButton::toggled, this, &TabAlts::onFilterChanged); + connect(chip, &QPushButton::toggled, this, &TabAlts::onSearchFilterChanged); return chip; }; emailChip = makeChip(QString()); @@ -137,7 +139,13 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q vpnSpin->setMinimum(1); vpnSpin->setMaximum(100000); vpnSpin->setValue(DEFAULT_VPN_THRESHOLD); - connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged); + connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onDisplayFilterChanged); + windowLabel = new QLabel; + monthsSpin = new QSpinBox; + monthsSpin->setMinimum(1); + monthsSpin->setMaximum(24); + monthsSpin->setValue(6); + connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); auto *filterBar = new QHBoxLayout; filterBar->addWidget(emailChip); @@ -145,6 +153,8 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q filterBar->addWidget(ipChip); filterBar->addWidget(vpnLabel); filterBar->addWidget(vpnSpin); + filterBar->addWidget(windowLabel); + filterBar->addWidget(monthsSpin); filterBar->addStretch(); altsTable = new QTableWidget(0, COL_ALTS_COUNT); @@ -235,8 +245,11 @@ void TabAlts::retranslateUi() ipChip->setToolTip(tr("Show or hide IP address matches")); vpnLabel->setText(tr("Hide IPs shared by more than")); vpnSpin->setSuffix(tr(" users")); + windowLabel->setText(tr("Session window")); + monthsSpin->setSuffix(tr(" months")); + monthsSpin->setToolTip(tr("How far back session IPs and client IDs are considered.")); - altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("IPs"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account")); @@ -355,6 +368,10 @@ void TabAlts::requestAlts(const QString &userName, int depth) Command_GetUserAlts cmd; cmd.set_user_name(userName.toStdString()); cmd.set_depth(static_cast(depth)); + cmd.set_include_email(emailChip->isChecked()); + cmd.set_include_clientid(clientIdChip->isChecked()); + cmd.set_include_ip(ipChip->isChecked()); + cmd.set_session_months(static_cast(monthsSpin->value())); PendingCommand *pend = client->prepareModeratorCommand(cmd); connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { if (userName == currentUser) { @@ -456,6 +473,9 @@ void TabAlts::renderTable() altsTable->setItem( row, COL_ALTS_SHARED, new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); + altsTable->setItem( + row, COL_ALTS_IPS, + new QTableWidgetItem(alt.has_ip_count() ? QString::number(alt.ip_count()) : QStringLiteral("-"))); altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); @@ -513,7 +533,15 @@ void TabAlts::renderTable() } } -void TabAlts::onFilterChanged() +void TabAlts::onSearchFilterChanged() +{ + if (currentUser.isEmpty()) { + return; + } + investigateUser(); +} + +void TabAlts::onDisplayFilterChanged() { if (lastAlts.isEmpty()) { return; diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 50aabf8f5..b0b241bf6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -44,7 +44,8 @@ private slots: void goBack(); void investigateSelected(); void altsResponse(const Response &response); - void onFilterChanged(); + void onSearchFilterChanged(); + void onDisplayFilterChanged(); void onAltSelected(); void userDetailsResponse(const Response &response); void warnHistoryResponse(const Response &response); @@ -82,6 +83,8 @@ private: QPushButton *ipChip; QLabel *vpnLabel; QSpinBox *vpnSpin; + QLabel *windowLabel; + QSpinBox *monthsSpin; QLabel *detailNameValue; QLabel *detailCountsValue; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 19aec92c3..d03f93667 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -170,6 +170,10 @@ message Command_GetUserAlts { } optional string user_name = 1; optional uint32 depth = 2 [default = 1]; // BFS depth: 1 = direct matches only, up to 3 for transitive alts-of-alts + optional bool include_email = 3 [default = true]; // match and expand over registration email + optional bool include_clientid = 4 [default = true]; // match and expand over current and historic client ids + optional bool include_ip = 5 [default = true]; // match and expand over recent session IPs + optional uint32 session_months = 6 [default = 6]; // session lookback window in months, 1-24 } message Command_GetModeratorLastLogins { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto index ce5e4fce2..c9ec9aa9a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -17,4 +17,5 @@ message ServerInfo_UserAlt { optional uint32 depth = 10; // BFS distance from investigated account; 1 = direct, 2+ = alt-of-alt optional uint32 shared_count = 11; // distinct users sharing the matched identifier; high values suggest VPN/shared network + optional uint32 ip_count = 12; // distinct recent session IPs of this account in the search window } diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index e25ae5e6c..661a1b254 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1987,6 +1987,8 @@ constexpr int MAX_USER_ALTS_PER_LEVEL = 200; // Identifier-heavy frontiers, e.g. VPN users with hundreds of recent IPs, are // truncated: this tool is deliberately a first-pass sweep, not an audit. constexpr int MAX_USER_ALT_IDENTIFIERS = 200; +constexpr int MIN_USER_ALT_MONTHS = 1; +constexpr int MAX_USER_ALT_MONTHS = 24; QString altInClause(const QString &prefix, const QList &values) { @@ -2037,7 +2039,12 @@ QList cappedIdentifiers(const QList &values) } } // namespace -QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, int depth) +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, + int depth, + bool useEmail, + bool useClientId, + bool useIp, + int sessionMonths) { QList results; @@ -2050,6 +2057,16 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin } else if (depth > MAX_USER_ALT_DEPTH) { depth = MAX_USER_ALT_DEPTH; } + if (sessionMonths < MIN_USER_ALT_MONTHS) { + sessionMonths = MIN_USER_ALT_MONTHS; + } else if (sessionMonths > MAX_USER_ALT_MONTHS) { + sessionMonths = MAX_USER_ALT_MONTHS; + } + + // Interpolated, not bound: placeholders are not allowed in INTERVAL + // expressions, and the clamped integer cannot inject SQL. + const QString sessionWindow = QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL ") + + QString::number(sessionMonths) + QStringLiteral(" MONTH)"); // Seed must exist; its row anchors the result at depth 0. QSqlQuery *seedQuery = prepareQuery("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " @@ -2122,32 +2139,46 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin QSet clientIdKeys; QSet ipKeys; - QSqlQuery frontierQuery(sqlDatabase); - prepareDynamicQuery(frontierQuery, QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + - frontierClause); - altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); - if (execSqlQuery(&frontierQuery)) { - while (frontierQuery.next()) { - // Email comparison is case-insensitive in SQL, so normalise the - // keys too or the membership tests below miss real matches. - appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); - appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + if (useEmail || useClientId) { + QSqlQuery frontierQuery(sqlDatabase); + prepareDynamicQuery(frontierQuery, + QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + + frontierClause); + altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&frontierQuery)) { + while (frontierQuery.next()) { + // Email comparison is case-insensitive in SQL, so normalise the + // keys too or the membership tests below miss real matches. + if (useEmail) { + appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + } + } } } // Recent session identifiers: IPs plus historic client ids, most recent // first so a capped list keeps what the account actually used lately. - QSqlQuery sessionQuery(sqlDatabase); - prepareDynamicQuery(sessionQuery, QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " - "FROM {prefix}_sessions WHERE user_name IN ") + - frontierClause + - QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " - "GROUP BY ip_address, clientid ORDER BY last_seen DESC")); - altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); - if (execSqlQuery(&sessionQuery)) { - while (sessionQuery.next()) { - appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); - appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + if (useIp || useClientId) { + QSqlQuery sessionQuery(sqlDatabase); + prepareDynamicQuery(sessionQuery, + QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " + "FROM {prefix}_sessions WHERE user_name IN ") + + frontierClause + sessionWindow + + QStringLiteral(" GROUP BY ip_address, clientid " + "ORDER BY last_seen DESC")); + altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&sessionQuery)) { + while (sessionQuery.next()) { + if (useIp) { + appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + } + } } } @@ -2179,14 +2210,13 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin if (!ipList.isEmpty()) { conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " "s.ip_address IN ") + - altInClause(QStringLiteral(":alt_ip"), ipList) + - QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"); + altInClause(QStringLiteral(":alt_ip"), ipList) + sessionWindow + QStringLiteral(")"); } if (!clientIdList.isEmpty()) { conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " "s.clientid IN ") + - altInClause(QStringLiteral(":alt_hcid"), clientIdList) + - QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"); + altInClause(QStringLiteral(":alt_hcid"), clientIdList) + sessionWindow + + QStringLiteral(")"); } candidateString += conditions.join(QStringLiteral(" OR ")); // Already-visited accounts match their own identifiers at every level and @@ -2251,7 +2281,7 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin altInClause(QStringLiteral(":alt_ecid"), clientIdList); } evidenceString += evidenceConditions.join(QStringLiteral(" OR ")); - evidenceString += QStringLiteral(") AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)"); + evidenceString += QStringLiteral(")") + sessionWindow; QSqlQuery evidenceQuery(sqlDatabase); prepareDynamicQuery(evidenceQuery, evidenceString); @@ -2346,9 +2376,8 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin prepareDynamicQuery(countQuery, QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions " "WHERE ip_address IN ") + - altInClause(QStringLiteral(":alt_ci"), neededIpList) + - QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " - "GROUP BY ip_address")); + altInClause(QStringLiteral(":alt_ci"), neededIpList) + sessionWindow + + QStringLiteral(" GROUP BY ip_address")); altBindValues(&countQuery, QStringLiteral(":alt_ci"), neededIpList); if (execSqlQuery(&countQuery)) { while (countQuery.next()) { @@ -2385,6 +2414,31 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin frontier = nextFrontier; } + // Distinct recent IP counts per account: cheap group signal without + // shipping full address histories to the client. + if (!results.isEmpty()) { + QList resultNames; + resultNames.reserve(results.size()); + for (const ServerInfo_UserAlt &alt : results) { + resultNames << QString::fromStdString(alt.user_name()); + } + QSqlQuery *ipCountQuery = prepareQuery( + QStringLiteral("SELECT user_name, COUNT(DISTINCT ip_address) FROM {prefix}_sessions WHERE user_name IN ") + + altInClause(QStringLiteral(":alt_ic"), resultNames) + sessionWindow + + QStringLiteral(" GROUP BY user_name")); + altBindValues(ipCountQuery, QStringLiteral(":alt_ic"), resultNames); + QMap ipCountsByUser; + if (execSqlQuery(ipCountQuery)) { + while (ipCountQuery->next()) { + ipCountsByUser.insert(ipCountQuery->value(0).toString(), ipCountQuery->value(1).toInt()); + } + } + for (ServerInfo_UserAlt &alt : results) { + alt.set_ip_count(static_cast( + ipCountsByUser.value(QString::fromStdString(alt.user_name()), 0))); + } + } + return results; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 327906b3c..c3cc57ab2 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -224,7 +224,12 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); - QList getUserAlts(const QString &userName, int depth = 1); + QList getUserAlts(const QString &userName, + int depth = 1, + bool useEmail = true, + bool useClientId = true, + bool useIp = true, + int sessionMonths = 6); QList getModeratorLastLogins(); // Uptime snapshot as recorded by Servatrice::statusUpdate() into the diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index b49b94ec0..c32ccad49 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2596,7 +2596,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Comma Response_UserAlts *re = new Response_UserAlts; const int depth = cmd.has_depth() ? static_cast(cmd.depth()) : 1; - const QList alts = sqlInterface->getUserAlts(userName, depth); + const int sessionMonths = cmd.has_session_months() ? static_cast(cmd.session_months()) : 6; + const QList alts = sqlInterface->getUserAlts( + userName, depth, cmd.include_email(), cmd.include_clientid(), cmd.include_ip(), sessionMonths); for (const ServerInfo_UserAlt &alt : alts) { re->add_alts()->CopyFrom(alt); } From a1a5aae467867804f719aa6eea150008e600a314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 06:02:50 +0100 Subject: [PATCH 6/8] Fix remaining review threads on the alts search controls - Disable keyboard tracking on the depth and window spin boxes so typing a two-digit value sends one search, not one per digit. - Reload the user under investigation on a filter change instead of reading the search box, which could switch to a name never submitted. - Keep a chip's last match count while it is unchecked; the server omits unchecked match types, so the label used to drop to "(0)". --- .../src/interface/widgets/tabs/tab_alts.cpp | 27 +++++++++++++++---- .../src/interface/widgets/tabs/tab_alts.h | 1 + 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index b13f67de4..d6b13b40e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -95,6 +95,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q depthSpin->setMinimum(1); depthSpin->setMaximum(MAX_ALT_DEPTH); depthSpin->setValue(1); + depthSpin->setKeyboardTracking(false); connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); investigateButton = new QPushButton; @@ -145,6 +146,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q monthsSpin->setMinimum(1); monthsSpin->setMaximum(24); monthsSpin->setValue(6); + monthsSpin->setKeyboardTracking(false); connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); auto *filterBar = new QHBoxLayout; @@ -355,12 +357,17 @@ void TabAlts::investigateUser() } currentUser = userName; updateTrail(); + reloadCurrentUser(); +} + +void TabAlts::reloadCurrentUser() +{ lastAlts.clear(); selectedUser.clear(); clearDetails(); altsTable->setRowCount(0); statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); - requestAlts(userName, depthSpin->value()); + requestAlts(currentUser, depthSpin->value()); } void TabAlts::requestAlts(const QString &userName, int depth) @@ -445,9 +452,17 @@ void TabAlts::renderTable() ++ipCount; } } - emailChip->setText(tr("Email (%1)").arg(emailCount)); - clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount)); - ipChip->setText(tr("IP (%1)").arg(ipCount)); + // The server omits unchecked match types, so keep the last count while a + // chip is off instead of relabelling it "(0)". + if (emailChip->isChecked()) { + emailChip->setText(tr("Email (%1)").arg(emailCount)); + } + if (clientIdChip->isChecked()) { + clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount)); + } + if (ipChip->isChecked()) { + ipChip->setText(tr("IP (%1)").arg(ipCount)); + } QFont userFont = altsTable->font(); userFont.setBold(true); @@ -538,7 +553,9 @@ void TabAlts::onSearchFilterChanged() if (currentUser.isEmpty()) { return; } - investigateUser(); + // A filter change reloads the user being investigated; it must not switch + // to a name that was typed into the search box but never submitted. + reloadCurrentUser(); } void TabAlts::onDisplayFilterChanged() diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index b0b241bf6..7f6dd4f42 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -55,6 +55,7 @@ private: void loadUser(const QString &userName); void goToTrailEntry(const QString &userName); void updateTrail(); + void reloadCurrentUser(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); void clearDetails(); From b4acbcc0cf65d3e54ba87723c083de491f67275f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 22:55:41 +0200 Subject: [PATCH 7/8] [Client] Active pill for alts table, dismissed pill for report queue --- .../widgets/tabs/alt_table_delegate.cpp | 22 ++++++++++++++++--- .../widgets/tabs/alt_table_delegate.h | 8 +++++-- .../widgets/tabs/report_status_delegate.cpp | 2 +- .../widgets/tabs/report_status_delegate.h | 6 ++--- .../src/interface/widgets/tabs/tab_alts.cpp | 8 +++++-- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp index 3cc412a7e..b011925ce 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -14,9 +14,14 @@ namespace constexpr int ICON_TEXT_GAP = 4; } // namespace -AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColumn, int sharedColumn, QObject *parent) +AltTableDelegate::AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + int activeColumn, + QObject *parent) : QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn), - sharedColumn(sharedColumn) + sharedColumn(sharedColumn), activeColumn(activeColumn) { } @@ -79,13 +84,24 @@ void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opti } } + if (index.column() == activeColumn) { + const QVariant activeData = index.data(Qt::UserRole); + const bool active = + activeData.isValid() ? activeData.toBool() : text.compare(QStringLiteral("yes"), Qt::CaseInsensitive) == 0; + const QPalette::ColorRole tintRole = active ? QPalette::Highlight : QPalette::PlaceholderText; + if (!pill_painter::paint(painter, opt, tintRole, emphasisRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); + } + return; + } + QStyledItemDelegate::paint(painter, opt, index); } QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { const QSize hint = QStyledItemDelegate::sizeHint(option, index); - if (index.column() == viaColumn) { + if (index.column() == viaColumn || index.column() == activeColumn) { return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y); } if (index.column() == warnsColumn || index.column() == bansColumn) { diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h index 73386b00d..6cd5c7fad 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -8,8 +8,10 @@ * Badge + icon painting for the User Alts table. The match-evidence column * renders as a palette-tinted pill per match type (email / client id / IP), and * an IP shared by more than one other account is dimmed. Warn/ban counts gain - * the style's warning/critical icons when nonzero. Selected rows and unknown - * content fall back to default painting. + * the style's warning/critical icons when nonzero. The active column renders as + * a palette-tinted pill (Highlight when active, PlaceholderText when inactive, + * from Qt::UserRole). Selected rows and unknown content fall back to default + * painting. */ class AltTableDelegate : public QStyledItemDelegate { @@ -19,6 +21,7 @@ public: int warnsColumn, int bansColumn, int sharedColumn, + int activeColumn, QObject *parent = nullptr); void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; @@ -28,6 +31,7 @@ private: int warnsColumn; int bansColumn; int sharedColumn; + int activeColumn; }; #endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp index 62e427e0e..361e89605 100644 --- a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -25,7 +25,7 @@ void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem & tintRole = QPalette::Highlight; } else if (status == QStringLiteral("assigned")) { tintRole = QPalette::Link; - } else if (status == QStringLiteral("resolved")) { + } else if (status == QStringLiteral("resolved") || status == QStringLiteral("dismissed")) { tintRole = QPalette::PlaceholderText; } diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.h b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h index 5c168bcd6..f01f6d3e3 100644 --- a/cockatrice/src/interface/widgets/tabs/report_status_delegate.h +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h @@ -6,9 +6,9 @@ /** * Status pill for the report queue table. Paints the status text on a - * translucent badge tinted with a palette role per status, so the colors - * always follow the active theme. Falls back to default painting for - * selected rows and unknown statuses. + * translucent badge tinted with a palette role per status (open, assigned, + * resolved, dismissed), so the colors always follow the active theme. + * Falls back to default painting for selected rows and unknown statuses. */ class ReportStatusDelegate : public QStyledItemDelegate { diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index d6b13b40e..d2cec3855 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -161,10 +161,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); - altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); + altsDelegate = + new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, COL_ALTS_ACTIVE, altsTable); altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_ACTIVE, altsDelegate); connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; @@ -497,7 +499,9 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); - altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + auto *activeItem = new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")); + activeItem->setData(Qt::UserRole, alt.is_active()); + altsTable->setItem(row, COL_ALTS_ACTIVE, activeItem); // shared_count already includes the investigated account, so anything // above 2 means the address is shared beyond that pair. if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && From f88f6b6a172864eeec0d89d0c09921276eb183b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 22:56:03 +0200 Subject: [PATCH 8/8] [Client] Flat moderation headers and detail tab shape --- cockatrice/CMakeLists.txt | 2 + .../widgets/tabs/moderation_header_style.cpp | 56 +++++++++++++++++++ .../widgets/tabs/moderation_header_style.h | 25 +++++++++ .../widgets/tabs/moderation_tab_style.cpp | 46 +++++++++++++++ .../widgets/tabs/moderation_tab_style.h | 25 +++++++++ .../src/interface/widgets/tabs/tab_alts.cpp | 2 + .../src/interface/widgets/tabs/tab_report.cpp | 4 ++ 7 files changed, 160 insertions(+) create mode 100644 cockatrice/src/interface/widgets/tabs/moderation_header_style.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/moderation_header_style.h create mode 100644 cockatrice/src/interface/widgets/tabs/moderation_tab_style.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/moderation_tab_style.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index fc593058a..12fd31765 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -381,6 +381,8 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp src/interface/widgets/tabs/alt_table_delegate.cpp + src/interface/widgets/tabs/moderation_header_style.cpp + src/interface/widgets/tabs/moderation_tab_style.cpp src/interface/widgets/tabs/report_status_delegate.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp diff --git a/cockatrice/src/interface/widgets/tabs/moderation_header_style.cpp b/cockatrice/src/interface/widgets/tabs/moderation_header_style.cpp new file mode 100644 index 000000000..1b9aadb6f --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/moderation_header_style.cpp @@ -0,0 +1,56 @@ +#include "moderation_header_style.h" + +#include +#include +#include +#include + +namespace +{ +constexpr qreal HOVER_WASH_ALPHA = 0.12; +} // namespace + +ModerationHeaderStyle::ModerationHeaderStyle(QStyle *base) : QProxyStyle(base) +{ +} + +void ModerationHeaderStyle::drawControl(ControlElement element, + const QStyleOption *option, + QPainter *painter, + const QWidget *widget) const +{ + if (element != CE_Header || option == nullptr || painter == nullptr) { + QProxyStyle::drawControl(element, option, painter, widget); + return; + } + + const auto *headerOpt = qstyleoption_cast(option); + if (headerOpt == nullptr || !headerOpt->icon.isNull()) { + QProxyStyle::drawControl(element, option, painter, widget); + return; + } + + painter->save(); + painter->fillRect(headerOpt->rect, headerOpt->palette.window()); + + const bool enabled = (headerOpt->state & State_Enabled) != 0; + if (enabled && (headerOpt->state & State_MouseOver) != 0) { + QColor wash = headerOpt->palette.color(QPalette::Highlight); + wash.setAlphaF(HOVER_WASH_ALPHA); + painter->fillRect(headerOpt->rect, wash); + } + + painter->setPen(headerOpt->palette.color(QPalette::Mid)); + painter->drawLine(headerOpt->rect.bottomLeft(), headerOpt->rect.bottomRight()); + painter->restore(); + + QRect textRect = headerOpt->rect.adjusted(4, 0, -4, 0); + const QString elided = headerOpt->fontMetrics.elidedText(headerOpt->text, Qt::ElideRight, textRect.width()); + QStyle *style = widget != nullptr ? widget->style() : QApplication::style(); + style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, headerOpt->palette, enabled, elided, + QPalette::WindowText); + + if (headerOpt->sortIndicator != QStyleOptionHeader::None) { + QProxyStyle::drawPrimitive(PE_IndicatorHeaderArrow, option, painter, widget); + } +} diff --git a/cockatrice/src/interface/widgets/tabs/moderation_header_style.h b/cockatrice/src/interface/widgets/tabs/moderation_header_style.h new file mode 100644 index 000000000..c9b95c77c --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/moderation_header_style.h @@ -0,0 +1,25 @@ +#ifndef MODERATION_HEADER_STYLE_H +#define MODERATION_HEADER_STYLE_H + +#include +#include + +/** + * Flat header sections for the moderation tables (report queue, user alts). + * Replaces the native gradient header with the palette Window background, a + * 1px Mid bottom rule, and WindowText labels, so headers follow the active + * theme. Sort indicators are still drawn by the base style. Headers with + * icons fall back to the base style untouched. + */ +class ModerationHeaderStyle : public QProxyStyle +{ + Q_OBJECT +public: + explicit ModerationHeaderStyle(QStyle *base = nullptr); + void drawControl(ControlElement element, + const QStyleOption *option, + QPainter *painter, + const QWidget *widget = nullptr) const override; +}; + +#endif // MODERATION_HEADER_STYLE_H diff --git a/cockatrice/src/interface/widgets/tabs/moderation_tab_style.cpp b/cockatrice/src/interface/widgets/tabs/moderation_tab_style.cpp new file mode 100644 index 000000000..0f5cb6444 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/moderation_tab_style.cpp @@ -0,0 +1,46 @@ +#include "moderation_tab_style.h" + +#include +#include + +namespace +{ +constexpr int UNDERLINE_HEIGHT = 2; +constexpr qreal HOVER_WASH_ALPHA = 0.08; +} // namespace + +ModerationTabStyle::ModerationTabStyle(QStyle *base) : QProxyStyle(base) +{ +} + +void ModerationTabStyle::drawControl(ControlElement element, + const QStyleOption *option, + QPainter *painter, + const QWidget *widget) const +{ + if (element != CE_TabBarTabShape || option == nullptr || painter == nullptr) { + QProxyStyle::drawControl(element, option, painter, widget); + return; + } + + const auto *tabOpt = qstyleoption_cast(option); + if (tabOpt == nullptr) { + QProxyStyle::drawControl(element, option, painter, widget); + return; + } + + painter->save(); + painter->fillRect(tabOpt->rect, tabOpt->palette.window()); + + const bool enabled = (tabOpt->state & State_Enabled) != 0; + if ((tabOpt->state & State_Selected) != 0) { + const QRect underline(tabOpt->rect.left(), tabOpt->rect.bottom() - UNDERLINE_HEIGHT + 1, tabOpt->rect.width(), + UNDERLINE_HEIGHT); + painter->fillRect(underline, tabOpt->palette.color(QPalette::Highlight)); + } else if (enabled && (tabOpt->state & State_MouseOver) != 0) { + QColor wash = tabOpt->palette.color(QPalette::Highlight); + wash.setAlphaF(HOVER_WASH_ALPHA); + painter->fillRect(tabOpt->rect, wash); + } + painter->restore(); +} diff --git a/cockatrice/src/interface/widgets/tabs/moderation_tab_style.h b/cockatrice/src/interface/widgets/tabs/moderation_tab_style.h new file mode 100644 index 000000000..57f61e5ed --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/moderation_tab_style.h @@ -0,0 +1,25 @@ +#ifndef MODERATION_TAB_STYLE_H +#define MODERATION_TAB_STYLE_H + +#include +#include + +/** + * Flat tab shapes for the moderation detail tabs (report queue detailTabs). + * Replaces the native tab frame with the palette Window background plus a + * 2px Highlight underline on the selected tab and a faint Highlight wash on + * hover. Labels (text, elide, focus) are left to the base style, so keyboard + * focus stays distinguishable. + */ +class ModerationTabStyle : public QProxyStyle +{ + Q_OBJECT +public: + explicit ModerationTabStyle(QStyle *base = nullptr); + void drawControl(ControlElement element, + const QStyleOption *option, + QPainter *painter, + const QWidget *widget = nullptr) const override; +}; + +#endif // MODERATION_TAB_STYLE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index d2cec3855..a0fc46295 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -6,6 +6,7 @@ #include "alt_table_delegate.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" +#include "moderation_header_style.h" #include #include @@ -78,6 +79,7 @@ void configureAltTable(QTableWidget *table) table->setAlternatingRowColors(true); table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); table->horizontalHeader()->setStretchLastSection(true); + table->horizontalHeader()->setStyle(new ModerationHeaderStyle(table->horizontalHeader()->style())); } } // namespace diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 0dda1ed66..e4ad4acbc 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -4,6 +4,8 @@ #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" +#include "moderation_header_style.h" +#include "moderation_tab_style.h" #include "report_status_delegate.h" #include "tab_supervisor.h" @@ -122,6 +124,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents); + table->horizontalHeader()->setStyle(new ModerationHeaderStyle(table->horizontalHeader()->style())); connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); table->setContextMenuPolicy(Qt::CustomContextMenu); connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu); @@ -238,6 +241,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T }); detailTabs = new QTabWidget; + detailTabs->tabBar()->setStyle(new ModerationTabStyle(detailTabs->tabBar()->style())); detailTabs->addTab(descPage, QString()); detailTabs->addTab(chatPage, QString()); detailTabs->addTab(commentsPage, QString());