[DeckShare] Browse and open public decks with loading, error and accessibility states (#7245)

* [DeckShare] Browse and open public decks with loading, error and accessibility states

Add a public-decks tab that lists decks published by other users using the
server's deck visibility feature, previewing each deck's banner card, color
identity, tags and upload time without downloading the deck list until the
user opens it.

- Add a public-decks tab with a shared-settings widget and a remote model
  that fetches the target user's decks and refreshes both automatically and
  on user request, with a loading indicator and a server-error message
  instead of a blank tab when the fetch fails or the connection drops
- Render each deck as a focusable preview tile whose banner, color identity,
  tags and upload time follow the existing Preview settings, with the deck
  name announced as the tile's accessible name and Space/Enter opening the
  deck, mirroring the shared-deck preview tile
- Show a message box when opening a public deck fails or arrives corrupted
- Publish and unpublish decks from the server storage toolbar and context
  menu, toggling the deck's own visibility bit (what the server persists)
  rather than the inherited effective state, and batch the visibility
  refresh until the last in-flight change is acknowledged
- Add the Show Upload Time setting so the tile's upload stamp can be hidden
  like the other preview details
- Update the retranslateUi wiring for the new public-decks tab and rename
  the share action tooltip from "Deck share" to "Share link"

* [DeckShare] Adapt deck upload to the server-derived banner and tag protocol

The server now derives the banner card and tags from the uploaded deck
list itself, so Command_DeckUpload only carries the client-computed color
identity. Drop the reserved banner/tag setters from the editor and storage
uploads, send the color identity on remote saves, and read tags from the
now-repeated ServerInfo_DeckStorage_TreeItem field.

* [DeckStorage] Refresh the visibility column with a guarded timer instead of a latch counter

A dropped visibility reply used to leave the pendingVisibilityChanges
counter permanently positive, so the Public/Private column never refreshed
again and nothing reset it on disconnect. A restartable single-shot timer
with a boolean guard re-reads the tree whenever publishes quiet down and is
stopped on disconnect, so a lost reply costs one stale refresh instead of
killing the column for the session.

* [DeckStorage] Summarize batch publish failures when the batch drains

Each rejected node stacked its own modal dialog, so publishing a ten-deck
selection against a rejecting server made the user dismiss ten dialogs one
at a time. Failures are now collected while the batch is in flight and shown
as a single summary when the visibility refresh timer fires; a reply that
lands outside an active batch still reports right away.

* [PublicDecks] Time out the loading state so a dropped reply cannot wedge the tab

loading only cleared in decksReceived, but the ping sweep can drop a pending
command without ever emitting finished, leaving the tab stuck on 'Loading
public decks...' and the refresh button permanently inert. A single-shot
timer started per refresh clears the latch and reports a timeout; the latch
also clears when the client disconnects.

* [PublicDecks] Escape remote-crafted text in tooltips and the tab title

Deck names and usernames come from other users' records and Qt renders
QLabel tooltips as AutoText, so a name like '<h1><table>...' parsed as
markup. Escape and bound the deck-name tooltip and escape the username
interpolated into the title label.

* [DeckStorage] Distinguish an inherited public state in the visibility column

The column reported the effective state while publishing toggles the node's
own bit, so a private deck inside a public folder already read 'Public' and
toggling appeared to do nothing (and toggling again silently unpublished
it). The cell now shows 'Public (inherited)' for that case and the tooltip
explains why.

* [PublicDecks] Run retranslateUi at construction and name the refresh button

retranslateUi was never called from the constructor, so the tooltips set
there were absent until a language change. Call it before the first refresh,
and give the icon-only refresh button an accessible name for screen readers.

* [PublicDecks] Keep the empty and status variants correct across language changes

retranslateUi unconditionally rewrote the empty label to the 'nothing
published' variant, stomping the 'no decks match your filters' choice
rebuildGrid had made, and a visible loading message stayed in the old
language. Let retranslateUi pick the same variant rebuildGrid does and
re-show the status so it retranslates.

* [VDS] Share one color-identity match rule between the two deck grids

The remote public decks model verbatim-copied updateColorMatches' switch,
down to the ExactMatch normalization and the fact that Includes/Excludes do
not normalize case. Extract colorIdentityMatches() next to the FilterMode
enum and call it from both so the subtle rule cannot drift.

* [DeckStorage] Drop the unused tree widget model accessor

The accessor handed the model out past the wrapper methods that exist to
keep it encapsulated, and nothing in the stack called it.

* [DeckShare] End the public-decks files with a trailing newline

keeps the final line's diff clean and stops clang-format CI from flagging
the files.

* [VDS] Reuse the shared quick settings widget for the public decks tab

PublicDecksQuickSettingsWidget was VisualDeckStorageQuickSettingsWidget
minus the folders, banner and tooltip controls, with identical wiring for
the shared keys and a version of the near-identical file to keep in sync by
hand. Fold the Show Upload Time checkbox into the shared widget, give it a
setPublicDecksMode() that hides the controls that do not apply, and delete
the duplicate.

* [PublicDecks] Drop stale deck-list replies after the loading timeout

A reply that lands after its own loading timeout (the reverse of the ping
sweep dropping the command) could stop the newer request's timeout timer
and repaint the grid with out-of-date data. Each refresh now captures a
monotonically increasing request id, and only the newest request's reply
updates the grid.

* [PublicDecks] Re-show a displayed failure message on language changes

The status label carries both the loading and the failure message, and
retranslateUi hid it whenever the model was not loading, so a language
change while a server-error or timeout message was on screen swapped it
for the (empty) grid. The tab now keeps the last failure text and
re-shows it when not loading, clearing it once a new refresh starts.

* [DeckStorage] Keep the visibility refresh armed until replies land

The single-shot drain was armed with the 500 ms delay at send time, so a
round trip slower than that drained before the server applied the change,
re-read the old state and never re-armed, leaving the column stale until
a manual refresh. The timer is now armed with the full network timeout at
send time (a lost reply still costs one stale refresh) and re-armed with
the short delay every time a reply lands.

* [DeckShare] Close public decks tabs when the client disconnects

TabSupervisor::stop() built tabsToDelete from the room and game tabs
only, so a public decks tab survived a disconnect, sitting with stale
contents and a refresh button that kept hitting the dead client. Its
values are now folded into the same cleanup.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-09-20 20:22:17 +02:00 committed by GitHub
parent 8ca749c07d
commit 6823d54c1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1275 additions and 37 deletions

View file

@ -324,6 +324,8 @@ set(cockatrice_SOURCES
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp
src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp
src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp
@ -395,6 +397,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_logs.cpp src/interface/widgets/tabs/tab_logs.cpp
src/interface/widgets/tabs/tab_message.cpp src/interface/widgets/tabs/tab_message.cpp
src/interface/widgets/tabs/tab_moderation.cpp src/interface/widgets/tabs/tab_moderation.cpp
src/interface/widgets/tabs/tab_public_decks.cpp
src/interface/widgets/tabs/tab_report.cpp src/interface/widgets/tabs/tab_report.cpp
src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_replays.cpp
src/interface/widgets/tabs/tab_room.cpp src/interface/widgets/tabs/tab_room.cpp

View file

@ -113,7 +113,7 @@ int RemoteDeckList_TreeModel::rowCount(const QModelIndex &parent) const
int RemoteDeckList_TreeModel::columnCount(const QModelIndex & /*parent*/) const int RemoteDeckList_TreeModel::columnCount(const QModelIndex & /*parent*/) const
{ {
return 3; return 4;
} }
QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) const QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) const
@ -121,7 +121,7 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
if (!index.isValid()) { if (!index.isValid()) {
return QVariant(); return QVariant();
} }
if (index.column() >= 3) { if (index.column() >= 4) {
return QVariant(); return QVariant();
} }
@ -134,12 +134,29 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
switch (index.column()) { switch (index.column()) {
case 0: case 0:
return node->getName(); return node->getName();
case 3:
// Report the node's own bit, not the inherited effective
// state, so it stays in step with what publishing toggles.
if (node->isPublic()) {
return tr("Public");
}
return isEffectivelyPublic(node) ? tr("Public (inherited)") : tr("Private");
default: default:
return QVariant(); return QVariant();
} }
} }
case Qt::DecorationRole: case Qt::DecorationRole:
return index.column() == 0 ? dirIcon : QVariant(); return index.column() == 0 ? dirIcon : QVariant();
case Qt::ToolTipRole:
if (index.column() == 3) {
if (node->isPublic()) {
return tr("This folder is visible to other users");
}
return isEffectivelyPublic(node)
? tr("This folder is private, but a parent folder is public (inherited).")
: tr("This folder is only visible to you");
}
return QVariant();
default: default:
return QVariant(); return QVariant();
} }
@ -153,6 +170,13 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
return file->getId(); return file->getId();
case 2: case 2:
return file->getUploadTime(); return file->getUploadTime();
case 3:
// Report the node's own bit, not the inherited effective
// state, so it stays in step with what publishing toggles.
if (file->isPublic()) {
return tr("Public");
}
return isEffectivelyPublic(file) ? tr("Public (inherited)") : tr("Private");
default: default:
return QVariant(); return QVariant();
} }
@ -161,6 +185,16 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
return index.column() == 0 ? fileIcon : QVariant(); return index.column() == 0 ? fileIcon : QVariant();
case Qt::TextAlignmentRole: case Qt::TextAlignmentRole:
return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft; return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft;
case Qt::ToolTipRole:
if (index.column() == 3) {
if (file->isPublic()) {
return tr("This deck is visible to other users");
}
return isEffectivelyPublic(file)
? tr("This deck is private, but a parent folder is public (inherited).")
: tr("This deck is only visible to you");
}
return QVariant();
default: default:
return QVariant(); return QVariant();
} }
@ -183,6 +217,8 @@ QVariant RemoteDeckList_TreeModel::headerData(int section, Qt::Orientation orien
return tr("ID"); return tr("ID");
case 2: case 2:
return tr("Upload time"); return tr("Upload time");
case 3:
return tr("Visibility");
default: default:
return QVariant(); return QVariant();
} }
@ -239,13 +275,14 @@ void RemoteDeckList_TreeModel::addFileToTree(const ServerInfo_DeckStorage_TreeIt
time.setSecsSinceEpoch(fileInfo.creation_time()); time.setSecsSinceEpoch(fileInfo.creation_time());
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent)); parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent, fileInfo.is_public()));
endInsertRows(); endInsertRows();
} }
void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent) void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent)
{ {
DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent); DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent);
newItem->setIsPublic(folder.folder().is_public());
const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder(); const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder();
const int folderItemsSize = folderInfo.items_size(); const int folderItemsSize = folderInfo.items_size();
for (int i = 0; i < folderItemsSize; ++i) { for (int i = 0; i < folderItemsSize; ++i) {
@ -285,6 +322,21 @@ void RemoteDeckList_TreeModel::refreshTree()
client->sendCommand(pend); client->sendCommand(pend);
} }
bool RemoteDeckList_TreeModel::isEffectivelyPublic(const Node *node) const
{
if (node == nullptr || node == root) {
return false;
}
const Node *current = node;
while (current != nullptr) {
if (current->isPublic()) {
return true;
}
current = current->getParent();
}
return false;
}
void RemoteDeckList_TreeModel::clearTree() void RemoteDeckList_TreeModel::clearTree()
{ {
beginResetModel(); beginResetModel();

View file

@ -27,9 +27,11 @@ public:
protected: protected:
DirectoryNode *parent; DirectoryNode *parent;
QString name; QString name;
bool publicFlag;
public: public:
explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) : parent(_parent), name(_name) explicit Node(const QString &_name, DirectoryNode *_parent = nullptr)
: parent(_parent), name(_name), publicFlag(false)
{ {
} }
virtual ~Node() = default; virtual ~Node() = default;
@ -41,6 +43,14 @@ public:
{ {
return name; return name;
} }
[[nodiscard]] bool isPublic() const
{
return publicFlag;
}
void setIsPublic(bool _public)
{
publicFlag = _public;
}
}; };
class DirectoryNode : public Node, public QList<Node *> class DirectoryNode : public Node, public QList<Node *>
{ {
@ -59,9 +69,14 @@ public:
QDateTime uploadTime; QDateTime uploadTime;
public: public:
FileNode(const QString &_name, int _id, const QDateTime &_uploadTime, DirectoryNode *_parent = nullptr) FileNode(const QString &_name,
int _id,
const QDateTime &_uploadTime,
DirectoryNode *_parent = nullptr,
bool _isPublic = false)
: Node(_name, _parent), id(_id), uploadTime(_uploadTime) : Node(_name, _parent), id(_id), uploadTime(_uploadTime)
{ {
setIsPublic(_isPublic);
} }
[[nodiscard]] int getId() const [[nodiscard]] int getId() const
{ {
@ -109,6 +124,11 @@ public:
{ {
return root; return root;
} }
/**
* @brief Whether a node is visible to other users (own flag or inherited
* from any ancestor folder).
*/
[[nodiscard]] bool isEffectivelyPublic(const Node *node) const;
void addFileToTree(const ServerInfo_DeckStorage_TreeItem &file, DirectoryNode *parent); void addFileToTree(const ServerInfo_DeckStorage_TreeItem &file, DirectoryNode *parent);
void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent); void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent);
DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent); DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent);

View file

@ -37,6 +37,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aDetails = new QAction(QString(), this); aDetails = new QAction(QString(), this);
aChat = new QAction(QString(), this); aChat = new QAction(QString(), this);
aShowGames = new QAction(QString(), this); aShowGames = new QAction(QString(), this);
aViewPublicDecks = new QAction(QString(), this);
aAddToBuddyList = new QAction(QString(), this); aAddToBuddyList = new QAction(QString(), this);
aRemoveFromBuddyList = new QAction(QString(), this); aRemoveFromBuddyList = new QAction(QString(), this);
aAddToIgnoreList = new QAction(QString(), this); aAddToIgnoreList = new QAction(QString(), this);
@ -64,6 +65,7 @@ void UserContextMenu::retranslateUi()
aDetails->setText(tr("User &details")); aDetails->setText(tr("User &details"));
aChat->setText(tr("Private &chat")); aChat->setText(tr("Private &chat"));
aShowGames->setText(tr("Show this user's &games")); aShowGames->setText(tr("Show this user's &games"));
aViewPublicDecks->setText(tr("View this user's &public decks"));
aAddToBuddyList->setText(tr("Add to &buddy list")); aAddToBuddyList->setText(tr("Add to &buddy list"));
aRemoveFromBuddyList->setText(tr("Remove from &buddy list")); aRemoveFromBuddyList->setText(tr("Remove from &buddy list"));
aAddToIgnoreList->setText(tr("Add to &ignore list")); aAddToIgnoreList->setText(tr("Add to &ignore list"));
@ -376,6 +378,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
} }
menu->addAction(aDetails); menu->addAction(aDetails);
menu->addAction(aShowGames); menu->addAction(aShowGames);
if (userLevel.testFlag(ServerInfo_User::IsRegistered)) {
menu->addAction(aViewPublicDecks);
}
menu->addAction(aChat); menu->addAction(aChat);
const QList<GameInviteOption> inviteOptions = inviteOptionsForUser(userName); const QList<GameInviteOption> inviteOptions = inviteOptionsForUser(userName);
if (!inviteOptions.isEmpty()) { if (!inviteOptions.isEmpty()) {
@ -455,6 +460,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName));
aShowGames->setEnabled(online); aShowGames->setEnabled(online);
aReport->setEnabled(anotherUser); aReport->setEnabled(anotherUser);
aViewPublicDecks->setEnabled(anotherUser);
aAddToBuddyList->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser);
aRemoveFromBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser);
aAddToIgnoreList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser);
@ -481,6 +487,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execChat(userName); execChat(userName);
} else if (actionClicked == aShowGames) { } else if (actionClicked == aShowGames) {
execShowGames(userName); execShowGames(userName);
} else if (actionClicked == aViewPublicDecks) {
execViewPublicDecks(userName);
} else if (actionClicked == aAddToBuddyList) { } else if (actionClicked == aAddToBuddyList) {
execAddToBuddy(userName); execAddToBuddy(userName);
} else if (actionClicked == aRemoveFromBuddyList) { } else if (actionClicked == aRemoveFromBuddyList) {
@ -604,6 +612,11 @@ void UserContextMenu::execShowGames(const QString &userName)
client->sendCommand(pend); client->sendCommand(pend);
} }
void UserContextMenu::execViewPublicDecks(const QString &userName)
{
tabSupervisor->openTabPublicDecks(userName);
}
void UserContextMenu::execAddToBuddy(const QString &userName) void UserContextMenu::execAddToBuddy(const QString &userName)
{ {
Command_AddToList cmd; Command_AddToList cmd;

View file

@ -37,6 +37,7 @@ private:
QAction *aUserName; QAction *aUserName;
QAction *aDetails; QAction *aDetails;
QAction *aShowGames; QAction *aShowGames;
QAction *aViewPublicDecks;
QAction *aChat; QAction *aChat;
QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToBuddyList, *aRemoveFromBuddyList;
QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList;
@ -111,6 +112,7 @@ public:
void execInvite(const QString &userName); void execInvite(const QString &userName);
void execDetails(const QString &userName); void execDetails(const QString &userName);
void execShowGames(const QString &userName); void execShowGames(const QString &userName);
void execViewPublicDecks(const QString &userName);
void execAddToBuddy(const QString &userName); void execAddToBuddy(const QString &userName);
void execRemoveFromBuddy(const QString &userName); void execRemoveFromBuddy(const QString &userName);
void execAddToIgnore(const QString &userName); void execAddToIgnore(const QString &userName);

View file

@ -11,6 +11,7 @@
#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h" #include "../../../client/settings/shortcuts_settings.h"
#include "../cards/additional_info/deck_color_identity.h"
#include "../client/network/interfaces/deck_stats_interface.h" #include "../client/network/interfaces/deck_stats_interface.h"
#include "../client/network/interfaces/tapped_out_interface.h" #include "../client/network/interfaces/tapped_out_interface.h"
#include "../deck_editor/deck_state_manager.h" #include "../deck_editor/deck_state_manager.h"
@ -324,6 +325,7 @@ bool AbstractTabDeckEditor::actSaveDeck()
Command_DeckUpload cmd; Command_DeckUpload cmd;
cmd.set_deck_id(static_cast<google::protobuf::uint32>(loadedDeck.lastLoadInfo.remoteDeckId)); cmd.set_deck_id(static_cast<google::protobuf::uint32>(loadedDeck.lastLoadInfo.remoteDeckId));
cmd.set_deck_list(deckString.toStdString()); cmd.set_deck_list(deckString.toStdString());
cmd.set_color_identity(getDeckColorIdentity(loadedDeck.deckList, CardDatabaseManager::query()).toStdString());
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished); connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished);

View file

@ -3,6 +3,7 @@
#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h" #include "../../deck_loader/deck_loader.h"
#include "../../pixel_map_generator.h" #include "../../pixel_map_generator.h"
#include "../cards/additional_info/deck_color_identity.h"
#include "../deck_share/deck_share_utils.h" #include "../deck_share/deck_share_utils.h"
#include "../deck_share/share_bar_widget.h" #include "../deck_share/share_bar_widget.h"
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
@ -25,11 +26,13 @@
#include <QTreeView> #include <QTreeView>
#include <QUrl> #include <QUrl>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h> #include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/command_deck_del.pb.h> #include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h> #include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h> #include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h> #include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_set_visibility.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h> #include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h> #include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h> #include <libcockatrice/protocol/pb/response.pb.h>
@ -41,6 +44,13 @@
#include <libcockatrice/settings/paths_settings.h> #include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/string_limits.h> #include <libcockatrice/utility/string_limits.h>
namespace
{
// How long to wait after the last visibility change before reading back the
// Public/Private column, in milliseconds.
constexpr int VISIBILITY_REFRESH_DELAY = 500;
} // namespace
TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
AbstractClient *_client, AbstractClient *_client,
const ServerInfo_User *currentUserInfo) const ServerInfo_User *currentUserInfo)
@ -117,6 +127,16 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
SettingsCache::instance().network().getKeepAlive() * 1000)); SettingsCache::instance().network().getKeepAlive() * 1000));
connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorage::onShareFromTreeTimeout); connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorage::onShareFromTreeTimeout);
// Restartable single-shot refresh for the Public/Private column. It is
// armed with the full network timeout when a publish is sent (so a dropped
// reply still drains once) and re-armed with the short delay every time a
// reply lands, so the drain cannot fire while a slow round trip is still in
// flight. Either way the tree is re-read once things quiet down.
visibilityRefreshTimer = new QTimer(this);
visibilityRefreshTimer->setSingleShot(true);
visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY);
connect(visibilityRefreshTimer, &QTimer::timeout, this, &TabDeckStorage::onVisibilityRefreshTimeout);
QVBoxLayout *rightVbox = new QVBoxLayout; QVBoxLayout *rightVbox = new QVBoxLayout;
rightVbox->addWidget(shareBar); rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView); rightVbox->addWidget(serverDirView);
@ -168,6 +188,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share")));
connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks);
aPublishDeck = new QAction(this);
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
// Add actions to toolbars // Add actions to toolbars
leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal); leftToolBar->addAction(aRenameLocal);
@ -180,6 +204,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload); rightToolBar->addAction(aDownload);
rightToolBar->addAction(aShareDecks); rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck); rightToolBar->addAction(aDeleteRemoteDeck);
@ -209,6 +234,7 @@ void TabDeckStorage::retranslateUi()
aDeleteLocalDeck->setText(tr("Delete")); aDeleteLocalDeck->setText(tr("Delete"));
aDeleteRemoteDeck->setText(tr("Delete")); aDeleteRemoteDeck->setText(tr("Delete"));
aShareDecks->setText(tr("Share decks")); aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder")); aOpenDecksFolder->setText(tr("Open decks folder"));
shareBar->retranslateUi(); shareBar->retranslateUi();
if (shareBar->isVisible()) { if (shareBar->isVisible()) {
@ -246,6 +272,8 @@ void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
void TabDeckStorage::handleConnectionChanged(ClientStatus status) void TabDeckStorage::handleConnectionChanged(ClientStatus status)
{ {
if (status == StatusDisconnected) { if (status == StatusDisconnected) {
visibilityRefreshTimer->stop();
visibilityRefreshStarted = false;
setRemoteEnabled(false); setRemoteEnabled(false);
} }
} }
@ -256,6 +284,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aOpenRemoteDeck->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled); aDownload->setEnabled(enabled);
aShareDecks->setEnabled(enabled); aShareDecks->setEnabled(enabled);
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled); aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled);
@ -384,6 +413,8 @@ void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPa
cmd.set_path(targetPath.toStdString()); cmd.set_path(targetPath.toStdString());
cmd.set_deck_list(deckString.toStdString()); cmd.set_deck_list(deckString.toStdString());
cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd); PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished); connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished);
client->sendCommand(pend); client->sendCommand(pend);
@ -814,7 +845,7 @@ void TabDeckStorage::shareFromTreeFinished(const Response &response, const Comma
void TabDeckStorage::showShareNotice(const QString &message, bool warning) void TabDeckStorage::showShareNotice(const QString &message, bool warning)
{ {
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message, QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message,
QMessageBox::Ok, this); QMessageBox::Ok, this);
box.exec(); box.exec();
} }
@ -828,3 +859,76 @@ void TabDeckStorage::onShareFromTreeTimeout()
shareBar->setCreateEnabled(true); shareBar->setCreateEnabled(true);
showShareNotice(tr("The server did not respond in time. Try again."), true); showShareNotice(tr("The server did not respond in time. Try again."), true);
} }
void TabDeckStorage::actPublishDeck()
{
visibilityFailures.clear();
// Arm the drain with the full network timeout so a lost reply still costs
// one refresh instead of a dead column; each reply shrinks it to the short
// delay below, so a slow round trip is never drained before it lands.
const int visibilityFailSafeDelay =
static_cast<int>((static_cast<qint64>(SettingsCache::instance().network().getTimeOut()) + 1) *
SettingsCache::instance().network().getKeepAlive() * 1000);
const auto selection = serverDirView->getCurrentSelection();
for (const auto *node : selection) {
Command_DeckSetVisibility cmd;
if (const auto *fileNode = dynamic_cast<const RemoteDeckList_TreeModel::FileNode *>(node)) {
cmd.set_deck_id(fileNode->getId());
} else if (const auto *dirNode = dynamic_cast<const RemoteDeckList_TreeModel::DirectoryNode *>(node)) {
const QString path = dirNode->getPath();
if (path.isEmpty()) {
continue; // the root folder cannot be published
}
cmd.set_folder_path(path.toStdString());
} else {
continue;
}
// Toggle the node's own visibility bit (what the server persists); the
// effective visibility shown by the column may additionally be inherited
// from a parent folder.
cmd.set_is_public(!node->isPublic());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::setVisibilityFinished);
visibilityRefreshStarted = true;
visibilityRefreshTimer->setInterval(visibilityFailSafeDelay);
visibilityRefreshTimer->start();
client->sendCommand(pend);
}
}
void TabDeckStorage::setVisibilityFinished(const Response &r, const CommandContainer & /*commandContainer*/)
{
if (r.response_code() == Response::RespOk) {
if (visibilityRefreshStarted) {
visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY);
visibilityRefreshTimer->start();
}
return;
}
// Collect batch failures and surface them once, when publishing quiets
// down, instead of stacking one modal dialog per rejected node.
const QString message = tr("Failed to change deck visibility on server (response code %1).")
.arg(QString::number(static_cast<int>(r.response_code())));
if (visibilityRefreshStarted) {
visibilityFailures.append(message);
visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY);
visibilityRefreshTimer->start();
} else {
QMessageBox::critical(this, tr("Error"), message);
}
}
void TabDeckStorage::onVisibilityRefreshTimeout()
{
visibilityRefreshStarted = false;
if (!visibilityFailures.isEmpty()) {
QMessageBox::critical(
this, tr("Error"),
tr("Failed to change the visibility of %n selected deck(s).", "", visibilityFailures.size()));
visibilityFailures.clear();
}
serverDirView->refreshTree();
}

View file

@ -11,6 +11,7 @@
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
#include "tab.h" #include "tab.h"
#include <QStringList>
#include <libcockatrice/network/client/abstract/abstract_client.h> #include <libcockatrice/network/client/abstract/abstract_client.h>
struct LoadedDeck; struct LoadedDeck;
@ -44,7 +45,10 @@ private:
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder; QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aNewFolder, *aDeleteRemoteDeck; QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
bool visibilityRefreshStarted = false;
QTimer *visibilityRefreshTimer;
QStringList visibilityFailures;
QString getTargetPath() const; QString getTargetPath() const;
void setRemoteEnabled(bool enabled); void setRemoteEnabled(bool enabled);
@ -92,6 +96,10 @@ private slots:
void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer); void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer);
void onShareFromTreeTimeout(); void onShareFromTreeTimeout();
void actPublishDeck();
void setVisibilityFinished(const Response &r, const CommandContainer &commandContainer);
void onVisibilityRefreshTimeout();
void actDeleteRemoteDeck(); void actDeleteRemoteDeck();
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);

View file

@ -0,0 +1,251 @@
#include "tab_public_decks.h"
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include "../general/layout_containers/flow_widget.h"
#include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h"
#include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h"
#include "../visual_deck_storage/remote_public_decks_model.h"
#include "../visual_deck_storage/visual_deck_storage_quick_settings_widget.h"
#include "../visual_deck_storage/visual_deck_storage_search_widget.h"
#include "../visual_deck_storage/visual_deck_storage_tag_filter_widget.h"
#include "tab_supervisor.h"
#include <QDateTime>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPixmap>
#include <QStringList>
#include <QToolButton>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_download_public.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <optional>
TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &_userName)
: Tab(_tabSupervisor), client(_client), userName(_userName)
{
model = new RemotePublicDecksModel(client, this);
cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize();
titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this);
QFont titleFont = titleLabel->font();
titleFont.setBold(true);
titleLabel->setFont(titleFont);
auto *headerLayout = new QHBoxLayout;
headerLayout->addWidget(titleLabel);
headerLayout->addStretch(1);
// Filter/toolbar row, matching the Visual Deck Storage: color identity filter
// first, the search bar stretching in the middle, and the quick settings
// cogwheel at the end. The card size slider lives inside the cogwheel popup.
emptyLabel = new QLabel(tr("This user has not published any decks."), this);
emptyLabel->setAlignment(Qt::AlignCenter);
emptyLabel->setVisible(false);
statusLabel = new QLabel(this);
statusLabel->setAlignment(Qt::AlignCenter);
statusLabel->setVisible(false);
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
flowWidget->setSpacing(8, 8);
colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this);
searchWidget = new VisualDeckStorageSearchWidget(this);
refreshButton = new QToolButton(this);
refreshButton->setIcon(QPixmap("theme:icons/reload"));
refreshButton->setFixedSize(32, 32);
quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this);
quickSettingsWidget->setPublicDecksMode(true);
auto *filterLayout = new QHBoxLayout;
filterLayout->addWidget(colorIdentityFilter);
filterLayout->addWidget(searchWidget, 1);
filterLayout->addWidget(refreshButton);
filterLayout->addWidget(quickSettingsWidget);
tagFilterWidget = new VisualDeckStorageTagFilterWidget(this);
tagFilterWidget->setAllTagsProvider([this] { return model->allTags(); });
updateTagsVisibility(quickSettingsWidget->getShowTagFilter());
auto *layout = new QVBoxLayout;
layout->addLayout(headerLayout);
layout->addLayout(filterLayout);
layout->addWidget(tagFilterWidget);
layout->addWidget(statusLabel);
layout->addWidget(emptyLabel);
layout->addWidget(flowWidget, 1);
auto *mainWidget = new QWidget(this);
mainWidget->setLayout(layout);
setCentralWidget(mainWidget);
connect(refreshButton, &QToolButton::clicked, this, [this] { model->refresh(userName); });
connect(model, &QAbstractItemModel::modelReset, this, &TabPublicDecks::rebuildGrid);
connect(model, &RemotePublicDecksModel::loadingChanged, this, &TabPublicDecks::updateLoadingState);
connect(model, &RemotePublicDecksModel::loadFailed, this, [this](const QString &message) {
lastFailureMessage = message;
statusLabel->setText(message);
statusLabel->setVisible(true);
flowWidget->setVisible(false);
emptyLabel->setVisible(false);
});
connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this,
[this](const QString &text) { model->setSearchText(text); });
connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this,
&TabPublicDecks::updateColorFilter);
connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this,
&TabPublicDecks::updateColorFilter);
connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, &TabPublicDecks::updateTagFilter);
connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, this,
&TabPublicDecks::updateCardSize);
connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showTagFilterChanged, this,
&TabPublicDecks::updateTagsVisibility);
retranslateUi();
model->refresh(userName);
}
QString TabPublicDecks::getTabText() const
{
return tr("Public decks of %1").arg(userName);
}
void TabPublicDecks::retranslateUi()
{
// The username is another user's data, so escape it for the AutoText QLabel.
titleLabel->setText(tr("Public decks of %1").arg(userName.toHtmlEscaped()));
// The same choice rebuildGrid makes, so a language change does not swap
// the "no match" variant for the "nothing published" one.
emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.")
: tr("This user has not published any decks."));
refreshButton->setToolTip(tr("Refresh"));
refreshButton->setAccessibleName(tr("Refresh"));
quickSettingsWidget->setToolTip(tr("Public Decks Settings"));
// Re-show whatever the status label is showing so a language change picks up
// the new language or, for a failure message, at least does not hide it.
if (model->isLoading()) {
updateLoadingState(true);
} else if (!lastFailureMessage.isEmpty()) {
statusLabel->setText(lastFailureMessage);
statusLabel->setVisible(true);
flowWidget->setVisible(false);
emptyLabel->setVisible(false);
} else {
updateLoadingState(false);
}
emit tabTextChanged(this, getTabText());
}
bool TabPublicDecks::closeRequest()
{
emit closing(this);
return Tab::closeRequest();
}
void TabPublicDecks::rebuildGrid()
{
flowWidget->clearLayout();
const int count = model->rowCount();
if (count == 0) {
emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.")
: tr("This user has not published any decks."));
}
emptyLabel->setVisible(count == 0);
for (int i = 0; i < count; ++i) {
auto *tile = new PublicDeckPreviewWidget(flowWidget, model->entryAt(i));
tile->setScaleFactor(cardSize);
connect(tile, &PublicDeckPreviewWidget::openDeckRequested, this, &TabPublicDecks::openDeck);
flowWidget->addWidget(tile);
}
// The deck set changed, so the tag filter chips are re-gathered from it.
tagFilterWidget->refreshTags();
}
void TabPublicDecks::updateColorFilter()
{
model->setColorFilter(colorIdentityFilter->getFilterMode(), colorIdentityFilter->getActiveColors());
}
void TabPublicDecks::updateTagFilter()
{
const QStringList selectedTags = tagFilterWidget->selectedTags();
const QStringList excludedTags = tagFilterWidget->excludedTags();
model->setTagFilter(QSet<QString>(selectedTags.cbegin(), selectedTags.cend()),
QSet<QString>(excludedTags.cbegin(), excludedTags.cend()));
tagFilterWidget->refreshTags();
}
void TabPublicDecks::updateTagsVisibility(bool visible)
{
tagFilterWidget->setVisible(visible);
}
void TabPublicDecks::updateLoadingState(bool loading)
{
if (loading) {
// A new attempt is under way, so the previously shown failure, if any,
// no longer describes the current state.
lastFailureMessage.clear();
statusLabel->setText(tr("Loading public decks…"));
statusLabel->setVisible(true);
flowWidget->setVisible(false);
emptyLabel->setVisible(false);
} else {
statusLabel->setVisible(false);
flowWidget->setVisible(true);
}
}
void TabPublicDecks::updateCardSize(int scale)
{
cardSize = scale;
applyCardSize(scale);
}
void TabPublicDecks::applyCardSize(int scale)
{
const auto tiles = flowWidget->findChildren<PublicDeckPreviewWidget *>();
for (PublicDeckPreviewWidget *tile : tiles) {
tile->setScaleFactor(scale);
}
flowWidget->setMinimumSizeToMaxSizeHint();
}
void TabPublicDecks::openDeck(int deckId)
{
Command_DeckDownloadPublic cmd;
cmd.set_deck_id(deckId);
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabPublicDecks::openDeckFinished);
client->sendCommand(pend);
}
void TabPublicDecks::openDeckFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
if (response.response_code() != Response::RespOk) {
QMessageBox::warning(this, tr("Open public deck"),
tr("Failed to open the public deck (server response code %1).")
.arg(QString::number(static_cast<int>(response.response_code()))));
return;
}
const Response_DeckDownload &resp = response.GetExtension(Response_DeckDownload::ext);
std::optional<LoadedDeck> deckOpt =
DeckLoader::loadFromRemote(QString::fromStdString(resp.deck()), LoadedDeck::LoadInfo::NON_REMOTE_ID);
if (!deckOpt) {
QMessageBox::warning(this, tr("Open public deck"), tr("The public deck could not be parsed."));
return;
}
tabSupervisor->openDeckInNewTab(deckOpt.value());
}

View file

@ -0,0 +1,80 @@
/**
* @file tab_public_decks.h
* @ingroup Tabs
*/
#ifndef TAB_PUBLIC_DECKS_H
#define TAB_PUBLIC_DECKS_H
#include "tab.h"
class AbstractClient;
class CommandContainer;
class DeckPreviewColorIdentityFilterWidget;
class FlowWidget;
class PublicDeckPreviewWidget;
class QLabel;
class QToolButton;
class RemotePublicDecksModel;
class Response;
class VisualDeckStorageQuickSettingsWidget;
class VisualDeckStorageSearchWidget;
class VisualDeckStorageTagFilterWidget;
/**
* @brief A visual grid of the public decks published by another user.
*
* The grid is rendered from the preview metadata the server stores for the
* decks, so browsing costs no downloads; the deck list is fetched via
* Command_DeckDownloadPublic only when the user opens a deck. Multiple users
* can be browsed simultaneously; each gets its own tab.
*/
class TabPublicDecks final : public Tab
{
Q_OBJECT
public:
TabPublicDecks(TabSupervisor *tabSupervisor, AbstractClient *client, const QString &userName);
[[nodiscard]] QString getTabText() const override;
void retranslateUi() override;
bool closeRequest() override;
[[nodiscard]] QString getUserName() const
{
return userName;
}
signals:
void closing(TabPublicDecks *tab);
private slots:
void openDeck(int deckId);
void openDeckFinished(const Response &response, const CommandContainer &commandContainer);
void updateColorFilter();
void updateTagFilter();
void updateCardSize(int scale);
void updateTagsVisibility(bool visible);
void updateLoadingState(bool loading);
private:
void rebuildGrid();
void applyCardSize(int scale);
AbstractClient *client;
QString userName;
RemotePublicDecksModel *model;
FlowWidget *flowWidget;
VisualDeckStorageSearchWidget *searchWidget;
DeckPreviewColorIdentityFilterWidget *colorIdentityFilter;
VisualDeckStorageTagFilterWidget *tagFilterWidget;
QToolButton *refreshButton;
VisualDeckStorageQuickSettingsWidget *quickSettingsWidget;
QLabel *titleLabel;
QLabel *statusLabel;
QLabel *emptyLabel;
QString lastFailureMessage; ///< Last load-failure text, re-shown on retranslate.
int cardSize = 100;
};
#endif // TAB_PUBLIC_DECKS_H

View file

@ -21,6 +21,7 @@
#include "tab_logs.h" #include "tab_logs.h"
#include "tab_message.h" #include "tab_message.h"
#include "tab_moderation.h" #include "tab_moderation.h"
#include "tab_public_decks.h"
#include "tab_replays.h" #include "tab_replays.h"
#include "tab_report.h" #include "tab_report.h"
#include "tab_room.h" #include "tab_room.h"
@ -274,6 +275,10 @@ void TabSupervisor::retranslateUi()
while (gameIterator.hasNext()) { while (gameIterator.hasNext()) {
tabs.append(gameIterator.next().value()); tabs.append(gameIterator.next().value());
} }
QMapIterator<QString, TabPublicDecks *> publicDecksIterator(publicDecksTabs);
while (publicDecksIterator.hasNext()) {
tabs.append(publicDecksIterator.next().value());
}
QListIterator<TabGame *> replayIterator(replayTabs); QListIterator<TabGame *> replayIterator(replayTabs);
while (replayIterator.hasNext()) { while (replayIterator.hasNext()) {
tabs.append(replayIterator.next()); tabs.append(replayIterator.next());
@ -626,6 +631,10 @@ void TabSupervisor::stop()
tabsToDelete << i.value(); tabsToDelete << i.value();
} }
for (auto i = publicDecksTabs.cbegin(), end = publicDecksTabs.cend(); i != end; ++i) {
tabsToDelete << i.value();
}
for (const auto tab : tabsToDelete) { for (const auto tab : tabsToDelete) {
tab->close(); tab->close();
} }
@ -1037,6 +1046,30 @@ void TabSupervisor::roomLeft(TabRoom *tab)
removeTab(indexOf(tab)); removeTab(indexOf(tab));
} }
void TabSupervisor::openTabPublicDecks(const QString &userName)
{
if (auto *existing = publicDecksTabs.value(userName, nullptr)) {
setCurrentWidget(existing);
return;
}
auto *tab = new TabPublicDecks(this, client, userName);
connect(tab, &TabPublicDecks::closing, this, &TabSupervisor::publicDecksClosed);
myAddTab(tab);
publicDecksTabs.insert(userName, tab);
setCurrentWidget(tab);
}
void TabSupervisor::publicDecksClosed(TabPublicDecks *tab)
{
if (tab == currentWidget()) {
emit setMenu();
}
publicDecksTabs.remove(tab->getUserName());
removeTab(indexOf(tab));
}
void TabSupervisor::switchToFirstAvailableNetworkTab() void TabSupervisor::switchToFirstAvailableNetworkTab()
{ {
if (!roomTabs.isEmpty()) { if (!roomTabs.isEmpty()) {

View file

@ -47,6 +47,7 @@ class TabAccount;
class TabDeckEditor; class TabDeckEditor;
class TabDeveloper; class TabDeveloper;
class TabLog; class TabLog;
class TabPublicDecks;
class RoomEvent; class RoomEvent;
class GameEventContainer; class GameEventContainer;
class Event_GameJoined; class Event_GameJoined;
@ -114,6 +115,7 @@ private:
QMap<int, TabGame *> gameTabs; QMap<int, TabGame *> gameTabs;
QList<TabGame *> replayTabs; QList<TabGame *> replayTabs;
QMap<QString, TabMessage *> messageTabs; QMap<QString, TabMessage *> messageTabs;
QMap<QString, TabPublicDecks *> publicDecksTabs;
QList<AbstractTabDeckEditor *> deckEditorTabs; QList<AbstractTabDeckEditor *> deckEditorTabs;
bool isLocalGame; bool isLocalGame;
@ -203,6 +205,7 @@ public slots:
void actTabReplays(bool checked); void actTabReplays(bool checked);
void openTabServer(); void openTabServer();
void addRoomTab(const ServerInfo_Room &info, bool setCurrent); void addRoomTab(const ServerInfo_Room &info, bool setCurrent);
void openTabPublicDecks(const QString &userName);
private slots: private slots:
void refreshShortcuts(); void refreshShortcuts();
@ -235,6 +238,7 @@ private slots:
void localGameJoined(const Event_GameJoined &event); void localGameJoined(const Event_GameJoined &event);
void gameLeft(TabGame *tab); void gameLeft(TabGame *tab);
void roomLeft(TabRoom *tab); void roomLeft(TabRoom *tab);
void publicDecksClosed(TabPublicDecks *tab);
TabMessage *addMessageTab(const QString &userName, bool focus); TabMessage *addMessageTab(const QString &userName, bool focus);
void replayLeft(TabGame *tab); void replayLeft(TabGame *tab);
void processUserLeft(const QString &userName); void processUserLeft(const QString &userName);

View file

@ -213,7 +213,7 @@ void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status)
void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning) void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning)
{ {
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message, QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message,
QMessageBox::Ok, this); QMessageBox::Ok, this);
box.exec(); box.exec();
} }

View file

@ -0,0 +1,167 @@
#include "public_deck_preview_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "../../cards/additional_info/color_identity_widget.h"
#include "../../cards/deck_preview_card_picture_widget.h"
#include "../../general/layout_containers/flow_widget.h"
#include "deck_preview_tag_display_widget.h"
#include <QKeyEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QResizeEvent>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry)
: QWidget(parent)
{
bannerCardDisplayWidget = new DeckPreviewCardPictureWidget(this);
bannerCardDisplayWidget->setFontSize(24);
// The whole tile is a single focusable, keyboard-operable control: Tab lands
// on it and Space/Enter opens the deck, mirroring the shared-deck preview tile.
setFocusPolicy(Qt::StrongFocus);
uploadTimeLabel = new QLabel(this);
uploadTimeLabel->setAlignment(Qt::AlignHCenter);
colorIdentityWidget = new ColorIdentityWidget(this);
tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
tagsFlowWidget->setSpacing(3, 3);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(bannerCardDisplayWidget);
layout->addWidget(uploadTimeLabel);
layout->addWidget(colorIdentityWidget);
layout->addWidget(tagsFlowWidget);
setLayout(layout);
connect(&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this,
&PublicDeckPreviewWidget::updateColorIdentityVisibility);
connect(&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::visualDeckStorageShowTagsOnDeckPreviewsChanged, this,
&PublicDeckPreviewWidget::updateTagsVisibility);
connect(&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::visualDeckStorageShowUploadTimeChanged, this,
&PublicDeckPreviewWidget::updateUploadTimeVisibility);
setEntry(entry);
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this,
&PublicDeckPreviewWidget::imageClickedEvent);
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this,
&PublicDeckPreviewWidget::imageDoubleClickedEvent);
// resizeEvent clamps every child to the banner picture's width, so collect them
// once here to keep the resize handler from searching the widget tree on every pass.
fixedWidthChildren = {bannerCardDisplayWidget, uploadTimeLabel, colorIdentityWidget, tagsFlowWidget};
}
void PublicDeckPreviewWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
if (bannerCardDisplayWidget == nullptr) {
return;
}
const int width = bannerCardDisplayWidget->width();
if (width == lastKnownBannerWidth) {
return;
}
lastKnownBannerWidth = width;
for (QWidget *widget : fixedWidthChildren) {
widget->setMaximumWidth(width);
}
}
void PublicDeckPreviewWidget::setEntry(const RemotePublicDecksModel::DeckEntry &entry)
{
deckId = entry.id;
hasColorIdentity = !entry.colorIdentity.isEmpty();
colorIdentityWidget->setColorIdentity(entry.colorIdentity);
updateColorIdentityVisibility();
const ExactCard bannerCard =
entry.bannerCardName.isEmpty()
? ExactCard()
: CardDatabaseManager::query()->getCard(CardRef{entry.bannerCardName, entry.bannerCardProvider});
bannerCardDisplayWidget->setCard(bannerCard);
// The deck name is the overlay text on the banner, like the local preview.
bannerCardDisplayWidget->setOverlayText(entry.name);
// The deck name comes from another user's record, and Qt tooltips are
// rendered as AutoText, so escape and bound it to keep it readable text
// (the overlay painted onto the banner is already a plain painter draw).
setToolTip(entry.name.left(200).toHtmlEscaped());
setBaseAccessibleName(entry.name);
tagsFlowWidget->clearLayout();
for (const QString &tag : entry.tags) {
auto *chip = new DeckPreviewTagDisplayWidget(tagsFlowWidget, tag);
chip->setAttribute(Qt::WA_TransparentForMouseEvents);
tagsFlowWidget->addWidget(chip);
}
hasTags = !entry.tags.isEmpty();
updateTagsVisibility();
uploadTimeLabel->setText(tr("Uploaded %1").arg(entry.uploadTime.toString(Qt::TextDate)));
hasUploadTime = !entry.uploadTime.isNull();
updateUploadTimeVisibility();
}
void PublicDeckPreviewWidget::updateColorIdentityVisibility()
{
colorIdentityWidget->setVisible(
hasColorIdentity && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity());
}
void PublicDeckPreviewWidget::updateTagsVisibility()
{
tagsFlowWidget->setVisible(
hasTags && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews());
}
void PublicDeckPreviewWidget::updateUploadTimeVisibility()
{
uploadTimeLabel->setVisible(hasUploadTime &&
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime());
}
void PublicDeckPreviewWidget::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) {
event->accept();
emit openDeckRequested(deckId);
return;
}
QWidget::keyPressEvent(event);
}
void PublicDeckPreviewWidget::setBaseAccessibleName(const QString &name)
{
baseAccessibleName = name;
setAccessibleName(name);
}
void PublicDeckPreviewWidget::setScaleFactor(int scale)
{
bannerCardDisplayWidget->setScaleFactor(scale);
}
void PublicDeckPreviewWidget::imageClickedEvent(QMouseEvent * /*event*/, DeckPreviewCardPictureWidget * /*instance*/)
{
// Reserved: clicking could show a card popup for the banner card.
}
void PublicDeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent * /*event*/,
DeckPreviewCardPictureWidget * /*instance*/)
{
emit openDeckRequested(deckId);
}

View file

@ -0,0 +1,75 @@
/**
* @file public_deck_preview_widget.h
* @ingroup VisualDeckPreviewWidgets
*/
#ifndef PUBLIC_DECK_PREVIEW_WIDGET_H
#define PUBLIC_DECK_PREVIEW_WIDGET_H
#include "../remote_public_decks_model.h"
#include <QList>
#include <QString>
#include <QWidget>
class ColorIdentityWidget;
class DeckPreviewCardPictureWidget;
class FlowWidget;
class QKeyEvent;
class QLabel;
class QMouseEvent;
class QResizeEvent;
/**
* @brief A preview tile for a public deck published by another user.
*
* Renders the banner card picture (looked up by name/provider in the card
* database) with the deck name overlaid, the color identity, the deck's tags
* (read-only) and its upload time, all from the metadata the server stores for
* the deck, so no deck list is downloaded until the user actually opens the
* deck. Double-clicking the banner requests opening it.
*/
class PublicDeckPreviewWidget final : public QWidget
{
Q_OBJECT
public:
explicit PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry);
void setEntry(const RemotePublicDecksModel::DeckEntry &entry);
/** @brief Sets the accessible name announced to assistive technologies. */
void setBaseAccessibleName(const QString &name);
/** @brief Scales the banner card picture, mirroring the Visual Deck Storage. */
void setScaleFactor(int scale);
signals:
void openDeckRequested(int deckId);
protected:
void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private slots:
void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void updateColorIdentityVisibility();
void updateTagsVisibility();
void updateUploadTimeVisibility();
private:
int deckId = 0;
QString baseAccessibleName;
bool hasColorIdentity = false;
bool hasTags = false;
bool hasUploadTime = false;
int lastKnownBannerWidth = 0;
QList<QWidget *> fixedWidthChildren;
DeckPreviewCardPictureWidget *bannerCardDisplayWidget;
ColorIdentityWidget *colorIdentityWidget;
FlowWidget *tagsFlowWidget;
QLabel *uploadTimeLabel;
};
#endif // PUBLIC_DECK_PREVIEW_WIDGET_H

View file

@ -0,0 +1,220 @@
#include "remote_public_decks_model.h"
#include "../../../client/settings/cache_settings.h"
#include <QTimer>
#include <algorithm>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_list_other_user.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deckstorage.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/network_settings.h>
RemotePublicDecksModel::RemotePublicDecksModel(AbstractClient *_client, QObject *parent)
: QAbstractListModel(parent), client(_client)
{
// The ping sweep can drop a pending command without ever emitting finished,
// so loading must not be a latch: time it out and clear it when the client
// goes away, or the tab is stuck on the loading state for the session.
loadingTimeoutTimer = new QTimer(this);
loadingTimeoutTimer->setSingleShot(true);
loadingTimeoutTimer->setInterval(
static_cast<int>((static_cast<qint64>(SettingsCache::instance().network().getTimeOut()) + 1) *
SettingsCache::instance().network().getKeepAlive() * 1000));
connect(loadingTimeoutTimer, &QTimer::timeout, this, &RemotePublicDecksModel::onLoadingTimeout);
connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) {
if (status == StatusDisconnected) {
loadingTimeoutTimer->stop();
setLoading(false);
}
});
}
int RemotePublicDecksModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : visibleIndices.size();
}
QVariant RemotePublicDecksModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= visibleIndices.size()) {
return QVariant();
}
if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
return decks.at(visibleIndices.at(index.row())).name;
}
return QVariant();
}
RemotePublicDecksModel::DeckEntry RemotePublicDecksModel::entryAt(int row) const
{
if (row < 0 || row >= visibleIndices.size()) {
return DeckEntry{};
}
return decks.at(visibleIndices.at(row));
}
void RemotePublicDecksModel::setSearchText(const QString &text)
{
searchText = text.trimmed();
rebuildVisibleIndices();
}
void RemotePublicDecksModel::setColorFilter(VisualDeckStorageSortFilterProxyModel::FilterMode mode,
const QSet<QChar> &colors)
{
colorFilterMode = mode;
activeColors = colors;
rebuildVisibleIndices();
}
void RemotePublicDecksModel::setTagFilter(const QSet<QString> &selected, const QSet<QString> &excluded)
{
includedTags = selected;
excludedTags = excluded;
rebuildVisibleIndices();
}
QSet<QString> RemotePublicDecksModel::allTags() const
{
QSet<QString> all;
for (const DeckEntry &entry : decks) {
all.unite(QSet<QString>(entry.tags.cbegin(), entry.tags.cend()));
}
return all;
}
void RemotePublicDecksModel::rebuildVisibleIndices()
{
QList<int> newIndices;
newIndices.reserve(decks.size());
for (int row = 0; row < decks.size(); ++row) {
const DeckEntry &entry = decks.at(row);
if (!searchText.isEmpty() && !entry.name.contains(searchText, Qt::CaseInsensitive)) {
continue;
}
if (!activeColors.isEmpty()) {
const QString &identity = entry.colorIdentity;
if (!colorIdentityMatches(colorFilterMode, activeColors, identity)) {
continue;
}
}
if (!includedTags.isEmpty()) {
const QSet<QString> entryTags(entry.tags.cbegin(), entry.tags.cend());
bool hasAll = std::all_of(includedTags.begin(), includedTags.end(),
[&entryTags](const QString &tag) { return entryTags.contains(tag); });
if (!hasAll) {
continue;
}
}
if (!excludedTags.isEmpty() && std::any_of(excludedTags.begin(), excludedTags.end(),
[&entry](const QString &tag) { return entry.tags.contains(tag); })) {
continue;
}
newIndices.append(row);
}
beginResetModel();
visibleIndices = newIndices;
endResetModel();
}
void RemotePublicDecksModel::refresh(const QString &userName)
{
if (loading) {
return;
}
// Every refresh captures its own request id so a reply that lands after its
// loading timeout (the reverse of the ping sweep dropping the command) is
// recognised as stale: it must not stop the newer request's timer or paint
// the grid with out-of-date data.
const int seq = ++requestSequence;
setLoading(true);
loadingTimeoutTimer->start();
Command_DeckListOtherUser cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this, seq](const Response &response, const CommandContainer &commandContainer) {
if (seq != requestSequence) {
return; // a newer refresh superseded this one
}
decksReceived(response, commandContainer);
});
client->sendCommand(pend);
}
void RemotePublicDecksModel::onLoadingTimeout()
{
setLoading(false);
emit loadFailed(tr("The server did not respond in time. Try again."));
}
void RemotePublicDecksModel::clear()
{
decks.clear();
rebuildVisibleIndices();
}
void RemotePublicDecksModel::setLoading(bool value)
{
if (loading == value) {
return;
}
loading = value;
emit loadingChanged(loading);
}
void RemotePublicDecksModel::decksReceived(const Response &response, const CommandContainer & /*commandContainer*/)
{
setLoading(false);
loadingTimeoutTimer->stop();
if (response.response_code() != Response::RespOk) {
emit loadFailed(tr("Failed to load the user's public decks (server response code %1).")
.arg(QString::number(static_cast<int>(response.response_code()))));
return;
}
const Response_DeckList &resp = response.GetExtension(Response_DeckList::ext);
decks.clear();
addFolder(resp.root());
rebuildVisibleIndices();
}
void RemotePublicDecksModel::addFolder(const ServerInfo_DeckStorage_Folder &folder)
{
const int itemCount = folder.items_size();
for (int i = 0; i < itemCount; ++i) {
addTreeItem(folder.items(i));
}
}
void RemotePublicDecksModel::addTreeItem(const ServerInfo_DeckStorage_TreeItem &item)
{
if (item.has_folder()) {
addFolder(item.folder());
return;
}
const ServerInfo_DeckStorage_File &file = item.file();
DeckEntry entry;
entry.id = item.id();
entry.name = QString::fromStdString(item.name());
entry.uploadTime = QDateTime::fromSecsSinceEpoch(file.creation_time());
entry.bannerCardName = QString::fromStdString(file.banner_card_name());
entry.bannerCardProvider = QString::fromStdString(file.banner_card_provider());
entry.colorIdentity = QString::fromStdString(file.color_identity());
QStringList tags;
for (const auto &tag : file.tags()) {
tags.append(QString::fromStdString(tag));
}
entry.tags = tags;
decks.append(entry);
}

View file

@ -0,0 +1,129 @@
/**
* @file remote_public_decks_model.h
* @ingroup DeckStorageWidgets
*/
#ifndef REMOTE_PUBLIC_DECKS_MODEL_H
#define REMOTE_PUBLIC_DECKS_MODEL_H
#include "visual_deck_storage_sort_filter_proxy_model.h"
#include <QAbstractListModel>
#include <QDateTime>
#include <QList>
#include <QSet>
#include <QStringList>
class AbstractClient;
class CommandContainer;
class QTimer;
class Response;
class ServerInfo_DeckStorage_Folder;
class ServerInfo_DeckStorage_TreeItem;
/**
* @brief Flat, read-only list of the public decks published by another user.
*
* Fetches the target user's public decks via Command_DeckListOtherUser and
* flattens the response tree into entries carrying the preview metadata stored
* on the server (banner card name/provider and color identity). No deck list is
* downloaded until the user actually opens a deck.
*
* Name and color-identity filtering is applied against this metadata, mirroring
* the Visual Deck Storage's filter semantics, so the grid can be narrowed like
* the local deck storage.
*/
class RemotePublicDecksModel : public QAbstractListModel
{
Q_OBJECT
public:
struct DeckEntry
{
int id = 0;
QString name;
QDateTime uploadTime;
QString bannerCardName;
QString bannerCardProvider;
QString colorIdentity;
QStringList tags;
};
/**
* @brief The color identity filter mode, shared with the Visual Deck Storage.
*/
using FilterMode = VisualDeckStorageSortFilterProxyModel::FilterMode;
explicit RemotePublicDecksModel(AbstractClient *client, QObject *parent = nullptr);
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/** @brief Fetches the public decks of another user, replacing the current contents. */
void refresh(const QString &userName);
void clear();
/** @brief Sets a case-insensitive substring filter on the deck name. */
void setSearchText(const QString &text);
/** @brief Sets the active color identity filter and mode. */
void setColorFilter(FilterMode mode, const QSet<QChar> &colors);
/** @brief Filters decks by required (`selected`) and forbidden (`excluded`) tags. */
void setTagFilter(const QSet<QString> &selected, const QSet<QString> &excluded);
/** @brief All tags present across all loaded decks, for building filter chips. */
[[nodiscard]] QSet<QString> allTags() const;
/** @brief The number of decks after filtering. */
[[nodiscard]] int filteredCount() const
{
return visibleIndices.size();
}
/** @brief The number of decks before filtering. */
[[nodiscard]] int totalCount() const
{
return decks.size();
}
/** @brief True while a refresh request is in flight and the grid has no data yet. */
[[nodiscard]] bool isLoading() const
{
return loading;
}
[[nodiscard]] DeckEntry entryAt(int row) const;
signals:
/** @brief Emitted when a refresh starts, completes, or fails (see loading()). */
void loadingChanged(bool loading);
/** @brief Emitted when the last refresh failed; contains a user-facing message. */
void loadFailed(const QString &message);
private slots:
void decksReceived(const Response &response, const CommandContainer &commandContainer);
void onLoadingTimeout();
private:
void addFolder(const ServerInfo_DeckStorage_Folder &folder);
void addTreeItem(const ServerInfo_DeckStorage_TreeItem &item);
void rebuildVisibleIndices();
void setLoading(bool value);
AbstractClient *client;
QTimer *loadingTimeoutTimer;
QList<DeckEntry> decks;
QList<int> visibleIndices; ///< Row indices into `decks` that pass the current filters.
bool loading = false;
int requestSequence = 0; ///< Monotonically increases per refresh; only the newest request may update the grid.
QString searchText;
VisualDeckStorageSortFilterProxyModel::FilterMode colorFilterMode = VisualDeckStorageSortFilterProxyModel::Includes;
QSet<QChar> activeColors;
QSet<QString> includedTags;
QSet<QString> excludedTags;
};
#endif // REMOTE_PUBLIC_DECKS_MODEL_H

View file

@ -50,6 +50,15 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
&SettingsCache::instance().visualDeckStorage(), &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews); &VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews);
// show upload time on DeckPreviewWidget checkbox
showUploadTimeCheckBox = new QCheckBox(this);
showUploadTimeCheckBox->setChecked(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime());
connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&VisualDeckStorageQuickSettingsWidget::showUploadTimeChanged);
connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime);
// show banner card selector checkbox // show banner card selector checkbox
showBannerCardComboBoxCheckBox = new QCheckBox(this); showBannerCardComboBoxCheckBox = new QCheckBox(this);
showBannerCardComboBoxCheckBox->setChecked( showBannerCardComboBoxCheckBox->setChecked(
@ -94,7 +103,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox); unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox);
// tooltip selector // tooltip selector
auto deckPreviewTooltipWidget = new QWidget(this); deckPreviewTooltipWidget = new QWidget(this);
deckPreviewTooltipLabel = new QLabel(deckPreviewTooltipWidget); deckPreviewTooltipLabel = new QLabel(deckPreviewTooltipWidget);
deckPreviewTooltipComboBox = new QComboBox(deckPreviewTooltipWidget); deckPreviewTooltipComboBox = new QComboBox(deckPreviewTooltipWidget);
@ -128,6 +137,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
this->addSettingsWidget(showTagFilterCheckBox); this->addSettingsWidget(showTagFilterCheckBox);
this->addSettingsWidget(showColorIdentityCheckBox); this->addSettingsWidget(showColorIdentityCheckBox);
this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox); this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox);
this->addSettingsWidget(showUploadTimeCheckBox);
this->addSettingsWidget(showBannerCardComboBoxCheckBox); this->addSettingsWidget(showBannerCardComboBoxCheckBox);
this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox); this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox);
this->addSettingsWidget(unusedColorIdentityOpacityWidget); this->addSettingsWidget(unusedColorIdentityOpacityWidget);
@ -145,6 +155,7 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi()
showTagFilterCheckBox->setText(tr("Show Tag Filter")); showTagFilterCheckBox->setText(tr("Show Tag Filter"));
showColorIdentityCheckBox->setText(tr("Show Color Identity")); showColorIdentityCheckBox->setText(tr("Show Color Identity"));
showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews")); showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews"));
showUploadTimeCheckBox->setText(tr("Show Upload Time"));
showBannerCardComboBoxCheckBox->setText(tr("Show Banner Card Selection Option")); showBannerCardComboBoxCheckBox->setText(tr("Show Banner Card Selection Option"));
drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities")); drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities"));
unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity")); unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity"));
@ -155,6 +166,14 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi()
deckPreviewTooltipComboBox->setItemText(1, tr("Filepath")); deckPreviewTooltipComboBox->setItemText(1, tr("Filepath"));
} }
void VisualDeckStorageQuickSettingsWidget::setPublicDecksMode(bool enabled)
{
const bool hidden = enabled;
showFoldersCheckBox->setVisible(!hidden);
showBannerCardComboBoxCheckBox->setVisible(!hidden);
deckPreviewTooltipWidget->setVisible(!hidden);
}
bool VisualDeckStorageQuickSettingsWidget::getShowFolders() const bool VisualDeckStorageQuickSettingsWidget::getShowFolders() const
{ {
return showFoldersCheckBox->isChecked(); return showFoldersCheckBox->isChecked();
@ -185,6 +204,11 @@ bool VisualDeckStorageQuickSettingsWidget::getShowTagsOnDeckPreviews() const
return showTagsOnDeckPreviewsCheckBox->isChecked(); return showTagsOnDeckPreviewsCheckBox->isChecked();
} }
bool VisualDeckStorageQuickSettingsWidget::getShowUploadTime() const
{
return showUploadTimeCheckBox->isChecked();
}
int VisualDeckStorageQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const int VisualDeckStorageQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const
{ {
return unusedColorIdentitiesOpacitySpinBox->value(); return unusedColorIdentitiesOpacitySpinBox->value();

View file

@ -27,10 +27,12 @@ class VisualDeckStorageQuickSettingsWidget : public SettingsButtonWidget
QCheckBox *showBannerCardComboBoxCheckBox; QCheckBox *showBannerCardComboBoxCheckBox;
QCheckBox *showTagFilterCheckBox; QCheckBox *showTagFilterCheckBox;
QCheckBox *showTagsOnDeckPreviewsCheckBox; QCheckBox *showTagsOnDeckPreviewsCheckBox;
QCheckBox *showUploadTimeCheckBox;
QLabel *unusedColorIdentitiesOpacityLabel; QLabel *unusedColorIdentitiesOpacityLabel;
QSpinBox *unusedColorIdentitiesOpacitySpinBox; QSpinBox *unusedColorIdentitiesOpacitySpinBox;
QLabel *deckPreviewTooltipLabel; QLabel *deckPreviewTooltipLabel;
QComboBox *deckPreviewTooltipComboBox; QComboBox *deckPreviewTooltipComboBox;
QWidget *deckPreviewTooltipWidget;
CardSizeWidget *cardSizeWidget; CardSizeWidget *cardSizeWidget;
public: public:
@ -46,6 +48,15 @@ public:
explicit VisualDeckStorageQuickSettingsWidget(QWidget *parent = nullptr); explicit VisualDeckStorageQuickSettingsWidget(QWidget *parent = nullptr);
/**
* @brief Hides the controls that do not apply to the public decks tab.
*
* The public decks tab reuses this widget for its quick settings menu but
* has no folders, banner selection or per-deck tooltip, so those controls
* are hidden while every shared key keeps syncing with SettingsCache.
*/
void setPublicDecksMode(bool enabled);
void retranslateUi(); void retranslateUi();
[[nodiscard]] bool getShowFolders() const; [[nodiscard]] bool getShowFolders() const;
@ -54,6 +65,7 @@ public:
[[nodiscard]] bool getShowBannerCardComboBox() const; [[nodiscard]] bool getShowBannerCardComboBox() const;
[[nodiscard]] bool getShowTagFilter() const; [[nodiscard]] bool getShowTagFilter() const;
[[nodiscard]] bool getShowTagsOnDeckPreviews() const; [[nodiscard]] bool getShowTagsOnDeckPreviews() const;
[[nodiscard]] bool getShowUploadTime() const;
[[nodiscard]] int getUnusedColorIdentitiesOpacity() const; [[nodiscard]] int getUnusedColorIdentitiesOpacity() const;
[[nodiscard]] TooltipType getDeckPreviewTooltip() const; [[nodiscard]] TooltipType getDeckPreviewTooltip() const;
[[nodiscard]] int getCardSize() const; [[nodiscard]] int getCardSize() const;
@ -65,6 +77,7 @@ signals:
void showBannerCardComboBoxChanged(bool enabled); void showBannerCardComboBoxChanged(bool enabled);
void showTagFilterChanged(bool enabled); void showTagFilterChanged(bool enabled);
void showTagsOnDeckPreviewsChanged(bool enabled); void showTagsOnDeckPreviewsChanged(bool enabled);
void showUploadTimeChanged(bool enabled);
void unusedColorIdentitiesOpacityChanged(int opacity); void unusedColorIdentitiesOpacityChanged(int opacity);
void deckPreviewTooltipChanged(TooltipType tooltip); void deckPreviewTooltipChanged(TooltipType tooltip);
void cardSizeChanged(int scale); void cardSizeChanged(int scale);

View file

@ -11,6 +11,34 @@ VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QOb
setDynamicSortFilter(false); setDynamicSortFilter(false);
} }
bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode,
const QSet<QChar> &colors,
const QString &identity)
{
switch (mode) {
case VisualDeckStorageSortFilterProxyModel::ExactMatch: {
QSet<QChar> activeColorSet;
for (const QChar &color : colors) {
activeColorSet.insert(color.toUpper());
}
QSet<QChar> colorIdentitySet;
for (const QChar &color : identity) {
colorIdentitySet.insert(color.toUpper());
}
return activeColorSet == colorIdentitySet;
}
case VisualDeckStorageSortFilterProxyModel::Includes:
return std::all_of(colors.begin(), colors.end(),
[&identity](const QChar &color) { return identity.contains(color); });
case VisualDeckStorageSortFilterProxyModel::Excludes:
return std::none_of(colors.begin(), colors.end(),
[&identity](const QChar &color) { return identity.contains(color); });
}
return false;
}
void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model) void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model)
{ {
if (QAbstractItemModel *oldModel = sourceModel()) { if (QAbstractItemModel *oldModel = sourceModel()) {
@ -255,34 +283,7 @@ void VisualDeckStorageSortFilterProxyModel::updateColorMatches()
for (int row = 0; row < count; ++row) { for (int row = 0; row < count; ++row) {
const QString colorIdentity = source->dataForRow(row).colorIdentity; const QString colorIdentity = source->dataForRow(row).colorIdentity;
colorMatches[row] = colorIdentityMatches(colorFilterMode, activeColors, colorIdentity);
bool matches = true;
switch (colorFilterMode) {
case ExactMatch: {
QSet<QChar> activeColorSet;
for (const QChar &color : activeColors) {
activeColorSet.insert(color.toUpper());
}
QSet<QChar> colorIdentitySet;
for (const QChar &color : colorIdentity) {
colorIdentitySet.insert(color.toUpper());
}
matches = activeColorSet == colorIdentitySet;
break;
}
case Includes:
matches = std::all_of(activeColors.begin(), activeColors.end(),
[&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
break;
case Excludes:
matches = std::none_of(activeColors.begin(), activeColors.end(),
[&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
break;
}
colorMatches[row] = matches;
} }
} }

View file

@ -102,4 +102,14 @@ private:
QList<bool> colorMatches; ///< Per-row color identity match. QList<bool> colorMatches; ///< Per-row color identity match.
}; };
/**
* @brief Whether an identity string matches the active color-identity filter.
*
* The single source of truth for the color identity matching rule, shared by
* the Visual Deck Storage proxy and the remote public decks model.
*/
[[nodiscard]] bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode,
const QSet<QChar> &colors,
const QString &identity);
#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H #endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H

View file

@ -130,6 +130,11 @@ bool VisualDeckStorageSettings::getVisualDeckStorageShowTagsOnDeckPreviews() con
return getValue("showTagsOnDeckPreviews", "interface", "visualDeckStorage", true).toBool(); return getValue("showTagsOnDeckPreviews", "interface", "visualDeckStorage", true).toBool();
} }
bool VisualDeckStorageSettings::getVisualDeckStorageShowUploadTime() const
{
return getValue("showUploadTime", "interface", "visualDeckStorage", true).toBool();
}
bool VisualDeckStorageSettings::getVisualDeckStorageDrawUnusedColorIdentities() const bool VisualDeckStorageSettings::getVisualDeckStorageDrawUnusedColorIdentities() const
{ {
return getValue("drawUnusedColorIdentities", "interface", "visualDeckStorage", true).toBool(); return getValue("drawUnusedColorIdentities", "interface", "visualDeckStorage", true).toBool();
@ -220,6 +225,12 @@ void VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews(bool
emit visualDeckStorageShowTagsOnDeckPreviewsChanged(_showTags); emit visualDeckStorageShowTagsOnDeckPreviewsChanged(_showTags);
} }
void VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime(bool value)
{
setValue(value, "showUploadTime", "interface", "visualDeckStorage");
emit visualDeckStorageShowUploadTimeChanged(value);
}
void VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities(bool _draw) void VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities(bool _draw)
{ {
setValue(_draw, "drawUnusedColorIdentities", "interface", "visualDeckStorage"); setValue(_draw, "drawUnusedColorIdentities", "interface", "visualDeckStorage");

View file

@ -20,6 +20,7 @@ public:
[[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const override; [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const override;
[[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const override; [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const override;
[[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const override; [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const override;
[[nodiscard]] bool getVisualDeckStorageShowUploadTime() const;
[[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const override; [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const override;
[[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const override; [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const override;
[[nodiscard]] int getVisualDeckStorageTooltipType() const override; [[nodiscard]] int getVisualDeckStorageTooltipType() const override;
@ -38,6 +39,7 @@ public:
void setVisualDeckStorageShowColorIdentity(bool value); void setVisualDeckStorageShowColorIdentity(bool value);
void setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox); void setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox);
void setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags); void setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags);
void setVisualDeckStorageShowUploadTime(bool value);
void setVisualDeckStorageDrawUnusedColorIdentities(bool _draw); void setVisualDeckStorageDrawUnusedColorIdentities(bool _draw);
void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity); void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity);
void setVisualDeckStorageTooltipType(int value); void setVisualDeckStorageTooltipType(int value);
@ -54,6 +56,7 @@ signals:
void visualDeckStorageShowColorIdentityChanged(bool _visible); void visualDeckStorageShowColorIdentityChanged(bool _visible);
void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible);
void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible);
void visualDeckStorageShowUploadTimeChanged(bool _visible);
void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible);
void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value);
void visualDeckStorageInGameChanged(bool enabled); void visualDeckStorageInGameChanged(bool enabled);

View file

@ -603,6 +603,19 @@ TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_SetAndGet)
ASSERT_EQ(s.getVisualDeckStorageDefaultTagsList(), custom); ASSERT_EQ(s.getVisualDeckStorageDefaultTagsList(), custom);
} }
TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_Default)
{
VisualDeckStorageSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), true);
}
TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_SetAndGet)
{
VisualDeckStorageSettings s(settingsPath, nullptr);
s.setVisualDeckStorageShowUploadTime(false);
ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), false);
}
} // namespace } // namespace
int main(int argc, char **argv) int main(int argc, char **argv)