[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

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

View file

@ -3,6 +3,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include "../../pixel_map_generator.h"
#include "../cards/additional_info/deck_color_identity.h"
#include "../deck_share/deck_share_utils.h"
#include "../deck_share/share_bar_widget.h"
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
@ -25,11 +26,13 @@
#include <QTreeView>
#include <QUrl>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.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_download.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_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
@ -41,6 +44,13 @@
#include <libcockatrice/settings/paths_settings.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,
AbstractClient *_client,
const ServerInfo_User *currentUserInfo)
@ -117,6 +127,16 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
SettingsCache::instance().network().getKeepAlive() * 1000));
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;
rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView);
@ -168,6 +188,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share")));
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
leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal);
@ -180,6 +204,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck);
@ -209,6 +234,7 @@ void TabDeckStorage::retranslateUi()
aDeleteLocalDeck->setText(tr("Delete"));
aDeleteRemoteDeck->setText(tr("Delete"));
aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder"));
shareBar->retranslateUi();
if (shareBar->isVisible()) {
@ -246,6 +272,8 @@ void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
void TabDeckStorage::handleConnectionChanged(ClientStatus status)
{
if (status == StatusDisconnected) {
visibilityRefreshTimer->stop();
visibilityRefreshStarted = false;
setRemoteEnabled(false);
}
}
@ -256,6 +284,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
aShareDecks->setEnabled(enabled);
aPublishDeck->setEnabled(enabled);
aNewFolder->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_deck_list(deckString.toStdString());
cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished);
client->sendCommand(pend);
@ -814,7 +845,7 @@ void TabDeckStorage::shareFromTreeFinished(const Response &response, const Comma
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);
box.exec();
}
@ -828,3 +859,76 @@ void TabDeckStorage::onShareFromTreeTimeout()
shareBar->setCreateEnabled(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 "tab.h"
#include <QStringList>
#include <libcockatrice/network/client/abstract/abstract_client.h>
struct LoadedDeck;
@ -44,7 +45,10 @@ private:
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
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;
void setRemoteEnabled(bool enabled);
@ -92,6 +96,10 @@ private slots:
void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer);
void onShareFromTreeTimeout();
void actPublishDeck();
void setVisibilityFinished(const Response &r, const CommandContainer &commandContainer);
void onVisibilityRefreshTimeout();
void actDeleteRemoteDeck();
void deleteFolderFinished(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_message.h"
#include "tab_moderation.h"
#include "tab_public_decks.h"
#include "tab_replays.h"
#include "tab_report.h"
#include "tab_room.h"
@ -274,6 +275,10 @@ void TabSupervisor::retranslateUi()
while (gameIterator.hasNext()) {
tabs.append(gameIterator.next().value());
}
QMapIterator<QString, TabPublicDecks *> publicDecksIterator(publicDecksTabs);
while (publicDecksIterator.hasNext()) {
tabs.append(publicDecksIterator.next().value());
}
QListIterator<TabGame *> replayIterator(replayTabs);
while (replayIterator.hasNext()) {
tabs.append(replayIterator.next());
@ -626,6 +631,10 @@ void TabSupervisor::stop()
tabsToDelete << i.value();
}
for (auto i = publicDecksTabs.cbegin(), end = publicDecksTabs.cend(); i != end; ++i) {
tabsToDelete << i.value();
}
for (const auto tab : tabsToDelete) {
tab->close();
}
@ -1037,6 +1046,30 @@ void TabSupervisor::roomLeft(TabRoom *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()
{
if (!roomTabs.isEmpty()) {

View file

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

View file

@ -213,7 +213,7 @@ void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status)
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);
box.exec();
}