Compare commits

...

3 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
47 changed files with 2653 additions and 63 deletions

View file

@ -45,12 +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
@ -59,6 +61,7 @@ set(cockatrice_SOURCES
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
@ -319,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
@ -378,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
@ -389,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
@ -444,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

@ -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,14 +27,16 @@ 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);
@ -50,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();
@ -61,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,13 +20,28 @@ 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);
@ -36,6 +51,7 @@ signals:
private:
QTimer *singleClickTimer;
QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event
bool emitClickImmediately;
protected:
void mousePressEvent(QMouseEvent *event) override;

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

@ -72,7 +72,11 @@ void DlgShareDeck::shareFinished(const Response &response, const CommandContaine
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);

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

@ -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

@ -324,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);

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,6 +2,7 @@
#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"
@ -24,11 +25,13 @@
#include <QTreeView>
#include <QUrl>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_set_visibility.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
@ -159,6 +162,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
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);
@ -171,6 +178,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck);
@ -200,6 +208,7 @@ void TabDeckStorage::retranslateUi()
aDeleteLocalDeck->setText(tr("Delete"));
aDeleteRemoteDeck->setText(tr("Delete"));
aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder"));
shareBar->retranslateUi();
}
@ -244,6 +253,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
aShareDecks->setEnabled(enabled);
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled);
@ -372,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);
@ -779,7 +795,11 @@ void TabDeckStorage::shareFromTreeFinished(const Response &response, const Comma
}
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);
@ -791,7 +811,49 @@ void TabDeckStorage::shareFromTreeFinished(const Response &response, const Comma
void TabDeckStorage::showShareNotice(const QString &message, bool warning)
{
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message,
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message,
QMessageBox::Ok, this);
box.exec();
}
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

@ -40,7 +40,8 @@ private:
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aNewFolder, *aDeleteRemoteDeck;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
int pendingVisibilityChanges = 0;
QString getTargetPath() const;
void setRemoteEnabled(bool enabled);
@ -87,6 +88,9 @@ private slots:
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

@ -169,7 +169,11 @@ void TabDeckStorageVisual::shareFinished(const Response &response, const Command
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);
@ -190,7 +194,7 @@ void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status)
void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning)
{
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message,
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message,
QMessageBox::Ok, this);
box.exec();
}

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

@ -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

@ -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

@ -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)