[DeckShare] Create temporary share links for local and server decks (#7243)

* [DeckShare] Create temporary share links for local and server decks

* [DeckShare] Address review findings and harden the share flows

Gate every share entry point on login, de-duplicate the share-link and
color-identity logic behind DeckShareUtils and an injected querier, and
replace the silent tray/status-bar notices with always-visible dialogs.

- abstract_tab_deck_editor: explain that sharing requires a connection
  instead of silently doing nothing when logged out
- tab_deck_storage: disable the share action on disconnect, reject
  folder/deck mixes and the root folder with clear warnings, re-enable
  Create on every entry/response so a dropped connection cannot leave
  the button disabled
- tab_deck_storage_visual: same login gate for the context-menu entry,
  visible success/error dialogs, and a symmetric in-flight guard
- getDeckColorIdentity now takes a CardDatabaseQuerier, dropping the
  CardDatabaseManager singleton access and enabling unit tests

* [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.

* [DeckShare] Use the stable server client for the visual deck storage tab

* [DeckShare] Extract the share-creation response handling into DeckShareUtils

* [DeckShare] Drop includes left unused by the share-response extraction

* [DeckShare] Format share expiry with the locale-aware short format

* [DeckShare] Build share links with QUrl and QUrlQuery for percent-encoding

* [DeckShare] Replace the duplicate computeColorIdentity with the shared getDeckColorIdentity

* [DeckShare] Recover the share controls when the server never answers

* [DeckShare] Provide the full share hint in each plural form

* [DeckShare] Join the selected-count label with a non-translatable separator

* [DeckShare] Retranslate the share button tooltip with the storage widget

* [DeckShare] Forward retranslateUi to the visual deck storage widget

* [DeckShare] Let the share bar owners supply the hint text

* [DeckShare] End the share-related headers and sources with a trailing newline

* [DeckShare] Keep the settings include in the project include block

* [DeckShare] Include the network settings header used by the share timeout

* [DeckShare] Resolve the share theme icon through themePixmap

QPixmap("theme:icons/share") has no file extension, so ThemeManager::assetPath()
is bypassed and the pixmap is always null. Use themePixmap(QStringLiteral("icons/share"))
like every other toolbar action, so the .svg (and dark/light variants) resolves.

* [DeckShare] Keep the share selection consistent with the visible decks

Filtered-out previews are hidden but kept alive, so selectedFilePaths() counted
them in the share and the selection highlight. Only decks the user can see are
now shared, and a deck that stops matching the filters is deselectd as the deck
pass runs, keeping the %n count and the highlight in sync with the screen.

* [DeckShare] Abandon an in-flight tree share on cancel

Leaving share mode never stopped the timeout timer, and a late response still
ran shareFromTreeFinished, copying the link and announcing success for a share
the user backed out of. Stopping the timer and tracking the outstanding request
by sequence number means a stale reply (or a timed-out one) after cancel is
ignored, and cancelling + re-entering share mode can no longer confuse the two
requests.

* [DeckShare] Abandon an in-flight tile share on cancel

exitShareMode() left shareTimeoutTimer running and did not abandon the pending
Command_DeckShareCreate, so a timer pop or a late success still reported the
share after the user cancelled. Stop the timer and ignore stale responses via a
sequence number, mirroring the tree tab.

* [DeckShare] Wire the status-changed handler after shareBar exists

handleConnectionChanged() dereferences shareBar->isVisible(), but the connection
was set up before shareBar was constructed and shareBar had no in-class
initializer. On any status change delivered before construction the slot read an
indeterminate pointer. Seed the connection (and the initial share availability)
after shareBar exists and give shareBar a = nullptr initializer.

* [DeckShare] Explain why a blank deck cannot be shared

A blank deck exited the share flow silently. The menu only disables the entry
via setSaveStatus(), a different predicate, so the path is reachable (e.g. add a
card and remove it again). Mirror the not-logged-in branch with a short
information dialog.

* [DeckShare] Restore the banner-text doc comment

Re-add the doc block above refreshBannerCardText() that was removed as part of
the share-selection work; it documents the coupling to refreshBannerCardToolTip.

* [DeckShare] Resolve the stable server client in the deck editor gate

actShareDeck went through tabSupervisor->getClient(), which hands back a
LocalClient while an offline game is running. LocalClient never sets its status,
so a logged-in user could not share from the deck editor during a local game,
and got a misleading "You must be connected" message. Expose the supervisor's
stable remote client and use it for the gate and the dialog, matching the other
share tabs.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-09-20 20:22:16 +02:00 committed by GitHub
parent a289d61765
commit ba2900dcb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1173 additions and 52 deletions

View file

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

View file

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

View file

@ -126,6 +126,11 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass()
const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool();
deckPreviewWidget->setVisible(matches);
if (!matches) {
// A deck that no longer matches the filters is dropped from the selection so its
// highlight cannot linger on an invisible preview or be counted in the share.
deckPreviewWidget->setShareSelected(false);
}
if (matches) {
++visibleDeckCount;
}
@ -209,6 +214,11 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget
&VisualDeckStorageWidget::deckLoadRequested);
connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget,
&VisualDeckStorageWidget::openDeckEditor);
connect(deckPreviewWidget, &DeckPreviewWidget::shareDeckRequested, visualDeckStorageWidget,
&VisualDeckStorageWidget::shareDeckRequested);
connect(deckPreviewWidget, &DeckPreviewWidget::shareSelectionToggled, visualDeckStorageWidget,
&VisualDeckStorageWidget::shareSelectionChanged);
deckPreviewWidget->setShareSelectable(visualDeckStorageWidget->isShareSelectable());
connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged,
deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor);
deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize());
@ -216,6 +226,18 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget
return deckPreviewWidget;
}
void VisualDeckStorageFolderDisplayWidget::setShareSelectable(bool selectable)
{
const auto previews = flowWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
preview->setShareSelectable(selectable);
}
const auto subFolders = findChildren<VisualDeckStorageFolderDisplayWidget *>();
for (VisualDeckStorageFolderDisplayWidget *subFolder : subFolders) {
subFolder->setShareSelectable(selectable);
}
}
/**
* @brief Creates, removes and keeps in sync the subfolder widgets of this folder.
*

View file

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

View file

@ -1,6 +1,7 @@
#include "visual_deck_storage_model.h"
#include "../../deck_loader/deck_loader.h"
#include "../cards/additional_info/deck_color_identity.h"
#include <QDir>
#include <QDirIterator>
@ -119,8 +120,6 @@ DeckScanResult scanDeckDirectory(const QString &deckPath)
}
} // namespace
static QString computeColorIdentity(const LoadedDeck &deck);
VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent)
{
}
@ -306,7 +305,7 @@ void VisualDeckStorageModel::beginLoad(int row)
}
// Color identity walks every card through the database, so compute it here to
// keep the completion handler on the UI thread cheap.
const QString colorIdentity = computeColorIdentity(*deck);
const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query());
return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity};
}));
}
@ -367,40 +366,6 @@ void VisualDeckStorageModel::drainPendingLoads()
}
}
/**
* @brief Computes the color identity of a deck in WUBRG order.
*/
static QString computeColorIdentity(const LoadedDeck &deck)
{
QStringList cardList = deck.deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE});
if (cardList.isEmpty()) {
return {};
}
QSet<QChar> colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G)
for (const QString &cardName : cardList) {
CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName);
if (currentCard) {
const QString colors = currentCard->getColors(); // Something like "WUB"
for (const QChar &color : colors) {
colorSet.insert(color);
}
}
}
// Ensure the color identity is in WUBRG order
QString colorIdentity;
const QString wubrgOrder = "WUBRG";
for (const QChar &color : wubrgOrder) {
if (colorSet.contains(color)) {
colorIdentity.append(color);
}
}
return colorIdentity;
}
/**
* @brief Recomputes all derived metadata of a row from its loaded deck.
*/
@ -414,7 +379,7 @@ void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool r
data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp());
data.bannerCard = deckList.getBannerCard();
if (recomputeColorIdentity) {
data.colorIdentity = computeColorIdentity(data.deck);
data.colorIdentity = getDeckColorIdentity(data.deck.deckList, CardDatabaseManager::query());
}
}

View file

@ -48,6 +48,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
refreshButton->setFixedSize(32, 32);
connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible);
shareButton = new QToolButton(this);
shareButton->setIcon(themePixmap(QStringLiteral("icons/share")));
shareButton->setFixedSize(32, 32);
shareButton->setVisible(false);
connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested);
quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this);
connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this,
&VisualDeckStorageWidget::updateShowFolders);
@ -58,6 +64,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
searchAndSortLayout->addWidget(sortWidget);
searchAndSortLayout->addWidget(searchWidget);
searchAndSortLayout->addWidget(refreshButton);
searchAndSortLayout->addWidget(shareButton);
searchAndSortLayout->addWidget(quickSettingsWidget);
// tag filter box
@ -158,11 +165,71 @@ void VisualDeckStorageWidget::retranslateUi()
databaseLoadIndicator->setText(tr("Loading database ..."));
refreshButton->setToolTip(tr("Refresh loaded files"));
shareButton->setToolTip(tr("Select decks to share"));
quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings"));
sortWidget->retranslateUi();
}
void VisualDeckStorageWidget::setShareSelectable(bool selectable)
{
if (shareSelectable == selectable) {
return;
}
shareSelectable = selectable;
if (folderWidget != nullptr) {
folderWidget->setShareSelectable(selectable);
}
emit shareSelectionChanged();
}
bool VisualDeckStorageWidget::isShareSelectable() const
{
return shareSelectable;
}
QStringList VisualDeckStorageWidget::selectedFilePaths() const
{
QStringList selectedPaths;
if (folderWidget != nullptr) {
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
// Filtered-out previews stay alive hidden in their sorted place, so only decks the
// user can actually see are part of the share.
if (preview->isVisible() && preview->isShareSelected()) {
selectedPaths.append(preview->filePath);
}
}
}
return selectedPaths;
}
void VisualDeckStorageWidget::clearShareSelection()
{
if (folderWidget != nullptr) {
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
preview->setShareSelected(false);
}
}
}
void VisualDeckStorageWidget::setShareAvailable(bool available)
{
shareButton->setVisible(available);
shareButton->setEnabled(available);
}
void VisualDeckStorageWidget::setShareSelectedFiles(const QStringList &paths)
{
if (folderWidget != nullptr) {
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
preview->setShareSelected(paths.contains(preview->filePath));
}
}
}
/**
* Gets a const pointer to the quick settings so that the values can be accessed.
*/

View file

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