From a7fa3f0852057e3a692264e00d57314976229c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:01:32 +0200 Subject: [PATCH 01/11] [Protocol] Add alt match evidence and search depth ServerInfo_UserAlt gains match_reason, depth, and shared_count so moderators can see why an account was flagged and how far it sits from the investigated user. Command_GetUserAlts gains a depth parameter (default 1) for transitive alt-of-alt searches. No behavior change: server still answers depth 1 and leaves the new fields empty until the follow-up server/client layers land. --- .../libcockatrice/protocol/pb/moderator_commands.proto | 1 + .../libcockatrice/protocol/pb/serverinfo_user_alt.proto | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 5298d3dad..7963a97f2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -165,6 +165,7 @@ message Command_GetUserAlts { optional Command_GetUserAlts ext = 1014; } 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 } 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 191dd5063..ce5e4fce2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -13,4 +13,8 @@ message ServerInfo_UserAlt { optional uint32 warn_count = 6; // number of warnings on record optional uint32 ban_count = 7; // number of bans on record optional bool is_active = 8; // account is not deactivated/banned + optional string match_reason = 9; // why flagged, e.g. "email", "clientid:abc", "ip:1.2.3.4"; empty = seed/legacy + 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 } From 3113289dbe96e3d07496773bcfb94c5ca7a21196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:08:25 +0200 Subject: [PATCH 02/11] [Server] Recursive alt search with match evidence getUserAlts() now walks a breadth-first graph to the requested depth (clamped 1-3): each level expands frontier accounts over registration email, current client id, recent session IPs (6-month window), and historic session client ids, so identifier changes no longer hide older alts. Every row carries match_reason (email / clientid: / ip:), depth, and shared_count fan-out so moderators can distinguish real alts from VPN/shared-network overlap. Command_GetUserAlts depth is plumbed through; unset depth defaults to 1, preserving previous single-hop behavior for old clients. --- .../src/servatrice_database_interface.cpp | 390 +++++++++++++++--- .../src/servatrice_database_interface.h | 2 +- servatrice/src/serversocketinterface.cpp | 3 +- 3 files changed, 343 insertions(+), 52 deletions(-) diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index ac3a19c8e..83a9df35d 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -1621,7 +1622,58 @@ QList Servatrice_DatabaseInterface::getUserSessions(cons return results; } -QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +namespace +{ +constexpr int MAX_USER_ALT_DEPTH = 3; +constexpr int MAX_USER_ALTS_PER_LEVEL = 200; +// Bounds per-level work and the prepared-statement cache (keyed by query text, +// so every distinct IN-list size is a new entry on the long-lived server). +// 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; + +QString altInClause(const QString &prefix, const QList &values) +{ + QStringList placeholders; + placeholders.reserve(values.size()); + for (int i = 0; i < values.size(); ++i) { + placeholders << prefix + QString::number(i); + } + return QStringLiteral("(") + placeholders.join(QStringLiteral(",")) + QStringLiteral(")"); +} + +void altBindValues(QSqlQuery *query, const QString &prefix, const QList &values) +{ + for (int i = 0; i < values.size(); ++i) { + query->bindValue(prefix + QString::number(i), values.at(i)); + } +} + +struct AltCandidate +{ + QString name; + QString email; + QString clientId; + qint64 registrationTime = 0; + qint64 lastLogin = 0; + bool hasLastLogin = false; + int warnCount = 0; + int banCount = 0; + bool isActive = false; +}; + +QList cappedIdentifiers(QSet values) +{ + QList list = values.values(); + std::sort(list.begin(), list.end()); + if (list.size() > MAX_USER_ALT_IDENTIFIERS) { + list.erase(list.begin() + MAX_USER_ALT_IDENTIFIERS, list.end()); + } + return list; +} +} // namespace + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, int depth) { QList results; @@ -1629,63 +1681,301 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin return results; } - // Seed account identifiers used to find related accounts - QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + if (depth < 1) { + depth = 1; + } else if (depth > MAX_USER_ALT_DEPTH) { + depth = MAX_USER_ALT_DEPTH; + } + + // 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), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE u.name = :user_name"); seedQuery->bindValue(":user_name", userName); if (!execSqlQuery(seedQuery) || !seedQuery->next()) { return results; } - const QString seedEmail = seedQuery->value(0).toString(); - const QString seedClientId = seedQuery->value(1).toString(); - QString queryString = "SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " - "UNIX_TIMESTAMP(a.last_login), " - "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " - "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " - "u.active " - "FROM {prefix}_users u " - "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " - "WHERE u.name = :user_name"; - if (!seedEmail.isEmpty()) { - queryString.append(" OR u.email = :seed_email"); - } - if (!seedClientId.isEmpty()) { - queryString.append(" OR u.clientid = :seed_clientid"); - } - queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s " - "WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 " - "WHERE s2.user_name = :user_name" - " AND s2.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))" - " AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)) " - "ORDER BY u.name LIMIT 200"); - - QSqlQuery *query = prepareQuery(queryString); - query->bindValue(":user_name", userName); - if (!seedEmail.isEmpty()) { - query->bindValue(":seed_email", seedEmail); - } - if (!seedClientId.isEmpty()) { - query->bindValue(":seed_clientid", seedClientId); - } - - if (!execSqlQuery(query)) { - qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; - return results; - } - - while (query->next()) { + auto appendAlt = [&results](const AltCandidate &candidate, const QString &matchReason, int matchDepth, + int sharedCount) { ServerInfo_UserAlt altDetails; - altDetails.set_user_name(query->value(0).toString().toStdString()); - altDetails.set_email(query->value(1).toString().toStdString()); - altDetails.set_clientid(query->value(2).toString().toStdString()); - altDetails.set_registration_time(query->value(3).toLongLong()); - if (!query->value(4).isNull()) { - altDetails.set_last_login(query->value(4).toLongLong()); + altDetails.set_user_name(candidate.name.toStdString()); + altDetails.set_email(candidate.email.toStdString()); + altDetails.set_clientid(candidate.clientId.toStdString()); + altDetails.set_registration_time(candidate.registrationTime); + if (candidate.hasLastLogin) { + altDetails.set_last_login(candidate.lastLogin); } - altDetails.set_warn_count(query->value(5).toInt()); - altDetails.set_ban_count(query->value(6).toInt()); - altDetails.set_is_active(query->value(7).toBool()); + altDetails.set_warn_count(candidate.warnCount); + altDetails.set_ban_count(candidate.banCount); + altDetails.set_is_active(candidate.isActive); + altDetails.set_match_reason(matchReason.toStdString()); + altDetails.set_depth(static_cast(matchDepth)); + altDetails.set_shared_count(static_cast(sharedCount)); results << altDetails; + }; + + AltCandidate seed{.name = seedQuery->value(0).toString(), + .email = seedQuery->value(1).toString(), + .clientId = seedQuery->value(2).toString(), + .registrationTime = seedQuery->value(3).toLongLong(), + .lastLogin = seedQuery->value(4).toLongLong(), + .hasLastLogin = !seedQuery->value(4).isNull(), + .warnCount = seedQuery->value(5).toInt(), + .banCount = seedQuery->value(6).toInt(), + .isActive = seedQuery->value(7).toBool()}; + appendAlt(seed, QStringLiteral("self"), 0, 1); + + QSet visited{userName}; + QList frontier{userName}; + for (int level = 1; level <= depth && !frontier.isEmpty(); ++level) { + const QString frontierClause = altInClause(QStringLiteral(":alt_name"), frontier); + + // Current identifiers of the frontier: registration email + client id. + QSet emails; + QSet clientIds; + QSet ips; + QSqlQuery *frontierQuery = + prepareQuery(QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + frontierClause); + altBindValues(frontierQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(frontierQuery)) { + while (frontierQuery->next()) { + const QString email = frontierQuery->value(0).toString(); + const QString clientId = frontierQuery->value(1).toString(); + if (!email.isEmpty()) { + emails << email; + } + if (!clientId.isEmpty()) { + clientIds << clientId; + } + } + } + + // Recent session identifiers: IPs plus historic client ids. + QSqlQuery *sessionQuery = prepareQuery( + QStringLiteral("SELECT DISTINCT ip_address, clientid FROM {prefix}_sessions WHERE user_name IN ") + + frontierClause + QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)")); + altBindValues(sessionQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(sessionQuery)) { + while (sessionQuery->next()) { + const QString ip = sessionQuery->value(0).toString(); + const QString clientId = sessionQuery->value(1).toString(); + if (!ip.isEmpty()) { + ips << ip; + } + if (!clientId.isEmpty()) { + clientIds << clientId; + } + } + } + + if (emails.isEmpty() && clientIds.isEmpty() && ips.isEmpty()) { + break; + } + + // Accounts sharing any frontier identifier. Historic client ids are matched + // through past sessions so identifier changes do not hide older alts. + QString candidateString = + QStringLiteral("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE "); + QStringList conditions; + QList emailList = cappedIdentifiers(emails); + QList clientIdList = cappedIdentifiers(clientIds); + QList ipList = cappedIdentifiers(ips); + if (!emailList.isEmpty()) { + conditions << QStringLiteral("u.email IN ") + altInClause(QStringLiteral(":alt_email"), emailList); + } + if (!clientIdList.isEmpty()) { + conditions << QStringLiteral("u.clientid IN ") + altInClause(QStringLiteral(":alt_cid"), clientIdList); + } + 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))"); + } + 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))"); + } + candidateString += conditions.join(QStringLiteral(" OR ")); + candidateString += QStringLiteral(" ORDER BY u.name LIMIT ") + QString::number(MAX_USER_ALTS_PER_LEVEL); + + QSqlQuery *candidateQuery = prepareQuery(candidateString); + altBindValues(candidateQuery, QStringLiteral(":alt_email"), emailList); + altBindValues(candidateQuery, QStringLiteral(":alt_cid"), clientIdList); + altBindValues(candidateQuery, QStringLiteral(":alt_ip"), ipList); + altBindValues(candidateQuery, QStringLiteral(":alt_hcid"), clientIdList); + if (!execSqlQuery(candidateQuery)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + QList candidates; + while (candidateQuery->next()) { + AltCandidate candidate; + candidate.name = candidateQuery->value(0).toString(); + if (visited.contains(candidate.name)) { + continue; + } + candidate.email = candidateQuery->value(1).toString(); + candidate.clientId = candidateQuery->value(2).toString(); + candidate.registrationTime = candidateQuery->value(3).toLongLong(); + candidate.hasLastLogin = !candidateQuery->value(4).isNull(); + candidate.lastLogin = candidateQuery->value(4).toLongLong(); + candidate.warnCount = candidateQuery->value(5).toInt(); + candidate.banCount = candidateQuery->value(6).toInt(); + candidate.isActive = candidateQuery->value(7).toBool(); + candidates << candidate; + } + + // Resolve session-level evidence (shared IP or historic client id) for + // candidates that do not match on current email or client id. + QMap candidateIp; + QMap candidateHistoricClientId; + QList unexplained; + for (const AltCandidate &candidate : candidates) { + const bool emailMatch = !candidate.email.isEmpty() && emails.contains(candidate.email); + const bool clientIdMatch = !candidate.clientId.isEmpty() && clientIds.contains(candidate.clientId); + if (!emailMatch && !clientIdMatch) { + unexplained << candidate.name; + } + } + if (!unexplained.isEmpty() && (!ips.isEmpty() || !clientIds.isEmpty())) { + QString evidenceString = + QStringLiteral("SELECT DISTINCT user_name, ip_address, clientid FROM {prefix}_sessions WHERE " + "user_name IN ") + + altInClause(QStringLiteral(":alt_ev"), unexplained) + QStringLiteral(" AND ("); + QStringList evidenceConditions; + if (!ipList.isEmpty()) { + evidenceConditions << QStringLiteral("ip_address IN ") + + altInClause(QStringLiteral(":alt_eip"), ipList); + } + if (!clientIdList.isEmpty()) { + evidenceConditions << QStringLiteral("clientid IN ") + + altInClause(QStringLiteral(":alt_ecid"), clientIdList); + } + evidenceString += evidenceConditions.join(QStringLiteral(" OR ")); + evidenceString += QStringLiteral(") AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)"); + + QSqlQuery *evidenceQuery = prepareQuery(evidenceString); + altBindValues(evidenceQuery, QStringLiteral(":alt_ev"), unexplained); + altBindValues(evidenceQuery, QStringLiteral(":alt_eip"), ipList); + altBindValues(evidenceQuery, QStringLiteral(":alt_ecid"), clientIdList); + if (execSqlQuery(evidenceQuery)) { + while (evidenceQuery->next()) { + const QString name = evidenceQuery->value(0).toString(); + const QString ip = evidenceQuery->value(1).toString(); + const QString clientId = evidenceQuery->value(2).toString(); + if (!ip.isEmpty() && ips.contains(ip) && !candidateIp.contains(name)) { + candidateIp.insert(name, ip); + } + if (!clientId.isEmpty() && clientIds.contains(clientId) && + !candidateHistoricClientId.contains(name)) { + candidateHistoricClientId.insert(name, clientId); + } + } + } + } + + // Fan-out counts so moderators can spot VPN/shared-network false positives. + QMap emailCounts; + QMap clientIdCounts; + QMap ipCounts; + QSet neededEmails; + QSet neededClientIds; + QSet neededIps; + for (const AltCandidate &candidate : candidates) { + if (!candidate.email.isEmpty() && emails.contains(candidate.email)) { + neededEmails << candidate.email; + } else if (!candidate.clientId.isEmpty() && clientIds.contains(candidate.clientId)) { + neededClientIds << candidate.clientId; + } else if (candidateHistoricClientId.contains(candidate.name)) { + neededClientIds << candidateHistoricClientId.value(candidate.name); + } else if (candidateIp.contains(candidate.name)) { + neededIps << candidateIp.value(candidate.name); + } + } + if (!neededEmails.isEmpty()) { + const QList neededEmailList = neededEmails.values(); + QSqlQuery *countQuery = prepareQuery(QStringLiteral("SELECT email, COUNT(*) FROM {prefix}_users WHERE " + "email IN ") + + altInClause(QStringLiteral(":alt_ce"), neededEmailList) + + QStringLiteral(" GROUP BY email")); + altBindValues(countQuery, QStringLiteral(":alt_ce"), neededEmailList); + if (execSqlQuery(countQuery)) { + while (countQuery->next()) { + emailCounts.insert(countQuery->value(0).toString(), countQuery->value(1).toInt()); + } + } + } + if (!neededClientIds.isEmpty()) { + const QList neededClientIdList = neededClientIds.values(); + QSqlQuery *countQuery = prepareQuery(QStringLiteral("SELECT clientid, COUNT(*) FROM {prefix}_users WHERE " + "clientid IN ") + + altInClause(QStringLiteral(":alt_cc"), neededClientIdList) + + QStringLiteral(" GROUP BY clientid")); + altBindValues(countQuery, QStringLiteral(":alt_cc"), neededClientIdList); + if (execSqlQuery(countQuery)) { + while (countQuery->next()) { + clientIdCounts.insert(countQuery->value(0).toString(), countQuery->value(1).toInt()); + } + } + } + if (!neededIps.isEmpty()) { + const QList neededIpList = neededIps.values(); + QSqlQuery *countQuery = prepareQuery( + 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")); + altBindValues(countQuery, QStringLiteral(":alt_ci"), neededIpList); + if (execSqlQuery(countQuery)) { + while (countQuery->next()) { + ipCounts.insert(countQuery->value(0).toString(), countQuery->value(1).toInt()); + } + } + } + + QList nextFrontier; + for (const AltCandidate &candidate : candidates) { + QString matchReason; + int sharedCount = 1; + if (!candidate.email.isEmpty() && emails.contains(candidate.email)) { + matchReason = QStringLiteral("email"); + sharedCount = qMax(1, emailCounts.value(candidate.email, 1)); + } else if (!candidate.clientId.isEmpty() && clientIds.contains(candidate.clientId)) { + matchReason = QStringLiteral("clientid:") + candidate.clientId; + sharedCount = qMax(1, clientIdCounts.value(candidate.clientId, 1)); + } else if (candidateHistoricClientId.contains(candidate.name)) { + const QString historicId = candidateHistoricClientId.value(candidate.name); + matchReason = QStringLiteral("clientid:") + historicId; + sharedCount = qMax(1, clientIdCounts.value(historicId, 1)); + } else if (candidateIp.contains(candidate.name)) { + const QString ip = candidateIp.value(candidate.name); + matchReason = QStringLiteral("ip:") + ip; + sharedCount = qMax(1, ipCounts.value(ip, 1)); + } else { + continue; + } + appendAlt(candidate, matchReason, level, sharedCount); + visited.insert(candidate.name); + nextFrontier << candidate.name; + } + frontier = nextFrontier; } return results; diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 0509d1a03..f76ca27f5 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -200,7 +200,7 @@ public: int &range, int &maxresults); QList getUserSessions(const QString &userName, int limit); - QList getUserAlts(const QString &userName); + QList getUserAlts(const QString &userName, int depth = 1); QList getModeratorLastLogins(); // Uptime snapshot as recorded by Servatrice::statusUpdate() into the diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 5f5d1ced5..a394e6bfd 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2565,7 +2565,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Comma } Response_UserAlts *re = new Response_UserAlts; - const QList alts = sqlInterface->getUserAlts(userName); + const int depth = cmd.has_depth() ? static_cast(cmd.depth()) : 1; + const QList alts = sqlInterface->getUserAlts(userName, depth); for (const ServerInfo_UserAlt &alt : alts) { re->add_alts()->CopyFrom(alt); } From f468315826c5107c38fe3961e8e2aa8fda809cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:18:14 +0200 Subject: [PATCH 03/11] [Client] Transient User Alts investigation tab New TabAlts tab with user search, depth selector (1-3), and an alt table showing per-row match evidence (Via, Depth, Shared fan-out) alongside the account data. Sends Command_GetUserAlts with the requested depth and degrades gracefully against old servers (missing evidence fields render as dashes). Wired as a transient single-reuse tab via TabSupervisor::openTabAlts with no menu action or persisted setting; closed on disconnect like the other staff tabs. --- cockatrice/CMakeLists.txt | 1 + .../src/interface/widgets/tabs/tab_alts.cpp | 170 ++++++++++++++++++ .../src/interface/widgets/tabs/tab_alts.h | 54 ++++++ .../interface/widgets/tabs/tab_supervisor.cpp | 22 ++- .../interface/widgets/tabs/tab_supervisor.h | 3 + 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 cockatrice/src/interface/widgets/tabs/tab_alts.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_alts.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 63ce7d1af..3b4350e24 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -393,6 +393,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_alts.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 diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp new file mode 100644 index 000000000..d43f5cfc6 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -0,0 +1,170 @@ +#include "tab_alts.h" + +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/response_user_alts.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +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 MAX_ALT_DEPTH = 3; +} // namespace + +TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) + : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::returnPressed, this, &TabAlts::investigateUser); + + depthSpin = new QSpinBox; + depthSpin->setMinimum(1); + depthSpin->setMaximum(MAX_ALT_DEPTH); + depthSpin->setValue(1); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); + + statusLabel = new QLabel; + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(depthSpin); + topBar->addWidget(investigateButton); + + altsTable = new QTableWidget(0, COL_ALTS_COUNT); + altsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + altsTable->setSelectionMode(QAbstractItemView::SingleSelection); + altsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + altsTable->verticalHeader()->setVisible(false); + altsTable->setAlternatingRowColors(true); + altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + altsTable->horizontalHeader()->setStretchLastSection(true); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addWidget(statusLabel); + mainLayout->addWidget(altsTable); + + retranslateUi(); + investigate(initialUser); +} + +void TabAlts::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + depthSpin->setPrefix(tr("Depth: ")); + depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts.")); + investigateButton->setText(tr("Investigate")); + if (currentUser.isEmpty()) { + statusLabel->setText(tr("Enter a user name to investigate.")); + } + + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), + tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), + tr("Active")}); +} + +QString TabAlts::formatEpoch(quint64 ts) const +{ + if (ts == 0) { + return tr("Unknown"); + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); +} + +void TabAlts::investigate(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + searchEdit->setText(userName); + investigateUser(); +} + +void TabAlts::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + currentUser = userName; + altsTable->setRowCount(0); + statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); + requestAlts(userName, depthSpin->value()); +} + +void TabAlts::requestAlts(const QString &userName, int depth) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_depth(static_cast(depth)); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabAlts::altsResponse); + client->sendCommand(pend); +} + +void TabAlts::altsResponse(const Response &response) +{ + altsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("User %1 not found.").arg(currentUser)); + return; + } + + const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + altsTable->setRowCount(resp.alts_size()); + for (int i = 0; i < resp.alts_size(); ++i) { + const ServerInfo_UserAlt &alt = resp.alts(i); + altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(i, COL_ALTS_VIA, + new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() + ? QString::fromStdString(alt.match_reason()) + : QStringLiteral("-"))); + altsTable->setItem(i, COL_ALTS_DEPTH, + new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); + altsTable->setItem( + i, COL_ALTS_SHARED, + new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); + altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + } + statusLabel->setText(tr("%1 account(s) related to %2.").arg(resp.alts_size()).arg(currentUser)); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h new file mode 100644 index 000000000..3b83cc4a9 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -0,0 +1,54 @@ +#ifndef TAB_ALTS_H +#define TAB_ALTS_H + +#include "tab.h" + +#include +#include +#include + +class AbstractClient; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTableWidget; +class Response; +class TabSupervisor; + +/** + * Staff alt-graph investigation tool. Shows accounts related to the + * investigated user (shared email, client id, or recent IP address), + * including transitive alts-of-alts, with per-row match evidence. + */ +class TabAlts : public Tab +{ + Q_OBJECT +public: + explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("User Alts"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void altsResponse(const Response &response); + +private: + void requestAlts(const QString &userName, int depth); + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + + QLineEdit *searchEdit; + QSpinBox *depthSpin; + QPushButton *investigateButton; + QLabel *statusLabel; + QTableWidget *altsTable; +}; + +#endif // TAB_ALTS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 2352d7d7b..29a19ba91 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -25,6 +25,7 @@ #include "tab.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_alts.h" #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" @@ -156,7 +157,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), - tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) + tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -300,6 +301,7 @@ void TabSupervisor::retranslateUi() tabs.append(tabLog); tabs.append(tabReport); tabs.append(tabModeration); + tabs.append(tabAlts); tabs.append(tabCardArtRules); tabs.append(tabDeveloper); QMapIterator roomIterator(roomTabs); @@ -600,6 +602,7 @@ void TabSupervisor::startLocal(const QList &_clients) tabLog = nullptr; tabReport = nullptr; tabModeration = nullptr; + tabAlts = nullptr; tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; @@ -648,6 +651,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabAlts) { + tabAlts->close(); + } if (tabCardArtRules) { tabCardArtRules->close(); } @@ -956,6 +962,20 @@ void TabSupervisor::openTabModeration(const QString &userName) aTabModeration->setChecked(true); } +void TabSupervisor::openTabAlts(const QString &userName) +{ + if (tabAlts) { + setCurrentWidget(tabAlts); + if (!userName.isEmpty()) { + tabAlts->investigate(userName); + } + return; + } + tabAlts = new TabAlts(this, client, userName); + myAddTab(tabAlts); + connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; }); +} + void TabSupervisor::actTabDeveloper(bool checked) { if (checked && !tabDeveloper) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 9c7dc48d1..d529c5022 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -50,6 +50,7 @@ class TabAdmin; class TabMessage; class TabReport; class TabModeration; +class TabAlts; class TabAccount; class TabDeckEditor; class TabDeveloper; @@ -116,6 +117,7 @@ private: TabLog *tabLog; TabReport *tabReport; TabModeration *tabModeration; + TabAlts *tabAlts; TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; @@ -209,6 +211,7 @@ public slots: /** Joins or spectates a game listed on a user's profile hover card. */ void joinGameFromUserCard(int gameId, int roomId, bool asSpectator); void openTabModeration(const QString &userName = {}); + void openTabAlts(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); From 3823dfa08712ec2293b22103d36fa91e918b0055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:23:33 +0200 Subject: [PATCH 04/11] [Client] Alt investigation entry points and in-tab navigation TabModeration gains an Open alt graph button plus alts-table double-click, both routing to the User Alts tab. UserContextMenu gains an Investigate alts action. TabAlts gains row double-click navigation with a Back history stack so moderators can walk the alt graph without retyping names. --- .../widgets/server/user/user_context_menu.cpp | 11 ++++++ .../widgets/server/user/user_context_menu.h | 2 ++ .../src/interface/widgets/tabs/tab_alts.cpp | 35 ++++++++++++++++++- .../src/interface/widgets/tabs/tab_alts.h | 4 +++ .../interface/widgets/tabs/tab_moderation.cpp | 25 +++++++++++++ .../interface/widgets/tabs/tab_moderation.h | 2 ++ 6 files changed, 78 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index c8fe97cda..2a96b0308 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -82,6 +82,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDemoteFromDeveloper = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); aInvestigateUser = new QAction(QString(), this); + aInvestigateAlts = new QAction(QString(), this); retranslateUi(); } @@ -110,6 +111,7 @@ void UserContextMenu::retranslateUi() aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aGetAdminNotes->setText(tr("View admin notes")); aInvestigateUser->setText(tr("Investigate user")); + aInvestigateAlts->setText(tr("Investigate &alts")); } void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) @@ -453,6 +455,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addSeparator(); menu->addAction(aGetAdminNotes); menu->addAction(aInvestigateUser); + menu->addAction(aInvestigateAlts); menu->addSeparator(); if (userLevel.testFlag(ServerInfo_User::IsModerator) && @@ -498,6 +501,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aBanHistory->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser); aInvestigateUser->setEnabled(anotherUser); + aInvestigateAlts->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); aPromoteToJudge->setEnabled(anotherUser); @@ -552,6 +556,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execAdminNotes(userName); } else if (actionClicked == aInvestigateUser) { execInvestigateUser(userName); + } else if (actionClicked == aInvestigateAlts) { + execInvestigateAlts(userName); } else if (actionClicked == aCopyToClipBoard) { QClipboard *clipboard = QGuiApplication::clipboard(); clipboard->setText(deckHash); @@ -746,6 +752,11 @@ void UserContextMenu::execInvestigateUser(const QString &userName) tabSupervisor->openTabModeration(userName); } +void UserContextMenu::execInvestigateAlts(const QString &userName) +{ + tabSupervisor->openTabAlts(userName); +} + void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod) { Command_AdjustMod cmd; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 2536819a4..86be5eff2 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -54,6 +54,7 @@ private: QAction *aGetAdminNotes; std::function()> gameInviteLinkProvider; QAction *aInvestigateUser; + QAction *aInvestigateAlts; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -127,6 +128,7 @@ public: void execWarnHistory(const QString &userName); void execAdminNotes(const QString &userName); void execInvestigateUser(const QString &userName); + void execInvestigateAlts(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index d43f5cfc6..5b625d1de 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -57,9 +57,14 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q investigateButton = new QPushButton; connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); + backButton = new QPushButton; + backButton->setEnabled(false); + connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack); + statusLabel = new QLabel; auto *topBar = new QHBoxLayout; + topBar->addWidget(backButton); topBar->addWidget(searchEdit); topBar->addWidget(depthSpin); topBar->addWidget(investigateButton); @@ -72,6 +77,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable->setAlternatingRowColors(true); altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); altsTable->horizontalHeader()->setStretchLastSection(true); + connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { + auto *userItem = altsTable->item(row, COL_ALTS_USER); + if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { + investigate(userItem->text()); + } + }); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); @@ -88,6 +99,8 @@ void TabAlts::retranslateUi() depthSpin->setPrefix(tr("Depth: ")); depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts.")); investigateButton->setText(tr("Investigate")); + backButton->setText(tr("Back")); + backButton->setToolTip(tr("Back to the previously investigated user")); if (currentUser.isEmpty()) { statusLabel->setText(tr("Enter a user name to investigate.")); } @@ -107,9 +120,29 @@ QString TabAlts::formatEpoch(quint64 ts) const void TabAlts::investigate(const QString &userName) { - if (userName.isEmpty()) { + if (userName.isEmpty() || userName == currentUser) { return; } + if (!currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } + loadUser(userName); +} + +void TabAlts::goBack() +{ + if (backStack.isEmpty()) { + return; + } + const QString previousUser = backStack.takeLast(); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(previousUser); +} + +void TabAlts::loadUser(const QString &userName) +{ + currentUser = userName; searchEdit->setText(userName); investigateUser(); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 3b83cc4a9..de5a11ab8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -35,18 +35,22 @@ public: private slots: void investigateUser(); + void goBack(); void altsResponse(const Response &response); private: + void loadUser(const QString &userName); void requestAlts(const QString &userName, int depth); [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + QList backStack; QLineEdit *searchEdit; QSpinBox *depthSpin; QPushButton *investigateButton; + QPushButton *backButton; QLabel *statusLabel; QTableWidget *altsTable; }; diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 09a640540..c04de44ce 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -6,6 +6,7 @@ #include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" +#include "tab_supervisor.h" #include #include @@ -139,7 +140,19 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli altsGroup = new QGroupBox; altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); + connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { + auto *userItem = altsTable->item(row, COL_ALTS_USER); + if (userItem != nullptr && !userItem->text().isEmpty()) { + getTabSupervisor()->openTabAlts(userItem->text()); + } + }); + openAltsButton = new QPushButton; + connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph); + auto *altsHeader = new QHBoxLayout; + altsHeader->addStretch(); + altsHeader->addWidget(openAltsButton); auto *altsLayout = new QVBoxLayout(altsGroup); + altsLayout->addLayout(altsHeader); altsLayout->addWidget(altsTable); sessionsGroup = new QGroupBox; @@ -197,6 +210,8 @@ void TabModeration::retranslateUi() userInfoNotesLabel->setText(tr("Admin notes:")); altsGroup->setTitle(tr("Alts")); + openAltsButton->setText(tr("Open alt graph")); + openAltsButton->setToolTip(tr("Open this user in the User Alts tab")); sessionsGroup->setTitle(tr("Sessions")); staffGroup->setTitle(tr("Staff Last Logins")); @@ -227,6 +242,7 @@ void TabModeration::clearUserData() sessionsTable->setRowCount(0); resetPasswordButton->setEnabled(false); removeAvatarButton->setEnabled(false); + openAltsButton->setEnabled(false); } void TabModeration::investigate(const QString &userName) @@ -247,6 +263,7 @@ void TabModeration::investigateUser() currentUser = userName; resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); + openAltsButton->setEnabled(true); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -254,6 +271,14 @@ void TabModeration::investigateUser() requestAlts(userName); } +void TabModeration::openAltsGraph() +{ + if (currentUser.isEmpty()) { + return; + } + getTabSupervisor()->openTabAlts(currentUser); +} + void TabModeration::requestUserInfo(const QString &userName) { userInfoNameValue->setText(userName); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index e352e90bb..47d191dc7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -36,6 +36,7 @@ public: private slots: void investigateUser(); + void openAltsGraph(); void userInfoResponse(const Response &response); void sessionsResponse(const Response &response); void altsResponse(const Response &response); @@ -77,6 +78,7 @@ private: QGroupBox *altsGroup; QTableWidget *altsTable; + QPushButton *openAltsButton; QGroupBox *sessionsGroup; QTableWidget *sessionsTable; QGroupBox *staffGroup; From e36b59c2f5cd8fd57b0d5008f61b43c6c25dd3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:29:49 +0200 Subject: [PATCH 05/11] [Client] Alt tab filters and unified user timeline TabAlts gains client-side match filters (Email / Client ID / IP toggles plus a hide-IPs-shared-by-more-than-N-users threshold for VPN noise) with a shown/hidden status count, and a Selected user panel that loads the row account's counts, admin notes, warning history, and ban history in one place. Detail requests are guarded against stale selections. --- .../src/interface/widgets/tabs/tab_alts.cpp | 316 ++++++++++++++++-- .../src/interface/widgets/tabs/tab_alts.h | 33 ++ 2 files changed, 325 insertions(+), 24 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 5b625d1de..06863c131 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -3,21 +3,31 @@ #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" -#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include +#include #include +#include +#include #include #include #include #include +#include #include #include #include #include +#include #include #include +#include #include +#include +#include +#include +#include +#include #include #include @@ -37,6 +47,21 @@ constexpr int COL_ALTS_ACTIVE = 10; constexpr int COL_ALTS_COUNT = 11; constexpr int MAX_ALT_DEPTH = 3; +// Starting default only (adjustable 1-100000): shared VPN exits routinely +// cover dozens of users, so 10 hides the obvious VPN noise but keeps +// small shared networks visible. +constexpr int DEFAULT_VPN_THRESHOLD = 10; + +void configureAltTable(QTableWidget *table) +{ + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + table->horizontalHeader()->setStretchLastSection(true); +} } // namespace TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) @@ -69,27 +94,80 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q topBar->addWidget(depthSpin); topBar->addWidget(investigateButton); + emailCheck = new QCheckBox; + emailCheck->setChecked(true); + connect(emailCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + clientIdCheck = new QCheckBox; + clientIdCheck->setChecked(true); + connect(clientIdCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + ipCheck = new QCheckBox; + ipCheck->setChecked(true); + connect(ipCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + vpnLabel = new QLabel; + vpnSpin = new QSpinBox; + vpnSpin->setMinimum(1); + vpnSpin->setMaximum(100000); + vpnSpin->setValue(DEFAULT_VPN_THRESHOLD); + connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged); + + auto *filterBar = new QHBoxLayout; + filterBar->addWidget(emailCheck); + filterBar->addWidget(clientIdCheck); + filterBar->addWidget(ipCheck); + filterBar->addWidget(vpnLabel); + filterBar->addWidget(vpnSpin); + filterBar->addStretch(); + altsTable = new QTableWidget(0, COL_ALTS_COUNT); - altsTable->setSelectionBehavior(QAbstractItemView::SelectRows); - altsTable->setSelectionMode(QAbstractItemView::SingleSelection); - altsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); - altsTable->verticalHeader()->setVisible(false); - altsTable->setAlternatingRowColors(true); - altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - altsTable->horizontalHeader()->setStretchLastSection(true); + configureAltTable(altsTable); connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { auto *userItem = altsTable->item(row, COL_ALTS_USER); if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { investigate(userItem->text()); } }); + connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected); + + detailNameValue = new QLabel; + detailCountsValue = new QLabel; + detailNotesEdit = new QTextEdit; + detailNotesEdit->setReadOnly(true); + // Compact cap so the warn/ban tables stay visible without scrolling the tab. + detailNotesEdit->setMaximumHeight(60); + warnsTable = new QTableWidget(0, 3); + configureAltTable(warnsTable); + bansTable = new QTableWidget(0, 4); + configureAltTable(bansTable); + + auto *detailGrid = new QGridLayout; + detailNameLabel = new QLabel; + detailCountsLabel = new QLabel; + detailNotesLabel = new QLabel; + detailGrid->addWidget(detailNameLabel, 0, 0); + detailGrid->addWidget(detailNameValue, 0, 1); + detailGrid->addWidget(detailCountsLabel, 0, 2); + detailGrid->addWidget(detailCountsValue, 0, 3); + detailGrid->addWidget(detailNotesLabel, 1, 0); + detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3); + + auto *detailTables = new QHBoxLayout; + detailTables->addWidget(warnsTable); + detailTables->addWidget(bansTable); + + detailsGroup = new QGroupBox; + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->addLayout(detailGrid); + detailsLayout->addLayout(detailTables); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); + mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); - mainLayout->addWidget(altsTable); + mainLayout->addWidget(altsTable, 3); + mainLayout->addWidget(detailsGroup, 2); retranslateUi(); + clearDetails(); investigate(initialUser); } @@ -105,9 +183,22 @@ void TabAlts::retranslateUi() statusLabel->setText(tr("Enter a user name to investigate.")); } + emailCheck->setText(tr("Email")); + clientIdCheck->setText(tr("Client ID")); + ipCheck->setText(tr("IP")); + vpnLabel->setText(tr("Hide IPs shared by more than")); + vpnSpin->setSuffix(tr(" users")); + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); + warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")}); + bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")}); + + detailsGroup->setTitle(tr("Selected user")); + detailNameLabel->setText(tr("Name:")); + detailCountsLabel->setText(tr("Counts:")); + detailNotesLabel->setText(tr("Admin notes:")); } QString TabAlts::formatEpoch(quint64 ts) const @@ -154,6 +245,9 @@ void TabAlts::investigateUser() return; } currentUser = userName; + lastAlts.clear(); + selectedUser.clear(); + clearDetails(); altsTable->setRowCount(0); statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); requestAlts(userName, depthSpin->value()); @@ -171,33 +265,207 @@ void TabAlts::requestAlts(const QString &userName, int depth) void TabAlts::altsResponse(const Response &response) { - altsTable->setRowCount(0); + lastAlts.clear(); if (response.response_code() != Response::RespOk) { + renderTable(); statusLabel->setText(tr("User %1 not found.").arg(currentUser)); return; } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); - altsTable->setRowCount(resp.alts_size()); for (int i = 0; i < resp.alts_size(); ++i) { - const ServerInfo_UserAlt &alt = resp.alts(i); - altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); - altsTable->setItem(i, COL_ALTS_VIA, + lastAlts << resp.alts(i); + } + renderTable(); +} + +bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const +{ + const QString name = QString::fromStdString(alt.user_name()); + if (name == currentUser) { + return true; + } + if (!alt.has_match_reason() || alt.match_reason().empty()) { + return true; + } + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + return emailCheck->isChecked(); + } + if (reason.startsWith(QStringLiteral("clientid:"))) { + return clientIdCheck->isChecked(); + } + if (reason.startsWith(QStringLiteral("ip:"))) { + return ipCheck->isChecked() && + (!alt.has_shared_count() || alt.shared_count() <= static_cast(vpnSpin->value())); + } + return true; +} + +void TabAlts::renderTable() +{ + altsTable->setRowCount(0); + int hidden = 0; + int row = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!rowVisible(alt)) { + ++hidden; + continue; + } + altsTable->insertRow(row); + altsTable->setItem(row, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(row, COL_ALTS_VIA, new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() ? QString::fromStdString(alt.match_reason()) : QStringLiteral("-"))); - altsTable->setItem(i, COL_ALTS_DEPTH, + altsTable->setItem(row, COL_ALTS_DEPTH, new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); altsTable->setItem( - i, COL_ALTS_SHARED, + row, COL_ALTS_SHARED, new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); - altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); - altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); - altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); - altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); - altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); - altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); - altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + 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()))); + 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"))); + ++row; + } + if (hidden == 0) { + statusLabel->setText(tr("%1 account(s) related to %2.").arg(lastAlts.size()).arg(currentUser)); + } else { + statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.") + .arg(row) + .arg(hidden) + .arg(lastAlts.size()) + .arg(currentUser)); + } +} + +void TabAlts::onFilterChanged() +{ + if (lastAlts.isEmpty()) { + return; + } + renderTable(); +} + +void TabAlts::onAltSelected() +{ + const auto selected = altsTable->selectedItems(); + if (selected.isEmpty()) { + selectedUser.clear(); + clearDetails(); + return; + } + auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER); + if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) { + return; + } + selectedUser = userItem->text(); + requestDetails(selectedUser); +} + +void TabAlts::requestDetails(const QString &userName) +{ + detailNameValue->setText(userName); + detailCountsValue->setText(tr("Loading...")); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); + + Command_ReportUserInfo infoCmd; + infoCmd.set_user_name(userName.toStdString()); + PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd); + connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + userDetailsResponse(response); + } + }); + client->sendCommand(infoPend); + + Command_GetWarnHistory warnCmd; + warnCmd.set_user_name(userName.toStdString()); + PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd); + connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + warnHistoryResponse(response); + } + }); + client->sendCommand(warnPend); + + Command_GetBanHistory banCmd; + banCmd.set_user_name(userName.toStdString()); + PendingCommand *banPend = client->prepareModeratorCommand(banCmd); + connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + banHistoryResponse(response); + } + }); + client->sendCommand(banPend); +} + +void TabAlts::clearDetails() +{ + detailNameValue->clear(); + detailCountsValue->clear(); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); +} + +void TabAlts::userDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + detailCountsValue->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + detailCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") + .arg(resp.total_reports()) + .arg(resp.total_bans()) + .arg(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + detailNotesEdit->setPlainText(tr("(none)")); + } +} + +void TabAlts::warnHistoryResponse(const Response &response) +{ + warnsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext); + warnsTable->setRowCount(resp.warn_list_size()); + for (int i = 0; i < resp.warn_list_size(); ++i) { + const ServerInfo_Warning &warn = resp.warn_list(i); + warnsTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); + warnsTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); + warnsTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(warn.reason()))); + } +} + +void TabAlts::banHistoryResponse(const Response &response) +{ + bansTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext); + bansTable->setRowCount(resp.ban_list_size()); + for (int i = 0; i < resp.ban_list_size(); ++i) { + const ServerInfo_Ban &ban = resp.ban_list(i); + bansTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); + bansTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); + bansTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); + bansTable->setItem(i, 3, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); } - statusLabel->setText(tr("%1 account(s) related to %2.").arg(resp.alts_size()).arg(currentUser)); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index de5a11ab8..65833f90d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -3,16 +3,20 @@ #include "tab.h" +#include #include #include +#include #include class AbstractClient; +class QCheckBox; class QLabel; class QLineEdit; class QPushButton; class QSpinBox; class QTableWidget; +class QTextEdit; class Response; class TabSupervisor; @@ -20,6 +24,7 @@ class TabSupervisor; * Staff alt-graph investigation tool. Shows accounts related to the * investigated user (shared email, client id, or recent IP address), * including transitive alts-of-alts, with per-row match evidence. + * Selecting a row loads that account's warnings, bans, and admin notes. */ class TabAlts : public Tab { @@ -37,15 +42,26 @@ private slots: void investigateUser(); void goBack(); void altsResponse(const Response &response); + void onFilterChanged(); + void onAltSelected(); + void userDetailsResponse(const Response &response); + void warnHistoryResponse(const Response &response); + void banHistoryResponse(const Response &response); private: void loadUser(const QString &userName); void requestAlts(const QString &userName, int depth); + void requestDetails(const QString &userName); + void clearDetails(); + void renderTable(); + [[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const; [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + QString selectedUser; QList backStack; + QList lastAlts; QLineEdit *searchEdit; QSpinBox *depthSpin; @@ -53,6 +69,23 @@ private: QPushButton *backButton; QLabel *statusLabel; QTableWidget *altsTable; + + QCheckBox *emailCheck; + QCheckBox *clientIdCheck; + QCheckBox *ipCheck; + QLabel *vpnLabel; + QSpinBox *vpnSpin; + + QLabel *detailNameValue; + QLabel *detailCountsValue; + QTextEdit *detailNotesEdit; + QTableWidget *warnsTable; + QTableWidget *bansTable; + + QGroupBox *detailsGroup; + QLabel *detailNameLabel; + QLabel *detailCountsLabel; + QLabel *detailNotesLabel; }; #endif // TAB_ALTS_H From 459c61933ba4af1657ed282d21deaab326fb9f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 21:26:45 +0200 Subject: [PATCH 06/11] [Client] Moderation and alts dashboards Turn the two staff tables into dashboards without behavior changes: TabModeration gets an identity header (display-size name, status, registered/last-login subline), Reports/Warnings/Bans/Alts count cards with an Open-graph deep link, full-width admin notes, and Alts/Sessions/Staff tabbed sections with tab memory. TabAlts gets toggle chips with match counts, bold usernames, palette-token dimming of shared-IP rows, and a resizable side card for the selected-user timeline with selection restore. Row activation works via Enter as well as double-click; async responses are guarded against stale investigations and retranslation re-applies cached dashboard data. --- .../src/interface/widgets/tabs/tab_alts.cpp | 163 ++++++++++---- .../src/interface/widgets/tabs/tab_alts.h | 8 +- .../interface/widgets/tabs/tab_moderation.cpp | 211 ++++++++++++------ .../interface/widgets/tabs/tab_moderation.h | 30 ++- 4 files changed, 286 insertions(+), 126 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 06863c131..ca4a495b9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -5,17 +5,19 @@ #include "libcockatrice/protocol/pb/response_user_alts.pb.h" #include -#include #include #include #include #include #include +#include #include #include #include #include +#include #include +#include #include #include #include @@ -46,6 +48,17 @@ constexpr int COL_ALTS_BANS = 9; constexpr int COL_ALTS_ACTIVE = 10; constexpr int COL_ALTS_COUNT = 11; +constexpr int COL_WARNS_TIME = 0; +constexpr int COL_WARNS_MODERATOR = 1; +constexpr int COL_WARNS_REASON = 2; +constexpr int COL_WARNS_COUNT = 3; + +constexpr int COL_BANS_TIME = 0; +constexpr int COL_BANS_MODERATOR = 1; +constexpr int COL_BANS_LENGTH = 2; +constexpr int COL_BANS_REASON = 3; +constexpr int COL_BANS_COUNT = 4; + constexpr int MAX_ALT_DEPTH = 3; // Starting default only (adjustable 1-100000): shared VPN exits routinely // cover dozens of users, so 10 hides the obvious VPN noise but keeps @@ -94,15 +107,16 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q topBar->addWidget(depthSpin); topBar->addWidget(investigateButton); - emailCheck = new QCheckBox; - emailCheck->setChecked(true); - connect(emailCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); - clientIdCheck = new QCheckBox; - clientIdCheck->setChecked(true); - connect(clientIdCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); - ipCheck = new QCheckBox; - ipCheck->setChecked(true); - connect(ipCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + auto makeChip = [this](const QString &text) { + auto *chip = new QPushButton(text); + chip->setCheckable(true); + chip->setChecked(true); + connect(chip, &QPushButton::toggled, this, &TabAlts::onFilterChanged); + return chip; + }; + emailChip = makeChip(QString()); + clientIdChip = makeChip(QString()); + ipChip = makeChip(QString()); vpnLabel = new QLabel; vpnSpin = new QSpinBox; vpnSpin->setMinimum(1); @@ -111,17 +125,20 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged); auto *filterBar = new QHBoxLayout; - filterBar->addWidget(emailCheck); - filterBar->addWidget(clientIdCheck); - filterBar->addWidget(ipCheck); + filterBar->addWidget(emailChip); + filterBar->addWidget(clientIdChip); + filterBar->addWidget(ipChip); filterBar->addWidget(vpnLabel); filterBar->addWidget(vpnSpin); filterBar->addStretch(); altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); - connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { - auto *userItem = altsTable->item(row, COL_ALTS_USER); + connect(altsTable, &QTableWidget::activated, this, [this](const QModelIndex &index) { + if (!index.isValid()) { + return; + } + auto *userItem = altsTable->item(index.row(), COL_ALTS_USER); if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { investigate(userItem->text()); } @@ -134,9 +151,9 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q detailNotesEdit->setReadOnly(true); // Compact cap so the warn/ban tables stay visible without scrolling the tab. detailNotesEdit->setMaximumHeight(60); - warnsTable = new QTableWidget(0, 3); + warnsTable = new QTableWidget(0, COL_WARNS_COUNT); configureAltTable(warnsTable); - bansTable = new QTableWidget(0, 4); + bansTable = new QTableWidget(0, COL_BANS_COUNT); configureAltTable(bansTable); auto *detailGrid = new QGridLayout; @@ -150,21 +167,24 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q detailGrid->addWidget(detailNotesLabel, 1, 0); detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3); - auto *detailTables = new QHBoxLayout; - detailTables->addWidget(warnsTable); - detailTables->addWidget(bansTable); - detailsGroup = new QGroupBox; auto *detailsLayout = new QVBoxLayout(detailsGroup); detailsLayout->addLayout(detailGrid); - detailsLayout->addLayout(detailTables); + detailsLayout->addWidget(warnsTable, 1); + detailsLayout->addWidget(bansTable, 1); + + auto *contentSplitter = new QSplitter(Qt::Horizontal); + contentSplitter->addWidget(altsTable); + contentSplitter->addWidget(detailsGroup); + contentSplitter->setStretchFactor(0, 3); + contentSplitter->setStretchFactor(1, 2); + detailsGroup->setMinimumWidth(260); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); - mainLayout->addWidget(altsTable, 3); - mainLayout->addWidget(detailsGroup, 2); + mainLayout->addWidget(contentSplitter, 1); retranslateUi(); clearDetails(); @@ -183,15 +203,19 @@ void TabAlts::retranslateUi() statusLabel->setText(tr("Enter a user name to investigate.")); } - emailCheck->setText(tr("Email")); - clientIdCheck->setText(tr("Client ID")); - ipCheck->setText(tr("IP")); + emailChip->setText(tr("Email")); + emailChip->setToolTip(tr("Show or hide email matches")); + clientIdChip->setText(tr("Client ID")); + clientIdChip->setToolTip(tr("Show or hide client ID matches")); + ipChip->setText(tr("IP")); + ipChip->setToolTip(tr("Show or hide IP address matches")); vpnLabel->setText(tr("Hide IPs shared by more than")); vpnSpin->setSuffix(tr(" users")); altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), 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")); warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")}); bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")}); @@ -199,6 +223,10 @@ void TabAlts::retranslateUi() detailNameLabel->setText(tr("Name:")); detailCountsLabel->setText(tr("Counts:")); detailNotesLabel->setText(tr("Admin notes:")); + + if (!lastAlts.isEmpty()) { + renderTable(); + } } QString TabAlts::formatEpoch(quint64 ts) const @@ -259,7 +287,11 @@ void TabAlts::requestAlts(const QString &userName, int depth) cmd.set_user_name(userName.toStdString()); cmd.set_depth(static_cast(depth)); PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabAlts::altsResponse); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); client->sendCommand(pend); } @@ -290,13 +322,13 @@ bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const } const QString reason = QString::fromStdString(alt.match_reason()); if (reason == QStringLiteral("email")) { - return emailCheck->isChecked(); + return emailChip->isChecked(); } if (reason.startsWith(QStringLiteral("clientid:"))) { - return clientIdCheck->isChecked(); + return clientIdChip->isChecked(); } if (reason.startsWith(QStringLiteral("ip:"))) { - return ipCheck->isChecked() && + return ipChip->isChecked() && (!alt.has_shared_count() || alt.shared_count() <= static_cast(vpnSpin->value())); } return true; @@ -304,7 +336,32 @@ bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const void TabAlts::renderTable() { + // Blocked so the programmatic refill neither wipes the details panel + // (via the selection-cleared path) nor refetches them on restore. + const QSignalBlocker blocker(altsTable); altsTable->setRowCount(0); + + int emailCount = 0; + int clientIdCount = 0; + int ipCount = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + ++emailCount; + } else if (reason.startsWith(QStringLiteral("clientid:"))) { + ++clientIdCount; + } else if (reason.startsWith(QStringLiteral("ip:"))) { + ++ipCount; + } + } + emailChip->setText(tr("Email (%1)").arg(emailCount)); + clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount)); + ipChip->setText(tr("IP (%1)").arg(ipCount)); + + QFont userFont = altsTable->font(); + userFont.setBold(true); + const QColor dimColor = palette().color(QPalette::Disabled, QPalette::Text); + int hidden = 0; int row = 0; for (const ServerInfo_UserAlt &alt : lastAlts) { @@ -313,7 +370,9 @@ void TabAlts::renderTable() continue; } altsTable->insertRow(row); - altsTable->setItem(row, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + auto *userItem = new QTableWidgetItem(QString::fromStdString(alt.user_name())); + userItem->setFont(userFont); + altsTable->setItem(row, COL_ALTS_USER, userItem); altsTable->setItem(row, COL_ALTS_VIA, new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() ? QString::fromStdString(alt.match_reason()) @@ -330,6 +389,14 @@ 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"))); + if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && + alt.shared_count() > 1) { + for (int col = 0; col < COL_ALTS_COUNT; ++col) { + if (auto *item = altsTable->item(row, col)) { + item->setForeground(dimColor); + } + } + } ++row; } if (hidden == 0) { @@ -341,6 +408,22 @@ void TabAlts::renderTable() .arg(lastAlts.size()) .arg(currentUser)); } + + if (!selectedUser.isEmpty()) { + bool restored = false; + for (int restoreRow = 0; restoreRow < altsTable->rowCount(); ++restoreRow) { + auto *userItem = altsTable->item(restoreRow, COL_ALTS_USER); + if (userItem != nullptr && userItem->text() == selectedUser) { + altsTable->setCurrentItem(userItem, QItemSelectionModel::Select | QItemSelectionModel::Rows); + restored = true; + break; + } + } + if (!restored) { + selectedUser.clear(); + clearDetails(); + } + } } void TabAlts::onFilterChanged() @@ -409,7 +492,7 @@ void TabAlts::requestDetails(const QString &userName) void TabAlts::clearDetails() { detailNameValue->clear(); - detailCountsValue->clear(); + detailCountsValue->setText(tr("Select a row to inspect its history.")); detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); @@ -446,9 +529,9 @@ void TabAlts::warnHistoryResponse(const Response &response) warnsTable->setRowCount(resp.warn_list_size()); for (int i = 0; i < resp.warn_list_size(); ++i) { const ServerInfo_Warning &warn = resp.warn_list(i); - warnsTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); - warnsTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); - warnsTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(warn.reason()))); + warnsTable->setItem(i, COL_WARNS_TIME, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); + warnsTable->setItem(i, COL_WARNS_MODERATOR, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); + warnsTable->setItem(i, COL_WARNS_REASON, new QTableWidgetItem(QString::fromStdString(warn.reason()))); } } @@ -463,9 +546,9 @@ void TabAlts::banHistoryResponse(const Response &response) bansTable->setRowCount(resp.ban_list_size()); for (int i = 0; i < resp.ban_list_size(); ++i) { const ServerInfo_Ban &ban = resp.ban_list(i); - bansTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); - bansTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); - bansTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); - bansTable->setItem(i, 3, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); + bansTable->setItem(i, COL_BANS_TIME, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); + bansTable->setItem(i, COL_BANS_MODERATOR, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); + bansTable->setItem(i, COL_BANS_LENGTH, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); + bansTable->setItem(i, COL_BANS_REASON, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 65833f90d..8c0086f25 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -10,7 +10,7 @@ #include class AbstractClient; -class QCheckBox; +class QGroupBox; class QLabel; class QLineEdit; class QPushButton; @@ -70,9 +70,9 @@ private: QLabel *statusLabel; QTableWidget *altsTable; - QCheckBox *emailCheck; - QCheckBox *clientIdCheck; - QCheckBox *ipCheck; + QPushButton *emailChip; + QPushButton *clientIdChip; + QPushButton *ipChip; QLabel *vpnLabel; QSpinBox *vpnSpin; diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index c04de44ce..2aacfb1be 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -94,38 +95,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli topBar->addWidget(removeAvatarButton); userInfoGroup = new QGroupBox; - userInfoNameLabel = new QLabel; userInfoNameValue = new QLabel; - userInfoRegisteredLabel = new QLabel; - userInfoRegisteredValue = new QLabel; - userInfoLastLoginLabel = new QLabel; - userInfoLastLoginValue = new QLabel; - userInfoStatusLabel = new QLabel; + QFont displayFont = userInfoNameValue->font(); + displayFont.setPointSize(displayFont.pointSize() + 6); + displayFont.setBold(true); + userInfoNameValue->setFont(displayFont); userInfoStatusValue = new QLabel; - userInfoCountsLabel = new QLabel; - userInfoCountsValue = new QLabel; + QFont statusFont = userInfoStatusValue->font(); + statusFont.setBold(true); + userInfoStatusValue->setFont(statusFont); + sublineLabel = new QLabel; + + auto makeCountCard = [](const QString &title, QLabel *&value) { + auto *card = new QGroupBox(title); + value = new QLabel(QStringLiteral("–"), card); + QFont cardFont = value->font(); + cardFont.setPointSize(cardFont.pointSize() + 6); + cardFont.setBold(true); + value->setFont(cardFont); + value->setAlignment(Qt::AlignCenter); + auto *cardLayout = new QVBoxLayout(card); + cardLayout->addWidget(value); + return card; + }; + cardReports = makeCountCard(QString(), cardReportsValue); + cardWarns = makeCountCard(QString(), cardWarnsValue); + cardBans = makeCountCard(QString(), cardBansValue); + + altsCard = new QGroupBox; + altsCountValue = new QLabel(QStringLiteral("–"), altsCard); + QFont altsFont = altsCountValue->font(); + altsFont.setPointSize(altsFont.pointSize() + 6); + altsFont.setBold(true); + altsCountValue->setFont(altsFont); + altsCountValue->setAlignment(Qt::AlignCenter); + openAltsButton = new QPushButton; + connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph); + auto *altsCardLayout = new QVBoxLayout(altsCard); + altsCardLayout->addWidget(altsCountValue); + altsCardLayout->addWidget(openAltsButton); + + auto *cardsRow = new QHBoxLayout; + cardsRow->addWidget(cardReports); + cardsRow->addWidget(cardWarns); + cardsRow->addWidget(cardBans); + cardsRow->addWidget(altsCard); + userInfoNotesLabel = new QLabel; userInfoNotesEdit = new QTextEdit; userInfoNotesEdit->setReadOnly(true); - auto *infoGrid = new QGridLayout; - infoGrid->addWidget(userInfoNameLabel, 0, 0); - infoGrid->addWidget(userInfoNameValue, 0, 1); - infoGrid->addWidget(userInfoRegisteredLabel, 0, 2); - infoGrid->addWidget(userInfoRegisteredValue, 0, 3); - infoGrid->addWidget(userInfoLastLoginLabel, 1, 0); - infoGrid->addWidget(userInfoLastLoginValue, 1, 1); - infoGrid->addWidget(userInfoStatusLabel, 1, 2); - infoGrid->addWidget(userInfoStatusValue, 1, 3); - infoGrid->addWidget(userInfoCountsLabel, 2, 0); - infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3); - infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop); - infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3); - infoGrid->setColumnStretch(1, 1); - infoGrid->setColumnStretch(3, 1); + auto *identityRow = new QHBoxLayout; + identityRow->addWidget(userInfoNameValue); + identityRow->addStretch(); + identityRow->addWidget(userInfoStatusValue); auto *infoLayout = new QVBoxLayout(userInfoGroup); - infoLayout->addLayout(infoGrid); + infoLayout->addLayout(identityRow); + infoLayout->addWidget(sublineLabel); + infoLayout->addLayout(cardsRow); + infoLayout->addWidget(userInfoNotesLabel); + infoLayout->addWidget(userInfoNotesEdit); auto configureTable = [](QTableWidget *table) { table->setSelectionBehavior(QAbstractItemView::SelectRows); @@ -137,31 +167,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli table->horizontalHeader()->setStretchLastSection(true); }; - altsGroup = new QGroupBox; altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); - connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { - auto *userItem = altsTable->item(row, COL_ALTS_USER); + connect(altsTable, &QTableWidget::activated, this, [this](const QModelIndex &index) { + if (!index.isValid()) { + return; + } + auto *userItem = altsTable->item(index.row(), COL_ALTS_USER); if (userItem != nullptr && !userItem->text().isEmpty()) { getTabSupervisor()->openTabAlts(userItem->text()); } }); - openAltsButton = new QPushButton; - connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph); - auto *altsHeader = new QHBoxLayout; - altsHeader->addStretch(); - altsHeader->addWidget(openAltsButton); - auto *altsLayout = new QVBoxLayout(altsGroup); - altsLayout->addLayout(altsHeader); + auto *altsPage = new QWidget; + auto *altsLayout = new QVBoxLayout(altsPage); altsLayout->addWidget(altsTable); - sessionsGroup = new QGroupBox; sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT); configureTable(sessionsTable); - auto *sessionsLayout = new QVBoxLayout(sessionsGroup); + auto *sessionsPage = new QWidget; + auto *sessionsLayout = new QVBoxLayout(sessionsPage); sessionsLayout->addWidget(sessionsTable); - staffGroup = new QGroupBox; staffTable = new QTableWidget(0, COL_STAFF_COUNT); configureTable(staffTable); refreshStaffButton = new QPushButton; @@ -169,19 +195,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli auto *staffHeader = new QHBoxLayout; staffHeader->addStretch(); staffHeader->addWidget(refreshStaffButton); - auto *staffLayout = new QVBoxLayout(staffGroup); + auto *staffPage = new QWidget; + auto *staffLayout = new QVBoxLayout(staffPage); staffLayout->addWidget(staffTable); staffLayout->addLayout(staffHeader); + sectionTabs = new QTabWidget; + sectionTabs->addTab(altsPage, QString()); + sectionTabs->addTab(sessionsPage, QString()); + sectionTabs->addTab(staffPage, QString()); + auto *splitter = new QSplitter(Qt::Vertical); splitter->addWidget(userInfoGroup); - splitter->addWidget(altsGroup); - splitter->addWidget(sessionsGroup); - splitter->addWidget(staffGroup); + splitter->addWidget(sectionTabs); splitter->setStretchFactor(0, 1); splitter->setStretchFactor(1, 2); - splitter->setStretchFactor(2, 2); - splitter->setStretchFactor(3, 1); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); @@ -202,21 +230,28 @@ void TabModeration::retranslateUi() refreshStaffButton->setText(tr("Refresh")); userInfoGroup->setTitle(tr("User Info")); - userInfoNameLabel->setText(tr("Name:")); - userInfoRegisteredLabel->setText(tr("Registered:")); - userInfoLastLoginLabel->setText(tr("Last login:")); - userInfoStatusLabel->setText(tr("Status:")); - userInfoCountsLabel->setText(tr("Counts:")); userInfoNotesLabel->setText(tr("Admin notes:")); - - altsGroup->setTitle(tr("Alts")); - openAltsButton->setText(tr("Open alt graph")); + cardReports->setTitle(tr("Reports")); + cardWarns->setTitle(tr("Warnings")); + cardBans->setTitle(tr("Bans")); + altsCard->setTitle(tr("Alts")); + openAltsButton->setText(tr("Open graph")); openAltsButton->setToolTip(tr("Open this user in the User Alts tab")); - sessionsGroup->setTitle(tr("Sessions")); - staffGroup->setTitle(tr("Staff Last Logins")); + + sectionTabs->setTabText(0, tr("Alts")); + sectionTabs->setTabText(1, tr("Sessions")); + sectionTabs->setTabText(2, tr("Staff Last Logins")); + + if (cachedUserInfoValid) { + applyUserInfo(); + } + if (cachedAltsCount >= 0) { + applyAltsCount(); + } altsTable->setHorizontalHeaderLabels({tr("User"), 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 open it in the User Alts tab")); sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")}); staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")}); } @@ -232,11 +267,15 @@ QString TabModeration::formatEpoch(quint64 ts) const void TabModeration::clearUserData() { currentUser.clear(); + cachedUserInfoValid = false; + cachedAltsCount = -1; userInfoNameValue->clear(); - userInfoRegisteredValue->clear(); - userInfoLastLoginValue->clear(); userInfoStatusValue->clear(); - userInfoCountsValue->clear(); + sublineLabel->clear(); + cardReportsValue->setText(QStringLiteral("–")); + cardWarnsValue->setText(QStringLiteral("–")); + cardBansValue->setText(QStringLiteral("–")); + altsCountValue->setText(QStringLiteral("–")); userInfoNotesEdit->clear(); altsTable->setRowCount(0); sessionsTable->setRowCount(0); @@ -261,9 +300,12 @@ void TabModeration::investigateUser() return; } currentUser = userName; + cachedUserInfoValid = false; + cachedAltsCount = -1; resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); openAltsButton->setEnabled(true); + altsCountValue->setText(QStringLiteral("…")); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -282,10 +324,11 @@ void TabModeration::openAltsGraph() void TabModeration::requestUserInfo(const QString &userName) { userInfoNameValue->setText(userName); - userInfoRegisteredValue->setText(tr("Loading...")); - userInfoLastLoginValue->setText(tr("Loading...")); + sublineLabel->setText(tr("Loading...")); userInfoStatusValue->clear(); - userInfoCountsValue->clear(); + cardReportsValue->setText(QStringLiteral("…")); + cardWarnsValue->setText(QStringLiteral("…")); + cardBansValue->setText(QStringLiteral("…")); userInfoNotesEdit->clear(); Command_ReportUserInfo cmd; @@ -298,9 +341,12 @@ void TabModeration::requestUserInfo(const QString &userName) void TabModeration::userInfoResponse(const Response &response) { if (response.response_code() != Response::RespOk) { - userInfoRegisteredValue->clear(); - userInfoLastLoginValue->clear(); + cachedUserInfoValid = false; + sublineLabel->clear(); userInfoStatusValue->setText(tr("Error loading user info.")); + cardReportsValue->setText(QStringLiteral("–")); + cardWarnsValue->setText(QStringLiteral("–")); + cardBansValue->setText(QStringLiteral("–")); return; } @@ -309,20 +355,28 @@ void TabModeration::userInfoResponse(const Response &response) return; } - userInfoRegisteredValue->setText(formatEpoch(resp.registration_time())); - userInfoLastLoginValue->setText(formatEpoch(resp.last_login())); + cachedUserInfo.CopyFrom(resp); + cachedUserInfoValid = true; + applyUserInfo(); +} + +void TabModeration::applyUserInfo() +{ + const Response_ReportUserInfo &resp = cachedUserInfo; + sublineLabel->setText(tr("Registered %1 · Last login %2") + .arg(formatEpoch(resp.registration_time())) + .arg(formatEpoch(resp.last_login()))); QStringList statusParts; statusParts << (resp.is_active() ? tr("active") : tr("inactive")); if (resp.has_is_admin() && resp.is_admin()) { statusParts << tr("admin"); } - userInfoStatusValue->setText(statusParts.join(", ")); + userInfoStatusValue->setText(statusParts.join(QStringLiteral(", "))); - userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") - .arg(resp.total_reports()) - .arg(resp.total_bans()) - .arg(resp.total_warns())); + cardReportsValue->setText(QString::number(resp.total_reports())); + cardWarnsValue->setText(QString::number(resp.total_warns())); + cardBansValue->setText(QString::number(resp.total_bans())); if (resp.has_admin_notes() && !resp.admin_notes().empty()) { userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); @@ -331,12 +385,21 @@ void TabModeration::userInfoResponse(const Response &response) } } +void TabModeration::applyAltsCount() +{ + altsCountValue->setText(tr("%1 related").arg(cachedAltsCount)); +} + void TabModeration::requestSessions(const QString &userName) { Command_GetUserSessions cmd; cmd.set_user_name(userName.toStdString()); PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + sessionsResponse(response); + } + }); client->sendCommand(pend); } @@ -368,7 +431,11 @@ void TabModeration::requestAlts(const QString &userName) Command_GetUserAlts cmd; cmd.set_user_name(userName.toStdString()); PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); client->sendCommand(pend); } @@ -376,10 +443,14 @@ void TabModeration::altsResponse(const Response &response) { altsTable->setRowCount(0); if (response.response_code() != Response::RespOk) { + cachedAltsCount = -1; + altsCountValue->setText(QStringLiteral("–")); return; } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + cachedAltsCount = resp.alts_size(); + applyAltsCount(); altsTable->setRowCount(resp.alts_size()); for (int i = 0; i < resp.alts_size(); ++i) { const ServerInfo_UserAlt &alt = resp.alts(i); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index 47d191dc7..ab3750080 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -5,6 +5,7 @@ #include #include +#include #include class AbstractClient; @@ -13,6 +14,7 @@ class QLabel; class QLineEdit; class QPushButton; class QTableWidget; +class QTabWidget; class QTextEdit; class Response; class TabSupervisor; @@ -52,10 +54,15 @@ private: void requestAlts(const QString &userName); void requestModeratorLogins(); void clearUserData(); + void applyUserInfo(); + void applyAltsCount(); [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + Response_ReportUserInfo cachedUserInfo; + bool cachedUserInfoValid = false; + int cachedAltsCount = -1; QLineEdit *searchEdit; QPushButton *investigateButton; @@ -63,26 +70,25 @@ private: QPushButton *removeAvatarButton; QGroupBox *userInfoGroup; - QLabel *userInfoNameLabel; QLabel *userInfoNameValue; - QLabel *userInfoRegisteredLabel; - QLabel *userInfoRegisteredValue; - QLabel *userInfoLastLoginLabel; - QLabel *userInfoLastLoginValue; - QLabel *userInfoStatusLabel; QLabel *userInfoStatusValue; - QLabel *userInfoCountsLabel; - QLabel *userInfoCountsValue; + QLabel *sublineLabel; + QGroupBox *cardReports; + QGroupBox *cardWarns; + QGroupBox *cardBans; + QGroupBox *altsCard; + QLabel *cardReportsValue; + QLabel *cardWarnsValue; + QLabel *cardBansValue; + QLabel *altsCountValue; QLabel *userInfoNotesLabel; QTextEdit *userInfoNotesEdit; - QGroupBox *altsGroup; + QTabWidget *sectionTabs; QTableWidget *altsTable; - QPushButton *openAltsButton; - QGroupBox *sessionsGroup; QTableWidget *sessionsTable; - QGroupBox *staffGroup; QTableWidget *staffTable; + QPushButton *openAltsButton; QPushButton *refreshStaffButton; }; From 925234ebbb1567ac07e4acf366ca5a7228d76e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 22:08:58 +0200 Subject: [PATCH 07/11] [Client] Report queue dashboard and status pills TabReport becomes a dashboard: selected-report identity header (display-size user, bold status, category/time/room subline), Description/Chat/Comments/Reported-user tabbed sections with tab memory, always-visible user context with empty cues, and bold reported users. ReportStatusDelegate paints palette-tinted status pills with paint-through selection, translated status strings, and font/elision handling. report_utils gains formatReportStatus plus an opt-out status-color flag so the delegate owns color in the mod queue while the user dialog keeps its colors. Async responses stay guarded; retranslation re-applies header and comment states. --- cockatrice/CMakeLists.txt | 1 + .../widgets/tabs/report_status_delegate.cpp | 77 +++++++++ .../widgets/tabs/report_status_delegate.h | 22 +++ .../src/interface/widgets/tabs/tab_report.cpp | 160 ++++++++++++++---- .../src/interface/widgets/tabs/tab_report.h | 15 +- .../widgets/utility/report_utils.cpp | 28 ++- .../interface/widgets/utility/report_utils.h | 4 +- 7 files changed, 262 insertions(+), 45 deletions(-) create mode 100644 cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/report_status_delegate.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 3b4350e24..45428148b 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -390,6 +390,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/report_status_delegate.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp new file mode 100644 index 000000000..fe59702c4 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -0,0 +1,77 @@ +#include "report_status_delegate.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) +{ +} + +void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + QString status = index.data(Qt::UserRole).toString(); + if (status.isEmpty()) { + status = index.data(Qt::DisplayRole).toString(); + } + + QPalette::ColorRole tintRole = QPalette::NoRole; + if (status == QStringLiteral("open")) { + tintRole = QPalette::Highlight; + } else if (status == QStringLiteral("assigned")) { + tintRole = QPalette::Link; + } else if (status == QStringLiteral("resolved")) { + tintRole = QPalette::PlaceholderText; + } + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + + if (tintRole == QPalette::NoRole) { + const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::PlaceholderText; + QStyle *fallbackStyle = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + fallbackStyle->drawItemText(painter, opt.rect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, opt.text, + textRole); + 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); +} + +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); +} diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.h b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h new file mode 100644 index 000000000..5c168bcd6 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h @@ -0,0 +1,22 @@ +#ifndef REPORT_STATUS_DELEGATE_H +#define REPORT_STATUS_DELEGATE_H + +#include +#include + +/** + * 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. + */ +class ReportStatusDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit ReportStatusDelegate(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; +}; + +#endif // REPORT_STATUS_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 9018e2477..70c4b0574 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -4,6 +4,7 @@ #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" +#include "report_status_delegate.h" #include "tab_supervisor.h" #include @@ -24,7 +25,7 @@ #include #include #include -#include +#include #include #include #include @@ -125,17 +126,34 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); table->setContextMenuPolicy(Qt::CustomContextMenu); connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu); + table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table)); userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog); - descGroup = new QGroupBox; + reportHeaderName = new QLabel; + QFont displayFont = reportHeaderName->font(); + displayFont.setPointSize(displayFont.pointSize() + 6); + displayFont.setBold(true); + reportHeaderName->setFont(displayFont); + reportHeaderStatus = new QLabel; + QFont statusFont = reportHeaderStatus->font(); + statusFont.setBold(true); + reportHeaderStatus->setFont(statusFont); + reportHeaderSubline = new QLabel; + + auto *reportIdentityRow = new QHBoxLayout; + reportIdentityRow->addWidget(reportHeaderName); + reportIdentityRow->addStretch(); + reportIdentityRow->addWidget(reportHeaderStatus); + + auto *descPage = new QWidget; descriptionEdit = new QTextEdit; descriptionEdit->setReadOnly(true); - auto *descLayout = new QVBoxLayout(descGroup); + auto *descLayout = new QVBoxLayout(descPage); descLayout->setContentsMargins(4, 4, 4, 4); descLayout->addWidget(descriptionEdit); - chatGroup = new QGroupBox; + auto *chatPage = new QWidget; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); QFont monoFont("monospace"); @@ -145,11 +163,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T monoFont.setPointSize(systemPointSize); } chatLogEdit->setFont(monoFont); - auto *chatLayout = new QVBoxLayout(chatGroup); + auto *chatLayout = new QVBoxLayout(chatPage); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); - commentsGroup = new QGroupBox; + auto *commentsPage = new QWidget; commentsEdit = new QTextEdit; commentsEdit->setReadOnly(true); auto *commentInputRow = new QHBoxLayout; @@ -160,12 +178,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment); commentInputRow->addWidget(commentInput); commentInputRow->addWidget(commentButton); - auto *commentsLayout = new QVBoxLayout(commentsGroup); + auto *commentsLayout = new QVBoxLayout(commentsPage); commentsLayout->setContentsMargins(4, 4, 4, 4); commentsLayout->addWidget(commentsEdit); commentsLayout->addLayout(commentInputRow); - userContextGroup = new QGroupBox; + auto *userPage = new QWidget; userContextName = new QLabel; userContextAccountAge = new QLabel; userContextReports = new QLabel; @@ -184,7 +202,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T userContextWarnsLabel = new QLabel; userContextNotesLabel = new QLabel; userContextRecentReportsLabel = new QLabel; - auto *ucLayout = new QGridLayout(userContextGroup); + auto *ucLayout = new QGridLayout(userPage); ucLayout->setContentsMargins(8, 8, 8, 8); ucLayout->addWidget(userContextUserLabel, 0, 0); ucLayout->addWidget(userContextName, 0, 1, 1, 3); @@ -200,7 +218,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T ucLayout->addWidget(userContextNotes, 3, 1, 1, 3); ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4); ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4); - userContextGroup->setVisible(false); statsGroup = new QGroupBox; statsGroup->setCheckable(true); @@ -228,13 +245,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T } }); - detailSplitter = new QSplitter(Qt::Vertical); - detailSplitter->addWidget(descGroup); - detailSplitter->addWidget(chatGroup); - detailSplitter->addWidget(commentsGroup); - detailSplitter->setStretchFactor(0, 1); - detailSplitter->setStretchFactor(1, 1); - detailSplitter->setStretchFactor(2, 2); + detailTabs = new QTabWidget; + detailTabs->addTab(descPage, QString()); + detailTabs->addTab(chatPage, QString()); + detailTabs->addTab(commentsPage, QString()); + detailTabs->addTab(userPage, QString()); assignButton = new QPushButton; resolveButton = new QPushButton; @@ -266,12 +281,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T layout->addLayout(topBar); layout->addWidget(statsLabel); layout->addWidget(table, 2); - layout->addWidget(detailSplitter, 1); - layout->addWidget(userContextGroup); + layout->addLayout(reportIdentityRow); + layout->addWidget(reportHeaderSubline); + layout->addWidget(detailTabs, 2); layout->addWidget(statsGroup); layout->addLayout(actionBar); retranslateUi(); + clearReportHeader(); setActionsEnabled(false); refreshList(); @@ -293,18 +310,18 @@ void TabReport::retranslateUi() table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")}); - descGroup->setTitle(tr("Description")); - chatGroup->setTitle(tr("Chat Log Context")); - chatGroup->setToolTip( - tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited.")); - commentsGroup->setTitle(tr("Comments / Thread")); + detailTabs->setTabText(0, tr("Description")); + detailTabs->setTabText(1, tr("Chat Log Context")); + detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be " + "incomplete or edited.")); + detailTabs->setTabText(2, tr("Comments / Thread")); + detailTabs->setTabText(3, tr("Reported User")); descriptionEdit->setPlaceholderText(tr("No description.")); chatLogEdit->setPlaceholderText(tr("No chat log attached.")); commentsEdit->setPlaceholderText(tr("No comments yet.")); commentInput->setPlaceholderText(tr("Type a reply...")); commentButton->setText(tr("Send")); - userContextGroup->setTitle(tr("Reported User Context")); userContextUserLabel->setText(tr("User:")); userContextAgeLabel->setText(tr("Account Age:")); userContextReportsLabel->setText(tr("Reports:")); @@ -312,10 +329,18 @@ void TabReport::retranslateUi() userContextWarnsLabel->setText(tr("Warns:")); userContextNotesLabel->setText(tr("Admin Notes:")); userContextRecentReportsLabel->setText(tr("Recent Reports Against User:")); - userContextAccountAge->setText(QString()); - userContextReports->setText(QString()); - userContextBans->setText(QString()); - userContextWarns->setText(QString()); + + if (headerReportId >= 0) { + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == headerReportId) { + updateReportHeader(r); + const QString status = QString::fromStdString(r.status()); + const bool canComment = (status == "open" || status == "assigned"); + commentInput->setPlaceholderText(canComment ? tr("Type a reply...") : tr("This report is closed.")); + break; + } + } + } statsGroup->setTitle(tr("Report Statistics")); @@ -408,7 +433,8 @@ void TabReport::reportListResponse(const Response &response) chatLogEdit->clear(); commentsEdit->clear(); setActionsEnabled(false); - userContextGroup->setVisible(false); + clearReportHeader(); + clearUserContext(); } } @@ -449,7 +475,7 @@ void TabReport::applyFilters() const ServerInfo_Report &r = filteredReports[row]; report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, - COL_STATUS, COL_ASSIGNED); + COL_STATUS, COL_ASSIGNED, false); table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name()))); @@ -460,10 +486,32 @@ void TabReport::applyFilters() 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); + } + } + table->setSortingEnabled(true); table->resizeColumnsToContents(); table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + if (headerReportId >= 0) { + bool stillVisible = false; + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == headerReportId) { + stillVisible = true; + break; + } + } + if (!stillVisible) { + clearReportHeader(); + clearUserContext(); + } + } + statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size())); } @@ -490,6 +538,31 @@ void TabReport::updateStats() .arg(dismissed)); } +void TabReport::updateReportHeader(const ServerInfo_Report &report) +{ + reportHeaderName->setText(QString::fromStdString(report.reported_user_name())); + reportHeaderStatus->setText(report_utils::formatReportStatus(QString::fromStdString(report.status()))); + + QStringList parts; + parts << QString::fromStdString(report.category()); + parts << QDateTime::fromSecsSinceEpoch(report.report_time()).toLocalTime().toString("yyyy-MM-dd HH:mm"); + if (report.has_room_id() && report.room_id() > 0) { + parts << tr("Room %1").arg(report.room_id()); + } + if (report.game_id() > 0) { + parts << tr("Game %1").arg(report.game_id()); + } + reportHeaderSubline->setText(parts.join(QStringLiteral(" · "))); +} + +void TabReport::clearReportHeader() +{ + headerReportId = -1; + reportHeaderName->setText(tr("Select a report to inspect.")); + reportHeaderStatus->clear(); + reportHeaderSubline->clear(); +} + void TabReport::onSelectionChanged() { const int row = table->currentRow(); @@ -500,7 +573,8 @@ void TabReport::onSelectionChanged() commentInput->clear(); commentButton->setEnabled(false); setActionsEnabled(false); - userContextGroup->setVisible(false); + clearReportHeader(); + clearUserContext(); return; } @@ -509,6 +583,18 @@ void TabReport::onSelectionChanged() updateActionStates(); } +void TabReport::clearUserContext() +{ + lastRequestedUser.clear(); + userContextName->setText(tr("—")); + userContextAccountAge->clear(); + userContextReports->clear(); + userContextBans->clear(); + userContextWarns->clear(); + userContextNotes->setPlainText(tr("Select a report to inspect.")); + userContextRecentReports->clear(); +} + void TabReport::showTableContextMenu(const QPoint &pos) { QTableWidgetItem *clickedItem = table->itemAt(pos); @@ -546,12 +632,17 @@ void TabReport::loadReportDetails(int reportId) for (const ServerInfo_Report &r : filteredReports) { if (r.report_id() == reportId) { descriptionEdit->setPlainText(QString::fromStdString(r.description())); + updateReportHeader(r); + if (headerReportId != reportId) { + headerReportId = reportId; + detailTabs->setCurrentIndex(0); + } QString reportedUser = QString::fromStdString(r.reported_user_name()); if (!reportedUser.isEmpty()) { requestUserInfo(reportedUser); } else { - userContextGroup->setVisible(false); + clearUserContext(); } QString status = QString::fromStdString(r.status()); @@ -871,12 +962,11 @@ void TabReport::addCommentResponse(const Response &response) void TabReport::requestUserInfo(const QString &userName) { if (userName.isEmpty()) { - userContextGroup->setVisible(false); + clearUserContext(); return; } lastRequestedUser = userName; - userContextGroup->setVisible(true); userContextName->setText(userName); userContextAccountAge->setText(tr("Loading...")); userContextReports->clear(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 32ebe1a3f..8607883ce 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -16,8 +16,8 @@ class QGroupBox; class QLabel; class QLineEdit; class QPushButton; -class QSplitter; class QTableWidget; +class QTabWidget; class QTextEdit; class QTimer; class GameReplay; @@ -71,6 +71,9 @@ private: void applyFilters(); void updateStats(); void loadReportDetails(int reportId); + void updateReportHeader(const ServerInfo_Report &report); + void clearReportHeader(); + void clearUserContext(); AbstractClient *client; @@ -80,10 +83,10 @@ private: QPushButton *refreshButton; QLabel *statsLabel; QTableWidget *table; - QSplitter *detailSplitter; - QGroupBox *descGroup; - QGroupBox *chatGroup; - QGroupBox *commentsGroup; + QLabel *reportHeaderName; + QLabel *reportHeaderStatus; + QLabel *reportHeaderSubline; + QTabWidget *detailTabs; QTextEdit *descriptionEdit; QTextEdit *chatLogEdit; QTextEdit *commentsEdit; @@ -99,7 +102,6 @@ private: QTimer *refreshTimer; UserContextMenu *userContextMenu; - QGroupBox *userContextGroup; QLabel *userContextName; QLabel *userContextAccountAge; QLabel *userContextReports; @@ -124,6 +126,7 @@ private: QList allReports; QList filteredReports; + int headerReportId = -1; int detailsRequestedReportId = -1; int detailsRequestSeq = 0; int selectedReportIdBeforeRefresh = -1; diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp index d7fc083c6..f10a7d7d1 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -64,6 +64,23 @@ QString formatReportTime(qint64 secondsSinceEpoch) return dt.toString("yyyy-MM-dd hh:mm"); } +QString formatReportStatus(const QString &status) +{ + if (status == QStringLiteral("open")) { + return QObject::tr("Open"); + } + if (status == QStringLiteral("assigned")) { + return QObject::tr("Assigned"); + } + if (status == QStringLiteral("resolved")) { + return QObject::tr("Resolved"); + } + if (status == QStringLiteral("dismissed")) { + return QObject::tr("Dismissed"); + } + return status; +} + void fillReportTableRow(QTableWidget *table, int row, const ServerInfo_Report &report, @@ -73,7 +90,8 @@ void fillReportTableRow(QTableWidget *table, int categoryColumn, int gameIdColumn, int statusColumn, - int assignedColumn) + int assignedColumn, + bool colorizeStatus) { auto *idItem = new QTableWidgetItem(QString::number(report.report_id())); idItem->setData(Qt::UserRole, report.report_id()); @@ -86,8 +104,12 @@ void fillReportTableRow(QTableWidget *table, table->setItem(row, gameIdColumn, new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString())); - auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status())); - setReportStatusItemColor(statusItem, QString::fromStdString(report.status())); + const QString rawStatus = QString::fromStdString(report.status()); + auto *statusItem = new QTableWidgetItem(formatReportStatus(rawStatus)); + statusItem->setData(Qt::UserRole, rawStatus); + if (colorizeStatus) { + setReportStatusItemColor(statusItem, rawStatus); + } table->setItem(row, statusColumn, statusItem); table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name()))); diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h index 679989486..e22dbd980 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.h +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -13,6 +13,7 @@ namespace report_utils QString formatReportCategory(const QString &category); QString formatReportTime(qint64 secondsSinceEpoch); +QString formatReportStatus(const QString &status); void fillReportTableRow(QTableWidget *table, int row, @@ -23,7 +24,8 @@ void fillReportTableRow(QTableWidget *table, int categoryColumn, int gameIdColumn, int statusColumn, - int assignedColumn); + int assignedColumn, + bool colorizeStatus = true); void renderReportDetails(QTextEdit *chatLogEdit, QTextEdit *commentsEdit, From 360dd0ee2af66dee821571501edd125d9cbdf70d 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 08/11] 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 | 30 ++++++++++++++++++ .../widgets/utility/table_activation.h | 31 +++++++++++++++++++ 7 files changed, 81 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 45428148b..fae0e6293 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -305,6 +305,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 52c9eaef0..e5b132905 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -230,7 +230,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 ca4a495b9..a88c346bf 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -1,5 +1,6 @@ #include "tab_alts.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" @@ -134,7 +135,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; } @@ -390,7 +391,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 2aacfb1be..1b2ad5ad8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,5 +1,6 @@ #include "tab_moderation.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" @@ -169,7 +170,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; } @@ -449,7 +450,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 70c4b0574..e5f1e18aa 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -471,6 +471,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]; @@ -484,11 +487,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..f6c3e4b26 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.cpp @@ -0,0 +1,30 @@ +#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..224514d76 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.h @@ -0,0 +1,31 @@ +/** + * @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 89789dc317a0695ad979301160487b47e0d15937 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 09/11] [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 fae0e6293..66cbe240a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -392,6 +392,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 a88c346bf..a7ee12c2c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -2,6 +2,7 @@ #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" @@ -101,6 +102,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); @@ -135,6 +145,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; @@ -173,6 +189,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); @@ -185,6 +205,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(); @@ -224,6 +245,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(); @@ -264,16 +287,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(); @@ -393,6 +441,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); } @@ -435,6 +486,13 @@ void TabAlts::onFilterChanged() renderTable(); } +void TabAlts::investigateSelected() +{ + if (!selectedUser.isEmpty() && selectedUser != currentUser) { + investigate(selectedUser); + } +} + void TabAlts::onAltSelected() { const auto selected = altsTable->selectedItems(); @@ -458,6 +516,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()); @@ -497,6 +556,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 13e06086625bb188ed9aff3f160228ca81b2321b 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 10/11] [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 66cbe240a..80c5a09dc 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -303,6 +303,8 @@ set(cockatrice_SOURCES src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp 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 a7ee12c2c..0630cd5a8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -106,9 +106,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); } }); @@ -146,8 +149,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); @@ -273,6 +274,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()) { @@ -438,6 +453,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 fbf11acddca738a229b8b28a9728499aed59de7c 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 11/11] [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 0630cd5a8..21c0a321a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -267,6 +267,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);