Compare commits

...

7 commits

Author SHA1 Message Date
Lukas Brübach
9ac2b41e3e [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"
2026-09-05 22:56:14 +02:00
Lukas Brübach
ad2fb40a2a [DeckShare] Open shared decks via links with a gated preview flow
- Serialized url-chain dispatcher in IntentUrlParser; queue-drained
  urlChainFinished(bool) drives the startup auto-connect fallback
- Open-shared-deck intent with sequential download state machine,
  15s per-item timeout, partial-success offer, livable Cancel via
  ApplicationModal dlg_login_prompt interactive fallback
- Preview dialog: download progress label, share vocab sweep,
  palette-highlight selection frame, Space/Enter keyboard toggle,
  NoFocus checkbox, double-click tile opens immediately
- Confirm-before-server-migration with one-shot restore to the
  previous server on failed/cancelled chains (statusChanged settle
  deferral), hostname-only identity comparisons
- Skip credential link when already connected; arrow-key navigation
  in FlowWidget; card glows use palette highlight
- Address code-review M1-M4 and UI/UX QA blockers 1-2
2026-09-05 22:56:14 +02:00
Lukas Brübach
90e11e0d63 [DeckShare] Fix share-link expiry build on the minimum-supported Qt
QTimeZone::UTC (the Initialization enum) only exists since Qt 6.7, so
Debian 12 and Ubuntu 24.04 (Qt 6.4) fail to compile the share-link expiry
handling in the share dialog and the two deck-storage tabs. Mirror the
existing games_model guard and fall back to Qt::UTC on older Qt.
2026-09-05 22:56:09 +02:00
Lukas Brübach
86f19d3f11 [DeckShare] Address review findings and harden the share flows
Gate every share entry point on login, de-duplicate the share-link and
color-identity logic behind DeckShareUtils and an injected querier, and
replace the silent tray/status-bar notices with always-visible dialogs.

- abstract_tab_deck_editor: explain that sharing requires a connection
  instead of silently doing nothing when logged out
- tab_deck_storage: disable the share action on disconnect, reject
  folder/deck mixes and the root folder with clear warnings, re-enable
  Create on every entry/response so a dropped connection cannot leave
  the button disabled
- tab_deck_storage_visual: same login gate for the context-menu entry,
  visible success/error dialogs, and a symmetric in-flight guard
- getDeckColorIdentity now takes a CardDatabaseQuerier, dropping the
  CardDatabaseManager singleton access and enabling unit tests
2026-09-05 01:13:55 +02:00
Lukas Brübach
ef948a64f4 [DeckShare] Create temporary share links for local and server decks 2026-09-04 23:00:36 +02:00
Lukas Brübach
052595cf81 [VDS] Decouple tag filter and fix reordered-chips crash 2026-09-04 22:44:41 +02:00
Lukas Brübach
7ce7ae0432 [Server] Add deck share links and public deck visibility 2026-09-04 22:41:44 +02:00
95 changed files with 4649 additions and 182 deletions

View file

@ -45,11 +45,14 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp
src/interface/widgets/dialogs/dlg_load_remote_deck.cpp
src/interface/widgets/dialogs/dlg_local_game_options.cpp
src/interface/widgets/dialogs/dlg_login_prompt.cpp
src/interface/widgets/dialogs/dlg_manage_sets.cpp
src/interface/widgets/dialogs/dlg_my_reports.cpp
src/interface/widgets/dialogs/dlg_register.cpp
src/interface/widgets/dialogs/dlg_report_user.cpp
src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp
src/interface/widgets/dialogs/dlg_share_deck.cpp
src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
src/interface/widgets/dialogs/dlg_settings.cpp
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp
@ -57,6 +60,9 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp
src/interface/widgets/dialogs/tip_of_the_day.cpp
src/interface/widgets/deck_share/deck_share_utils.cpp
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp
src/interface/widgets/deck_share/share_bar_widget.cpp
src/filters/deck_filter_string.cpp
src/filters/filter_builder.cpp
src/filters/filter_tree_model.cpp
@ -163,6 +169,7 @@ set(cockatrice_SOURCES
src/interface/palette_editor/palette_grid_widget.cpp
src/interface/palette_editor/palette_editor_dialog.cpp
src/interface/widgets/cards/additional_info/color_identity_widget.cpp
src/interface/widgets/cards/additional_info/deck_color_identity.cpp
src/interface/widgets/cards/additional_info/mana_cost_widget.cpp
src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp
src/interface/widgets/cards/art_crop_attribution.cpp
@ -315,6 +322,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_item_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_model.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp
@ -374,6 +383,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp
src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp
src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp
src/interface/widgets/tabs/public_decks_quick_settings_widget.cpp
src/interface/widgets/tabs/tab.cpp
src/interface/widgets/tabs/tab_account.cpp
src/interface/widgets/tabs/tab_admin.cpp
@ -385,6 +395,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_logs.cpp
src/interface/widgets/tabs/tab_message.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_replays.cpp
src/interface/widgets/tabs/tab_room.cpp
@ -440,6 +451,8 @@ set(cockatrice_SOURCES
src/interface/intents/intent_login.h
src/interface/intents/intent_open_server_room_by_name.cpp
src/interface/intents/intent_open_server_room_by_name.h
src/interface/intents/intent_open_shared_deck.cpp
src/interface/intents/intent_open_shared_deck.h
src/interface/intents/url_parser.cpp
src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_info_popup.cpp

View file

@ -0,0 +1,14 @@
#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H
#define COCKATRICE_CONTEXT_OPEN_DECK_H
#include "context_connect_to_server.h"
#include <QString>
struct ContextOpenDeck
{
ContextConnectToServer serverContext;
QString shareToken;
};
#endif // COCKATRICE_CONTEXT_OPEN_DECK_H

View file

@ -2,10 +2,11 @@
Intent::Intent(QObject *parent) : QObject(parent)
{
// An intent is done as soon as it reports success or failure. Deleting it
// also tears down its dependency chain and disconnects any signal wiring.
// An intent is done as soon as it reports success, failure, or cancellation.
// Deleting it also tears down its dependency chain and disconnects any signal wiring.
connect(this, &Intent::finished, this, &QObject::deleteLater);
connect(this, &Intent::failed, this, &QObject::deleteLater);
connect(this, &Intent::cancelled, this, &QObject::deleteLater);
}
Intent::~Intent() = default;
@ -46,3 +47,11 @@ void Intent::emitFailed(const QString &reason)
emit failed(reason);
}
}
void Intent::emitCancelled()
{
if (!completed) {
completed = true;
emit cancelled();
}
}

View file

@ -16,6 +16,7 @@ public:
signals:
void finished();
void failed(QString reason);
void cancelled();
protected:
// --- Subclasses must implement these ---
@ -29,6 +30,7 @@ protected:
// Emit the outcome exactly once; ignore late signals after the intent is done.
void emitFinished();
void emitFailed(const QString &reason);
void emitCancelled();
private:
bool completed = false;

View file

@ -19,13 +19,10 @@ bool IntentJoinServerGame::checkPrecondition() const
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
return false;
}
// peerPort() reflects the actual TCP peer, which may differ from the
// configured server port (e.g. when connecting through a proxy), so only
// the hostname is compared here.
if (remoteClient->peerName() != context->roomContext.serverContext.hostname) {
return false;
}
if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) {
// serverName() reflects the server the client was configured to connect to,
// which may differ from the actual TCP peer (e.g. when connecting through a
// proxy), so only the hostname is compared here.
if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) {
return false;
}

View file

@ -1,8 +1,11 @@
#include "intent_login.h"
#include "../../client/settings/cache_settings.h"
#include "../widgets/dialogs/dlg_login_prompt.h"
#include "libcockatrice/settings/servers_settings.h"
#include <QDialog>
IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context)
{
}
@ -29,5 +32,27 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
void IntentGetLoginCredentials::onPreconditionNotSatisfied()
{
emitFailed(tr("No saved credentials for this server"));
// No credentials saved for the target server: ask the user for them. They
// opt into saving them so later links to the same server connect directly.
const QString serverText = context->hostname + ":" + context->port;
DlgLoginPrompt dialog(serverText);
// ApplicationModal: the dialog has no parent (the intent is not a widget),
// so WindowModal would not actually block any other window.
dialog.setWindowModality(Qt::ApplicationModal);
if (dialog.exec() != QDialog::Accepted) {
emitCancelled();
return;
}
context->username = dialog.username();
context->password = dialog.password();
if (dialog.savePassword() && !context->username.isEmpty()) {
ServersSettings &servers = SettingsCache::instance().servers();
servers.addNewServer(context->hostname, context->hostname, context->port, context->username, context->password,
true);
}
emitFinished();
}

View file

@ -0,0 +1,190 @@
#include "intent_open_shared_deck.h"
#include "../deck_loader/deck_loader.h"
#include "../widgets/dialogs/dlg_shared_decks_preview.h"
#include "../widgets/tabs/tab_supervisor.h"
#include "intent_connect_to_server.h"
#include <QMessageBox>
#include <QTimer>
#include <libcockatrice/card/database/card_database_querier.h>
#include <libcockatrice/protocol/pb/command_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
#include <libcockatrice/protocol/pending_command.h>
IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
const CardDatabaseQuerier *_querier,
std::unique_ptr<ContextOpenDeck> _context)
: Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), querier(_querier),
context(_context.release())
{
downloadTimer = new QTimer(this);
downloadTimer->setSingleShot(true);
downloadTimer->setInterval(15000);
connect(downloadTimer, &QTimer::timeout, this, &IntentOpenSharedDeck::onDownloadTimeout);
}
bool IntentOpenSharedDeck::checkPrecondition() const
{
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
return false;
}
// serverName() reflects the server the client was configured to connect to,
// which may differ from the actual TCP peer (e.g. when connecting through a
// proxy), so only the hostname is compared here.
return remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) == 0;
}
void IntentOpenSharedDeck::onPreconditionSatisfied()
{
// Resolve the share token to its items first; a share can contain more than
// one deck, and each item is downloaded by id.
Command_DeckShareList cmd;
cmd.set_token(context->shareToken.toStdString());
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::listShareFinished);
remoteClient->sendCommand(pend);
}
void IntentOpenSharedDeck::onPreconditionNotSatisfied()
{
runDependency(new IntentConnectToServer(remoteClient, &context->serverContext));
}
void IntentOpenSharedDeck::listShareFinished(const Response &response, const CommandContainer & /* commandContainer */)
{
if (response.response_code() != Response::RespOk) {
emitFailed(tr("The shared deck could not be found or has expired"));
return;
}
const Response_DeckShareList &resp = response.GetExtension(Response_DeckShareList::ext);
if (resp.items_size() == 0) {
emitFailed(tr("The shared deck is empty"));
return;
}
QList<ServerInfo_DeckShareItem> items;
items.reserve(resp.items_size());
for (const ServerInfo_DeckShareItem &item : resp.items()) {
items.append(item);
itemNames.insert(item.id(), QString::fromStdString(item.name()));
}
const QString serverText = context->serverContext.hostname + ":" + context->serverContext.port;
// Ask the user which decks to open before downloading anything.
previewDialog = new DlgSharedDecksPreview(tabSupervisor, querier, QString::fromStdString(resp.name()),
resp.expires_at(), serverText, items);
connect(previewDialog, &DlgSharedDecksPreview::openRequested, this, &IntentOpenSharedDeck::startDownloads);
connect(previewDialog, &DlgSharedDecksPreview::cancelled, this, &IntentOpenSharedDeck::emitCancelled);
connect(previewDialog, &DlgSharedDecksPreview::cancelled, previewDialog, &QWidget::deleteLater);
previewDialog->show();
previewDialog->raise();
previewDialog->activateWindow();
}
void IntentOpenSharedDeck::startDownloads(const QList<int> &itemIds)
{
pendingItemIds = itemIds;
totalItems = itemIds.size();
completedItems = 0;
loadedDecks.clear();
downloadNextItem();
}
void IntentOpenSharedDeck::downloadNextItem()
{
if (pendingItemIds.isEmpty()) {
finishAll();
return;
}
currentItemId = pendingItemIds.takeFirst();
downloadTimer->start();
Command_DeckShareDownload cmd;
cmd.set_token(context->shareToken.toStdString());
cmd.set_item_id(currentItemId);
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::downloadShareFinished);
remoteClient->sendCommand(pend);
}
void IntentOpenSharedDeck::downloadShareFinished(const Response &response,
const CommandContainer & /* commandContainer */)
{
downloadTimer->stop();
QString failureReason;
if (response.response_code() != Response::RespOk) {
failureReason = tr("Failed to download the shared deck");
} else {
const Response_DeckShareDownload &resp = response.GetExtension(Response_DeckShareDownload::ext);
const QString deckString = QString::fromStdString(resp.deck());
if (deckString.isEmpty()) {
failureReason = tr("The shared deck is empty");
} else {
std::optional<LoadedDeck> deckOpt =
DeckLoader::loadFromRemote(deckString, LoadedDeck::LoadInfo::NON_REMOTE_ID);
if (!deckOpt) {
failureReason = tr("The shared deck could not be loaded");
} else {
loadedDecks.append(deckOpt.value());
++completedItems;
previewDialog->setDownloadProgress(completedItems, totalItems,
itemNames.value(currentItemId, tr("Unknown deck")));
downloadNextItem();
return;
}
}
}
onItemFailure(failureReason);
}
void IntentOpenSharedDeck::onItemFailure(const QString &reason)
{
downloadTimer->stop();
if (loadedDecks.isEmpty()) {
previewDialog->deleteLater();
emitFailed(reason);
return;
}
const int downloadedCount = loadedDecks.size();
const QMessageBox::StandardButton answer = QMessageBox::question(
previewDialog, tr("Open shared decks"),
tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount)
.arg(itemNames.value(currentItemId, tr("Unknown deck"))),
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (answer == QMessageBox::Yes) {
finishAll();
} else {
previewDialog->deleteLater();
emitCancelled();
}
}
void IntentOpenSharedDeck::onDownloadTimeout()
{
onItemFailure(tr("Timed out while downloading the shared deck"));
}
void IntentOpenSharedDeck::finishAll()
{
previewDialog->deleteLater();
for (const LoadedDeck &deck : loadedDecks) {
tabSupervisor->openDeckInNewTab(deck);
}
emitFinished();
}

View file

@ -0,0 +1,59 @@
#ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H
#define COCKATRICE_INTENT_OPEN_SHARED_DECK_H
#include "contexts/context_open_deck.h"
#include "intent.h"
#include "remote_client.h"
#include <QList>
#include <QMap>
#include <QScopedPointer>
#include <memory>
class TabSupervisor;
struct LoadedDeck;
class CardDatabaseQuerier;
class DlgSharedDecksPreview;
class QTimer;
class IntentOpenSharedDeck : public Intent
{
Q_OBJECT
public:
IntentOpenSharedDeck(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
const CardDatabaseQuerier *_querier,
std::unique_ptr<ContextOpenDeck> _context);
protected:
bool checkPrecondition() const override;
void onPreconditionSatisfied() override;
void onPreconditionNotSatisfied() override;
private slots:
void listShareFinished(const Response &response, const CommandContainer &commandContainer);
void downloadShareFinished(const Response &response, const CommandContainer &commandContainer);
void onDownloadTimeout();
private:
void startDownloads(const QList<int> &itemIds);
void downloadNextItem();
void onItemFailure(const QString &reason);
void finishAll();
TabSupervisor *tabSupervisor;
RemoteClient *remoteClient;
const CardDatabaseQuerier *querier;
QScopedPointer<ContextOpenDeck> context;
DlgSharedDecksPreview *previewDialog = nullptr;
QTimer *downloadTimer;
QMap<int, QString> itemNames;
QList<int> pendingItemIds;
QList<LoadedDeck> loadedDecks;
int currentItemId = 0;
int totalItems = 0;
int completedItems = 0;
};
#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H

View file

@ -1,19 +1,28 @@
#include "url_parser.h"
#include "../../client/settings/cache_settings.h"
#include "../widgets/tabs/tab_room.h"
#include "../widgets/tabs/tab_supervisor.h"
#include "../window_main.h"
#include "contexts/context_join_game.h"
#include "contexts/context_open_deck.h"
#include "intent.h"
#include "intent_join_server_game.h"
#include "intent_login.h"
#include "intent_open_shared_deck.h"
#include <QDebug>
#include <QLoggingCategory>
#include <QMessageBox>
#include <QUrl>
#include <QUrlQuery>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/settings/servers_settings.h>
#include <memory>
inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser");
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
{
}
@ -29,16 +38,33 @@ void IntentUrlParser::handle(const QString &urlStr)
const QString action = url.host();
QUrlQuery query(url);
qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action;
QList<Intent *> chain;
Intent *firstIntent = nullptr;
if (action == "joingame") {
handleJoinGame(query);
firstIntent = createJoinGameIntent(query, chain);
} else if (action == "opendeck") {
// handleOpenDeck(query);
firstIntent = createOpenDeckIntent(query, chain);
} else {
qWarning() << "Unknown intent:" << action;
}
if (firstIntent == nullptr) {
// The link was invalid or the user declined the confirm: nothing runs.
// Report the idle state when no other chain is queued so that a startup
// launch (which skipped its own connection for this URL) falls back to it.
if (!chainRunning && pendingChains.isEmpty()) {
emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn);
}
return;
}
pendingChains.append(chain);
startNextChain();
}
void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, QList<Intent *> &chain)
{
auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); };
@ -49,21 +75,21 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
if (ctx->roomContext.serverContext.hostname.isEmpty()) {
showError(tr("Missing or empty hostname in the game link"));
return;
return nullptr;
}
bool ok = false;
ctx->roomContext.serverContext.port.toUShort(&ok);
if (!ok) {
showError(tr("Invalid or missing port in the game link"));
return;
return nullptr;
}
ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok);
if (!ok) {
showError(tr("Invalid or missing room id in the game link"));
return;
return nullptr;
}
ok = false;
@ -71,7 +97,7 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
if (!ok) {
showError(tr("Invalid or missing game id in the game link"));
return;
return nullptr;
}
const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded);
@ -80,24 +106,32 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
const QMessageBox::StandardButton answer = QMessageBox::question(
mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (answer != QMessageBox::Yes) {
return;
return nullptr;
}
RemoteClient *client = mainWindow->getRemoteClient();
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
// The join game intent owns the context and the credential lookup; once the
// chain finishes (or fails) it deletes the whole tree.
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
auto joinGameIntent =
new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx));
auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx));
joinGameIntent->setParent(this);
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
getLoginCredentialsIntent->setParent(joinGameIntent);
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
chain.append(joinGameIntent);
connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
getLoginCredentialsIntent->execute();
Intent *firstIntent = joinGameIntent;
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
getLoginCredentialsIntent->setParent(joinGameIntent);
chain.insert(0, getLoginCredentialsIntent);
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled);
firstIntent = getLoginCredentialsIntent;
}
return firstIntent;
}
QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription)
@ -134,3 +168,221 @@ QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context,
.arg(gameDescription, gameIdStr, roomTab->getRoomName(), server)
: tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server);
}
Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, QList<Intent *> &chain)
{
auto showError = [this](const QString &message) {
QMessageBox::warning(mainWindow, tr("Open shared deck"), message);
};
auto ctx = std::make_unique<ContextOpenDeck>();
ctx->serverContext.hostname = query.queryItemValue("hostname");
ctx->serverContext.port = query.queryItemValue("port");
ctx->shareToken = query.queryItemValue("share");
qCDebug(UrlParserLog) << "Open-deck intent: host" << ctx->serverContext.hostname << "port"
<< ctx->serverContext.port << "token length" << ctx->shareToken.length();
if (ctx->serverContext.hostname.isEmpty()) {
showError(tr("Missing or empty hostname in the share link"));
return nullptr;
}
bool ok = false;
const quint16 port = ctx->serverContext.port.toUShort(&ok);
if (!ok || port == 0) {
showError(tr("Invalid or missing port in the share link"));
return nullptr;
}
if (ctx->shareToken.isEmpty()) {
showError(tr("Missing or empty share value in the share link"));
return nullptr;
}
RemoteClient *client = mainWindow->getRemoteClient();
// When the link would move us away from a live session, ask first — the
// open deck download needs the connection the user already has. Remember
// the current session so a failed or cancelled chain can restore it.
const bool migrating =
client->getStatus() == StatusLoggedIn && !isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port);
if (migrating) {
const QString target = QStringLiteral("%1:%2").arg(ctx->serverContext.hostname, ctx->serverContext.port);
const QString current =
QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort()));
const QMessageBox::StandardButton answer = QMessageBox::question(
mainWindow, tr("Open shared deck"),
tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current),
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (answer != QMessageBox::Yes) {
return nullptr;
}
migrationTargetHost = ctx->serverContext.hostname;
migrationTargetPort = ctx->serverContext.port;
previousServerHost = client->serverName();
previousServerPort = QString::number(client->serverPort());
pendingRestore = true;
}
ContextConnectToServer *serverContext = &ctx->serverContext;
// The open deck intent owns the context and the credential lookup; once
// the chain finishes (or fails) it deletes the whole tree.
auto openDeckIntent =
new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx));
openDeckIntent->setParent(this);
chain.append(openDeckIntent);
connect(openDeckIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
Intent *firstIntent = openDeckIntent;
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
getLoginCredentialsIntent->setParent(openDeckIntent);
chain.insert(0, getLoginCredentialsIntent);
connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed);
connect(getLoginCredentialsIntent, &Intent::cancelled, openDeckIntent, &Intent::cancelled);
firstIntent = getLoginCredentialsIntent;
}
return firstIntent;
}
bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const
{
Q_UNUSED(port);
// Deliberately hostname-only (no port): the intents' preconditions apply the
// same rule, so a link to the same host on another port still connects
// rather than silently reusing an existing session on a different server.
RemoteClient *client = mainWindow->getRemoteClient();
return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0;
}
void IntentUrlParser::startNextChain()
{
if (chainRunning || pendingChains.isEmpty()) {
return;
}
chainRunning = true;
currentChainSucceeded = false;
const QList<Intent *> chain = pendingChains.takeFirst();
if (chain.isEmpty()) {
chainRunning = false;
return;
}
// Only the last intent completes the chain; its terminal signal ends the
// whole run. Cancellation of an intermediate intent (e.g. declined login
// prompt) is forwarded onto the last intent in the chain builders above.
Intent *finalIntent = chain.last();
connect(finalIntent, &Intent::finished, this, [this]() {
currentChainSucceeded = true;
chainEnded();
});
connect(finalIntent, &Intent::failed, this, &IntentUrlParser::chainEnded);
connect(finalIntent, &Intent::cancelled, this, &IntentUrlParser::chainEnded);
chain.first()->execute();
}
void IntentUrlParser::chainEnded()
{
chainRunning = false;
// Only a failed or cancelled chain restores the session the link migrated
// away from; a successful one leaves the user where they are.
if (pendingRestore && !currentChainSucceeded) {
restorePreviousServer();
}
pendingRestore = false;
startNextChain();
// Only report the terminal state once the queue has fully drained, so a
// queued follow-up link keeps the startup fallback out of the picture.
if (!chainRunning && pendingChains.isEmpty()) {
emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn);
}
}
void IntentUrlParser::restorePreviousServer()
{
if (previousServerHost.isEmpty()) {
return;
}
RemoteClient *client = mainWindow->getRemoteClient();
const ClientStatus status = client->getStatus();
// A failed/cancelled chain can fire while the client is still settling the
// in-flight connection attempt (wrong password, connect timeout). Only
// decide once the client has settled into logged-in or disconnected;
// deciding mid-connect would strand the user offline from their previous
// server.
if (status == StatusDisconnected || status == StatusLoggedIn) {
restoreToPreviousServer();
return;
}
auto waitConnection = std::make_shared<QMetaObject::Connection>();
*waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, client, waitConnection]() {
const ClientStatus settled = client->getStatus();
if (settled == StatusDisconnected || settled == StatusLoggedIn) {
QObject::disconnect(*waitConnection);
restoreToPreviousServer();
}
});
}
void IntentUrlParser::restoreToPreviousServer()
{
RemoteClient *client = mainWindow->getRemoteClient();
// Back on the previous server already → nothing to undo.
if (client->serverName().compare(previousServerHost, Qt::CaseInsensitive) == 0 &&
QString::number(client->serverPort()) == previousServerPort) {
return;
}
// When logged in somewhere, only intervene if that somewhere is the server
// the link moved us to; if the user went elsewhere on their own, leave them.
if (client->getStatus() == StatusLoggedIn) {
const bool onMigrationTarget = client->serverName().compare(migrationTargetHost, Qt::CaseInsensitive) == 0 &&
QString::number(client->serverPort()) == migrationTargetPort;
if (!onMigrationTarget) {
return;
}
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(previousServerHost, previousServerPort);
if (index >= 0 && servers.hasLoginData(previousServerHost, previousServerPort)) {
const QString username =
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
const QString password =
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
client->connectToServer(previousServerHost, previousServerPort.toUInt(), username, password);
return;
}
client->disconnectFromServer();
return;
}
if (client->getStatus() != StatusDisconnected) {
return;
}
// The link's connection attempt failed: reconnect to the previous server
// when credentials are saved, otherwise stay offline.
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(previousServerHost, previousServerPort);
if (index >= 0 && servers.hasLoginData(previousServerHost, previousServerPort)) {
const QString username =
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
const QString password =
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
client->connectToServer(previousServerHost, previousServerPort.toUInt(), username, password);
}
}

View file

@ -1,10 +1,22 @@
#ifndef COCKATRICE_URL_PARSER_H
#define COCKATRICE_URL_PARSER_H
#include <QList>
#include <QObject>
#include <QUrlQuery>
class Intent;
class MainWindow;
struct ContextJoinGame;
/**
* @brief Parses cockatrice:// links and runs them as serialized intent chains.
*
* Links are parsed by action (joingame/opendeck) and translated into an intent
* chain. Chains are queued and run one at a time: a document can hand multiple
* links to the window while an earlier chain still connects, and running two
* connect chains concurrently tears the connection down. urlChainFinished is
* emitted once the queue has fully drained.
*/
class IntentUrlParser : public QObject
{
Q_OBJECT
@ -12,12 +24,34 @@ class IntentUrlParser : public QObject
public:
IntentUrlParser(QObject *parent, MainWindow *mainWindow);
void handle(const QString &urlStr);
void handleJoinGame(const QUrlQuery &query);
signals:
/** @brief Emitted when the last queued chain ended; carries whether the client is logged in. */
void urlChainFinished(bool connected);
private:
Intent *createJoinGameIntent(const QUrlQuery &query, QList<Intent *> &chain);
Intent *createOpenDeckIntent(const QUrlQuery &query, QList<Intent *> &chain);
QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription);
[[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const;
void startNextChain();
void chainEnded();
void restorePreviousServer();
void restoreToPreviousServer();
MainWindow *mainWindow;
QList<QList<Intent *>> pendingChains;
bool chainRunning = false;
bool currentChainSucceeded = false;
// Set when an open-deck link migrates the session to another server. If the
// chain then fails or is cancelled while still on that server, the previous
// session is restored (reconnect if credentials are saved, else disconnect).
QString migrationTargetHost;
QString migrationTargetPort;
QString previousServerHost;
QString previousServerPort;
bool pendingRestore = false;
};
#endif // COCKATRICE_URL_PARSER_H

View file

@ -85,7 +85,7 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
}
lastWidth = totalWidth;
const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
const int totalHeight = qMax(0, totalWidth / 6); // Set height to 1/4 of the width
setFixedHeight(totalHeight);
const int count = layout->count();
@ -97,6 +97,10 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
const int availableWidth = totalWidth - (spacing * (count - 1));
const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
if (iconSize <= 0) {
lastIconSize = iconSize;
return;
}
if (iconSize == lastIconSize) {
return;
}

View file

@ -0,0 +1,37 @@
#include "deck_color_identity.h"
#include <QSet>
#include <libcockatrice/card/database/card_database_querier.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db)
{
const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE});
if (cardList.isEmpty()) {
return {};
}
QSet<QChar> colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G)
for (const QString &cardName : cardList) {
CardInfoPtr currentCard = db->getCardInfo(cardName);
if (currentCard) {
const QString colors = currentCard->getColors(); // returns something like "WUB"
for (const QChar &color : colors) {
colorSet.insert(color);
}
}
}
// Ensure the color identity is in WUBRG order
QString colorIdentity;
const QString wubrgOrder = "WUBRG";
for (const QChar &color : wubrgOrder) {
if (colorSet.contains(color)) {
colorIdentity.append(color);
}
}
return colorIdentity;
}

View file

@ -0,0 +1,20 @@
#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H
#define COCKATRICE_DECK_COLOR_IDENTITY_H
#include <QString>
class CardDatabaseQuerier;
class DeckList;
/**
* @brief Computes the color identity of a deck (e.g. "WUBRG") from the color
* symbols of all cards in the main deck and sideboard, ordered WUBRG.
*
* Shared as a free function so the deck storage previews and the deck share
* dialog compute identities identically.
*
* @param db Card database used to look up card color symbols.
*/
QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db);
#endif // COCKATRICE_DECK_COLOR_IDENTITY_H

View file

@ -133,12 +133,14 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event)
path.addRoundedRect(glowRect, radius, radius);
// Soft outer glow
QColor glowColor(0, 150, 255, 80); // subtle blu
QColor glowColor = palette().color(QPalette::Highlight);
glowColor.setAlpha(80);
painter.setPen(QPen(glowColor, 6));
painter.drawPath(path);
// Thin inner border for crispness
QColor borderColor(0, 150, 255, 200);
QColor borderColor = palette().color(QPalette::Highlight);
borderColor.setAlpha(200);
painter.setPen(QPen(borderColor, 2));
painter.drawRoundedRect(pixmapRect, radius, radius);

View file

@ -27,18 +27,23 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
const QColor &textColor,
const QColor &outlineColor,
const int fontSize,
const Qt::Alignment alignment)
const Qt::Alignment alignment,
const bool _emitClickImmediately)
: CardInfoPictureWithTextOverlayWidget(parent,
hoverToZoomEnabled,
raiseOnEnter,
textColor,
outlineColor,
fontSize,
alignment)
alignment),
emitClickImmediately(_emitClickImmediately)
{
singleClickTimer = new QTimer(this);
singleClickTimer->setSingleShot(true);
connect(singleClickTimer, &QTimer::timeout, this, [this]() { emit imageClicked(lastMouseEvent, this); });
connect(singleClickTimer, &QTimer::timeout, this, [this]() {
emit imageClicked(lastMouseEvent, this);
emit imageSingleClicked();
});
connect(&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this,
&CardInfoPictureWidget::setRaiseOnEnterEnabled);
@ -47,8 +52,13 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
lastMouseEvent = event;
singleClickTimer->start(QApplication::doubleClickInterval());
if (emitClickImmediately) {
emit imageClicked(event, this);
emit imageSingleClicked();
} else {
lastMouseEvent = event;
singleClickTimer->start(QApplication::doubleClickInterval());
}
} else {
emit imageClicked(event, this);
event->accept();
@ -58,7 +68,14 @@ void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event)
void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
singleClickTimer->stop(); // Prevent single-click logic
emit imageDoubleClicked(lastMouseEvent, this);
if (emitClickImmediately) {
// Do not report a second single click for the second press of the
// double-click; the consumer maps the double-click to select+open.
lastMouseEvent = event;
emit imageDoubleClicked(event, this);
} else {
singleClickTimer->stop(); // Prevent single-click logic
emit imageDoubleClicked(lastMouseEvent, this);
}
}
}

View file

@ -20,21 +20,38 @@ class DeckPreviewCardPictureWidget final : public CardInfoPictureWithTextOverlay
Q_OBJECT
public:
/**
* @brief Constructs a DeckPreviewCardPictureWidget.
* @param parent The parent widget.
* @param hoverToZoomEnabled If this widget will spawn a larger widget when hovered over.
* @param raiseOnEnter If the widget raises its border when the mouse enters.
* @param textColor The color of the overlay text.
* @param outlineColor The color of the outline around the text.
* @param fontSize The font size of the overlay text.
* @param alignment The alignment of the text within the overlay.
* @param emitClickImmediately If true, a left click is reported immediately on click
* instead of after the double-click interval. Use this for selection surfaces
* where reacting to a double-click (select-and-open) would needlessly delay the
* single-click feedback. The double-click signal is still emitted.
*/
explicit DeckPreviewCardPictureWidget(QWidget *parent,
bool hoverToZoomEnabled = false,
bool raiseOnEnter = false,
const QColor &textColor = Qt::white,
const QColor &outlineColor = Qt::black,
int fontSize = 12,
Qt::Alignment alignment = Qt::AlignCenter);
Qt::Alignment alignment = Qt::AlignCenter,
bool _emitClickImmediately = false);
signals:
void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void imageSingleClicked();
void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
private:
QTimer *singleClickTimer;
QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event
bool emitClickImmediately;
protected:
void mousePressEvent(QMouseEvent *event) override;

View file

@ -0,0 +1,28 @@
#include "deck_share_utils.h"
#include <QClipboard>
#include <QGuiApplication>
#include <QTimeZone>
#include <libcockatrice/network/client/abstract/abstract_client.h>
namespace DeckShareUtils
{
QString buildShareLink(const AbstractClient *client, const QString &token)
{
return QString("cockatrice://opendeck?share=%1&hostname=%2&port=%3")
.arg(token, client->serverName(), QString::number(client->serverPort()));
}
QString copyShareLinkToClipboard(const QString &link)
{
QGuiApplication::clipboard()->setText(link);
return link;
}
QString formatShareExpiry(const QDateTime &expiry)
{
return expiry.toLocalTime().toString();
}
} // namespace DeckShareUtils

View file

@ -0,0 +1,41 @@
/**
* @file deck_share_utils.h
* @ingroup DeckShareWidgets
*/
//! \todo Document this file.
#ifndef DECK_SHARE_UTILS_H
#define DECK_SHARE_UTILS_H
#include <QDateTime>
#include <QString>
class AbstractClient;
/**
* @brief Shared helpers for creating temporary deck shares.
*/
namespace DeckShareUtils
{
/**
* @brief Builds the cockatrice:// link for a freshly created deck share.
* @param client Used to embed the target server's hostname and port.
* @param token The share token from Response_DeckShareCreate.
*/
QString buildShareLink(const AbstractClient *client, const QString &token);
/**
* @brief Copies the share link to the clipboard.
* @return The link that was copied.
*/
QString copyShareLinkToClipboard(const QString &link);
/**
* @brief Formats the expiration timestamp for a share.
*/
QString formatShareExpiry(const QDateTime &expiry);
} // namespace DeckShareUtils
#endif // DECK_SHARE_UTILS_H

View file

@ -0,0 +1,78 @@
#include "share_bar_widget.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
ShareBarWidget::ShareBarWidget(QWidget *parent) : QWidget(parent)
{
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(12, 10, 12, 10);
layout->setSpacing(8);
hintLabel = new QLabel(this);
hintLabel->setWordWrap(true);
nameEdit = new QLineEdit(this);
nameEdit->setMaximumWidth(260);
countLabel = new QLabel(this);
cancelButton = new QPushButton(this);
connect(cancelButton, &QPushButton::clicked, this, &ShareBarWidget::cancelRequested);
createButton = new QPushButton(this);
createButton->setDefault(true);
connect(createButton, &QPushButton::clicked, this, &ShareBarWidget::createRequested);
layout->addWidget(hintLabel, 1);
layout->addWidget(nameEdit);
layout->addWidget(countLabel);
layout->addStretch();
layout->addWidget(cancelButton);
layout->addWidget(createButton);
setLayout(layout);
retranslateUi();
}
void ShareBarWidget::retranslateUi()
{
nameEdit->setPlaceholderText(tr("Share name"));
cancelButton->setText(tr("Cancel"));
createButton->setText(tr("Create share link"));
hintLabel->setText(tr("Click deck tiles to select the decks you want to share."));
}
QString ShareBarWidget::name() const
{
return nameEdit->text().trimmed();
}
void ShareBarWidget::setName(const QString &value)
{
nameEdit->setText(value);
}
void ShareBarWidget::setCountText(const QString &text)
{
countLabel->setText(text);
}
void ShareBarWidget::setHintText(const QString &text, bool visible)
{
hintLabel->setText(text);
hintLabel->setVisible(visible);
}
void ShareBarWidget::setCreateEnabled(bool enabled)
{
createButton->setEnabled(enabled);
}
void ShareBarWidget::focusName()
{
nameEdit->setFocus();
}

View file

@ -0,0 +1,63 @@
/**
* @file share_bar_widget.h
* @ingroup DeckShareWidgets
*/
//! \todo Document this file.
#ifndef SHARE_BAR_WIDGET_H
#define SHARE_BAR_WIDGET_H
#include <QWidget>
class QLabel;
class QLineEdit;
class QPushButton;
/**
* @brief The activated toolbar used to create a temporary deck share.
*
* A single reusable component shared by the local visual deck storage and the
* remote server deck storage tabs, so the share workflow renders identically in
* both places. It owns its own widgets, strings, and layout; the owning tab only
* sets the count/hint text and reacts to the create/cancel signals.
*/
class ShareBarWidget final : public QWidget
{
Q_OBJECT
public:
explicit ShareBarWidget(QWidget *parent = nullptr);
void retranslateUi();
/** @return The trimmed name entered by the user. */
[[nodiscard]] QString name() const;
/** @brief Resets the name field to the given default. */
void setName(const QString &name);
/** @brief Sets the selected-count summary label text. */
void setCountText(const QString &text);
/** @brief Sets the explainer hint text, showing it when @p visible is true. */
void setHintText(const QString &text, bool visible);
/** @brief Enables or disables the create-share-link button (guards double submission). */
void setCreateEnabled(bool enabled);
/** @brief Moves keyboard focus to the name field. */
void focusName();
signals:
void createRequested();
void cancelRequested();
private:
QLabel *hintLabel;
QLineEdit *nameEdit;
QLabel *countLabel;
QPushButton *cancelButton;
QPushButton *createButton;
};
#endif // SHARE_BAR_WIDGET_H

View file

@ -0,0 +1,139 @@
#include "shared_deck_preview_widget.h"
#include "../cards/additional_info/color_identity_widget.h"
#include "../cards/deck_preview_card_picture_widget.h"
#include <QCheckBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_querier.h>
SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent,
const CardDatabaseQuerier *querier,
const QString &deckName,
const QString &bannerCardName,
const QString &colorIdentity,
const QString &gameFormat,
const QString &deckToolTip)
: QWidget(parent)
{
bannerCardDisplayWidget =
new DeckPreviewCardPictureWidget(this, false, false, Qt::white, Qt::black, 12, Qt::AlignCenter, true);
bannerCardDisplayWidget->setScaleFactor(100);
const ExactCard bannerCard = bannerCardName.isEmpty() ? ExactCard() : querier->getCard(CardRef{bannerCardName, {}});
bannerCardDisplayWidget->setCard(bannerCard);
bannerCardDisplayWidget->setOverlayText(deckName);
setToolTip(deckToolTip.isEmpty() ? deckName : deckToolTip);
setFocusPolicy(Qt::StrongFocus);
setBaseAccessibleName(deckName);
colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity);
colorIdentityWidget->setVisible(!colorIdentity.isEmpty());
gameFormatLabel = new QLabel(gameFormat, this);
gameFormatLabel->setAlignment(Qt::AlignCenter);
gameFormatLabel->setVisible(!gameFormat.isEmpty());
selectionCheckBox = new QCheckBox(this);
selectionCheckBox->setToolTip(tr("Select this deck"));
// The tile itself is focusable (Space/Enter toggles); keep the checkbox
// from creating a second tab stop per tile.
selectionCheckBox->setFocusPolicy(Qt::NoFocus);
// Selection frame reused from the deck-preview selection covenant: a
// palette(highlight) border around the banner card, shown while selected.
selectionFrame = new QFrame(bannerCardDisplayWidget);
selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents);
selectionFrame->setStyleSheet(QStringLiteral(
"QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }"));
selectionFrame->setVisible(false);
auto *selectionRow = new QHBoxLayout;
selectionRow->addWidget(selectionCheckBox);
selectionRow->addStretch(1);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addLayout(selectionRow);
layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter);
layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter);
layout->addWidget(gameFormatLabel, 0, Qt::AlignHCenter);
setLayout(layout);
connect(selectionCheckBox, &QCheckBox::toggled, this, [this](bool checked) {
updateSelectionVisual(checked);
emit selectionToggled(checked);
});
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this,
&SharedDeckPreviewWidget::toggleSelection);
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this,
&SharedDeckPreviewWidget::activate);
}
bool SharedDeckPreviewWidget::isSelected() const
{
return selectionCheckBox->isChecked();
}
void SharedDeckPreviewWidget::setSelected(bool selected)
{
if (isSelected() == selected) {
return;
}
selectionCheckBox->setChecked(selected);
}
void SharedDeckPreviewWidget::updateSelectionVisual(bool selected)
{
selectionFrame->setVisible(selected);
selectionFrame->raise();
if (selected) {
setAccessibleName(baseAccessibleName + tr(" (selected)"));
} else {
setAccessibleName(baseAccessibleName);
}
}
void SharedDeckPreviewWidget::setBaseAccessibleName(const QString &name)
{
baseAccessibleName = name;
setAccessibleName(name);
}
void SharedDeckPreviewWidget::toggleSelection()
{
setSelected(!isSelected());
}
void SharedDeckPreviewWidget::activate()
{
setSelected(true);
emit activated();
}
void SharedDeckPreviewWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
updateSelectionFrameGeometry();
}
void SharedDeckPreviewWidget::updateSelectionFrameGeometry()
{
if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) {
return;
}
selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1));
}
void SharedDeckPreviewWidget::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) {
toggleSelection();
event->accept();
return;
}
QWidget::keyPressEvent(event);
}

View file

@ -0,0 +1,77 @@
/**
* @file shared_deck_preview_widget.h
* @ingroup DeckShareWidgets
*/
//! \todo Document this file.
#ifndef SHARED_DECK_PREVIEW_WIDGET_H
#define SHARED_DECK_PREVIEW_WIDGET_H
#include <QWidget>
class ColorIdentityWidget;
class DeckPreviewCardPictureWidget;
class QCheckBox;
class QFrame;
class QKeyEvent;
class QLabel;
class QResizeEvent;
class CardDatabaseQuerier;
/**
* @brief A selectable preview tile for a deck that has no local file.
*
* Renders a banner card picture (looked up by name in the card database), the
* deck name, color identity and game format. Used to preview decks shared via a
* cockatrice:// link (metadata from Command_DeckShareList) and the deck
* currently open in the deck editor.
*
* Selection follows the deck-preview covenant: the tile reports its click
* immediately (no double-click interval delay), a palette(highlight) frame
* marks the selected tile, and Space/Enter toggles selection from the keyboard.
* A double click selects the tile and emits activated() so the caller can open
* just that deck.
*/
class SharedDeckPreviewWidget : public QWidget
{
Q_OBJECT
public:
explicit SharedDeckPreviewWidget(QWidget *parent,
const CardDatabaseQuerier *querier,
const QString &deckName,
const QString &bannerCardName,
const QString &colorIdentity,
const QString &gameFormat = QString(),
const QString &deckToolTip = QString());
[[nodiscard]] bool isSelected() const;
void setSelected(bool selected);
void setBaseAccessibleName(const QString &name);
signals:
void selectionToggled(bool selected);
void activated();
protected:
void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private slots:
void toggleSelection();
void activate();
private:
void updateSelectionVisual(bool selected);
void updateSelectionFrameGeometry();
DeckPreviewCardPictureWidget *bannerCardDisplayWidget;
ColorIdentityWidget *colorIdentityWidget;
QLabel *gameFormatLabel;
QCheckBox *selectionCheckBox;
QFrame *selectionFrame;
QString baseAccessibleName;
};
#endif // SHARED_DECK_PREVIEW_WIDGET_H

View file

@ -0,0 +1,50 @@
#include "dlg_login_prompt.h"
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QVBoxLayout>
DlgLoginPrompt::DlgLoginPrompt(const QString &serverText, QWidget *parent) : QDialog(parent)
{
setWindowTitle(tr("Sign in"));
auto *mainLayout = new QVBoxLayout(this);
mainLayout->addWidget(
new QLabel(tr("This link requires you to be signed in.\nSign in to %1:").arg(serverText), this));
auto *formLayout = new QFormLayout;
usernameEdit = new QLineEdit(this);
passwordEdit = new QLineEdit(this);
passwordEdit->setEchoMode(QLineEdit::Password);
formLayout->addRow(tr("Username:"), usernameEdit);
formLayout->addRow(tr("Password:"), passwordEdit);
mainLayout->addLayout(formLayout);
savePasswordCheckBox = new QCheckBox(tr("Save password for this server"), this);
mainLayout->addWidget(savePasswordCheckBox);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addWidget(buttonBox);
usernameEdit->setFocus();
}
QString DlgLoginPrompt::username() const
{
return usernameEdit->text().trimmed();
}
QString DlgLoginPrompt::password() const
{
return passwordEdit->text();
}
bool DlgLoginPrompt::savePassword() const
{
return savePasswordCheckBox->isChecked();
}

View file

@ -0,0 +1,40 @@
/**
* @file dlg_login_prompt.h
* @ingroup ConnectionDialogs
*/
//! \todo Document this file.
#ifndef DLG_LOGIN_PROMPT_H
#define DLG_LOGIN_PROMPT_H
#include <QDialog>
class QCheckBox;
class QLineEdit;
/**
* @brief Small sign-in dialog used when a cockatrice:// link needs credentials
* that are not saved for the target server.
*
* The entered name and password are handed to the intent chain; when the user
* opts to save them, they are stored in the server settings so that later links
* to the same server connect seamlessly.
*/
class DlgLoginPrompt : public QDialog
{
Q_OBJECT
public:
explicit DlgLoginPrompt(const QString &serverText, QWidget *parent = nullptr);
[[nodiscard]] QString username() const;
[[nodiscard]] QString password() const;
[[nodiscard]] bool savePassword() const;
private:
QLineEdit *usernameEdit;
QLineEdit *passwordEdit;
QCheckBox *savePasswordCheckBox;
};
#endif // DLG_LOGIN_PROMPT_H

View file

@ -0,0 +1,89 @@
#include "dlg_share_deck.h"
#include "../cards/additional_info/deck_color_identity.h"
#include "../deck_share/deck_share_utils.h"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QTimeZone>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pending_command.h>
DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer<DeckList> &_deck, QWidget *_parent)
: QDialog(_parent), client(_client), deck(_deck)
{
setWindowTitle(tr("Share deck"));
auto *layout = new QVBoxLayout(this);
nameEdit = new QLineEdit(this);
nameEdit->setText(tr("Shared deck"));
auto *form = new QFormLayout;
form->addRow(tr("Share name:"), nameEdit);
layout->addLayout(form);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Create share link"));
buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel"));
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgShareDeck::actShare);
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgShareDeck::reject);
this->buttonBox = buttonBox;
layout->addWidget(buttonBox);
}
void DlgShareDeck::actShare()
{
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
Command_DeckShareCreate cmd;
cmd.set_name(nameEdit->text().trimmed().toStdString());
if (cmd.name().empty()) {
cmd.set_name(tr("Shared deck").toStdString());
}
DeckShareItem *item = cmd.add_items();
item->set_deck_list(deck->writeToString_Native().toStdString());
item->set_color_identity(getDeckColorIdentity(*deck, CardDatabaseManager::query()).toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgShareDeck::shareFinished);
client->sendCommand(pend);
}
void DlgShareDeck::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
if (response.response_code() != Response::RespOk) {
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
QMessageBox::critical(this, tr("Share deck"),
tr("Failed to create the share link (server response code %1).")
.arg(QString::number(static_cast<int>(response.response_code()))));
return;
}
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
const QString token = QString::fromStdString(resp.token());
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
#else
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
#endif
const QString link = DeckShareUtils::buildShareLink(client, token);
DeckShareUtils::copyShareLinkToClipboard(link);
QMessageBox::information(this, tr("Share deck"),
tr("Share link created and copied to the clipboard:\n\n%1\n\n"
"The share expires on %2.")
.arg(link, DeckShareUtils::formatShareExpiry(expiry)));
accept();
}

View file

@ -0,0 +1,43 @@
/**
* @file dlg_share_deck.h
* @ingroup Dialogs
*/
//! \todo Document this file.
#ifndef DLG_SHARE_DECK_H
#define DLG_SHARE_DECK_H
#include <QDialog>
#include <QSharedPointer>
class AbstractClient;
class CommandContainer;
class DeckList;
class QDialogButtonBox;
class QLineEdit;
class Response;
/**
* @brief Slim dialog to create a temporary share for the deck open in the editor.
*
* Asks for a share name, sends Command_DeckShareCreate for the single inline
* deck, and copies the resulting link to the clipboard.
*/
class DlgShareDeck : public QDialog
{
Q_OBJECT
public:
DlgShareDeck(AbstractClient *_client, const QSharedPointer<DeckList> &_deck, QWidget *parent = nullptr);
private slots:
void actShare();
void shareFinished(const Response &response, const CommandContainer &commandContainer);
private:
AbstractClient *client;
QSharedPointer<DeckList> deck;
QLineEdit *nameEdit;
QDialogButtonBox *buttonBox;
};
#endif // DLG_SHARE_DECK_H

View file

@ -0,0 +1,178 @@
#include "dlg_shared_decks_preview.h"
#include "../deck_share/shared_deck_preview_widget.h"
#include "../general/layout_containers/flow_widget.h"
#include <QCloseEvent>
#include <QDateTime>
#include <QDialogButtonBox>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_querier.h>
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
const CardDatabaseQuerier *querier,
const QString &shareName,
qint64 expiresAt,
const QString &serverText,
const QList<ServerInfo_DeckShareItem> &items)
: QDialog(parent)
{
setWindowTitle(tr("Open shared decks"));
resize(700, 500);
auto *mainLayout = new QVBoxLayout(this);
auto *titleLabel = new QLabel(tr("Share: %1").arg(shareName.isEmpty() ? tr("Untitled") : shareName), this);
QFont titleFont = titleLabel->font();
titleFont.setBold(true);
titleFont.setPointSize(titleFont.pointSize() + 2);
titleLabel->setFont(titleFont);
mainLayout->addWidget(titleLabel);
if (!serverText.isEmpty()) {
mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText), this));
}
if (expiresAt > 0) {
const QString expiryText = QDateTime::fromSecsSinceEpoch(expiresAt).toLocalTime().toString(Qt::TextDate);
mainLayout->addWidget(new QLabel(tr("This share link expires on %1").arg(expiryText), this));
}
downloadStatusLabel = new QLabel(this);
downloadStatusLabel->setVisible(false);
mainLayout->addWidget(downloadStatusLabel);
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
mainLayout->addWidget(flowWidget, 1);
for (const ServerInfo_DeckShareItem &item : items) {
QStringList tags;
for (const auto &tag : item.tags()) {
tags.append(QString::fromStdString(tag));
}
auto *tile = new SharedDeckPreviewWidget(
this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()),
QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", "));
flowWidget->addWidget(tile);
tiles.append(tile);
itemIds.append(item.id());
}
if (tiles.size() == 1) {
tiles.first()->setSelected(true);
}
auto *buttonBox = new QDialogButtonBox(this);
openSelectedButton = buttonBox->addButton(tr("Open selected"), QDialogButtonBox::AcceptRole);
openAllButton = buttonBox->addButton(tr("Open all"), QDialogButtonBox::ActionRole);
buttonBox->addButton(tr("Cancel"), QDialogButtonBox::RejectRole);
mainLayout->addWidget(buttonBox);
connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() {
onCancel();
close();
});
// Esc calls QDialog::reject() directly (which hides the dialog without a
// close event), so route it through the same guarded cancel as the button.
connect(this, &QDialog::rejected, this, [this]() {
onCancel();
close();
});
connect(openSelectedButton, &QPushButton::clicked, this, &DlgSharedDecksPreview::openSelected);
connect(buttonBox, &QDialogButtonBox::clicked, this, [this, buttonBox](QAbstractButton *button) {
if (buttonBox->buttonRole(button) == QDialogButtonBox::ActionRole) {
openAll();
}
});
for (SharedDeckPreviewWidget *tile : tiles) {
connect(tile, &SharedDeckPreviewWidget::selectionToggled, this,
&DlgSharedDecksPreview::updateOpenSelectedEnabled);
}
for (int i = 0; i < tiles.size(); ++i) {
const int itemId = itemIds.at(i);
// Double-clicking a tile selects it and opens just that deck.
connect(tiles.at(i), &SharedDeckPreviewWidget::activated, this, [this, itemId]() {
resultEmitted = true;
setDownloading(true);
emit openRequested(QList<int>{itemId});
});
}
updateOpenSelectedEnabled();
}
QList<int> DlgSharedDecksPreview::selectedItemIds() const
{
QList<int> selectedIds;
for (int i = 0; i < tiles.size(); ++i) {
if (tiles.at(i)->isSelected()) {
selectedIds.append(itemIds.at(i));
}
}
return selectedIds;
}
void DlgSharedDecksPreview::openSelected()
{
const QList<int> selectedIds = selectedItemIds();
if (selectedIds.isEmpty()) {
return;
}
resultEmitted = true;
setDownloading(true);
emit openRequested(selectedIds);
}
void DlgSharedDecksPreview::openAll()
{
resultEmitted = true;
setDownloading(true);
emit openRequested(itemIds);
}
void DlgSharedDecksPreview::setDownloading(bool downloading)
{
if (downloadInProgress == downloading) {
return;
}
downloadInProgress = downloading;
downloadStatusLabel->setVisible(downloading);
for (SharedDeckPreviewWidget *tile : tiles) {
tile->setEnabled(!downloading);
}
openSelectedButton->setEnabled(!downloading);
openAllButton->setEnabled(!downloading);
}
void DlgSharedDecksPreview::setDownloadProgress(int done, int total, const QString &currentDeckName)
{
if (!downloadInProgress) {
return;
}
downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName));
}
void DlgSharedDecksPreview::updateOpenSelectedEnabled()
{
openSelectedButton->setEnabled(!selectedItemIds().isEmpty());
}
void DlgSharedDecksPreview::onCancel()
{
if (!resultEmitted || downloadInProgress) {
resultEmitted = true;
emit cancelled();
}
}
void DlgSharedDecksPreview::closeEvent(QCloseEvent *event)
{
onCancel();
QDialog::closeEvent(event);
}

View file

@ -0,0 +1,68 @@
#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
#include <QDialog>
#include <QList>
class FlowWidget;
class QCloseEvent;
class QLabel;
class QPushButton;
class ServerInfo_DeckShareItem;
class SharedDeckPreviewWidget;
class CardDatabaseQuerier;
/**
* @brief Non-modal preview of the decks contained in a shared-deck link.
*
* Lets the user pick which of the shared decks to open before anything is
* downloaded. Emits openRequested with the ids of the chosen decks, or
* cancelled when the user closes the dialog without choosing. Once the user
* picks, the dialog switches into a "downloading" state: the tiles and open
* buttons are disabled, a progress label shows the current download and Cancel
* stays functional so the download can be aborted.
*/
class DlgSharedDecksPreview : public QDialog
{
Q_OBJECT
public:
explicit DlgSharedDecksPreview(QWidget *parent,
const CardDatabaseQuerier *querier,
const QString &shareName,
qint64 expiresAt,
const QString &serverText,
const QList<ServerInfo_DeckShareItem> &items);
void setDownloadProgress(int done, int total, const QString &currentDeckName);
public slots:
void setDownloading(bool downloading);
signals:
void openRequested(const QList<int> &itemIds);
void cancelled();
protected:
void closeEvent(QCloseEvent *event) override;
private slots:
void openSelected();
void openAll();
void updateOpenSelectedEnabled();
void onCancel();
private:
QList<int> selectedItemIds() const;
FlowWidget *flowWidget;
QList<SharedDeckPreviewWidget *> tiles;
QList<int> itemIds;
QPushButton *openSelectedButton;
QPushButton *openAllButton;
QLabel *downloadStatusLabel;
bool resultEmitted = false;
bool downloadInProgress = false;
};
#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H

View file

@ -7,6 +7,7 @@
#include "flow_widget.h"
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QResizeEvent>
#include <QScrollArea>
#include <QSizePolicy>
@ -177,6 +178,50 @@ QLayoutItem *FlowWidget::itemAt(int index) const
return flowLayout->itemAt(index);
}
void FlowWidget::keyPressEvent(QKeyEvent *event)
{
// Keyboard navigation between the flow items: arrow keys move focus just
// like clicking the sibling tiles would. Only items that can take keyboard
// focus (e.g. the deck-preview tiles in shared-deck links) are visited.
const bool moveForward = event->key() == Qt::Key_Right || event->key() == Qt::Key_Down;
const bool moveBackward = event->key() == Qt::Key_Left || event->key() == Qt::Key_Up;
if (!moveForward && !moveBackward) {
QWidget::keyPressEvent(event);
return;
}
QList<QWidget *> focusableItems;
for (int i = 0; i < flowLayout->count(); ++i) {
QWidget *item = flowLayout->itemAt(i)->widget();
if (item != nullptr && (item->focusPolicy() & Qt::TabFocus)) {
focusableItems.append(item);
}
}
if (focusableItems.isEmpty()) {
QWidget::keyPressEvent(event);
return;
}
int currentIndex = -1;
for (int i = 0; i < focusableItems.size(); ++i) {
if (focusableItems.at(i)->hasFocus()) {
currentIndex = i;
break;
}
}
const int delta = moveForward ? 1 : -1;
int nextIndex;
if (currentIndex < 0) {
nextIndex = moveForward ? 0 : focusableItems.size() - 1;
} else {
nextIndex = (currentIndex + delta + focusableItems.size()) % focusableItems.size();
}
focusableItems.value(nextIndex)->setFocus();
event->accept();
}
int FlowWidget::count() const
{
return flowLayout->count();

View file

@ -11,6 +11,7 @@
#include "../../../layouts/flow_layout.h"
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLoggingCategory>
#include <QScrollArea>
#include <QWidget>
@ -44,6 +45,7 @@ public slots:
protected:
void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private:
Qt::Orientation flowDirection;

View file

@ -28,6 +28,9 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d
aSaveDeckAs = new QAction(QString(), this);
connect(aSaveDeckAs, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actSaveDeckAs);
aShareDeck = new QAction(QString(), this);
connect(aShareDeck, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actShareDeck);
aLoadDeckFromClipboard = new QAction(QString(), this);
connect(aLoadDeckFromClipboard, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actLoadDeckFromClipboard);
@ -96,6 +99,7 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d
addMenu(loadRecentDeckMenu);
addAction(aSaveDeck);
addAction(aSaveDeckAs);
addAction(aShareDeck);
addSeparator();
addAction(aLoadDeckFromClipboard);
addMenu(editDeckInClipboardMenu);
@ -120,6 +124,7 @@ void DeckEditorMenu::setSaveStatus(bool newStatus)
{
aSaveDeck->setEnabled(newStatus);
aSaveDeckAs->setEnabled(newStatus);
aShareDeck->setEnabled(newStatus);
aSaveDeckToClipboard->setEnabled(newStatus);
aSaveDeckToClipboardNoSetInfo->setEnabled(newStatus);
aSaveDeckToClipboardRaw->setEnabled(newStatus);
@ -157,6 +162,7 @@ void DeckEditorMenu::retranslateUi()
aClearRecents->setText(tr("Clear"));
aSaveDeck->setText(tr("&Save deck"));
aSaveDeckAs->setText(tr("Save deck &as..."));
aShareDeck->setText(tr("Share deck..."));
aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard..."));

View file

@ -21,7 +21,8 @@ public:
QAction *aNewDeck, *aLoadDeck, *aClearRecents, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard,
*aEditDeckInClipboard, *aEditDeckInClipboardRaw, *aSaveDeckToClipboard, *aSaveDeckToClipboardNoSetInfo,
*aSaveDeckToClipboardRaw, *aSaveDeckToClipboardRawNoSetInfo, *aPrintDeck, *aLoadDeckFromWebsite,
*aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aClose;
*aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aShareDeck,
*aClose;
QMenu *loadRecentDeckMenu, *analyzeDeckMenu, *editDeckInClipboardMenu, *saveDeckToClipboardMenu;
void setSaveStatus(bool newStatus);

View file

@ -113,7 +113,7 @@ int RemoteDeckList_TreeModel::rowCount(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
@ -121,7 +121,7 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
if (!index.isValid()) {
return QVariant();
}
if (index.column() >= 3) {
if (index.column() >= 4) {
return QVariant();
}
@ -134,12 +134,20 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
switch (index.column()) {
case 0:
return node->getName();
case 3:
return isEffectivelyPublic(node) ? tr("Public") : tr("Private");
default:
return QVariant();
}
}
case Qt::DecorationRole:
return index.column() == 0 ? dirIcon : QVariant();
case Qt::ToolTipRole:
if (index.column() == 3) {
return isEffectivelyPublic(node) ? tr("This folder is visible to other users")
: tr("This folder is only visible to you");
}
return QVariant();
default:
return QVariant();
}
@ -153,6 +161,8 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
return file->getId();
case 2:
return file->getUploadTime();
case 3:
return isEffectivelyPublic(file) ? tr("Public") : tr("Private");
default:
return QVariant();
}
@ -161,6 +171,12 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
return index.column() == 0 ? fileIcon : QVariant();
case Qt::TextAlignmentRole:
return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft;
case Qt::ToolTipRole:
if (index.column() == 3) {
return isEffectivelyPublic(file) ? tr("This deck is visible to other users")
: tr("This deck is only visible to you");
}
return QVariant();
default:
return QVariant();
}
@ -183,6 +199,8 @@ QVariant RemoteDeckList_TreeModel::headerData(int section, Qt::Orientation orien
return tr("ID");
case 2:
return tr("Upload time");
case 3:
return tr("Visibility");
default:
return QVariant();
}
@ -239,13 +257,14 @@ void RemoteDeckList_TreeModel::addFileToTree(const ServerInfo_DeckStorage_TreeIt
time.setSecsSinceEpoch(fileInfo.creation_time());
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();
}
void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent)
{
DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent);
newItem->setIsPublic(folder.folder().is_public());
const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder();
const int folderItemsSize = folderInfo.items_size();
for (int i = 0; i < folderItemsSize; ++i) {
@ -285,6 +304,21 @@ void RemoteDeckList_TreeModel::refreshTree()
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()
{
beginResetModel();

View file

@ -27,9 +27,11 @@ public:
protected:
DirectoryNode *parent;
QString name;
bool publicFlag;
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;
@ -41,6 +43,14 @@ public:
{
return name;
}
[[nodiscard]] bool isPublic() const
{
return publicFlag;
}
void setIsPublic(bool _public)
{
publicFlag = _public;
}
};
class DirectoryNode : public Node, public QList<Node *>
{
@ -59,9 +69,14 @@ public:
QDateTime uploadTime;
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)
{
setIsPublic(_isPublic);
}
[[nodiscard]] int getId() const
{
@ -109,6 +124,11 @@ public:
{
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 addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent);
DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent);
@ -125,6 +145,10 @@ private:
public:
explicit RemoteDeckList_TreeWidget(AbstractClient *_client, QWidget *parent = nullptr);
[[nodiscard]] RemoteDeckList_TreeModel *getModel() const
{
return treeModel;
}
[[nodiscard]] RemoteDeckList_TreeModel::Node *getNode(const QModelIndex &ind) const;
[[nodiscard]] RemoteDeckList_TreeModel::Node *getCurrentItem() const;
[[nodiscard]] QList<RemoteDeckList_TreeModel::Node *> getCurrentSelection() const;

View file

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

View file

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

View file

@ -19,6 +19,7 @@
#include "../interface/widgets/dialogs/dlg_load_deck.h"
#include "../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h"
#include "../interface/widgets/dialogs/dlg_load_deck_from_website.h"
#include "../interface/widgets/dialogs/dlg_share_deck.h"
#include "../utility/visibility_change_listener.h"
#include "tab_supervisor.h"
@ -323,6 +324,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_tags(loadedDeck.deckList.getTags().join(QStringLiteral(",")).toStdString());
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished);
@ -382,6 +384,25 @@ bool AbstractTabDeckEditor::actSaveDeckAs()
return true;
}
/**
* @brief Opens the deck share dialog with the current deck preselected.
*/
void AbstractTabDeckEditor::actShareDeck()
{
if (tabSupervisor->getClient()->getStatus() != StatusLoggedIn) {
QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck."));
return;
}
const QSharedPointer<DeckList> deck = deckStateManager->getDeckListShared();
if (deck->isBlankDeck()) {
return;
}
DlgShareDeck shareDialog(tabSupervisor->getClient(), deck, this);
shareDialog.exec();
}
/**
* @brief Callback for remote deck save completion.
* @param response Server response.

View file

@ -214,6 +214,9 @@ protected slots:
/** @brief Saves the current deck under a new name. */
virtual bool actSaveDeckAs();
/** @brief Opens the deck share dialog for the current deck. */
void actShareDeck();
/** @brief Loads a deck from the clipboard. */
virtual void actLoadDeckFromClipboard();

View file

@ -0,0 +1,151 @@
#include "public_decks_quick_settings_widget.h"
#include "../../../client/settings/cache_settings.h"
#include "../cards/card_size_widget.h"
#include <QCheckBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QSpinBox>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
PublicDecksQuickSettingsWidget::PublicDecksQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent)
{
// show color identity on preview tiles checkbox
showColorIdentityCheckBox = new QCheckBox(this);
showColorIdentityCheckBox->setChecked(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity());
connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&PublicDecksQuickSettingsWidget::showColorIdentityChanged);
connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowColorIdentity);
// show tags on preview tiles checkbox
showTagsOnDeckPreviewsCheckBox = new QCheckBox(this);
showTagsOnDeckPreviewsCheckBox->setChecked(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews());
connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&PublicDecksQuickSettingsWidget::showTagsOnDeckPreviewsChanged);
connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED,
&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews);
// show the last modified / upload time on preview tiles checkbox
showUploadTimeCheckBox = new QCheckBox(this);
showUploadTimeCheckBox->setChecked(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime());
connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&PublicDecksQuickSettingsWidget::showUploadTimeChanged);
connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime);
// show tag filter box checkbox
showTagFilterCheckBox = new QCheckBox(this);
showTagFilterCheckBox->setChecked(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter());
connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&PublicDecksQuickSettingsWidget::showTagFilterChanged);
connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowTagFilter);
// draw unused color identities checkbox
drawUnusedColorIdentitiesCheckBox = new QCheckBox(this);
drawUnusedColorIdentitiesCheckBox->setChecked(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities());
connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&PublicDecksQuickSettingsWidget::drawUnusedColorIdentitiesChanged);
connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED,
&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities);
// unused color identities opacity selector
auto unusedColorIdentityOpacityWidget = new QWidget(this);
unusedColorIdentitiesOpacityLabel = new QLabel(unusedColorIdentityOpacityWidget);
unusedColorIdentitiesOpacitySpinBox = new QSpinBox(unusedColorIdentityOpacityWidget);
unusedColorIdentitiesOpacitySpinBox->setMinimum(0);
unusedColorIdentitiesOpacitySpinBox->setMaximum(100);
unusedColorIdentitiesOpacitySpinBox->setValue(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageUnusedColorIdentitiesOpacity());
connect(unusedColorIdentitiesOpacitySpinBox, qOverload<int>(&QSpinBox::valueChanged), this,
&PublicDecksQuickSettingsWidget::unusedColorIdentitiesOpacityChanged);
connect(unusedColorIdentitiesOpacitySpinBox, qOverload<int>(&QSpinBox::valueChanged),
&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageUnusedColorIdentitiesOpacity);
unusedColorIdentitiesOpacityLabel->setBuddy(unusedColorIdentitiesOpacitySpinBox);
auto unusedColorIdentityOpacityLayout = new QHBoxLayout(unusedColorIdentityOpacityWidget);
unusedColorIdentityOpacityLayout->setContentsMargins(11, 0, 11, 0);
unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacityLabel);
unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox);
// card size slider (kept at the bottom, like the Visual Deck Storage)
cardSizeWidget =
new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize());
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, this,
&PublicDecksQuickSettingsWidget::cardSizeChanged);
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setVisualDeckStorageCardSize);
this->addSettingsWidget(showColorIdentityCheckBox);
this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox);
this->addSettingsWidget(showUploadTimeCheckBox);
this->addSettingsWidget(showTagFilterCheckBox);
this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox);
this->addSettingsWidget(unusedColorIdentityOpacityWidget);
this->addSettingsWidget(cardSizeWidget);
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this,
&PublicDecksQuickSettingsWidget::retranslateUi);
retranslateUi();
}
void PublicDecksQuickSettingsWidget::retranslateUi()
{
showColorIdentityCheckBox->setText(tr("Show Color Identity"));
showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews"));
showUploadTimeCheckBox->setText(tr("Show Upload Time"));
showTagFilterCheckBox->setText(tr("Show Tag Filter"));
drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities"));
unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity"));
unusedColorIdentitiesOpacitySpinBox->setSuffix("%");
}
bool PublicDecksQuickSettingsWidget::getDrawUnusedColorIdentities() const
{
return drawUnusedColorIdentitiesCheckBox->isChecked();
}
bool PublicDecksQuickSettingsWidget::getShowColorIdentity() const
{
return showColorIdentityCheckBox->isChecked();
}
bool PublicDecksQuickSettingsWidget::getShowTagFilter() const
{
return showTagFilterCheckBox->isChecked();
}
bool PublicDecksQuickSettingsWidget::getShowTagsOnDeckPreviews() const
{
return showTagsOnDeckPreviewsCheckBox->isChecked();
}
bool PublicDecksQuickSettingsWidget::getShowUploadTime() const
{
return showUploadTimeCheckBox->isChecked();
}
int PublicDecksQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const
{
return unusedColorIdentitiesOpacitySpinBox->value();
}
CardSizeWidget *PublicDecksQuickSettingsWidget::getCardSizeWidget() const
{
return cardSizeWidget;
}

View file

@ -0,0 +1,57 @@
/**
* @file public_decks_quick_settings_widget.h
* @ingroup Tabs
* @brief The quick settings menu for the public decks tab.
* Manages the widgets in the quick settings menu dropdown of the public decks
* tab, and syncs their values with the same SettingsCache keys the Visual Deck
* Storage uses, so shared preview widgets (color identity, tags) behave the
* same way in both places.
*/
#ifndef PUBLIC_DECKS_QUICK_SETTINGS_WIDGET_H
#define PUBLIC_DECKS_QUICK_SETTINGS_WIDGET_H
#include "../quick_settings/settings_button_widget.h"
class CardSizeWidget;
class QCheckBox;
class QLabel;
class QSpinBox;
class PublicDecksQuickSettingsWidget : public SettingsButtonWidget
{
Q_OBJECT
QCheckBox *showColorIdentityCheckBox;
QCheckBox *drawUnusedColorIdentitiesCheckBox;
QCheckBox *showTagFilterCheckBox;
QCheckBox *showTagsOnDeckPreviewsCheckBox;
QCheckBox *showUploadTimeCheckBox;
QLabel *unusedColorIdentitiesOpacityLabel;
QSpinBox *unusedColorIdentitiesOpacitySpinBox;
CardSizeWidget *cardSizeWidget;
public:
explicit PublicDecksQuickSettingsWidget(QWidget *parent = nullptr);
void retranslateUi();
[[nodiscard]] bool getDrawUnusedColorIdentities() const;
[[nodiscard]] bool getShowColorIdentity() const;
[[nodiscard]] bool getShowTagFilter() const;
[[nodiscard]] bool getShowTagsOnDeckPreviews() const;
[[nodiscard]] bool getShowUploadTime() const;
[[nodiscard]] int getUnusedColorIdentitiesOpacity() const;
[[nodiscard]] CardSizeWidget *getCardSizeWidget() const;
signals:
void drawUnusedColorIdentitiesChanged(bool enabled);
void showColorIdentityChanged(bool enabled);
void showTagFilterChanged(bool enabled);
void showTagsOnDeckPreviewsChanged(bool enabled);
void showUploadTimeChanged(bool enabled);
void unusedColorIdentitiesOpacityChanged(int opacity);
void cardSizeChanged(int scale);
};
#endif // PUBLIC_DECKS_QUICK_SETTINGS_WIDGET_H

View file

@ -2,30 +2,41 @@
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.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"
#include "../interface/widgets/utility/get_text_with_max.h"
#include <QAction>
#include <QApplication>
#include <QDateTime>
#include <QDebug>
#include <QDesktopServices>
#include <QFileSystemModel>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QInputDialog>
#include <QLineEdit>
#include <QMessageBox>
#include <QTimeZone>
#include <QToolBar>
#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>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/paths_settings.h>
@ -91,8 +102,17 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
serverDirView = new RemoteDeckList_TreeWidget(client);
connect(serverDirView, &QTreeView::doubleClicked, this, &TabDeckStorage::actRemoteDoubleClick);
connect(serverDirView->selectionModel(), &QItemSelectionModel::selectionChanged, this,
[this] { onServerSelectionChanged(); });
// Share bar for creating a share link from the selected server decks/folders.
shareBar = new ShareBarWidget(this);
connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorage::actShareSelection);
connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorage::cancelShareDecks);
shareBar->setVisible(false);
QVBoxLayout *rightVbox = new QVBoxLayout;
rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView);
rightVbox->addLayout(rightToolBarLayout);
rightGroupBox = new QGroupBox;
@ -138,6 +158,14 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aDeleteRemoteDeck->setIcon(QPixmap("theme:icons/remove_row"));
connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck);
aShareDecks = new QAction(this);
aShareDecks->setIcon(QPixmap("theme: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);
@ -149,6 +177,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck);
@ -177,7 +207,10 @@ void TabDeckStorage::retranslateUi()
aNewFolder->setText(tr("New folder"));
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();
}
QString TabDeckStorage::getTargetPath() const
@ -219,12 +252,15 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
aShareDecks->setEnabled(enabled);
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled);
if (enabled) {
serverDirView->refreshTree();
} else {
setShareModeEnabled(false);
serverDirView->clearTree();
}
}
@ -346,6 +382,12 @@ void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPa
cmd.set_path(targetPath.toStdString());
cmd.set_deck_list(deckString.toStdString());
const CardRef bannerCard = deck.getBannerCard();
cmd.set_banner_card_name(bannerCard.name.toStdString());
cmd.set_banner_card_provider(bannerCard.providerId.toStdString());
cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString());
cmd.set_tags(deck.getTags().join(QStringLiteral(",")).toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished);
client->sendCommand(pend);
@ -625,3 +667,193 @@ void TabDeckStorage::deleteFolderFinished(const Response &response, const Comman
serverDirView->removeNode(toDelete);
}
}
void TabDeckStorage::actShareDecks()
{
setShareModeEnabled(true);
}
void TabDeckStorage::cancelShareDecks()
{
setShareModeEnabled(false);
}
void TabDeckStorage::setShareModeEnabled(bool enabled)
{
shareBar->setVisible(enabled);
if (enabled) {
shareBar->setCreateEnabled(true);
shareBar->setName(tr("Shared decks"));
onServerSelectionChanged();
shareBar->focusName();
} else {
serverDirView->clearSelection();
}
}
void TabDeckStorage::onServerSelectionChanged()
{
if (!shareBar->isVisible()) {
return;
}
const auto selection = serverDirView->getCurrentSelection();
int folders = 0;
int files = 0;
for (const auto *node : selection) {
if (dynamic_cast<const RemoteDeckList_TreeModel::DirectoryNode *>(node)) {
++folders;
} else {
++files;
}
}
QString hint;
if (folders > 1) {
hint = tr("Only one folder can be shared at a time.");
} else if (folders > 0 && files > 0) {
hint = tr("Share either a folder or decks, not both.");
} else if (folders == 0 && files == 0) {
hint = tr("Select folders or decks in the tree to share.");
}
shareBar->setHintText(hint, !hint.isEmpty());
QStringList parts;
if (folders > 0) {
parts << tr("%n folder(s)", "", folders);
}
if (files > 0) {
parts << tr("%n deck(s)", "", files);
}
shareBar->setCountText(parts.isEmpty() ? tr("No decks selected") : tr("Selected: %1").arg(parts.join(tr(", "))));
}
void TabDeckStorage::actShareSelection()
{
const auto selection = serverDirView->getCurrentSelection();
QString sharedFolder;
bool hasFile = false;
bool hasFolder = false;
for (const auto *node : selection) {
if (const auto *dirNode = dynamic_cast<const RemoteDeckList_TreeModel::DirectoryNode *>(node)) {
hasFolder = true;
if (!sharedFolder.isEmpty()) {
showShareNotice(tr("Only one folder can be shared at a time."), true);
return;
}
sharedFolder = dirNode->getPath();
} else {
hasFile = true;
}
}
if (hasFile && hasFolder) {
showShareNotice(tr("Share either a folder or decks, not both."), true);
return;
}
if (hasFolder && sharedFolder.isEmpty()) {
showShareNotice(tr("The root folder cannot be shared."), true);
return;
}
Command_DeckShareCreate cmd;
cmd.set_name(shareBar->name().toStdString());
if (cmd.name().empty()) {
cmd.set_name(tr("Shared decks").toStdString());
}
if (!sharedFolder.isEmpty()) {
cmd.set_folder_path(sharedFolder.toStdString());
} else {
for (const auto *node : selection) {
if (const auto *fileNode = dynamic_cast<const RemoteDeckList_TreeModel::FileNode *>(node)) {
DeckShareItem *item = cmd.add_items();
item->set_deck_id(fileNode->getId());
}
}
}
if (cmd.items_size() == 0 && cmd.folder_path().empty()) {
showShareNotice(tr("Select decks to share."), true);
return;
}
shareBar->setCreateEnabled(false);
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::shareFromTreeFinished);
client->sendCommand(pend);
}
void TabDeckStorage::shareFromTreeFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
shareBar->setCreateEnabled(true);
if (response.response_code() != Response::RespOk) {
qWarning() << "failed to create deck share:" << response.response_code();
showShareNotice(tr("Failed to create the share link (server response code %1).")
.arg(QString::number(static_cast<int>(response.response_code()))),
true);
return;
}
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
const QString token = QString::fromStdString(resp.token());
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
#else
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
#endif
const QString link = DeckShareUtils::buildShareLink(client, token);
DeckShareUtils::copyShareLinkToClipboard(link);
showShareNotice(
tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(expiry)));
setShareModeEnabled(false);
}
void TabDeckStorage::showShareNotice(const QString &message, bool warning)
{
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message,
QMessageBox::Ok, this);
box.exec();
}
void TabDeckStorage::actPublishDeck()
{
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);
client->sendCommand(pend);
++pendingVisibilityChanges;
}
}
void TabDeckStorage::setVisibilityFinished(const Response &r, const CommandContainer & /*commandContainer*/)
{
if (r.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"),
tr("Failed to change deck visibility on server (response code %1).")
.arg(QString::number(static_cast<int>(r.response_code()))));
}
// Refresh once the last in-flight change has been acknowledged so the
// Public/Private column reflects every selected node.
if (--pendingVisibilityChanges == 0) {
serverDirView->refreshTree();
}
}

View file

@ -24,6 +24,7 @@ class QTreeWidgetItem;
class QGroupBox;
class CommandContainer;
class Response;
class ShareBarWidget;
class TabDeckStorage : public Tab
{
@ -35,14 +36,20 @@ private:
QToolBar *leftToolBar, *rightToolBar;
RemoteDeckList_TreeWidget *serverDirView;
QGroupBox *leftGroupBox, *rightGroupBox;
ShareBarWidget *shareBar;
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aNewFolder, *aDeleteRemoteDeck;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
int pendingVisibilityChanges = 0;
QString getTargetPath() const;
void setRemoteEnabled(bool enabled);
void showShareNotice(const QString &message, bool warning = false);
void setShareModeEnabled(bool enabled);
void uploadDeck(const QString &filePath, const QString &targetPath);
void deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *node);
@ -75,6 +82,15 @@ private slots:
void actNewFolder();
void newFolderFinished(const Response &response, const CommandContainer &commandContainer);
void actShareDecks();
void actShareSelection();
void cancelShareDecks();
void onServerSelectionChanged();
void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer);
void actPublishDeck();
void setVisibilityFinished(const Response &r, const CommandContainer &commandContainer);
void actDeleteRemoteDeck();
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);

View file

@ -0,0 +1,230 @@
#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_search_widget.h"
#include "../visual_deck_storage/visual_deck_storage_tag_filter_widget.h"
#include "public_decks_quick_settings_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), 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);
searchWidget->setPlaceholderText(tr("Search by deck name"));
refreshButton = new QToolButton(this);
refreshButton->setIcon(QPixmap("theme:icons/reload"));
refreshButton->setFixedSize(32, 32);
quickSettingsWidget = new PublicDecksQuickSettingsWidget(this);
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) {
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, &PublicDecksQuickSettingsWidget::cardSizeChanged, this,
&TabPublicDecks::updateCardSize);
connect(quickSettingsWidget, &PublicDecksQuickSettingsWidget::showTagFilterChanged, this,
&TabPublicDecks::updateTagsVisibility);
model->refresh(userName);
}
QString TabPublicDecks::getTabText() const
{
return tr("Public decks of %1").arg(userName);
}
void TabPublicDecks::retranslateUi()
{
titleLabel->setText(tr("Public decks of %1").arg(userName));
searchWidget->setPlaceholderText(tr("Search by deck name"));
emptyLabel->setText(tr("This user has not published any decks."));
refreshButton->setToolTip(tr("Refresh"));
quickSettingsWidget->setToolTip(tr("Public Decks Settings"));
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) {
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,79 @@
/**
* @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 PublicDecksQuickSettingsWidget;
class QLabel;
class QToolButton;
class RemotePublicDecksModel;
class Response;
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;
PublicDecksQuickSettingsWidget *quickSettingsWidget;
QLabel *titleLabel;
QLabel *statusLabel;
QLabel *emptyLabel;
int cardSize = 100;
};
#endif // TAB_PUBLIC_DECKS_H

View file

@ -20,6 +20,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"
@ -267,6 +268,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());
@ -986,6 +991,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

@ -46,6 +46,7 @@ class TabModeration;
class TabAccount;
class TabDeckEditor;
class TabLog;
class TabPublicDecks;
class RoomEvent;
class GameEventContainer;
class Event_GameJoined;
@ -112,6 +113,7 @@ private:
QMap<int, TabGame *> gameTabs;
QList<TabGame *> replayTabs;
QMap<QString, TabMessage *> messageTabs;
QMap<QString, TabPublicDecks *> publicDecksTabs;
QList<AbstractTabDeckEditor *> deckEditorTabs;
bool isLocalGame;
@ -196,6 +198,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();
@ -226,6 +229,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

@ -1,10 +1,21 @@
#include "tab_deck_storage_visual.h"
#include "../../../deck_loader/deck_loader.h"
#include "../../cards/additional_info/deck_color_identity.h"
#include "../../deck_share/deck_share_utils.h"
#include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h"
#include "../tab_supervisor.h"
#include <QDateTime>
#include <QMessageBox>
#include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pending_command.h>
TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor)
: Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this))
@ -14,12 +25,42 @@ TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor)
&TabDeckStorageVisual::actOpenLocalDeck);
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor, this,
&TabDeckStorageVisual::openDeckEditor);
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareDeckRequested, this,
&TabDeckStorageVisual::actShareDeck);
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareSelectionChanged, this,
&TabDeckStorageVisual::onShareSelectionChanged);
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareRequested, this, [this] {
if (shareDeckAvailable) {
enterShareMode();
}
});
AbstractClient *client = tabSupervisor->getClient();
connect(client, &AbstractClient::statusChanged, this, &TabDeckStorageVisual::handleConnectionChanged);
shareDeckAvailable = (client->getStatus() == StatusLoggedIn);
visualDeckStorageWidget->setShareAvailable(shareDeckAvailable);
auto *widget = new QWidget(this);
auto *layout = new QVBoxLayout(widget);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
widget->setLayout(layout);
this->setCentralWidget(widget);
layout->addWidget(visualDeckStorageWidget);
shareBar = new ShareBarWidget(this);
connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorageVisual::actShareSelected);
connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorageVisual::exitShareMode);
layout->insertWidget(0, shareBar);
shareBar->setVisible(false);
retranslateUi();
}
void TabDeckStorageVisual::retranslateUi()
{
shareBar->retranslateUi();
}
void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath)
@ -33,3 +74,127 @@ void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath)
emit openDeckEditor(deckOpt.value());
}
void TabDeckStorageVisual::enterShareMode(const QStringList &preselectFiles)
{
if (!shareDeckAvailable) {
return; // sharing is gated on being logged in
}
shareBar->setCreateEnabled(true);
visualDeckStorageWidget->setShareSelectable(true);
visualDeckStorageWidget->setShareSelectedFiles(preselectFiles);
shareBar->setName(tr("Shared decks"));
shareBar->setVisible(true);
updateShareHint();
shareBar->focusName();
}
void TabDeckStorageVisual::exitShareMode()
{
visualDeckStorageWidget->setShareSelectable(false);
visualDeckStorageWidget->clearShareSelection();
shareBar->setVisible(false);
}
void TabDeckStorageVisual::actShareDeck(const QString &filePath)
{
if (!shareDeckAvailable) {
QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck."));
return;
}
enterShareMode({filePath});
}
void TabDeckStorageVisual::onShareSelectionChanged()
{
if (shareBar->isVisible()) {
updateShareHint();
}
}
void TabDeckStorageVisual::updateShareHint()
{
const int count = visualDeckStorageWidget->selectedFilePaths().size();
shareBar->setCountText(tr("%n deck(s)", "", count));
if (count == 0) {
shareBar->setHintText(tr("Click deck tiles to select the decks you want to share."), true);
} else {
shareBar->setHintText(tr("%n deck(s) selected. Create the link to share %1 with other players.", "", count)
.arg(count == 1 ? tr("it") : tr("them")),
true);
}
}
void TabDeckStorageVisual::actShareSelected()
{
const QStringList filePaths = visualDeckStorageWidget->selectedFilePaths();
if (filePaths.isEmpty()) {
QMessageBox::warning(this, tr("Share decks"), tr("Select at least one deck to share."));
return;
}
Command_DeckShareCreate cmd;
cmd.set_name(shareBar->name().toStdString());
if (cmd.name().empty()) {
cmd.set_name(tr("Shared decks").toStdString());
}
for (const QString &filePath : filePaths) {
std::optional<LoadedDeck> deckOpt =
DeckLoader::loadFromFile(filePath, DeckFileFormat::getFormatFromName(filePath), true);
if (!deckOpt) {
QMessageBox::warning(this, tr("Share decks"), tr("Unable to load deck file %1").arg(filePath));
return;
}
DeckShareItem *item = cmd.add_items();
item->set_deck_list(deckOpt->deckList.writeToString_Native().toStdString());
item->set_color_identity(getDeckColorIdentity(deckOpt->deckList, CardDatabaseManager::query()).toStdString());
}
shareBar->setCreateEnabled(false);
PendingCommand *pend = tabSupervisor->getClient()->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorageVisual::shareFinished);
tabSupervisor->getClient()->sendCommand(pend);
}
void TabDeckStorageVisual::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
shareBar->setCreateEnabled(true);
if (response.response_code() != Response::RespOk) {
showShareNotice(tr("Failed to create the share link (server response code %1).")
.arg(QString::number(static_cast<int>(response.response_code()))),
true);
return;
}
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
const QString token = QString::fromStdString(resp.token());
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
#else
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
#endif
const QString link = DeckShareUtils::buildShareLink(tabSupervisor->getClient(), token);
DeckShareUtils::copyShareLinkToClipboard(link);
showShareNotice(
tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(expiry)));
exitShareMode();
}
void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status)
{
shareDeckAvailable = (status == StatusLoggedIn);
visualDeckStorageWidget->setShareAvailable(shareDeckAvailable);
if (!shareDeckAvailable && shareBar->isVisible()) {
exitShareMode();
}
}
void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning)
{
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message,
QMessageBox::Ok, this);
box.exec();
}

View file

@ -7,8 +7,12 @@
#ifndef TAB_DECK_STORAGE_VISUAL_H
#define TAB_DECK_STORAGE_VISUAL_H
#include "../../deck_share/share_bar_widget.h"
#include "../tab.h"
#include <QStringList>
#include <libcockatrice/network/client/abstract/abstract_client.h>
struct LoadedDeck;
class AbstractClient;
class CommandContainer;
@ -27,22 +31,49 @@ class TabDeckStorageVisual final : public Tab
Q_OBJECT
public:
explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor);
void retranslateUi() override
{
}
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
return tr("Visual Deck Storage");
}
/**
* @brief Enters share-selection mode, optionally preselecting the given deck files.
*/
void enterShareMode(const QStringList &preselectFiles = {});
/**
* @brief Leaves share-selection mode and clears the selection.
*/
void exitShareMode();
[[nodiscard]] bool isShareModeActive() const
{
return shareBar->isVisible();
}
public slots:
void actOpenLocalDeck(const QString &filePath);
void actShareDeck(const QString &filePath);
signals:
void openDeckEditor(const LoadedDeck &deck);
private slots:
void actShareSelected();
void shareFinished(const Response &response, const CommandContainer &commandContainer);
void onShareSelectionChanged();
void handleConnectionChanged(ClientStatus status);
private:
void showShareNotice(const QString &message, bool warning = false);
void updateShareHint();
VisualDeckStorageWidget *visualDeckStorageWidget;
ShareBarWidget *shareBar;
bool shareDeckAvailable = false;
};
#endif
#endif

View file

@ -1,11 +1,10 @@
#include "deck_preview_color_identity_filter_widget.h"
#include "../../cards/additional_info/mana_symbol_widget.h"
#include "../visual_deck_storage_widget.h"
#include <QSet>
DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent)
DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(QWidget *parent)
: QWidget(parent), layout(new QHBoxLayout(this))
{
setLayout(layout);

View file

@ -14,14 +14,12 @@
#include <QSet>
#include <QWidget>
class VisualDeckStorageWidget;
class DeckPreviewColorIdentityFilterWidget : public QWidget
{
Q_OBJECT
public:
explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent);
explicit DeckPreviewColorIdentityFilterWidget(QWidget *parent = nullptr);
void retranslateUi();
/**

View file

@ -4,6 +4,7 @@
#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h"
#include "../../../deck_loader/deck_loader.h"
#include "../../cards/additional_info/color_identity_widget.h"
#include "../../cards/additional_info/deck_color_identity.h"
#include "../../cards/deck_preview_card_picture_widget.h"
#include "../visual_deck_storage_quick_settings_widget.h"
#include "../visual_deck_storage_tag_filter_widget.h"
@ -11,6 +12,7 @@
#include "deck_preview_deck_tags_display_widget.h"
#include <QFileInfo>
#include <QFrame>
#include <QInputDialog>
#include <QLabel>
#include <QMenu>
@ -27,7 +29,7 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
VisualDeckStorageWidget *_visualDeckStorageWidget,
VisualDeckStorageModel *_model,
const QString &_filePath)
: QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath)
: QWidget(_parent), filePath(_filePath), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model)
{
layout = new QVBoxLayout(this);
setLayout(layout);
@ -36,6 +38,8 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled);
pictureWidget->setFontSize(24);
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent);
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageSingleClicked, this,
&DeckPreviewWidget::imageSingleClicked);
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this,
&DeckPreviewWidget::imageDoubleClickedEvent);
bannerCardDisplayWidget = pictureWidget;
@ -99,6 +103,15 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
// to keep the resize handler from searching the widget tree on every layout pass.
fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel,
bannerCardComboBox};
// Child of the banner widget so the frame tracks the banner's selection animation
// (which animates the banner's position) instead of staying at a stale static offset.
selectionFrame = new QFrame(bannerCardDisplayWidget);
selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents);
selectionFrame->setStyleSheet(QStringLiteral(
"QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }"));
selectionFrame->setVisible(false);
bannerCardDisplayWidget->installEventFilter(this);
}
void DeckPreviewWidget::retranslateUi()
@ -122,6 +135,63 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event)
for (QWidget *widget : fixedWidthChildren) {
widget->setMaximumWidth(width);
}
updateSelectionFrameGeometry();
}
bool DeckPreviewWidget::eventFilter(QObject *watched, QEvent *event)
{
if (watched == bannerCardDisplayWidget && (event->type() == QEvent::Resize || event->type() == QEvent::Move)) {
updateSelectionFrameGeometry();
}
return QWidget::eventFilter(watched, event);
}
void DeckPreviewWidget::setShareSelectable(bool selectable)
{
shareSelectable = selectable;
if (!selectable) {
setShareSelected(false);
}
updateSelectionStyle();
}
void DeckPreviewWidget::setShareSelected(bool selected)
{
if (shareSelected == selected) {
return;
}
shareSelected = selected;
updateSelectionStyle();
emit shareSelectionToggled(selected);
}
bool DeckPreviewWidget::isShareSelected() const
{
return shareSelected;
}
bool DeckPreviewWidget::isShareSelectable() const
{
return shareSelectable;
}
void DeckPreviewWidget::updateSelectionFrameGeometry()
{
if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) {
return;
}
// Frame is a child of the banner, so it is positioned in banner coordinates and
// tracks the banner's selection animation automatically. A small inset keeps the
// highlight visible around the card art without occluding it.
selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1));
selectionFrame->raise();
}
void DeckPreviewWidget::updateSelectionStyle()
{
if (selectionFrame != nullptr) {
selectionFrame->setVisible(shareSelectable && isShareSelected());
}
}
void DeckPreviewWidget::enterEvent(QEnterEvent *event)
@ -226,10 +296,6 @@ void DeckPreviewWidget::updateTagsVisibility(bool visible)
}
}
/**
* Refreshes the banner card text.
* This also calls `refreshBannerCardToolTip`, since those two often need to be updated together.
*/
void DeckPreviewWidget::refreshBannerCardText()
{
bannerCardDisplayWidget->setOverlayText(getDisplayName());
@ -337,10 +403,20 @@ void DeckPreviewWidget::imageClickedEvent(QMouseEvent *event, DeckPreviewCardPic
}
}
void DeckPreviewWidget::imageSingleClicked()
{
if (isShareSelectable()) {
setShareSelected(!isShareSelected());
}
}
void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance)
{
Q_UNUSED(event);
Q_UNUSED(instance);
if (isShareSelectable()) {
return; // in share mode a double click would just toggle a single selection
}
emit deckLoadRequested(filePath);
}
@ -365,6 +441,9 @@ QMenu *DeckPreviewWidget::createRightClickMenu()
}
});
connect(menu->addAction(tr("Share deck...")), &QAction::triggered, this,
[this] { emit shareDeckRequested(filePath); });
connect(menu->addAction(tr("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget,
&DeckPreviewDeckTagsDisplayWidget::openTagEditDlg);

View file

@ -17,6 +17,7 @@
#include <QWidget>
class QEnterEvent;
class QFrame;
class QLabel;
class QMenu;
class QMouseEvent;
@ -41,9 +42,19 @@ public:
*/
DeckPreviewCardPictureWidget *bannerCardDisplayWidget;
/** @brief The path of the deck file backing this preview. */
QString filePath;
void setShareSelectable(bool selectable);
void setShareSelected(bool selected);
[[nodiscard]] bool isShareSelected() const;
[[nodiscard]] bool isShareSelectable() const;
signals:
void deckLoadRequested(const QString &filePath);
void openDeckEditor(const LoadedDeck &deck);
void shareDeckRequested(const QString &filePath);
void shareSelectionToggled(bool selected);
public slots:
/**
@ -66,6 +77,7 @@ public slots:
protected:
void enterEvent(QEnterEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
private:
[[nodiscard]] int row() const;
@ -76,6 +88,7 @@ private:
QMenu *createRightClickMenu();
void addSetBannerCardMenu(QMenu *menu);
void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void imageSingleClicked();
void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void actRenameDeck();
@ -84,7 +97,6 @@ private:
VisualDeckStorageWidget *visualDeckStorageWidget;
VisualDeckStorageModel *model;
QString filePath;
QVBoxLayout *layout;
ColorIdentityWidget *colorIdentityWidget;
DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget;
@ -92,6 +104,12 @@ private:
QComboBox *bannerCardComboBox;
QList<QWidget *> fixedWidthChildren; ///< Children clamped to the picture width on resize.
int lastKnownBannerWidth = -1; ///< The picture width last applied to the children.
QFrame *selectionFrame = nullptr;
bool shareSelectable = false;
bool shareSelected = false;
void updateSelectionStyle();
void updateSelectionFrameGeometry();
};
class NoScrollFilter : public QObject

View file

@ -0,0 +1,164 @@
#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);
setToolTip(entry.name);
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,202 @@
#include "remote_public_decks_model.h"
#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>
RemotePublicDecksModel::RemotePublicDecksModel(AbstractClient *_client, QObject *parent)
: QAbstractListModel(parent), client(_client)
{
}
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;
bool colorMatch = true;
switch (colorFilterMode) {
case VisualDeckStorageSortFilterProxyModel::ExactMatch: {
QSet<QChar> activeSet;
for (const QChar &color : activeColors) {
activeSet.insert(color.toUpper());
}
QSet<QChar> identitySet;
for (const QChar &color : identity) {
identitySet.insert(color.toUpper());
}
colorMatch = activeSet == identitySet;
break;
}
case VisualDeckStorageSortFilterProxyModel::Includes:
colorMatch = std::all_of(activeColors.begin(), activeColors.end(),
[&identity](const QChar &color) { return identity.contains(color); });
break;
case VisualDeckStorageSortFilterProxyModel::Excludes:
colorMatch = std::none_of(activeColors.begin(), activeColors.end(),
[&identity](const QChar &color) { return identity.contains(color); });
break;
}
if (!colorMatch) {
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;
}
setLoading(true);
Command_DeckListOtherUser cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &RemotePublicDecksModel::decksReceived);
client->sendCommand(pend);
}
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);
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());
const QString tagsString = QString::fromStdString(file.tags());
entry.tags = tagsString.split(QLatin1Char(','), Qt::SkipEmptyParts);
decks.append(entry);
}

View file

@ -0,0 +1,125 @@
/**
* @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 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);
private:
void addFolder(const ServerInfo_DeckStorage_Folder &folder);
void addTreeItem(const ServerInfo_DeckStorage_TreeItem &item);
void rebuildVisibleIndices();
void setLoading(bool value);
AbstractClient *client;
QList<DeckEntry> decks;
QList<int> visibleIndices; ///< Row indices into `decks` that pass the current filters.
bool loading = false;
QString searchText;
VisualDeckStorageSortFilterProxyModel::FilterMode colorFilterMode = VisualDeckStorageSortFilterProxyModel::Includes;
QSet<QChar> activeColors;
QSet<QString> includedTags;
QSet<QString> excludedTags;
};
#endif // REMOTE_PUBLIC_DECKS_MODEL_H

View file

@ -209,6 +209,11 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget
&VisualDeckStorageWidget::deckLoadRequested);
connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget,
&VisualDeckStorageWidget::openDeckEditor);
connect(deckPreviewWidget, &DeckPreviewWidget::shareDeckRequested, visualDeckStorageWidget,
&VisualDeckStorageWidget::shareDeckRequested);
connect(deckPreviewWidget, &DeckPreviewWidget::shareSelectionToggled, visualDeckStorageWidget,
&VisualDeckStorageWidget::shareSelectionChanged);
deckPreviewWidget->setShareSelectable(visualDeckStorageWidget->isShareSelectable());
connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged,
deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor);
deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize());
@ -216,6 +221,18 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget
return deckPreviewWidget;
}
void VisualDeckStorageFolderDisplayWidget::setShareSelectable(bool selectable)
{
const auto previews = flowWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
preview->setShareSelectable(selectable);
}
const auto subFolders = findChildren<VisualDeckStorageFolderDisplayWidget *>();
for (VisualDeckStorageFolderDisplayWidget *subFolder : subFolders) {
subFolder->setShareSelectable(selectable);
}
}
/**
* @brief Creates, removes and keeps in sync the subfolder widgets of this folder.
*

View file

@ -51,6 +51,7 @@ public slots:
*/
void scheduleReconcile();
void updateShowFolders(bool enabled);
void setShareSelectable(bool selectable);
signals:
/**

View file

@ -39,3 +39,8 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) :
connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); });
}
void VisualDeckStorageSearchWidget::setPlaceholderText(const QString &text)
{
searchBar->setPlaceholderText(text);
}

View file

@ -19,6 +19,8 @@ class VisualDeckStorageSearchWidget : public QWidget
public:
explicit VisualDeckStorageSearchWidget(QWidget *parent);
void setPlaceholderText(const QString &text);
signals:
/**
* Emitted once the debounce timer fires after the user stopped typing.

View file

@ -2,14 +2,10 @@
#include "../general/layout_containers/flow_widget.h"
#include "deck_preview/deck_preview_tag_display_widget.h"
#include "visual_deck_storage_model.h"
#include "visual_deck_storage_sort_filter_proxy_model.h"
#include "visual_deck_storage_widget.h"
#include <QHBoxLayout>
VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent)
: QWidget(_parent), parent(_parent)
VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(QWidget *parent) : QWidget(parent)
{
setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
@ -25,97 +21,62 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto
layout->addWidget(flowWidget);
}
void VisualDeckStorageTagFilterWidget::setAllTagsProvider(const std::function<QSet<QString>()> &provider)
{
allTagsProvider = provider;
}
void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
refreshTags();
}
/**
* @brief The tags of all decks currently accepted by the proxy model.
*/
QSet<QString> VisualDeckStorageTagFilterWidget::gatherAllTags() const
{
QSet<QString> allTags;
auto *proxy = parent->proxyModel();
for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) {
const QModelIndex index = proxy->index(proxyRow, 0);
if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) {
continue;
}
const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList();
for (const QString &tag : deckTags) {
allTags.insert(tag);
}
}
return allTags;
}
void VisualDeckStorageTagFilterWidget::refreshTags()
{
QSet<QString> allTags = gatherAllTags();
removeTagsNotInList(allTags);
addTagsIfNotPresent(allTags);
sortTags();
}
const QSet<QString> allTags = allTagsProvider ? allTagsProvider() : QSet<QString>();
void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet<QString> &tags)
{
// Existing chips survive if their tag is still part of the deck set, or if the chip
// is currently selected/excluded. Everything else is dropped. Dropped chips must NOT
// be re-added to the layout afterwards: they are scheduled for a deferred delete, and
// the flow layout would keep a dangling reference to them once the deletion runs on
// the next event-loop cycle.
QList<DeckPreviewTagDisplayWidget *> chips;
for (DeckPreviewTagDisplayWidget *tagWidget : findChildren<DeckPreviewTagDisplayWidget *>()) {
const QString &tagName = tagWidget->getTagName();
// Keep the tag widget if it is either selected or excluded
if (!tags.contains(tagName) && tagWidget->getState() == TagState::NotSelected) {
if (tagWidget->getState() != TagState::NotSelected || allTags.contains(tagWidget->getTagName())) {
chips.append(tagWidget);
} else {
flowWidget->removeWidget(tagWidget);
tagWidget->deleteLater();
}
}
}
void VisualDeckStorageTagFilterWidget::addTagsIfNotPresent(const QSet<QString> &tags)
{
for (const QString &tag : tags) {
addTagIfNotPresent(tag);
}
}
void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag)
{
// Check if the tag already exists in the flow widget
bool tagExists = false;
for (DeckPreviewTagDisplayWidget *tagWidget : findChildren<DeckPreviewTagDisplayWidget *>()) {
if (tagWidget->getTagName() == tag) {
tagExists = true;
break;
// Add chips for tags that are not shown yet.
for (const QString &tag : allTags) {
bool tagExists = false;
for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
if (tagWidget->getTagName() == tag) {
tagExists = true;
break;
}
}
if (!tagExists) {
auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag);
connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this,
&VisualDeckStorageTagFilterWidget::filterChanged);
flowWidget->addWidget(newTagWidget);
chips.append(newTagWidget);
}
}
// If the tag doesn't exist, add a new DeckPreviewTagDisplayWidget
if (!tagExists) {
auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag);
connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent,
&VisualDeckStorageWidget::updateTagFilter);
flowWidget->addWidget(newTagWidget);
}
}
void VisualDeckStorageTagFilterWidget::sortTags()
{
// Get all tag widgets
QList<DeckPreviewTagDisplayWidget *> tagWidgets = findChildren<DeckPreviewTagDisplayWidget *>();
// Sort widgets by tag name
std::sort(tagWidgets.begin(), tagWidgets.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) {
// Clear and re-add the chips in sorted order.
std::sort(chips.begin(), chips.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) {
return a->getTagName().toLower() < b->getTagName().toLower();
});
// Clear and re-add widgets in sorted order
for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) {
for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
flowWidget->removeWidget(tagWidget);
}
for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) {
for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
flowWidget->addWidget(tagWidget);
}
}

View file

@ -9,26 +9,27 @@
#include <QSet>
#include <QStringList>
#include <QWidget>
#include <functional>
class FlowWidget;
class VisualDeckStorageWidget;
class VisualDeckStorageTagFilterWidget : public QWidget
{
Q_OBJECT
VisualDeckStorageWidget *parent;
FlowWidget *flowWidget;
[[nodiscard]] QSet<QString> gatherAllTags() const;
void removeTagsNotInList(const QSet<QString> &tags);
void addTagsIfNotPresent(const QSet<QString> &tags);
void addTagIfNotPresent(const QString &tag);
void sortTags();
std::function<QSet<QString>()> allTagsProvider;
public:
explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent);
explicit VisualDeckStorageTagFilterWidget(QWidget *parent = nullptr);
[[nodiscard]] QStringList getAllKnownTags() const;
/**
* @brief Sets a provider for the full set of tags to draw chips from.
*/
void setAllTagsProvider(const std::function<QSet<QString>()> &provider);
/**
* @brief The tags currently in "selected" state.
*/
@ -39,9 +40,15 @@ public:
*/
[[nodiscard]] QStringList excludedTags() const;
signals:
/**
* Emitted whenever a chip's selection/exclusion state changes.
*/
void filterChanged();
public slots:
/**
* @brief Rebuilds the tag chips from the tags of the currently visible decks.
* @brief Rebuilds the tag chips from the currently available tags.
*/
void refreshTags();
void showEvent(QShowEvent *event) override;

View file

@ -47,6 +47,13 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
refreshButton->setFixedSize(32, 32);
connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible);
shareButton = new QToolButton(this);
shareButton->setIcon(QPixmap("theme:icons/share"));
shareButton->setFixedSize(32, 32);
shareButton->setToolTip(tr("Select decks to share"));
shareButton->setVisible(false);
connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested);
quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this);
connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this,
&VisualDeckStorageWidget::updateShowFolders);
@ -57,10 +64,14 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
searchAndSortLayout->addWidget(sortWidget);
searchAndSortLayout->addWidget(searchWidget);
searchAndSortLayout->addWidget(refreshButton);
searchAndSortLayout->addWidget(shareButton);
searchAndSortLayout->addWidget(quickSettingsWidget);
// tag filter box
tagFilterWidget = new VisualDeckStorageTagFilterWidget(this);
tagFilterWidget->setAllTagsProvider([this] { return gatherVisibleTags(); });
connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this,
&VisualDeckStorageWidget::updateTagFilter);
updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter());
deckPreviewSelectionAnimationEnabled =
@ -159,6 +170,63 @@ void VisualDeckStorageWidget::retranslateUi()
sortWidget->retranslateUi();
}
void VisualDeckStorageWidget::setShareSelectable(bool selectable)
{
if (shareSelectable == selectable) {
return;
}
shareSelectable = selectable;
if (folderWidget != nullptr) {
folderWidget->setShareSelectable(selectable);
}
emit shareSelectionChanged();
}
bool VisualDeckStorageWidget::isShareSelectable() const
{
return shareSelectable;
}
QStringList VisualDeckStorageWidget::selectedFilePaths() const
{
QStringList selectedPaths;
if (folderWidget != nullptr) {
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
if (preview->isShareSelected()) {
selectedPaths.append(preview->filePath);
}
}
}
return selectedPaths;
}
void VisualDeckStorageWidget::clearShareSelection()
{
if (folderWidget != nullptr) {
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
preview->setShareSelected(false);
}
}
}
void VisualDeckStorageWidget::setShareAvailable(bool available)
{
shareButton->setVisible(available);
shareButton->setEnabled(available);
}
void VisualDeckStorageWidget::setShareSelectedFiles(const QStringList &paths)
{
if (folderWidget != nullptr) {
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
preview->setShareSelected(paths.contains(preview->filePath));
}
}
}
/**
* Gets a const pointer to the quick settings so that the values can be accessed.
*/
@ -216,6 +284,25 @@ void VisualDeckStorageWidget::updateTagFilter()
tagFilterWidget->refreshTags();
}
/**
* @brief The tags of all decks currently accepted by the proxy model.
*/
QSet<QString> VisualDeckStorageWidget::gatherVisibleTags() const
{
QSet<QString> allTags;
for (int proxyRow = 0; proxyRow < storageProxyModel->rowCount(); ++proxyRow) {
const QModelIndex index = storageProxyModel->index(proxyRow, 0);
if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) {
continue;
}
const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList();
for (const QString &tag : deckTags) {
allTags.insert(tag);
}
}
return allTags;
}
/**
* Pushes the color identity filter widget's state into the proxy model.
*/

View file

@ -33,6 +33,12 @@ public:
explicit VisualDeckStorageWidget(QWidget *parent);
void refreshIfPossible();
void retranslateUi();
void setShareSelectable(bool selectable);
[[nodiscard]] bool isShareSelectable() const;
[[nodiscard]] QStringList selectedFilePaths() const;
void setShareSelectedFiles(const QStringList &paths);
void clearShareSelection();
void setShareAvailable(bool available);
VisualDeckStorageTagFilterWidget *tagFilterWidget;
bool deckPreviewSelectionAnimationEnabled;
@ -63,6 +69,9 @@ public slots:
signals:
void deckLoadRequested(const QString &filePath);
void openDeckEditor(const LoadedDeck &deck);
void shareDeckRequested(const QString &filePath);
void shareSelectionChanged();
void shareRequested();
protected:
void resizeEvent(QResizeEvent *event) override;
@ -70,6 +79,7 @@ protected:
private:
void reapplySortAndFilters();
[[nodiscard]] QSet<QString> gatherVisibleTags() const;
private:
QVBoxLayout *layout;
@ -80,12 +90,14 @@ private:
VisualDeckStorageSearchWidget *searchWidget;
DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget;
QToolButton *refreshButton;
QToolButton *shareButton;
VisualDeckStorageQuickSettingsWidget *quickSettingsWidget;
QScrollArea *scrollArea;
VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr;
VisualDeckStorageModel *storageModel = nullptr;
VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr;
QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads.
bool shareSelectable = false;
};
#endif // VISUAL_DECK_STORAGE_WIDGET_H

View file

@ -507,6 +507,7 @@ MainWindow::MainWindow(QWidget *parent)
connectionController = new ConnectionController(this, this);
urlParser = new IntentUrlParser(this, this);
connect(urlParser, &IntentUrlParser::urlChainFinished, this, &MainWindow::onUrlChainFinished);
createActions();
createMenus();
@ -722,6 +723,7 @@ void MainWindow::applyStartupDestination()
connect(credentials, &Intent::finished, connector, &Intent::execute);
connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed);
connect(credentials, &Intent::cancelled, this, [this]() { startupDestinationFailed(tr("Sign-in cancelled")); });
connect(connector, &Intent::finished, this,
[this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); });
connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed);
@ -862,18 +864,7 @@ void MainWindow::changeEvent(QEvent *event)
} else if (event->type() == QEvent::ActivationChange) {
if (isActiveWindow() && !bHasActivated) {
bHasActivated = true;
if (!connectTo.isEmpty()) {
qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo;
connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(),
connectTo.password());
} else if (SettingsCache::instance().servers().getAutoConnect() &&
!SettingsCache::instance().debug().getLocalGameOnStartup() &&
!startupDestinationConnectsToServer()) {
qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect...";
DlgConnect dlg(this);
connectionController->connectToServerDirect(dlg.getHost(), static_cast<unsigned int>(dlg.getPort()),
dlg.getPlayerName(), dlg.getPassword());
}
attemptStartupAutoConnect();
}
}
@ -899,6 +890,40 @@ void MainWindow::handleCockatriceLink(const QString &url)
urlParser->handle(url);
}
void MainWindow::attemptStartupAutoConnect()
{
if (startupAutoConnectAttempted || skipStartupAutoConnect) {
return;
}
startupAutoConnectAttempted = true;
if (!connectTo.isEmpty()) {
qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo;
connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(),
connectTo.password());
} else if (SettingsCache::instance().servers().getAutoConnect() &&
!SettingsCache::instance().debug().getLocalGameOnStartup() && !startupDestinationConnectsToServer()) {
qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect...";
DlgConnect dlg(this);
connectionController->connectToServerDirect(dlg.getHost(), static_cast<unsigned int>(dlg.getPort()),
dlg.getPlayerName(), dlg.getPassword());
}
}
void MainWindow::onUrlChainFinished(bool connected)
{
// A cockatrice:// link owns the startup connection while it runs. When its
// chain ended without connecting (declined, invalid, offline), fall back to
// the startup connection so the activation launch still behaves like a
// normal launch.
if (connected || !skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) {
return;
}
qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup connect";
skipStartupAutoConnect = false;
attemptStartupAutoConnect();
}
void MainWindow::cardDatabaseLoadingFailed()
{
if (askedForDbUpdater) {

View file

@ -75,6 +75,7 @@ public slots:
void actCheckClientUpdates();
void actConnect();
void actExit();
void handleCockatriceLink(const QString &url);
private slots:
void updateTabMenu(const QList<QMenu *> &newMenuList);
void statusChanged(ClientStatus _status);
@ -92,7 +93,7 @@ private slots:
void actOpenSettingsFolder();
void actShow();
void showWindowIfHidden();
void handleCockatriceLink(const QString &url);
void onUrlChainFinished(bool connected);
void cardUpdateError(QProcess::ProcessError err);
void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus);
@ -120,6 +121,8 @@ private slots:
void startupDestinationFailed(const QString &reason);
[[nodiscard]] bool startupDestinationConnectsToServer() const;
void attemptStartupAutoConnect();
private:
static const QString appName;
static const QStringList fileNameFilters;
@ -158,6 +161,8 @@ private:
LagMonitor lagMonitor; ///< watches the main thread for event loop stalls
LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph
bool bHasActivated, askedForDbUpdater;
bool skipStartupAutoConnect = false;
bool startupAutoConnectAttempted = false;
QProcess *cardUpdateProcess;
DlgViewLog *logviewDialog;
GameReplay *replay;
@ -170,6 +175,16 @@ public:
{
connectTo = QUrl(QString("cockatrice://%1").arg(url));
}
// When set, the window's own startup connection (--connect or auto-connect
// on first activation) is skipped. Used for activation launches: the intent
// chain triggered by a cockatrice:// URL owns the connection, and letting
// auto-connect race against it caused two connectToServer calls to tear
// each other down. onUrlChainFinished() clears this and retries the startup
// connection when the link's chain ended without connecting.
void setSkipStartupAutoConnect(bool skip)
{
skipStartupAutoConnect = skip;
}
~MainWindow() override;
RemoteClient *getRemoteClient() const

View file

@ -26,7 +26,6 @@
#include "client/url_scheme_event_filter.h"
#include "database/interface/settings_card_preference_provider.h"
#include "interface/intents/intent_open_local_deck.h"
#include "interface/intents/url_parser.h"
#include "interface/logger.h"
#include "interface/pixel_map_generator.h"
#include "interface/theme_manager.h"
@ -45,6 +44,7 @@
#include <QMessageBox>
#include <QSystemTrayIcon>
#include <QTranslator>
#include <algorithm>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/rng/rng_sfmt.h>
#include <libcockatrice/settings/appearance_settings.h>
@ -273,10 +273,12 @@ int main(int argc, char *argv[])
SingleInstanceManager instance;
if (hasActivationFiles) {
qInfo() << "Activation launch, files:" << startupFiles;
// Activation launch: hand off to the primary instance if one is
// running, otherwise become the primary ourselves. Do this before
// constructing the main window so a hand-off exits cheaply.
if (!instance.tryRun(startupFiles)) {
qInfo() << "Handed off to a running instance, exiting";
// Sent successfully → exit
return 0;
}
@ -325,10 +327,22 @@ int main(int argc, char *argv[])
MainWindow ui;
// A URL launch must own the connection: the intent chain triggered by the
// URL connects to the server named in the URL, so the window's own startup
// auto-connect must not race against it (two connectToServer calls tear
// each other down via doDisconnectFromServer).
const bool hasUrlActivation = std::any_of(startupFiles.begin(), startupFiles.end(), [](const QString &file) {
return file.startsWith(QStringLiteral("cockatrice://"));
});
ui.setSkipStartupAutoConnect(hasUrlActivation);
auto handleActivation = [&ui](const QString &file) {
if (file.startsWith("cockatrice://")) {
auto urlParser = new IntentUrlParser(&ui, &ui);
urlParser->handle(file);
qInfo() << "Handling URL activation:" << file;
// Route through the window's persistent url parser: it serializes
// link chains so activations handed over while another chain is
// still connecting do not connect concurrently.
ui.handleCockatriceLink(file);
} else if (QFileInfo(file).exists()) {
auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file);
QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) {

View file

@ -2,6 +2,14 @@
#include <QDir>
namespace
{
// Sent by the primary instance after it has read a forwarded payload. Without
// an acknowledgment, a second instance cannot tell a live primary apart from a
// stale socket left behind by a process that is still shutting down.
const QByteArray ACK_MESSAGE = QByteArrayLiteral("COCKATRICE_ACK");
} // namespace
SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent)
{
}
@ -72,7 +80,14 @@ bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend)
socket.flush();
socket.waitForBytesWritten(1000);
return true;
// Only report a successful hand-off once the primary has acknowledged that
// it actually read the payload. A socket that connects but never answers
// belongs to a process that is dying, so the caller must not treat this as
// a hand-off (otherwise it would exit without anyone handling the files).
if (!socket.waitForReadyRead(1000)) {
return false;
}
return socket.readAll() == ACK_MESSAGE;
}
void SingleInstanceManager::handleNewConnection()
@ -111,6 +126,14 @@ void SingleInstanceManager::handleNewConnection()
QStringList files;
payloadStream >> files;
// Acknowledge receipt as soon as the payload is parsed, before the
// primary starts handling it. The handlers run synchronously and can
// take longer than the sender's readiness timeout (e.g. a modal
// confirmation box), which would otherwise make a live primary look
// dead and cause duplicate handling.
socket->write(ACK_MESSAGE);
socket->flush();
emit filesReceived(files);
// Reset buffer (single message use-case)

View file

@ -15,9 +15,15 @@ set(PROTO_FILES
command_deck_del.proto
command_deck_del_dir.proto
command_deck_download.proto
command_deck_download_public.proto
command_deck_list.proto
command_deck_list_other_user.proto
command_deck_new_dir.proto
command_deck_select.proto
command_deck_set_visibility.proto
command_deck_share_create.proto
command_deck_share_download.proto
command_deck_share_list.proto
command_deck_upload.proto
command_del_counter.proto
command_delete_arrow.proto
@ -135,6 +141,9 @@ set(PROTO_FILES
response_card_art_rule_entry.proto
response_deck_download.proto
response_deck_list.proto
response_deck_share_create.proto
response_deck_share_download.proto
response_deck_share_list.proto
response_deck_upload.proto
response_dump_zone.proto
response_forgotpasswordrequest.proto
@ -172,6 +181,7 @@ set(PROTO_FILES
serverinfo_cardcounter.proto
serverinfo_chat_message.proto
serverinfo_counter.proto
serverinfo_deck_share_item.proto
serverinfo_deckstorage.proto
serverinfo_game.proto
serverinfo_gametype.proto

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckDownloadPublic {
extend SessionCommand {
optional Command_DeckDownloadPublic ext = 1031;
}
optional uint32 deck_id = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckListOtherUser {
extend SessionCommand {
optional Command_DeckListOtherUser ext = 1029;
}
optional string user_name = 1;
}

View file

@ -0,0 +1,13 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckSetVisibility {
extend SessionCommand {
optional Command_DeckSetVisibility ext = 1030;
}
// Set the public visibility of a single deck (mutually exclusive with folder_path).
optional uint32 deck_id = 1;
// Set the public visibility of a folder (all decks under it inherit).
optional string folder_path = 2;
optional bool is_public = 3;
}

View file

@ -0,0 +1,24 @@
syntax = "proto2";
import "session_commands.proto";
message DeckShareItem {
// Reference an existing deck in the sharer's personal deck storage.
// Mutually exclusive with deck_list.
optional uint32 deck_id = 1;
// Inline deck content in the native format.
// Mutually exclusive with deck_id.
optional string deck_list = 2;
// Color identity of the deck (e.g. "WUBRG"), computed by the sharing client.
optional string color_identity = 3;
}
message Command_DeckShareCreate {
extend SessionCommand {
optional Command_DeckShareCreate ext = 1026;
}
optional string name = 1;
repeated DeckShareItem items = 2;
// Path of a folder in the sharer's personal deck storage. When set, all
// decks in that folder are shared (resolved by the server).
optional string folder_path = 3;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckShareDownload {
extend SessionCommand {
optional Command_DeckShareDownload ext = 1028;
}
optional string token = 1;
optional uint32 item_id = 2;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckShareList {
extend SessionCommand {
optional Command_DeckShareList ext = 1027;
}
optional string token = 1;
}

View file

@ -8,4 +8,12 @@ message Command_DeckUpload {
optional string path = 1; // to upload a new deck
optional uint32 deck_id = 2; // to replace an existing deck
optional string deck_list = 3;
optional bool is_public = 4; // mark the deck public on upload (publish)
// Preview metadata computed by the uploading client (see ServerInfo_DeckStorage_File).
optional string banner_card_name = 5;
optional string banner_card_provider = 6;
optional string color_identity = 7;
// Comma-separated list of tag names associated with the deck, used to render
// and filter another user's public decks on the client.
optional string tags = 8;
}

View file

@ -80,6 +80,9 @@ message Response {
REPLAY_LIST = 1100; // Response listing replays
REPLAY_DOWNLOAD = 1101; // Response for replay download
REPLAY_GET_CODE = 1102; // Response containing replay code
DECK_SHARE_CREATE = 1103; // Response to deck share creation
DECK_SHARE_LIST = 1104; // Response listing shared decks
DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download
CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "response.proto";
message Response_DeckShareCreate {
extend Response {
optional Response_DeckShareCreate ext = 1103;
}
optional string token = 1;
optional uint64 expires_at = 2;
optional uint32 item_count = 3;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "response.proto";
message Response_DeckShareDownload {
extend Response {
optional Response_DeckShareDownload ext = 1105;
}
optional string deck = 1;
}

View file

@ -0,0 +1,12 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_deck_share_item.proto";
message Response_DeckShareList {
extend Response {
optional Response_DeckShareList ext = 1104;
}
optional string name = 1;
optional uint64 expires_at = 2;
repeated ServerInfo_DeckShareItem items = 3;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
message ServerInfo_DeckShareItem {
optional uint32 id = 1;
optional string name = 2;
repeated string tags = 3;
optional string banner_card = 4;
optional string game_format = 5;
optional string color_identity = 6;
}

View file

@ -1,10 +1,22 @@
syntax = "proto2";
message ServerInfo_DeckStorage_File {
optional uint32 creation_time = 1;
optional bool is_public = 2;
// Preview metadata computed by the uploading client, so other clients can
// render this deck (e.g. in a visual storage grid) without downloading the
// full deck list. Empty for decks uploaded before the metadata columns.
optional string banner_card_name = 3;
optional string banner_card_provider = 4;
optional string color_identity = 5;
// Comma-separated list of tag names, matching the corresponding
// ServerInfo_DeckStorage_File upload metadata. Empty for decks uploaded
// before the tags column existed.
optional string tags = 6;
}
message ServerInfo_DeckStorage_Folder {
repeated ServerInfo_DeckStorage_TreeItem items = 1;
optional bool is_public = 2;
}
message ServerInfo_DeckStorage_TreeItem {

View file

@ -28,6 +28,12 @@ message SessionCommand {
FORGOT_PASSWORD_CHALLENGE = 1023;
REQUEST_PASSWORD_SALT = 1024;
SET_CARD_ART_PARAMS = 1025;
DECK_SHARE_CREATE = 1026;
DECK_SHARE_LIST = 1027;
DECK_SHARE_DOWNLOAD = 1028;
DECK_LIST_OTHER_USER = 1029;
DECK_SET_VISIBILITY = 1030;
DECK_DOWNLOAD_PUBLIC = 1031;
REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101;
REPLAY_MODIFY_MATCH = 1102;

View file

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

View file

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

View file

@ -0,0 +1,71 @@
-- Servatrice db migration from version 36 to version 37
-- Deck sharing (temporary share links + permanent public decks).
--
-- This feature was developed behind several intermediate migrations that have
-- never shipped, so they are folded into this single 36 -> 37 migration:
-- temporary share links, permanent public-deck visibility, preview metadata,
-- and per-deck tags.
-- 1. Temporary deck shares: a named bundle of decks that can be fetched by
-- anyone who knows the (unguessable) token, until the share expires.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`token` varchar(64) NOT NULL,
`name` varchar(64) NOT NULL,
`created_by` int(7) unsigned NULL,
`created_at` datetime NOT NULL,
`expires_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
KEY `expires_at` (`expires_at`),
FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Individual decks inside a share bundle. Content is materialized at share
-- time so expiring/deleting a share can cascade cleanly. The metadata columns
-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when
-- using prepared statements.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`share_id` int(7) unsigned zerofill NOT NULL,
`name` varchar(50) NOT NULL,
`tags` text NULL,
`banner_card` varchar(255) NULL,
`game_format` varchar(50) NULL,
`color_identity` varchar(5) NULL,
`content` text NOT NULL,
`position` int(7) NOT NULL,
PRIMARY KEY (`id`),
KEY `share_id` (`share_id`),
FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- 2. Permanent deck sharing: add public visibility flags to the deck storage
-- tables. A deck is visible to other users if it is marked public, or if any
-- ancestor folder is marked public (inherited). Existing decks default to
-- private, so the upgrade does not expose any data.
ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `content`;
ALTER TABLE `cockatrice_decklist_folders`
ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `name`;
-- 3. Per-deck preview metadata so clients can render another user's public
-- decks (e.g. in a visual deck storage grid) without downloading each deck
-- list. The metadata is computed by the uploading client; decks uploaded
-- before this migration have empty values until they are re-uploaded.
ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `banner_card_name` varchar(255) NULL AFTER `content`,
ADD COLUMN `banner_card_provider` varchar(32) NULL AFTER `banner_card_name`,
ADD COLUMN `color_identity` varchar(5) NULL AFTER `banner_card_provider`;
-- 4. Per-deck tags for public decks. The uploading client sends a
-- comma-separated tag string (matching the deck's own tags), so another user's
-- public decks can render and filter by tag without downloading each deck list.
-- Decks uploaded before this migration have NULL tags until they are
-- re-uploaded.
ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `tags` text NULL AFTER `color_identity`;
UPDATE cockatrice_schema_version SET version=37 WHERE version=36;

View file

@ -439,3 +439,19 @@ ssl_cert=ssl_cert.pem
; Filename of the private key for the server-to-server certificate
ssl_key=ssl_key.pem
[deck_share]
; How many days a created deck share link remains valid before it expires.
; Default: 7
expiry_days=7
; How often (in minutes) the server checks for and removes expired deck shares.
; A value of 0 disables the automatic cleanup.
; Default: 60
cleanup_interval=60
; Maximum number of decks a single share link can contain.
; Default: 50
max_decks_per_share=50

View file

@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
INSERT INTO cockatrice_schema_version VALUES(36);
INSERT INTO cockatrice_schema_version VALUES(37);
-- users and user data tables
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
@ -63,16 +63,56 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` (
`name` varchar(50) NOT NULL,
`upload_time` datetime NOT NULL,
`content` text NOT NULL,
`is_public` tinyint(1) NOT NULL DEFAULT 0,
`banner_card_name` varchar(255) NULL,
`banner_card_provider` varchar(32) NULL,
`color_identity` varchar(5) NULL,
`tags` text NULL,
PRIMARY KEY (`id`),
KEY `FolderPlusUser` (`id_folder`,`id_user`),
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Temporary deck shares: a named bundle of decks that can be fetched by
-- anyone who knows the (unguessable) token, until the share expires.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`token` varchar(64) NOT NULL,
`name` varchar(64) NOT NULL,
`created_by` int(7) unsigned NULL,
`created_at` datetime NOT NULL,
`expires_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
KEY `expires_at` (`expires_at`),
FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Individual decks inside a share bundle. Content is materialized at share
-- time so expiring/deleting a share can cascade cleanly. The metadata columns
-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when
-- using prepared statements.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`share_id` int(7) unsigned zerofill NOT NULL,
`name` varchar(50) NOT NULL,
`tags` text NULL,
`banner_card` varchar(255) NULL,
`game_format` varchar(50) NULL,
`color_identity` varchar(5) NULL,
`content` text NOT NULL,
`position` int(7) NOT NULL,
PRIMARY KEY (`id`),
KEY `share_id` (`share_id`),
FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `cockatrice_decklist_folders` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`id_parent` int(7) unsigned zerofill NOT NULL,
`id_user` int(7) unsigned NULL,
`name` varchar(30) NOT NULL,
`is_public` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `ParentPlusUser` (`id_parent`,`id_user`),
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE

View file

@ -428,6 +428,14 @@ bool Servatrice::initServer()
statusUpdateClock->start(getServerStatusUpdateTime());
}
deckShareCleanupClock = new QTimer(this);
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares()));
const int deckShareCleanupInterval = getDeckShareCleanupInterval();
if (deckShareCleanupInterval > 0) {
qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms";
deckShareCleanupClock->start(deckShareCleanupInterval);
}
// SOCKET SERVER
if (getNumberOfTCPPools() > 0) {
gameServer =
@ -600,6 +608,11 @@ void Servatrice::setRequiredFeatures(const QString &featureList)
qDebug() << "Set required client features to:" << serverRequiredFeatureList;
}
void Servatrice::cleanupExpiredDeckShares()
{
servatriceDatabaseInterface->cleanupExpiredDeckShares();
}
void Servatrice::statusUpdate()
{
if (!servatriceDatabaseInterface->checkSql()) {
@ -1012,6 +1025,22 @@ int Servatrice::getServerStatusUpdateTime() const
return settingsCache->value("server/statusupdate", 15000).toInt();
}
int Servatrice::getDeckShareExpiryDays() const
{
return settingsCache->value("deck_share/expiry_days", 7).toInt();
}
int Servatrice::getDeckShareCleanupInterval() const
{
// default: every 60 minutes
return settingsCache->value("deck_share/cleanup_interval", 60).toInt() * 60000;
}
int Servatrice::getDeckShareMaxDecksPerShare() const
{
return settingsCache->value("deck_share/max_decks_per_share", 50).toInt();
}
int Servatrice::getNumberOfTCPPools() const
{
return settingsCache->value("server/number_pools", 1).toInt();

View file

@ -143,6 +143,7 @@ public:
private slots:
void statusUpdate();
void shutdownTimeout();
void cleanupExpiredDeckShares();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -156,6 +157,7 @@ private:
AuthenticationMethod authenticationMethod;
DatabaseType databaseType;
QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock;
Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer;
@ -267,6 +269,9 @@ public:
int getMaxGameInactivityTime() const override;
int getMaxPlayerInactivityTime() const override;
int getClientKeepAlive() const override;
int getDeckShareExpiryDays() const;
int getDeckShareCleanupInterval() const;
int getDeckShareMaxDecksPerShare() const;
int getMaxUsersPerAddress() const;
int getMessageCountingInterval() const override;
int getMaxMessageCountPerInterval() const override;

View file

@ -7,6 +7,7 @@
#include <QChar>
#include <QDateTime>
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
@ -1026,6 +1027,127 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user
return deck;
}
bool Servatrice_DatabaseInterface::createDeckShare(const QString &token,
const QString &name,
int userId,
const QList<DeckShareItemRecord> &items,
int expiryDays)
{
checkSql();
if (items.isEmpty()) {
return false;
}
sqlDatabase.transaction();
QSqlQuery *query = prepareQuery("insert into {prefix}_deck_share (token, name, created_by, created_at, expires_at) "
"values (:token, :name, :created_by, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))");
query->bindValue(":token", token);
query->bindValue(":name", name);
query->bindValue(":created_by", userId < 1 ? QVariant() : userId);
query->bindValue(":days", expiryDays);
if (!execSqlQuery(query)) {
sqlDatabase.rollback();
return false;
}
const int shareId = query->lastInsertId().toInt();
for (int i = 0; i < items.size(); ++i) {
const DeckShareItemRecord &item = items.at(i);
QSqlQuery *itemQuery = prepareQuery("insert into {prefix}_deck_share_item (share_id, name, tags, banner_card, "
"game_format, color_identity, content, position) values (:share_id, :name, "
":tags, :banner_card, :game_format, :color_identity, :content, :position)");
itemQuery->bindValue(":share_id", shareId);
itemQuery->bindValue(":name", item.name);
QJsonArray tagArray;
for (const QString &tag : item.tags) {
tagArray.append(tag);
}
itemQuery->bindValue(":tags", QString::fromUtf8(QJsonDocument(tagArray).toJson(QJsonDocument::Compact)));
itemQuery->bindValue(":banner_card", item.bannerCard);
itemQuery->bindValue(":game_format", item.gameFormat);
itemQuery->bindValue(":color_identity", item.colorIdentity);
itemQuery->bindValue(":content", item.content);
itemQuery->bindValue(":position", i);
if (!execSqlQuery(itemQuery)) {
sqlDatabase.rollback();
return false;
}
}
sqlDatabase.commit();
return true;
}
bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token,
QString &name,
qint64 &expiresAt,
QList<DeckShareItemRecord> &items)
{
checkSql();
QSqlQuery *query =
prepareQuery("select id, name, UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where token = "
":token and expires_at > now()");
query->bindValue(":token", token);
execSqlQuery(query);
if (!query->next()) {
return false;
}
const int shareId = query->value(0).toInt();
name = query->value(1).toString();
expiresAt = query->value(2).toLongLong();
items.clear();
QSqlQuery *itemQuery =
prepareQuery("select id, name, tags, banner_card, game_format, color_identity from {prefix}_deck_share_item "
"where share_id = :share_id order by position");
itemQuery->bindValue(":share_id", shareId);
execSqlQuery(itemQuery);
while (itemQuery->next()) {
DeckShareItemRecord item;
item.id = itemQuery->value(0).toInt();
item.name = itemQuery->value(1).toString();
const QJsonArray tagArray = QJsonDocument::fromJson(itemQuery->value(2).toString().toUtf8()).array();
for (const QJsonValue &tag : tagArray) {
item.tags.append(tag.toString());
}
item.bannerCard = itemQuery->value(3).toString();
item.gameFormat = itemQuery->value(4).toString();
item.colorIdentity = itemQuery->value(5).toString();
items.append(item);
}
return true;
}
bool Servatrice_DatabaseInterface::getDeckShareItem(const QString &token, int itemId, QString &content)
{
checkSql();
QSqlQuery *query = prepareQuery("select i.content from {prefix}_deck_share_item i join {prefix}_deck_share s on "
"s.id = i.share_id where s.token = :token and s.expires_at > now() and i.id = :id");
query->bindValue(":token", token);
query->bindValue(":id", itemId);
execSqlQuery(query);
if (!query->next()) {
return false;
}
content = query->value(0).toString();
return true;
}
void Servatrice_DatabaseInterface::cleanupExpiredDeckShares()
{
checkSql();
QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where expires_at < now()");
execSqlQuery(query);
}
void Servatrice_DatabaseInterface::logMessage(const int senderId,
const QString &senderName,
const QString &senderIp,

View file

@ -13,10 +13,22 @@
#include <server.h>
#include <server_database_interface.h>
#define DATABASE_SCHEMA_VERSION 36
#define DATABASE_SCHEMA_VERSION 37
class Servatrice;
/** @brief Metadata of a single deck inside a temporary deck share bundle. */
struct DeckShareItemRecord
{
int id = -1; ///< Database id, used for downloads.
QString name; ///< Deck name.
QStringList tags; ///< Deck tags.
QString bannerCard; ///< Banner card name (deck image).
QString gameFormat; ///< Game format the deck was built for.
QString colorIdentity; ///< Color identity, e.g. "WUBRG".
QString content; ///< Deck content (native format); empty in list queries.
};
class Servatrice_DatabaseInterface : public Server_DatabaseInterface
{
Q_OBJECT
@ -79,6 +91,25 @@ public:
const QList<GameReplay *> &replayList) override;
DeckList *getDeckFromDatabase(int deckId, int userId) override;
/** @brief Creates a new temporary deck share bundle. Returns false on failure. */
bool createDeckShare(const QString &token,
const QString &name,
int userId,
const QList<DeckShareItemRecord> &items,
int expiryDays);
/**
* @brief Looks up a valid (non-expired) share bundle by token.
* @return false if the token is unknown or expired.
*/
bool getDeckShareList(const QString &token, QString &name, qint64 &expiresAt, QList<DeckShareItemRecord> &items);
/**
* @brief Fetches the content of one item of a valid share bundle.
* @return false if the token is unknown/expired or the item does not belong to the bundle.
*/
bool getDeckShareItem(const QString &token, int itemId, QString &content);
/** @brief Deletes all expired share bundles (cascades to their items). */
void cleanupExpiredDeckShares();
int getNextGameId() override;
int getNextReplayId() override;
int getActiveUserCount(QString connectionType = QString()) override;

View file

@ -34,18 +34,26 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QRandomGenerator>
#include <QRegularExpression>
#include <QSqlError>
#include <QSqlQuery>
#include <QString>
#include <algorithm>
#include <game/server_player.h>
#include <iostream>
#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_download_public.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list_other_user.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_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
#include <libcockatrice/protocol/pb/command_replay_download.pb.h>
@ -77,6 +85,9 @@
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response_forgotpasswordrequest.pb.h>
#include <libcockatrice/protocol/pb/response_get_admin_notes.pb.h>
@ -201,6 +212,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdRemoveFromList(cmd.GetExtension(Command_RemoveFromList::ext), rc);
case SessionCommand::DECK_LIST:
return cmdDeckList(cmd.GetExtension(Command_DeckList::ext), rc);
case SessionCommand::DECK_LIST_OTHER_USER:
return cmdDeckListOtherUser(cmd.GetExtension(Command_DeckListOtherUser::ext), rc);
case SessionCommand::DECK_SET_VISIBILITY:
return cmdDeckSetVisibility(cmd.GetExtension(Command_DeckSetVisibility::ext), rc);
case SessionCommand::DECK_DOWNLOAD_PUBLIC:
return cmdDeckDownloadPublic(cmd.GetExtension(Command_DeckDownloadPublic::ext), rc);
case SessionCommand::DECK_NEW_DIR:
return cmdDeckNewDir(cmd.GetExtension(Command_DeckNewDir::ext), rc);
case SessionCommand::DECK_DEL_DIR:
@ -244,6 +261,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc);
case SessionCommand::SET_CARD_ART_PARAMS:
return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc);
case SessionCommand::DECK_SHARE_CREATE:
return cmdDeckShareCreate(cmd.GetExtension(Command_DeckShareCreate::ext), rc);
case SessionCommand::DECK_SHARE_LIST:
return cmdDeckShareList(cmd.GetExtension(Command_DeckShareList::ext), rc);
case SessionCommand::DECK_SHARE_DOWNLOAD:
return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc);
case SessionCommand::ACCOUNT_PASSWORD:
return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc);
case SessionCommand::REQUEST_PASSWORD_SALT:
@ -470,46 +493,70 @@ int AbstractServerSocketInterface::getDeckPathId(const QString &path)
return getDeckPathId(0, path.split("/"));
}
bool AbstractServerSocketInterface::deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder)
bool AbstractServerSocketInterface::deckListHelper(int folderId,
ServerInfo_DeckStorage_Folder *folder,
int userId,
bool inheritedPublic,
bool publicOnly)
{
QSqlQuery *query = sqlInterface->prepareQuery(
"select id, name from {prefix}_decklist_folders where id_parent = :id_parent and id_user = :id_user");
QSqlQuery *query = sqlInterface->prepareQuery("select id, name, is_public from {prefix}_decklist_folders where "
"id_parent = :id_parent and id_user = :id_user");
query->bindValue(":id_parent", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
QMap<int, QString> results;
QList<std::pair<int, std::pair<QString, bool>>> folderRows;
while (query->next()) {
results[query->value(0).toInt()] = query->value(1).toString();
folderRows.append({query->value(0).toInt(), {query->value(1).toString(), query->value(2).toBool()}});
}
std::sort(folderRows.begin(), folderRows.end(), [](const auto &a, const auto &b) { return a.first < b.first; });
for (const auto &[folderIdValue, folderInfo] : folderRows) {
const QString name = folderInfo.first;
const bool ownPublic = folderInfo.second;
const bool effectivePublic = inheritedPublic || ownPublic;
if (publicOnly && !effectivePublic) {
continue;
}
for (int key : results.keys()) {
ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items();
newItem->set_id(key);
newItem->set_name(results.value(key).toStdString());
newItem->set_id(folderIdValue);
newItem->set_name(name.toStdString());
newItem->mutable_folder()->set_is_public(ownPublic);
if (!deckListHelper(newItem->id(), newItem->mutable_folder())) {
if (!deckListHelper(newItem->id(), newItem->mutable_folder(), userId, effectivePublic, publicOnly)) {
return false;
}
}
query = sqlInterface->prepareQuery("select id, name, upload_time from {prefix}_decklist_files where id_folder = "
":id_folder and id_user = :id_user");
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags from {prefix}_decklist_files where "
"id_folder = :id_folder and id_user = :id_user");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
while (query->next()) {
const bool ownPublic = query->value(3).toBool();
if (publicOnly && !(inheritedPublic || ownPublic)) {
continue;
}
ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items();
newItem->set_id(query->value(0).toInt());
newItem->set_name(query->value(1).toString().toStdString());
ServerInfo_DeckStorage_File *newFile = newItem->mutable_file();
newFile->set_creation_time(query->value(2).toDateTime().toSecsSinceEpoch());
newFile->set_is_public(ownPublic);
newFile->set_banner_card_name(query->value(4).toString().toStdString());
newFile->set_banner_card_provider(query->value(5).toString().toStdString());
newFile->set_color_identity(query->value(6).toString().toStdString());
newFile->set_tags(query->value(7).toString().toStdString());
}
return true;
@ -530,7 +577,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_
Response_DeckList *re = new Response_DeckList;
ServerInfo_DeckStorage_Folder *root = re->mutable_root();
if (!deckListHelper(0, root)) {
if (!deckListHelper(0, root, userInfo->id(), false, false)) {
return Response::RespContextError;
}
@ -538,6 +585,156 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
const QString userName = nameFromStdString(cmd.user_name());
const int userId = sqlInterface->getUserIdInDB(userName);
if (userId == -1) {
return Response::RespNameNotFound;
}
Response_DeckList *re = new Response_DeckList;
ServerInfo_DeckStorage_Folder *root = re->mutable_root();
if (!deckListHelper(0, root, userId, false, true)) {
return Response::RespContextError;
}
rc.setResponseExtension(re);
return Response::RespOk;
}
int AbstractServerSocketInterface::getDeckOwnerId(int deckId)
{
QSqlQuery *query = sqlInterface->prepareQuery("select id_user from {prefix}_decklist_files where id = :id");
query->bindValue(":id", deckId);
if (!sqlInterface->execSqlQuery(query)) {
return -1;
}
if (!query->next()) {
return -1;
}
return query->value(0).toInt();
}
bool AbstractServerSocketInterface::isDeckEffectivelyPublic(int deckId)
{
QSqlQuery *query =
sqlInterface->prepareQuery("select is_public, id_folder from {prefix}_decklist_files where id = :id");
query->bindValue(":id", deckId);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
if (!query->next()) {
return false;
}
if (query->value(0).toBool()) {
return true;
}
int folderId = query->value(1).toInt();
int guard = 0;
while (folderId != 0 && guard < 100) {
QSqlQuery *folderQuery =
sqlInterface->prepareQuery("select is_public, id_parent from {prefix}_decklist_folders where id = :id");
folderQuery->bindValue(":id", folderId);
if (!sqlInterface->execSqlQuery(folderQuery)) {
return false;
}
if (!folderQuery->next()) {
return false;
}
if (folderQuery->value(0).toBool()) {
return true;
}
folderId = folderQuery->value(1).toInt();
++guard;
}
return false;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd,
ResponseContainer & /*rc*/)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
if (cmd.has_deck_id()) {
QSqlQuery *query =
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
if (!query->next()) {
return Response::RespNameNotFound;
}
query = sqlInterface->prepareQuery("update {prefix}_decklist_files set is_public = :is_public where id = :id");
query->bindValue(":is_public", cmd.is_public() ? 1 : 0);
query->bindValue(":id", cmd.deck_id());
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
} else if (cmd.has_folder_path()) {
const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path()));
if (folderId == -1 || folderId == 0) {
return Response::RespNameNotFound;
}
QSqlQuery *query =
sqlInterface->prepareQuery("update {prefix}_decklist_folders set is_public = :is_public where id = :id");
query->bindValue(":is_public", cmd.is_public() ? 1 : 0);
query->bindValue(":id", folderId);
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
} else {
return Response::RespInvalidData;
}
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
const int deckId = cmd.deck_id();
const int ownerId = getDeckOwnerId(deckId);
if (ownerId == -1 || !isDeckEffectivelyPublic(deckId)) {
return Response::RespNameNotFound;
}
DeckList *deck;
try {
deck = sqlInterface->getDeckFromDatabase(deckId, ownerId);
} catch (Response::ResponseCode &r) {
return r;
}
Response_DeckDownload *re = new Response_DeckDownload;
re->set_deck(deck->writeToString_Native().toStdString());
rc.setResponseExtension(re);
delete deck;
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckNewDir(const Command_DeckNewDir &cmd,
ResponseContainer & /*rc*/)
{
@ -678,11 +875,18 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
QSqlQuery *query =
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content) values(:id_folder, :id_user, :name, NOW(), :content)");
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, "
":banner_card_name, :banner_card_provider, :color_identity, :tags)");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", nameFromStdString(cmd.banner_card_name()));
query->bindValue(":banner_card_provider", nameFromStdString(cmd.banner_card_provider()));
query->bindValue(":color_identity", nameFromStdString(cmd.color_identity()));
query->bindValue(":tags", nameFromStdString(cmd.tags()));
sqlInterface->execSqlQuery(query);
Response_DeckUpload *re = new Response_DeckUpload;
@ -690,26 +894,42 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
fileInfo->set_id(query->lastInsertId().toInt());
fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public());
rc.setResponseExtension(re);
} else if (cmd.has_deck_id()) {
QSqlQuery *query =
sqlInterface->prepareQuery("update {prefix}_decklist_files set name=:name, upload_time=NOW(), "
"content=:content where id = :id_deck and id_user = :id_user");
"content=:content, banner_card_name=:banner_card_name, "
"banner_card_provider=:banner_card_provider, color_identity=:color_identity, "
"tags=:tags where id = :id_deck and id_user = :id_user");
query->bindValue(":id_deck", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":banner_card_name", nameFromStdString(cmd.banner_card_name()));
query->bindValue(":banner_card_provider", nameFromStdString(cmd.banner_card_provider()));
query->bindValue(":color_identity", nameFromStdString(cmd.color_identity()));
query->bindValue(":tags", nameFromStdString(cmd.tags()));
sqlInterface->execSqlQuery(query);
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound;
}
QSqlQuery *visibilityQuery =
sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and "
"id_user = :id_user");
visibilityQuery->bindValue(":id", cmd.deck_id());
visibilityQuery->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(visibilityQuery);
const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool();
Response_DeckUpload *re = new Response_DeckUpload;
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
fileInfo->set_id(cmd.deck_id());
fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
fileInfo->mutable_file()->set_is_public(isPublic);
rc.setResponseExtension(re);
} else {
return Response::RespInvalidData;
@ -740,6 +960,179 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm
return Response::RespOk;
}
namespace
{
/** @brief Builds a cryptographically random, URL-safe share token. */
QString generateShareToken()
{
QByteArray bytes(32, Qt::Uninitialized);
QRandomGenerator::system()->fillRange(reinterpret_cast<quint32 *>(bytes.data()), bytes.size() / sizeof(quint32));
return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
/** @brief Extracts the share metadata for a deck, materializing its content. */
DeckShareItemRecord makeShareItemFromDeck(const DeckList &deck, const QString &colorIdentity)
{
DeckShareItemRecord item;
item.name = deck.getName();
if (item.name.isEmpty()) {
item.name = "Unnamed deck";
}
item.tags = deck.getTags();
item.bannerCard = deck.getBannerCard().name;
item.gameFormat = deck.getGameFormat();
QString sanitizedColorIdentity;
for (const QChar &color : colorIdentity) {
const QChar upper = color.toUpper();
if (QStringLiteral("WUBRG").contains(upper) && !sanitizedColorIdentity.contains(upper)) {
sanitizedColorIdentity.append(upper);
}
}
item.colorIdentity = sanitizedColorIdentity;
item.content = deck.writeToString_Native();
return item;
}
} // namespace
Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const Command_DeckShareCreate &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QList<DeckShareItemRecord> items;
if (cmd.items_size() > 0) {
for (const DeckShareItem &shareItem : cmd.items()) {
if (shareItem.has_deck_list()) {
DeckList deck;
if (!deck.loadFromString_Native(fileFromStdString(shareItem.deck_list()))) {
return Response::RespContextError;
}
items.append(makeShareItemFromDeck(deck, nameFromStdString(shareItem.color_identity())));
} else if (shareItem.has_deck_id()) {
DeckList *deck;
try {
deck = sqlInterface->getDeckFromDatabase(shareItem.deck_id(), userInfo->id());
} catch (Response::ResponseCode &r) {
return r;
}
items.append(makeShareItemFromDeck(*deck, nameFromStdString(shareItem.color_identity())));
delete deck;
} else {
return Response::RespInvalidData;
}
}
} else if (cmd.has_folder_path()) {
const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path()));
if (folderId == -1) {
return Response::RespNameNotFound;
}
QSqlQuery *query = sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_folder = "
":id_folder and id_user = :id_user");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
while (query->next()) {
DeckList *deck;
try {
deck = sqlInterface->getDeckFromDatabase(query->value(0).toInt(), userInfo->id());
} catch (Response::ResponseCode &r) {
return r;
}
items.append(makeShareItemFromDeck(*deck, QString()));
delete deck;
}
} else {
return Response::RespInvalidData;
}
if (items.isEmpty()) {
return Response::RespInvalidData;
}
const int maxItems = servatrice->getDeckShareMaxDecksPerShare();
if (items.size() > maxItems) {
return Response::RespTooManyRequests;
}
QString shareName = nameFromStdString(cmd.name());
if (shareName.isEmpty()) {
shareName = "Shared decks";
}
const QString token = generateShareToken();
if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays())) {
return Response::RespInvalidData;
}
Response_DeckShareCreate *re = new Response_DeckShareCreate;
re->set_token(token.toStdString());
re->set_expires_at(
QDateTime::currentDateTimeUtc().addDays(servatrice->getDeckShareExpiryDays()).toSecsSinceEpoch());
re->set_item_count(items.size());
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareList(const Command_DeckShareList &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QString name;
qint64 expiresAt = 0;
QList<DeckShareItemRecord> items;
if (!sqlInterface->getDeckShareList(nameFromStdString(cmd.token()), name, expiresAt, items)) {
return Response::RespNameNotFound;
}
Response_DeckShareList *re = new Response_DeckShareList;
re->set_name(name.toStdString());
re->set_expires_at(expiresAt);
for (const DeckShareItemRecord &item : items) {
ServerInfo_DeckShareItem *itemInfo = re->add_items();
itemInfo->set_id(item.id);
itemInfo->set_name(item.name.toStdString());
for (const QString &tag : item.tags) {
itemInfo->add_tags(tag.toStdString());
}
itemInfo->set_banner_card(item.bannerCard.toStdString());
itemInfo->set_game_format(item.gameFormat.toStdString());
itemInfo->set_color_identity(item.colorIdentity.toStdString());
}
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareDownload(const Command_DeckShareDownload &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QString content;
if (!sqlInterface->getDeckShareItem(nameFromStdString(cmd.token()), cmd.item_id(), content)) {
return Response::RespNameNotFound;
}
Response_DeckShareDownload *re = new Response_DeckShareDownload;
re->set_deck(content.toStdString());
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdReplayList(const Command_ReplayList & /*cmd*/,
ResponseContainer &rc)
{

View file

@ -44,11 +44,17 @@ class ServerInfo_DeckStorage_Folder;
class Command_AddToList;
class Command_RemoveFromList;
class Command_DeckList;
class Command_DeckListOtherUser;
class Command_DeckNewDir;
class Command_DeckDelDir;
class Command_DeckDel;
class Command_DeckDownload;
class Command_DeckDownloadPublic;
class Command_DeckUpload;
class Command_DeckSetVisibility;
class Command_DeckShareCreate;
class Command_DeckShareList;
class Command_DeckShareDownload;
class Command_ReplayList;
class Command_ReplayDownload;
class Command_ReplayModifyMatch;
@ -95,8 +101,16 @@ private:
Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc);
int getDeckPathId(int basePathId, QStringList path);
int getDeckPathId(const QString &path);
bool deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder);
bool deckListHelper(int folderId,
ServerInfo_DeckStorage_Folder *folder,
int userId,
bool inheritedPublic,
bool publicOnly);
int getDeckOwnerId(int deckId);
bool isDeckEffectivelyPublic(int deckId);
Response::ResponseCode cmdDeckList(const Command_DeckList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer &rc);
void deckDelDirHelper(int basePathId);
void sendServerMessage(const QString userName, const QString message);
@ -105,6 +119,10 @@ private:
Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc);
DeckList *getDeckFromDatabase(int deckId);
Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareDownload(const Command_DeckShareDownload &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplayList(const Command_ReplayList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplayDownload(const Command_ReplayDownload &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplayModifyMatch(const Command_ReplayModifyMatch &cmd, ResponseContainer &rc);

View file

@ -571,6 +571,19 @@ TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_SetAndGet)
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
int main(int argc, char **argv)