Merge branch 'master' into tooomm-ci_qt-install-action

This commit is contained in:
tooomm 2026-06-27 11:16:52 +02:00 committed by GitHub
commit 2845c729c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 4097 additions and 189 deletions

View file

@ -47,26 +47,21 @@ jobs:
tag: ${{ steps.configure.outputs.tag }}
sha: ${{ steps.configure.outputs.sha }}
steps:
steps:
- name: "Configure"
env:
RESOLVED_SHA: ${{ case(github.event_name == 'pull_request', github.event.pull_request.head.sha, github.sha) }}
id: configure
shell: bash
run: |
tag_regex='^refs/tags/'
if [[ $GITHUB_EVENT_NAME == pull-request ]]; then # pull request
sha="${{github.event.pull_request.head.sha}}"
elif [[ $GITHUB_REF =~ $tag_regex ]]; then # release
sha="$GITHUB_SHA"
tag="${GITHUB_REF/refs\/tags\//}"
echo "tag=$tag" >>"$GITHUB_OUTPUT"
else # push to branch
sha="$GITHUB_SHA"
if [[ "$GITHUB_REF_TYPE" == 'tag' ]]; then # release
echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
fi
echo "sha=$sha" >>"$GITHUB_OUTPUT"
echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT"
- name: "Checkout"
if: steps.configure.outputs.tag != null
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # fetch all history for all branches and tags
@ -92,7 +87,7 @@ jobs:
run: |
args=()
[[ $prerelease == yes ]] && args+=(--prerelease)
gh release create "$tag_name" --verify-tag --draft "${args[@]}" \
--target "$target" \
--title "$release_name" \
@ -105,48 +100,48 @@ jobs:
# The files in ".ci/$distro$version" correspond to the values given here
include:
- distro: Arch
allow-failure: yes
package: skip # We are packaged in Arch already
- distro: Servatrice_Debian
version: 12
package: DEB
server_only: yes
test: skip
- distro: Debian
version: 12
package: DEB
test: skip # Running tests on all distros is superfluous
- distro: Debian
version: 13
package: DEB
- distro: Fedora
version: 43
package: RPM
test: skip # Running tests on all distros is superfluous
- distro: Fedora
version: 44
package: RPM
- distro: Ubuntu
version: 24.04
package: DEB
test: skip # Running tests on all distros is superfluous
- distro: Ubuntu
version: 26.04
package: DEB
name: ${{ matrix.distro }} ${{ matrix.version }}
@ -163,7 +158,7 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Restore compiler cache (ccache)"
id: ccache_restore
@ -203,7 +198,7 @@ jobs:
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
args+=(--suffix "$SUFFIX")
RUN --server --release --package "$package" "${args[@]}"
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
@ -211,9 +206,10 @@ jobs:
if: github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
continue-on-error: true
env:
CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }}
GH_TOKEN: ${{ github.token }}
run: |
if gh cache delete --repo ${{ github.repository }} ${{ steps.ccache_restore.outputs.cache-primary-key }}; then
if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then
echo "Cache deleted successfully"
fi
@ -256,8 +252,9 @@ jobs:
if: steps.attestation.outcome == 'success'
shell: bash
env:
BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "${{ steps.build.outputs.path }}" --repo Cockatrice/Cockatrice
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
build-vcpkg:
strategy:
@ -267,7 +264,7 @@ jobs:
- os: macOS
target: 13
runner: macos-15-intel
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
@ -284,7 +281,7 @@ jobs:
- os: macOS
target: 14
runner: macos-14
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
@ -300,7 +297,7 @@ jobs:
- os: macOS
target: 15
runner: macos-15
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
@ -316,7 +313,7 @@ jobs:
- os: macOS
target: 15
runner: macos-15
ccache_eviction_age: 7d
cmake_generator: Ninja
qt_version: 6.11.*
@ -330,7 +327,7 @@ jobs:
- os: Windows
target: 10
runner: windows-2025
cmake_generator: "Visual Studio 18 2026"
cmake_generator_platform: x64
make_package: 1
@ -350,7 +347,7 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
@ -456,9 +453,10 @@ jobs:
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
continue-on-error: true
env:
CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }}
GH_TOKEN: ${{ github.token }}
run: |
if gh cache delete --repo ${{ github.repository }} ${{ steps.ccache_restore.outputs.cache-primary-key }}; then
if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then
echo "Cache deleted successfully"
fi
@ -473,18 +471,20 @@ jobs:
if: matrix.os == 'macOS' && matrix.make_package && needs.configure.outputs.tag != null
id: sign_macos
env:
BUILD_PATH: ${{ steps.build.outputs.path }}
MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}
run: |
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]
then
security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
/usr/bin/codesign --sign="$MACOS_CERTIFICATE_NAME" --entitlements=".ci/macos.entitlements" --options=runtime --force --deep --timestamp --verbose "${{ steps.build.outputs.path }}"
/usr/bin/codesign --sign="$MACOS_CERTIFICATE_NAME" --entitlements=".ci/macos.entitlements" --options=runtime --force --deep --timestamp --verbose "$BUILD_PATH"
fi
- name: "[macOS] Notarize app bundle"
if: matrix.os == 'macOS' && steps.sign_macos.outcome == 'success'
env:
BUILD_PATH: ${{ steps.build.outputs.path }}
MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}
MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}
MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }}
@ -499,7 +499,7 @@ jobs:
# Therefore, we create a zip file containing our app bundle, so that we can send it to the
# notarization service
echo "Creating temp notarization archive"
ditto -c -k --keepParent "${{ steps.build.outputs.path }}" "notarization.zip"
ditto -c -k --keepParent "$BUILD_PATH" "notarization.zip"
# Here we send the notarization request to the Apple's Notarization service, waiting for the result.
# This typically takes a few seconds inside a CI environment, but it might take more depending on the App
@ -511,7 +511,7 @@ jobs:
# Finally, we need to "attach the staple" to our executable, which will allow our app to be
# validated by macOS even when an internet connection is not available.
echo "Attach staple"
xcrun stapler staple "${{ steps.build.outputs.path }}"
xcrun stapler staple "$BUILD_PATH"
fi
- name: "Upload artifact"
@ -557,5 +557,6 @@ jobs:
if: steps.attestation.outcome == 'success'
shell: bash
env:
BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "${{ steps.build.outputs.path }}" --repo Cockatrice/Cockatrice
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice

View file

@ -22,7 +22,7 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 20 # should be enough to find merge base

View file

@ -31,7 +31,7 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Docker metadata"
id: metadata

View file

@ -21,7 +21,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive

View file

@ -20,9 +20,11 @@ jobs:
steps:
- name: "Checkout repo"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Pull translated strings from Transifex"
# Do not run this step for PR's from forks, they don't have access to the secret
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: transifex/cli-action@v2
with:
# Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config
@ -41,11 +43,11 @@ jobs:
author: github-actions <github-actions@github.com> # owner of the commit
body: |
Pulled all translated strings from [Transifex][1].
---
*This PR is automatically generated and updated by the workflow at `.github/workflows/translations-pull.yml`. Review [action runs][2].*<br>
*After merging, all new languages and translations are available in the next build.*
[1]: https://explore.transifex.com/cockatrice/cockatrice/
[2]: https://github.com/Cockatrice/Cockatrice/actions/workflows/translations-pull.yml?query=branch%3Amaster
branch: ci-update_translations
@ -61,11 +63,9 @@ jobs:
if: github.event_name != 'pull_request'
shell: bash
env:
STATUS: ${{ steps.create_pr.outputs.pull-request-operation }}
PR_NUMBER: ${{ steps.create_pr.outputs.pull-request-number }}
PR_URL: ${{ steps.create_pr.outputs.pull-request-url }}
STATUS: ${{ case(steps.create_pr.outputs.pull-request-operation == 'none', 'unchanged', steps.create_pr.outputs.pull-request-operation) }}
run: |
if [[ "$STATUS" == "none" ]]; then
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} unchanged!" >> $GITHUB_STEP_SUMMARY
else
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} $STATUS!" >> $GITHUB_STEP_SUMMARY
fi
echo "URL: ${{ steps.create_pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
echo "PR #$PR_NUMBER $STATUS!" >> "$GITHUB_STEP_SUMMARY"
echo "URL: $PR_URL" >> "$GITHUB_STEP_SUMMARY"

View file

@ -20,7 +20,7 @@ jobs:
steps:
- name: "Checkout repo"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Install lupdate"
shell: bash
@ -29,12 +29,13 @@ jobs:
sudo apt-get install -y --no-install-recommends qttools5-dev-tools
- name: "Update Cockatrice translation source"
env:
FILE: cockatrice/cockatrice_en@source.ts
id: cockatrice
shell: bash
run: |
FILE="cockatrice/cockatrice_en@source.ts"
export DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
FILE="$FILE" DIRS="$DIRS" .ci/update_translation_source_strings.sh
run: >
DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
.ci/update_translation_source_strings.sh
- name: "Update Oracle translation source"
id: oracle
@ -77,11 +78,9 @@ jobs:
if: github.event_name != 'pull_request'
shell: bash
env:
STATUS: ${{ steps.create_pr.outputs.pull-request-operation }}
PR_NUMBER: ${{ steps.create_pr.outputs.pull-request-number }}
PR_URL: ${{ steps.create_pr.outputs.pull-request-url }}
STATUS: ${{ case(steps.create_pr.outputs.pull-request-operation == 'none', 'unchanged', steps.create_pr.outputs.pull-request-operation) }}
run: |
if [[ "$STATUS" == "none" ]]; then
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} unchanged!" >> $GITHUB_STEP_SUMMARY
else
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} $STATUS!" >> $GITHUB_STEP_SUMMARY
fi
echo "URL: ${{ steps.create_pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
echo "PR #$PR_NUMBER $STATUS!" >> "$GITHUB_STEP_SUMMARY"
echo "URL: $PR_URL" >> "$GITHUB_STEP_SUMMARY"

View file

@ -117,21 +117,22 @@ ${If} $InstDir == ""
; we need to set a default based on the install mode
StrCpy $InstDir $0
${EndIf}
Call SetModeDestinationFromInstdir
; --- Detect portable install when using /R ---
; --- Detect portable install when using /R (must come BEFORE SetModeDestinationFromInstdir) ---
${If} $ReinstallMode = 1
IfFileExists "$InstDir\portable.dat" 0 not_portable
StrCpy $PortableMode 1
Goto portable_done
not_portable:
StrCpy $PortableMode 0
portable_done:
${EndIf}
; Now that $PortableMode reflects reality, commit InstDir into the correct slot
Call SetModeDestinationFromInstdir
${If} $ReinstallMode = 1
${AndIf} $PortableMode = 0
Call AutoUninstallIfNeeded
${EndIf}

View file

@ -236,10 +236,14 @@ set(cockatrice_SOURCES
src/interface/widgets/server/handle_public_servers.cpp
src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp
src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp
src/interface/widgets/server/user/user_avatar_provider.cpp
src/interface/widgets/server/user/user_card_art_provider.cpp
src/interface/widgets/server/user/user_card_settings_dialog.cpp
src/interface/widgets/server/user/user_context_menu.cpp
src/interface/widgets/server/user/user_info_box.cpp
src/interface/widgets/server/user/user_info_connection.cpp
src/interface/widgets/server/user/user_list_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_widget.cpp
src/interface/widgets/settings_page/appearance_settings_page.cpp
src/interface/widgets/settings_page/deck_editor_settings_page.cpp
@ -327,6 +331,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab.cpp
src/interface/widgets/tabs/tab_account.cpp
src/interface/widgets/tabs/tab_admin.cpp
src/interface/widgets/tabs/tab_card_art_rules.cpp
src/interface/widgets/tabs/tab_deck_editor.cpp
src/interface/widgets/tabs/tab_deck_storage.cpp
src/interface/widgets/tabs/tab_game.cpp
@ -349,6 +354,8 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h
src/interface/widgets/utility/compact_push_button.cpp
src/interface/widgets/utility/compact_push_button.h
src/interface/widgets/server/user/user_info_popup.cpp
src/interface/widgets/server/user/user_info_popup.h
)
add_subdirectory(sounds)

View file

@ -309,6 +309,7 @@ SettingsCache::SettingsCache()
cardViewExpandedRowsMax = settings->value("interface/cardViewExpandedRowsMax", 20).toInt();
closeEmptyCardView = settings->value("interface/closeEmptyCardView", true).toBool();
focusCardViewSearchBar = settings->value("interface/focusCardViewSearchBar", true).toBool();
keepGameChatFocus = settings->value("interface/keepGameChatFocus", false).toBool();
showDragSelectionCount = settings->value("interface/showlassoselectioncount", true).toBool();
showTotalSelectionCount = settings->value("interface/showpersistentselectioncount", true).toBool();
@ -370,6 +371,7 @@ SettingsCache::SettingsCache()
openDeckInNewTab = settings->value("editor/openDeckInNewTab", false).toBool();
rewindBufferingMs = settings->value("replay/rewindBufferingMs", 200).toInt();
styleUserList = settings->value("appearance/styleUserList", true).toBool();
chatMention = settings->value("chat/mention", true).toBool();
chatMentionCompleter = settings->value("chat/mentioncompleter", true).toBool();
chatMentionForeground = settings->value("chat/mentionforeground", true).toBool();
@ -457,6 +459,13 @@ void SettingsCache::setFocusCardViewSearchBar(QT_STATE_CHANGED_T value)
settings->setValue("interface/focusCardViewSearchBar", focusCardViewSearchBar);
}
void SettingsCache::setKeepGameChatFocus(QT_STATE_CHANGED_T value)
{
keepGameChatFocus = value;
settings->setValue("interface/keepGameChatFocus", keepGameChatFocus);
emit keepGameChatFocusChanged(keepGameChatFocus);
}
void SettingsCache::setKnownMissingFeatures(const QString &_knownMissingFeatures)
{
knownMissingFeatures = _knownMissingFeatures;
@ -1037,6 +1046,13 @@ void SettingsCache::setRewindBufferingMs(int _rewindBufferingMs)
settings->setValue("replay/rewindBufferingMs", rewindBufferingMs);
}
void SettingsCache::setStyleUserList(QT_STATE_CHANGED_T _styleUserList)
{
styleUserList = static_cast<bool>(_styleUserList);
settings->setValue("appearance/styleUserList", styleUserList);
emit styleUserListChanged();
}
void SettingsCache::setChatMention(QT_STATE_CHANGED_T _chatMention)
{
chatMention = static_cast<bool>(_chatMention);

View file

@ -190,11 +190,13 @@ signals:
void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod);
void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme);
void masterVolumeChanged(int value);
void styleUserListChanged();
void chatMentionCompleterChanged();
void downloadSpoilerTimeIndexChanged();
void downloadSpoilerStatusChanged();
void useTearOffMenusChanged(bool state);
void roundCardCornersChanged(bool roundCardCorners);
void keepGameChatFocusChanged(bool value);
private:
QSettings *settings;
@ -283,6 +285,7 @@ private:
bool autoRotateSidewaysLayoutCards;
bool openDeckInNewTab;
int rewindBufferingMs;
bool styleUserList;
bool chatMention;
bool chatMentionCompleter;
QString chatMentionColor;
@ -306,6 +309,7 @@ private:
int cardViewExpandedRowsMax;
bool closeEmptyCardView;
bool focusCardViewSearchBar;
bool keepGameChatFocus;
int pixmapCacheSize;
int networkCacheSize;
int redirectCacheTtl;
@ -736,6 +740,10 @@ public:
{
return rewindBufferingMs;
}
[[nodiscard]] bool getStyleUserList() const
{
return styleUserList;
}
[[nodiscard]] bool getChatMention() const
{
return chatMention;
@ -935,6 +943,7 @@ public:
void setCardViewExpandedRowsMax(int value);
void setCloseEmptyCardView(QT_STATE_CHANGED_T value);
void setFocusCardViewSearchBar(QT_STATE_CHANGED_T value);
void setKeepGameChatFocus(QT_STATE_CHANGED_T value);
QString getClientID() override
{
return clientID;
@ -967,6 +976,10 @@ public:
{
return focusCardViewSearchBar;
}
[[nodiscard]] bool getKeepGameChatFocus() const
{
return keepGameChatFocus;
}
[[nodiscard]] ShortcutsSettings &shortcuts() const
{
return *shortcutsSettings;
@ -1106,6 +1119,7 @@ public slots:
void setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards);
void setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab);
void setRewindBufferingMs(int _rewindBufferingMs);
void setStyleUserList(QT_STATE_CHANGED_T _styleUserList);
void setChatMention(QT_STATE_CHANGED_T _chatMention);
void setChatMentionCompleter(QT_STATE_CHANGED_T _chatMentionCompleter);
void setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground);

View file

@ -223,6 +223,10 @@ private:
{"TabDeckEditor/aLoadDeck", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck..."),
parseSequenceString("Ctrl+O"),
ShortcutGroup::Deck_Editor)},
{"TabDeckEditor/aLoadDeckFromWebsite",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load deck from online service..."),
parseSequenceString("Ctrl+Shift+O"),
ShortcutGroup::Deck_Editor)},
{"TabDeckEditor/aLoadDeckFromClipboard",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck from Clipboard..."),
parseSequenceString("Ctrl+Shift+V"),
@ -283,6 +287,10 @@ private:
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck from Clipboard..."),
parseSequenceString("Ctrl+Shift+V"),
ShortcutGroup::Game_Lobby)},
{"DeckViewContainer/loadFromWebsiteButton",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load from website..."),
parseSequenceString("Ctrl+Shift+O"),
ShortcutGroup::Game_Lobby)},
{"DeckViewContainer/unloadDeckButton", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Unload Deck"),
parseSequenceString("Ctrl+Alt+U"),
ShortcutGroup::Game_Lobby)},

View file

@ -882,7 +882,8 @@ void PlayerActions::actCreateToken(TokenInfo tokenToCreate)
ExactCard correctedCard = CardDatabaseManager::query()->guessCard({lastTokenInfo.name, lastTokenInfo.providerId});
if (correctedCard) {
lastTokenInfo.name = correctedCard.getName();
lastTokenTableRow = TableZone::tableRowToGridY(correctedCard.getInfo().getUiAttributes().tableRow);
int tableRow = lastTokenInfo.faceDown ? 2 : correctedCard.getInfo().getUiAttributes().tableRow;
lastTokenTableRow = TableZone::tableRowToGridY(tableRow);
if (lastTokenInfo.pt.isEmpty()) {
lastTokenInfo.pt = correctedCard.getInfo().getPowTough();
}

View file

@ -360,6 +360,16 @@ void DeckViewScene::rebuildTree()
return;
}
QStringList requiredZones = {DECK_ZONE_MAIN, DECK_ZONE_SIDE};
for (const QString &zoneName : requiredZones) {
if (!cardContainers.contains(zoneName)) {
auto *container = new DeckViewCardContainer(zoneName);
cardContainers.insert(zoneName, container);
addItem(container);
}
}
for (auto *currentZone : deck->getZoneNodes()) {
DeckViewCardContainer *container = cardContainers.value(currentZone->getName(), 0);
if (!container) {

View file

@ -209,6 +209,7 @@ void DeckViewContainer::refreshShortcuts()
loadLocalButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadLocalButton"));
loadRemoteButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadRemoteButton"));
loadFromClipboardButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadFromClipboardButton"));
loadFromWebsiteButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadFromWebsiteButton"));
unloadDeckButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/unloadDeckButton"));
readyStartButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/readyStartButton"));
sideboardLockButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/sideboardLockButton"));

View file

@ -34,7 +34,6 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
{
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
setFocusPolicy(Qt::ClickFocus);
setViewportUpdateMode(BoundingRectViewportUpdate);
connect(scene, &GameScene::sceneRectChanged, this, &GameView::updateSceneRect);
@ -44,6 +43,9 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
connect(scene, &GameScene::sigStopRubberBand, this, &GameView::stopRubberBand);
connect(scene, &QGraphicsScene::selectionChanged, this, [this]() { updateTotalSelectionCount(); });
setFocusDisabled(SettingsCache::instance().getKeepGameChatFocus());
connect(&SettingsCache::instance(), &SettingsCache::keepGameChatFocusChanged, this, &GameView::setFocusDisabled);
aCloseMostRecentZoneView = new QAction(this);
connect(aCloseMostRecentZoneView, &QAction::triggered, scene, &GameScene::closeMostRecentZoneView);
@ -186,3 +188,12 @@ void GameView::updateTotalSelectionCount(const QSize &viewSize)
totalCountLabel->hide();
}
}
/**
* Disabling focus on the game view will allow chat to maintain the autofocusing behavior of pre 2.10.3,
* at the cost of disabling the zone view search bar.
*/
void GameView::setFocusDisabled(bool disabled)
{
setFocusPolicy(disabled ? Qt::NoFocus : Qt::ClickFocus);
}

View file

@ -31,6 +31,7 @@ private slots:
void stopRubberBand();
void refreshShortcuts();
void updateTotalSelectionCount(const QSize &viewSize = QSize());
void setFocusDisabled(bool disabled);
public slots:
void updateSceneRect(const QRectF &rect);

View file

@ -75,6 +75,11 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
searchEditProxy->setZValue(ZValues::DRAG_ITEM);
vbox->addItem(searchEditProxy);
// hide search bar if chat autofocus setting is enabled, since typing into it will no longer work anyway
searchEditProxy->setVisible(!SettingsCache::instance().getKeepGameChatFocus());
connect(&SettingsCache::instance(), &SettingsCache::keepGameChatFocusChanged, searchEditProxy,
[searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); });
// top row
QGraphicsLinearLayout *hTopRow = new QGraphicsLinearLayout(Qt::Horizontal);

View file

@ -219,9 +219,25 @@ void DlgUpdate::downloadError(const QString &errorString)
void DlgUpdate::downloadSuccessful(const QUrl &filepath)
{
setLabel(tr("Installing..."));
QString installerPath = filepath.toLocalFile();
QString appDir = QDir::toNativeSeparators(QCoreApplication::applicationDirPath());
QProcess process;
process.setProgram(installerPath);
// NSIS needs the /D= argument to be an UNQUOTED string, even if it contains spaces. Qt likes to quote arguments if
// they contain spaces, so we use the windows exclusive QProcess::setNativeArguments in the only case where this is
// relevant, which preserves the argument unquoted.
#ifdef Q_OS_WIN
process.setNativeArguments(QString("/R /D=%1").arg(appDir));
#else
// Linux/macOS: normal argument passing (not relevant since they update differently.)
process.setArguments({"/R", QString("/D=%1").arg(appDir)});
#endif
// Try to open the installer. If it opens, quit Cockatrice
if (QProcess::startDetached(
QString("\"%1\" /R /D=\"%2\"").arg(filepath.toLocalFile(), QCoreApplication::applicationDirPath()))) {
if (process.startDetached()) {
QMetaObject::invokeMethod(static_cast<MainWindow *>(parent()), "close", Qt::QueuedConnection);
qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice";
close();

View file

@ -193,6 +193,8 @@ void DeckEditorMenu::refreshShortcuts()
aEditDeckInClipboardRaw->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aEditDeckInClipboardRaw"));
aPrintDeck->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aPrintDeck"));
aLoadDeckFromWebsite->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aLoadDeckFromWebsite"));
aExportDeckDecklist->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aExportDeckDecklist"));
aExportDeckDecklistXyz->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aExportDeckDecklistXyz"));
aAnalyzeDeckDeckstats->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aAnalyzeDeck"));

View file

@ -0,0 +1,48 @@
#include "user_avatar_provider.h"
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
UserAvatarProvider::UserAvatarProvider(AbstractClient *client, QObject *parent) : QObject(parent), client(client)
{
}
const QMap<QString, QPixmap> &UserAvatarProvider::cache() const
{
return avatarCache;
}
void UserAvatarProvider::requestAvatar(const QString &userName)
{
if (avatarCache.contains(userName) || pending.contains(userName)) {
return;
}
pending.insert(userName);
Command_GetUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &r) {
pending.remove(userName);
const auto &response = r.GetExtension(Response_GetUserInfo::ext);
const auto &user = response.user_info();
const std::string &bmp = user.avatar_bmp();
QPixmap avatar;
if (!bmp.empty() &&
avatar.loadFromData(reinterpret_cast<const uchar *>(bmp.data()), static_cast<uint>(bmp.size()))) {
avatarCache.insert(userName, avatar);
} else {
avatarCache.insert(userName, QPixmap());
}
emit avatarUpdated(userName);
});
client->sendCommand(pend);
}

View file

@ -0,0 +1,30 @@
#ifndef COCKATRICE_USER_AVATAR_PROVIDER_H
#define COCKATRICE_USER_AVATAR_PROVIDER_H
#include <QMap>
#include <QObject>
#include <QPixmap>
#include <QSet>
class AbstractClient;
class UserAvatarProvider : public QObject
{
Q_OBJECT
public:
explicit UserAvatarProvider(AbstractClient *client, QObject *parent = nullptr);
void requestAvatar(const QString &userName);
const QMap<QString, QPixmap> &cache() const;
signals:
void avatarUpdated(const QString &userName);
private:
AbstractClient *client;
QMap<QString, QPixmap> avatarCache;
QSet<QString> pending;
};
#endif // COCKATRICE_USER_AVATAR_PROVIDER_H

View file

@ -0,0 +1,146 @@
#include "user_card_art_provider.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include <QPointer>
#include <libcockatrice/card/database/card_database_manager.h>
static QString makeKey(const QString &user, const QString &card)
{
return user + u'|' + card;
}
UserCardArtProvider::UserCardArtProvider(QObject *parent) : QObject(parent)
{
dbReady = (CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok);
if (!dbReady) {
connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this,
&UserCardArtProvider::onDatabaseReady);
}
}
void UserCardArtProvider::onDatabaseReady()
{
dbReady = true;
processQueue();
}
const QMap<QString, QPixmap> &UserCardArtProvider::cache() const
{
return cardArtCache;
}
void UserCardArtProvider::requestCardArt(const QString &userName, const QString &cardName)
{
if (cardName.isEmpty()) {
return;
}
const QString key = makeKey(userName, cardName);
if (cardArtCache.contains(key) || pending.contains(key)) {
return;
}
pending.insert(key);
queue.enqueue(key);
processQueue();
}
QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes)
{
const QSize sz = fullRes.size();
const int marginX = sz.width() * 0.07;
const int topMargin = sz.height() * 0.11;
const int bottomMargin = sz.height() * 0.45;
const QRect foilRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin);
return fullRes.copy(foilRect.intersected(fullRes.rect()));
}
void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap)
{
if (!cardArtCache.contains(key)) {
cacheInsertionOrder.append(key);
while (cacheInsertionOrder.size() > MaxCacheEntries) {
const QString evicted = cacheInsertionOrder.takeFirst();
cardArtCache.remove(evicted);
}
}
cardArtCache.insert(key, pixmap);
}
void UserCardArtProvider::processQueue()
{
if (!dbReady) {
return;
}
while (!queue.isEmpty()) {
const QString key = queue.dequeue();
const QStringList parts = key.split(u'|');
if (parts.size() != 2) {
pending.remove(key);
continue;
}
const QString userName = parts.at(0);
const QString cardName = parts.at(1);
ExactCard card = CardDatabaseManager::query()->getCard({cardName});
if (!card) {
pending.remove(key);
continue;
}
QPixmap fullRes;
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
// Synchronous hit (already loaded/on disk)
if (!fullRes.isNull()) {
insertIntoCache(key, cropCardArt(fullRes));
pending.remove(key);
emit cardArtUpdated(userName);
continue;
}
// Async load required.
QPointer<UserCardArtProvider> self(this);
auto conn = std::make_shared<QMetaObject::Connection>();
*conn = connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this,
[self, key, userName, card, conn]() mutable {
if (!self) {
return;
}
QObject::disconnect(*conn);
QPixmap fullRes;
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (!fullRes.isNull()) {
self->insertIntoCache(key, self->cropCardArt(fullRes));
} else {
self->insertIntoCache(key, QPixmap());
}
self->pending.remove(key);
emit self->cardArtUpdated(userName);
// Resume processing remaining queued items.
self->processQueue();
});
// Stop here. We'll continue when the async load finishes.
return;
}
}

View file

@ -0,0 +1,39 @@
#ifndef COCKATRICE_USER_CARD_ART_PROVIDER_H
#define COCKATRICE_USER_CARD_ART_PROVIDER_H
#include <QMap>
#include <QObject>
#include <QPixmap>
#include <QQueue>
#include <QSet>
class UserCardArtProvider : public QObject
{
Q_OBJECT
public:
explicit UserCardArtProvider(QObject *parent = nullptr);
void requestCardArt(const QString &userName, const QString &cardName);
const QMap<QString, QPixmap> &cache() const;
static QPixmap cropCardArt(const QPixmap &fullRes);
signals:
void cardArtUpdated(const QString &userName);
public slots:
void onDatabaseReady();
private:
bool dbReady = false;
static constexpr int MaxCacheEntries = 300;
QList<QString> cacheInsertionOrder; // FIFO eviction
QMap<QString, QPixmap> cardArtCache;
QSet<QString> pending;
QQueue<QString> queue;
void processQueue();
void insertIntoCache(const QString &key, const QPixmap &pixmap);
};
#endif // COCKATRICE_USER_CARD_ART_PROVIDER_H

View file

@ -0,0 +1,264 @@
#include "user_card_settings_dialog.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include "card/card_completer_proxy_model.h"
#include "card/card_search_model.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "user_card_art_provider.h"
#include "user_list_painter.h"
#include <QCompleter>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QRegularExpression>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
CardArtPreviewWidget::CardArtPreviewWidget(QWidget *parent) : QWidget(parent)
{
setMinimumSize(400, 72);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
}
void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap)
{
sourcePixmap = pixmap;
update();
}
void CardArtPreviewWidget::setParams(const CardArtParams &p)
{
params = p;
update();
}
void CardArtPreviewWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
const QRect rect = this->rect();
const QColor accentColor(100, 116, 139);
const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2);
QLinearGradient bg(cardRect.topLeft(), cardRect.topRight());
bg.setColorAt(0, accentColor.darker(320));
bg.setColorAt(1, QColor(18, 22, 30));
painter.setPen(Qt::NoPen);
painter.setBrush(bg);
painter.drawRoundedRect(cardRect, 6, 6);
painter.setBrush(accentColor);
painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
if (sourcePixmap.isNull()) {
painter.setPen(QColor(150, 150, 150));
painter.drawText(rect, Qt::AlignCenter, tr("No card selected"));
return;
}
UserListPainter::drawCardArt(&painter, rect, rect.right() - 4,
QString(), // userName not needed for override path
nullptr, // no cache
params,
&sourcePixmap // 👈 direct pixmap
);
// Avatar placeholder so the left-margin interaction is visible
const int avatarX = rect.left() + 14;
const int avatarY = rect.top() + (rect.height() - 36) / 2;
const QRect avatarRect(avatarX, avatarY, 36, 36);
QPainterPath clip;
clip.addEllipse(avatarRect);
painter.save();
painter.setClipPath(clip);
painter.setBrush(accentColor.darker(200));
painter.setPen(Qt::NoPen);
painter.drawEllipse(avatarRect);
painter.restore();
painter.setPen(QPen(QColor(70, 80, 95), 2));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(avatarRect.adjusted(-1, -1, 1, 1));
}
UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initial, QWidget *parent)
: QDialog(parent), currentParams(initial)
{
setWindowTitle(tr("Card Art Settings"));
setMinimumWidth(500);
setupUi();
// Seed UI from initial params
if (!initial.cardName.isEmpty()) {
searchBar->setText(initial.cardName);
onCardNameChanged(initial.cardName);
}
marginLSpin->setValue(initial.marginPctL);
marginRSpin->setValue(initial.marginPctR);
verticalOffsetSpin->setValue(initial.verticalOffset);
zoomSpin->setValue(initial.zoom);
}
CardArtParams UserCardArtSettingsDialog::params() const
{
return currentParams;
}
QDoubleSpinBox *UserCardArtSettingsDialog::makeSpinBox(double min, double max, double value, double step)
{
auto *spin = new QDoubleSpinBox;
spin->setRange(min, max);
spin->setSingleStep(step);
spin->setDecimals(3);
spin->setValue(value);
return spin;
}
void UserCardArtSettingsDialog::initializeSearchBar()
{
searchBar = new QLineEdit;
searchBar->setPlaceholderText(tr("Type a card name..."));
cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this);
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
searchModel = new CardSearchModel(cardDatabaseDisplayModel, this);
proxyModel = new CardCompleterProxyModel(this);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxyModel->setFilterRole(Qt::DisplayRole);
completer = new QCompleter(proxyModel, this);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(15);
searchBar->setCompleter(completer);
connect(searchBar, &QLineEdit::textEdited, searchModel, &CardSearchModel::updateSearchResults);
connect(searchBar, &QLineEdit::textEdited, this, [this](const QString &text) {
const QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete();
}
});
connect(completer, static_cast<void (QCompleter::*)(const QString &)>(&QCompleter::activated), this,
[this](const QString &completion) {
if (searchBar->text() != completion) {
searchBar->setText(completion);
searchBar->setCursorPosition(searchBar->text().length());
}
onCardNameChanged(completion);
});
// Also trigger a load when the user hits Return on a typed name
connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); });
}
void UserCardArtSettingsDialog::setupUi()
{
initializeSearchBar();
marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01);
marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01);
verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01);
zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05);
auto *form = new QFormLayout;
form->addRow(tr("Card name:"), searchBar);
form->addRow(tr("Left margin (%):"), marginLSpin);
form->addRow(tr("Right margin (%):"), marginRSpin);
form->addRow(tr("Vertical offset:"), verticalOffsetSpin);
form->addRow(tr("Zoom:"), zoomSpin);
auto *controlsGroup = new QGroupBox(tr("Parameters"));
controlsGroup->setLayout(form);
preview = new CardArtPreviewWidget;
auto *previewLayout = new QVBoxLayout;
previewLayout->addWidget(preview);
auto *previewGroup = new QGroupBox(tr("Preview"));
previewGroup->setLayout(previewLayout);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
auto *removeBtn = new QPushButton(tr("Remove Banner Card"));
buttons->addButton(removeBtn, QDialogButtonBox::ResetRole);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(removeBtn, &QPushButton::clicked, this, [this]() {
currentParams = CardArtParams{}; // empty cardName signals removal
accept();
});
auto *root = new QVBoxLayout;
root->addWidget(controlsGroup);
root->addWidget(previewGroup);
root->addWidget(buttons);
setLayout(root);
connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
}
void UserCardArtSettingsDialog::onCardNameChanged(const QString &name)
{
if (name.isEmpty()) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
return;
}
const ExactCard card = CardDatabaseManager::query()->getCard({name});
if (!card) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
return;
}
currentParams.cardName = name;
QPixmap fullRes;
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (fullRes.isNull()) {
connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this, card](const PrintingInfo &) {
disconnect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, nullptr);
QPixmap loaded;
CardPictureLoader::getPixmap(loaded, card, QSize(745, 1040));
currentPixmap = UserCardArtProvider::cropCardArt(loaded);
preview->setPixmap(currentPixmap);
});
return;
}
currentPixmap = UserCardArtProvider::cropCardArt(fullRes);
preview->setPixmap(currentPixmap);
}
void UserCardArtSettingsDialog::onParamChanged()
{
currentParams.marginPctL = marginLSpin->value();
currentParams.marginPctR = marginRSpin->value();
currentParams.verticalOffset = verticalOffsetSpin->value();
currentParams.zoom = zoomSpin->value();
preview->setParams(currentParams);
}

View file

@ -0,0 +1,70 @@
#ifndef COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H
#define COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H
#include "user_list_painter.h"
#include <QDialog>
#include <QPixmap>
class QCompleter;
class QLineEdit;
class QDoubleSpinBox;
class CardDatabaseModel;
class CardDatabaseDisplayModel;
class CardSearchModel;
class CardCompleterProxyModel;
class CardArtPreviewWidget : public QWidget
{
Q_OBJECT
public:
explicit CardArtPreviewWidget(QWidget *parent = nullptr);
void setPixmap(const QPixmap &pixmap);
void setParams(const CardArtParams &params);
protected:
void paintEvent(QPaintEvent *event) override;
private:
QPixmap sourcePixmap;
CardArtParams params;
};
class UserCardArtSettingsDialog : public QDialog
{
Q_OBJECT
public:
explicit UserCardArtSettingsDialog(const CardArtParams &initial = {}, QWidget *parent = nullptr);
CardArtParams params() const;
private slots:
void onCardNameChanged(const QString &name);
void onParamChanged();
private:
void setupUi();
void initializeSearchBar();
QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step);
QLineEdit *searchBar;
QCompleter *completer;
CardDatabaseModel *cardDatabaseModel;
CardDatabaseDisplayModel *cardDatabaseDisplayModel;
CardSearchModel *searchModel;
CardCompleterProxyModel *proxyModel;
QDoubleSpinBox *marginLSpin;
QDoubleSpinBox *marginRSpin;
QDoubleSpinBox *verticalOffsetSpin;
QDoubleSpinBox *zoomSpin;
CardArtPreviewWidget *preview;
QPixmap currentPixmap;
CardArtParams currentParams;
};
#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H

View file

@ -542,3 +542,113 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
delete menu;
}
void UserContextMenu::execChat(const QString &userName)
{
emit openMessageDialog(userName, true);
}
void UserContextMenu::execDetails(const QString &userName)
{
auto *w = new UserInfoBox(client, false, static_cast<QWidget *>(parent()),
Qt::Dialog | Qt::WindowTitleHint | Qt::CustomizeWindowHint | Qt::WindowCloseButtonHint);
w->setAttribute(Qt::WA_DeleteOnClose);
w->updateInfo(userName);
}
void UserContextMenu::execShowGames(const QString &userName)
{
Command_GetGamesOfUser cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::gamesOfUserReceived);
client->sendCommand(pend);
}
void UserContextMenu::execAddToBuddy(const QString &userName)
{
Command_AddToList cmd;
cmd.set_list("buddy");
cmd.set_user_name(userName.toStdString());
client->sendCommand(client->prepareSessionCommand(cmd));
}
void UserContextMenu::execRemoveFromBuddy(const QString &userName)
{
Command_RemoveFromList cmd;
cmd.set_list("buddy");
cmd.set_user_name(userName.toStdString());
client->sendCommand(client->prepareSessionCommand(cmd));
}
void UserContextMenu::execAddToIgnore(const QString &userName)
{
Command_AddToList cmd;
cmd.set_list("ignore");
cmd.set_user_name(userName.toStdString());
client->sendCommand(client->prepareSessionCommand(cmd));
}
void UserContextMenu::execRemoveFromIgnore(const QString &userName)
{
Command_RemoveFromList cmd;
cmd.set_list("ignore");
cmd.set_user_name(userName.toStdString());
client->sendCommand(client->prepareSessionCommand(cmd));
}
void UserContextMenu::execBan(const QString &userName)
{
Command_GetUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUser_processUserInfoResponse);
client->sendCommand(pend);
}
void UserContextMenu::execWarn(const QString &userName)
{
Command_GetUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUser_processUserInfoResponse);
client->sendCommand(pend);
}
void UserContextMenu::execBanHistory(const QString &userName)
{
Command_GetBanHistory cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUserHistory_processResponse);
client->sendCommand(pend);
}
void UserContextMenu::execWarnHistory(const QString &userName)
{
Command_GetWarnHistory cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUserHistory_processResponse);
client->sendCommand(pend);
}
void UserContextMenu::execAdminNotes(const QString &userName)
{
Command_GetAdminNotes cmd;
cmd.set_user_name(userName.toStdString());
auto *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::getAdminNotes_processResponse);
client->sendCommand(pend);
}
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod, bool shouldBeJudge)
{
Command_AdjustMod cmd;
cmd.set_user_name(userName.toStdString());
cmd.set_should_be_mod(shouldBeMod);
cmd.set_should_be_judge(shouldBeJudge);
PendingCommand *pend = client->prepareAdminCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse);
client->sendCommand(pend);
}

View file

@ -74,6 +74,27 @@ public:
int playerId,
const QString &deckHash,
ChatView *chatView = nullptr);
const UserListProxy *getUserListProxy() const
{
return userListProxy;
}
// Individual action entry points — used by UserInfoPopup to trigger
// actions without re-running the full context menu flow.
void execChat(const QString &userName);
void execDetails(const QString &userName);
void execShowGames(const QString &userName);
void execAddToBuddy(const QString &userName);
void execRemoveFromBuddy(const QString &userName);
void execAddToIgnore(const QString &userName);
void execRemoveFromIgnore(const QString &userName);
void execBan(const QString &userName);
void execWarn(const QString &userName);
void execBanHistory(const QString &userName);
void execWarnHistory(const QString &userName);
void execAdminNotes(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod, bool shouldBeJudge);
};
#endif

View file

@ -5,6 +5,7 @@
#include "../../interface/widgets/dialogs/dlg_edit_password.h"
#include "../../interface/widgets/dialogs/dlg_edit_user.h"
#include "../../interface/widgets/utility/get_text_with_max.h"
#include "user_card_settings_dialog.h"
#include <QDateTime>
#include <QGridLayout>
@ -61,11 +62,13 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren
buttonsLayout->addWidget(&editButton);
buttonsLayout->addWidget(&passwordButton);
buttonsLayout->addWidget(&avatarButton);
buttonsLayout->addWidget(&bannerCardButton);
mainLayout->addLayout(buttonsLayout, 7, 0, 1, 3);
connect(&editButton, &QPushButton::clicked, this, &UserInfoBox::actEdit);
connect(&passwordButton, &QPushButton::clicked, this, &UserInfoBox::actPassword);
connect(&avatarButton, &QPushButton::clicked, this, &UserInfoBox::actAvatar);
connect(&bannerCardButton, &QPushButton::clicked, this, &UserInfoBox::actBannerCard);
}
setWindowTitle(tr("User Information"));
@ -83,11 +86,15 @@ void UserInfoBox::retranslateUi()
editButton.setText(tr("Edit"));
passwordButton.setText(tr("Change password"));
avatarButton.setText(tr("Change avatar"));
bannerCardButton.setText(tr("Edit Banner Card"));
}
void UserInfoBox::updateInfo(const ServerInfo_User &user)
{
userLevel = UserLevelFlags(user.user_level());
currentUserInfo = user;
hasUserInfo = true;
const UserLevelFlags userLevel(user.user_level());
pawnColors = user.pawn_colors();
privLevel = QString::fromStdString(user.privlevel());
@ -306,6 +313,48 @@ void UserInfoBox::actAvatar()
client->sendCommand(pend);
}
void UserInfoBox::actBannerCard()
{
CardArtParams initial;
if (hasUserInfo && currentUserInfo.has_card_art_params()) {
const auto &cap = currentUserInfo.card_art_params();
initial.cardName = QString::fromStdString(cap.card_name());
initial.marginPctL = cap.margin_pct_l();
initial.marginPctR = cap.margin_pct_r();
initial.verticalOffset = cap.vertical_offset();
initial.zoom = cap.zoom();
}
UserCardArtSettingsDialog dlg(initial, this);
if (dlg.exec() != QDialog::Accepted) {
return;
}
const CardArtParams p = dlg.params();
Command_SetCardArtParams cmd;
cmd.set_card_name(p.cardName.toStdString());
if (!p.cardName.isEmpty()) {
cmd.set_margin_pct_l(p.marginPctL);
cmd.set_margin_pct_r(p.marginPctR);
cmd.set_vertical_offset(p.verticalOffset);
cmd.set_zoom(p.zoom);
}
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, [p, this](const Response &r) {
if (r.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"),
tr("The selected card is blacklisted on this server or another error occurred."));
} else {
updateInfo(nameLabel.text()); // re-fetch so currentUserInfo reflects the change
QMessageBox::information(this, tr("Information"),
p.cardName.isEmpty() ? tr("Banner card removed.") : tr("Banner card updated."));
}
});
client->sendCommand(pend);
}
void UserInfoBox::processEditResponse(const Response &r)
{
switch (r.response_code()) {

View file

@ -12,6 +12,7 @@
#include <QPushButton>
#include <QWidget>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/utility/days_years_between.h>
class AbstractClient;
@ -25,9 +26,11 @@ private:
bool editable;
QLabel avatarPic, userLevelIcon, nameLabel, realNameLabel1, realNameLabel2, countryLabel1, countryLabel2,
countryLabel3, userLevelLabel1, userLevelLabel2, accountAgeLabel1, accountAgeLabel2;
QPushButton editButton, passwordButton, avatarButton;
QPushButton editButton, passwordButton, avatarButton, bannerCardButton;
QPixmap avatarPixmap;
bool hasAvatar;
ServerInfo_User currentUserInfo;
bool hasUserInfo = false;
UserLevelFlags userLevel;
ServerInfo_User::PawnColorsOverride pawnColors;
QString privLevel;
@ -37,6 +40,7 @@ private:
public:
UserInfoBox(AbstractClient *_client, bool editable, QWidget *parent = nullptr, Qt::WindowFlags flags = {});
void retranslateUi();
private slots:
void processResponse(const Response &r);
void processEditResponse(const Response &r);
@ -47,6 +51,7 @@ private slots:
void actEditInternal(const Response &r);
void actPassword();
void actAvatar();
void actBannerCard();
public slots:
void updateInfo(const ServerInfo_User &user);
void updateInfo(const QString &userName);

View file

@ -0,0 +1,656 @@
#include "user_info_popup.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/widgets/tabs/tab_supervisor.h"
#include "user_list_painter.h"
#include <QApplication>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QScreen>
#include <QScrollBar>
#include <QStandardItem>
#include <QStyledItemDelegate>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
// ── Compact game row delegate ─────────────────────────────────────────────────
class PopupGameDelegate : public QStyledItemDelegate
{
public:
using QStyledItemDelegate::QStyledItemDelegate;
QSize sizeHint(const QStyleOptionViewItem &, const QModelIndex &) const override
{
return QSize(0, 38);
}
void paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const override
{
const QVariant var = index.data(PopupRoles::GameData);
if (!var.isValid()) {
QStyledItemDelegate::paint(p, option, index);
return;
}
p->save();
p->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing);
const QRect rect = option.rect;
const ServerInfo_Game game = var.value<ServerInfo_Game>();
const bool selected = option.state & QStyle::State_Selected;
p->fillRect(rect, selected ? QColor(35, 45, 62) : QColor(14, 18, 26));
// State colour dot
const QColor dot = game.started() ? QColor(239, 68, 68)
: (game.player_count() >= game.max_players()) ? QColor(249, 115, 22)
: game.with_password() ? QColor(59, 130, 246)
: QColor(34, 197, 94);
p->setPen(Qt::NoPen);
p->setBrush(dot);
p->drawEllipse(QRectF(rect.left() + 9, rect.top() + (rect.height() - 8) / 2.0, 8, 8));
// Game title (bold, elided)
QFont tf = option.font;
tf.setBold(true);
p->setFont(tf);
p->setPen(QColor(205, 215, 230));
const int textX = rect.left() + 26;
const int countW = 52;
const int titleW = rect.width() - textX - countW - 6;
p->drawText(QRect(textX, rect.top(), titleW, rect.height()), Qt::AlignVCenter | Qt::AlignLeft,
QFontMetrics(tf).elidedText(QString::fromStdString(game.description()), Qt::ElideRight, titleW));
// Player count
const bool full = game.player_count() >= game.max_players();
p->setFont(option.font);
p->setPen(full ? QColor(249, 115, 22) : QColor(110, 128, 150));
p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()),
Qt::AlignVCenter | Qt::AlignRight,
QStringLiteral("%1/%2").arg(game.player_count()).arg(game.max_players()));
// Row separator
p->setPen(QColor(24, 32, 44));
p->drawLine(rect.bottomLeft(), rect.bottomRight());
p->restore();
}
};
// ── UserInfoHeaderWidget ──────────────────────────────────────────────────────
UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent)
{
setFixedHeight(HeaderHeight);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
}
void UserInfoHeaderWidget::setUserData(const ServerInfo_User &user,
bool online,
const QPixmap &avatar,
const QPixmap &cardArt,
const CardArtParams &params)
{
m_user = user;
m_online = online;
m_avatar = avatar;
m_cardArt = cardArt;
m_params = params;
update();
}
void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
{
QPainter p(this);
p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
const QRect rect = this->rect();
const UserLevelFlags level(m_user.user_level());
const QString userName = QString::fromStdString(m_user.name());
const QString privLevel = QString::fromStdString(m_user.privlevel());
// Dark base
p.fillRect(rect, QColor(14, 18, 26));
// ── Card art background ───────────────────────────────────────────────────
if (!m_cardArt.isNull()) {
const int w = rect.width();
const int h = rect.height();
const int mL = qRound(w * m_params.marginPctL);
const int mR = qRound(w * m_params.marginPctR);
const int dW = w - mL - mR;
const double base = qMax(double(dW) / m_cardArt.width(), double(h) / m_cardArt.height());
const double scale = base * m_params.zoom;
const int sW = qRound(m_cardArt.width() * scale);
const int sH = qRound(m_cardArt.height() * scale);
const QPixmap scaled = m_cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
const int srcX = (sW - dW) / 2;
const int srcY = qBound(0, qRound((sH - h) * m_params.verticalOffset), qMax(0, sH - h));
QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
QPainter mask(&img);
mask.setCompositionMode(QPainter::CompositionMode_DestinationIn);
QLinearGradient g(0, 0, img.width(), 0);
g.setColorAt(0.00, Qt::transparent);
g.setColorAt(0.18, Qt::white);
g.setColorAt(0.82, Qt::white);
g.setColorAt(1.00, Qt::transparent);
mask.fillRect(img.rect(), g);
}
p.setOpacity(0.48);
p.drawImage(mL, 0, img);
p.setOpacity(1.0);
}
// Bottom gradient overlay so avatar and text are always legible
{
QLinearGradient ov(0, 0, 0, rect.height());
ov.setColorAt(0.0, QColor(14, 18, 26, 0));
ov.setColorAt(0.55, QColor(14, 18, 26, 110));
ov.setColorAt(1.0, QColor(14, 18, 26, 230));
p.fillRect(rect, ov);
}
// ── Avatar ────────────────────────────────────────────────────────────────
const QColor accent = [&]() -> QColor {
if (level.testFlag(ServerInfo_User::IsAdmin)) {
return QColor(245, 158, 11);
}
if (level.testFlag(ServerInfo_User::IsModerator)) {
return QColor(59, 130, 246);
}
if (level.testFlag(ServerInfo_User::IsJudge)) {
return QColor(168, 85, 247);
}
return QColor(100, 116, 139);
}();
const int ax = LeftPad;
const int ay = rect.height() - AvatarSize - 10;
const QRect ar(ax, ay, AvatarSize, AvatarSize);
QPainterPath clip;
clip.addEllipse(ar);
p.save();
p.setClipPath(clip);
if (!m_avatar.isNull()) {
p.drawPixmap(ar, m_avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
} else {
p.setPen(Qt::NoPen);
p.setBrush(accent.darker(200));
p.drawEllipse(ar);
const QPixmap pawn =
UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, m_user.pawn_colors(), false, privLevel);
p.drawPixmap(ar.center().x() - AvatarPawnSize / 2, ar.center().y() - AvatarPawnSize / 2, pawn);
}
p.restore();
// Status ring
p.setPen(QPen(m_online ? QColor(34, 197, 94) : QColor(70, 80, 95), 2.5));
p.setBrush(Qt::NoBrush);
p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25));
// ── Username + badge ──────────────────────────────────────────────────────
const int tx = ax + AvatarSize + AvatarToTextGap;
const int tw = rect.width() - tx - 8;
QFont nf = font();
nf.setBold(true);
nf.setPointSizeF(nf.pointSizeF() * 1.12);
p.setFont(nf);
p.setPen(m_online ? QColor(220, 228, 240) : QColor(90, 100, 115));
p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft,
QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw));
// Level / priv badge
struct
{
QString text;
QColor color;
} badge;
if (level.testFlag(ServerInfo_User::IsAdmin)) {
badge = {"ADMIN", QColor(245, 158, 11)};
} else if (level.testFlag(ServerInfo_User::IsModerator)) {
badge = {"MOD", QColor(59, 130, 246)};
} else if (level.testFlag(ServerInfo_User::IsJudge)) {
badge = {"JUDGE", QColor(168, 85, 247)};
} else if (privLevel == "VIP") {
badge = {"VIP", QColor(20, 184, 166)};
} else if (privLevel == "DONATOR") {
badge = {"DONATOR", QColor(249, 115, 22)};
}
if (!badge.text.isEmpty()) {
QFont bf = font();
bf.setPointSizeF(bf.pointSizeF() * 0.70);
bf.setBold(true);
p.setFont(bf);
const QFontMetrics bfm(bf);
const int bw = bfm.horizontalAdvance(badge.text) + 10;
const QRect br(tx, ay + AvatarSize / 2 + 6, bw, 15);
p.setPen(Qt::NoPen);
p.setBrush(badge.color.darker(160));
p.drawRoundedRect(br, 3, 3);
p.setPen(badge.color.lighter(150));
p.drawText(br, Qt::AlignCenter, badge.text);
}
}
// ── UserInfoPopup ─────────────────────────────────────────────────────────────
UserInfoPopup::UserInfoPopup(TabSupervisor *ts,
AbstractClient *client,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap,
QWidget *parent)
: QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), m_ts(ts), m_client(client), m_avatarCache(avatarCache),
m_cardArtCache(cardArtCache), m_cardArtParamsMap(cardArtParamsMap)
{
setAttribute(Qt::WA_ShowWithoutActivating);
setFixedWidth(PopupWidth);
setFrameShape(QFrame::NoFrame);
buildUi();
}
void UserInfoPopup::buildUi()
{
setStyleSheet(QStringLiteral("UserInfoPopup {"
" background:#0e1218;"
" border:1px solid #1e2838;"
" border-radius:8px;"
"}"));
auto *root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
// Header
m_header = new UserInfoHeaderWidget(this);
root->addWidget(m_header);
// Action area — rebuilt per user
m_actionArea = new QWidget(this);
m_actionArea->setStyleSheet(QStringLiteral("background:#0e1218;"));
root->addWidget(m_actionArea);
// Thin separator
auto *sep = new QFrame(this);
sep->setFrameShape(QFrame::HLine);
sep->setStyleSheet(QStringLiteral("color:#1a2434; margin: 0 8px;"));
root->addWidget(sep);
// Games header row
auto *gh = new QHBoxLayout;
gh->setContentsMargins(10, 4, 8, 2);
auto *gl = new QLabel(tr("Games"), this);
gl->setStyleSheet(QStringLiteral("color:#6882a0; font-size:11px; font-weight:bold; background:transparent;"));
gh->addWidget(gl);
gh->addStretch();
m_refreshBtn = new QPushButton(QStringLiteral(""), this);
m_refreshBtn->setFixedSize(20, 20);
m_refreshBtn->setFlat(true);
m_refreshBtn->setStyleSheet(
QStringLiteral("QPushButton{color:#6882a0;border:none;font-size:14px;background:transparent;}"
"QPushButton:hover{color:white;}"));
connect(m_refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames);
gh->addWidget(m_refreshBtn);
root->addLayout(gh);
// Status label
m_gamesStatus = new QLabel(this);
m_gamesStatus->setAlignment(Qt::AlignCenter);
m_gamesStatus->setStyleSheet(
QStringLiteral("color:#3a4a5e; font-size:11px; padding:10px; background:transparent;"));
root->addWidget(m_gamesStatus);
// Games list
m_gamesModel = new QStandardItemModel(this);
m_gamesView = new QListView(this);
m_gamesView->setModel(m_gamesModel);
m_gamesView->setItemDelegate(new PopupGameDelegate(m_gamesView));
m_gamesView->setFrameShape(QFrame::NoFrame);
m_gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_gamesView->setMaximumHeight(220);
m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}"
"QListView::item:selected{background:#232e42;}"));
m_gamesView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu);
root->addWidget(m_gamesView);
// Close button — positioned absolutely in the top-right corner
m_closeBtn = new QPushButton(QStringLiteral(""), this);
m_closeBtn->setFixedSize(22, 22);
m_closeBtn->setFlat(true);
m_closeBtn->setStyleSheet(QStringLiteral("QPushButton{background:rgba(14,18,26,180);color:#607080;"
"border:none;border-radius:11px;font-size:10px;}"
"QPushButton:hover{color:white;background:rgba(200,50,50,200);}"));
connect(m_closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested);
}
// ── Action button factory ─────────────────────────────────────────────────────
static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p)
{
auto *b = new QPushButton(label, p);
b->setToolTip(tip);
b->setFixedHeight(26);
b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
b->setStyleSheet(QStringLiteral("QPushButton{"
" background:#192030;color:#b8c8de;border:1px solid #263040;"
" border-radius:4px;font-size:11px;padding:0 4px;"
"}"
"QPushButton:hover{background:#223050;color:white;}"
"QPushButton:pressed{background:#162030;}"
"QPushButton:disabled{color:#384858;border-color:#192030;}"));
return b;
}
void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
{
// Clear previous contents
delete m_actionArea->layout();
const auto old = m_actionArea->findChildren<QPushButton *>(QString{}, Qt::FindDirectChildrenOnly);
for (auto *w : old) {
w->deleteLater();
}
const QString name = QString::fromStdString(userInfo.name());
const auto ownLevel = UserLevelFlags(m_ts->getUserInfo()->user_level());
const bool isSelf = (name == QString::fromStdString(m_ts->getUserInfo()->name()));
const bool isMod = ownLevel.testFlag(ServerInfo_User::IsModerator);
const bool isAdmin = ownLevel.testFlag(ServerInfo_User::IsAdmin);
const auto their = UserLevelFlags(userInfo.user_level());
const bool isReg = their.testFlag(ServerInfo_User::IsRegistered);
auto *grid = new QGridLayout(m_actionArea);
grid->setContentsMargins(8, 6, 8, 6);
grid->setSpacing(4);
int row = 0, col = 0;
const int cols = 3;
auto add = [&](QPushButton *btn) {
grid->addWidget(btn, row, col);
if (++col == cols) {
col = 0;
++row;
}
};
// ── Always visible ────────────────────────────────────────────────────────
auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), m_actionArea);
chat->setEnabled(!isSelf && online);
connect(chat, &QPushButton::clicked, this, [this, name] { emit chatRequested(name); });
add(chat);
auto *prof = makeBtn(tr("Profile"), tr("View user profile"), m_actionArea);
connect(prof, &QPushButton::clicked, this, [this, name] { emit detailsRequested(name); });
add(prof);
auto *games = makeBtn(tr("Games"), tr("Show this user's games"), m_actionArea);
games->setEnabled(!isSelf && online);
connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); });
add(games);
// ── Buddy / ignore (registered users only) ────────────────────────────────
if (!isSelf && isReg) {
if (isBuddy) {
auto *b = makeBtn(tr(" Buddy"), tr("Remove from buddy list"), m_actionArea);
connect(b, &QPushButton::clicked, this, [this, name] { emit removeBuddyRequested(name); });
add(b);
} else {
auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), m_actionArea);
connect(b, &QPushButton::clicked, this, [this, name] { emit addBuddyRequested(name); });
add(b);
}
if (isIgnored) {
auto *b = makeBtn(tr(" Ignore"), tr("Remove from ignore list"), m_actionArea);
connect(b, &QPushButton::clicked, this, [this, name] { emit removeIgnoreRequested(name); });
add(b);
} else {
auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), m_actionArea);
connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); });
add(b);
}
}
// ── Moderator actions ─────────────────────────────────────────────────────
if (!isSelf && (isMod || isAdmin)) {
if (col != 0) {
++row;
col = 0;
} // start mod section on a fresh row
auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), m_actionArea);
auto *warn = makeBtn(tr("Warn"), tr("Warn user"), m_actionArea);
auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), m_actionArea);
auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), m_actionArea);
connect(ban, &QPushButton::clicked, this, [this, name] { emit banRequested(name); });
connect(warn, &QPushButton::clicked, this, [this, name] { emit warnRequested(name); });
connect(bLog, &QPushButton::clicked, this, [this, name] { emit banHistoryRequested(name); });
connect(wLog, &QPushButton::clicked, this, [this, name] { emit warnHistoryRequested(name); });
add(ban);
add(warn);
add(bLog);
add(wLog);
}
// ── Admin actions ─────────────────────────────────────────────────────────
if (!isSelf && isAdmin) {
auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), m_actionArea);
connect(notes, &QPushButton::clicked, this, [this, name] { emit adminNotesRequested(name); });
add(notes);
if (their.testFlag(ServerInfo_User::IsModerator)) {
auto *b = makeBtn(tr(" Mod"), tr("Demote from moderator"), m_actionArea);
connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromModRequested(name); });
add(b);
} else if (isReg) {
auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), m_actionArea);
connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToModRequested(name); });
add(b);
}
if (their.testFlag(ServerInfo_User::IsJudge)) {
auto *b = makeBtn(tr(" Judge"), tr("Demote from judge"), m_actionArea);
connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromJudgeRequested(name); });
add(b);
} else if (isReg) {
auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), m_actionArea);
connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); });
add(b);
}
}
m_actionArea->adjustSize();
}
void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
{
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
adjustSize();
}
void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
{
const QModelIndex idx = m_gamesView->indexAt(pos);
if (!idx.isValid()) {
return;
}
const QVariant var = idx.data(PopupRoles::GameData);
if (!var.isValid()) {
return;
}
const ServerInfo_Game game = var.value<ServerInfo_Game>();
QMenu menu(this);
menu.setStyleSheet(
QStringLiteral("QMenu{background:#12182a;color:#c8d8ec;border:1px solid #1e2838;border-radius:4px;}"
"QMenu::item:selected{background:#223050;}"));
const bool canJoin = !game.started() && game.player_count() < game.max_players();
QAction *join = menu.addAction(tr("Join game"));
join->setEnabled(canJoin);
QAction *spec = nullptr;
if (game.spectators_allowed()) {
spec = menu.addAction(tr("Spectate"));
}
const QAction *chosen = menu.exec(m_gamesView->viewport()->mapToGlobal(pos));
if (!chosen) {
return;
}
if (chosen == join) {
emit joinGameRequested(game.game_id(), game.room_id(), false);
} else if (spec && chosen == spec) {
emit joinGameRequested(game.game_id(), game.room_id(), true);
}
}
// ── showForUser ───────────────────────────────────────────────────────────────
void UserInfoPopup::showForUser(const QString &userName,
const ServerInfo_User &userInfo,
bool online,
bool isBuddy,
bool isIgnored)
{
m_currentUser = userName;
m_currentUserInfo = userInfo;
m_currentOnline = online;
// Header
const QPixmap avatar = m_avatarCache ? m_avatarCache->value(userName) : QPixmap{};
const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(userName))
? m_cardArtParamsMap->value(userName)
: CardArtParams{};
const QString artKey = userName + u'|' + params.cardName;
const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{};
m_header->setUserData(userInfo, online, avatar, cardArt, params);
// Actions
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
// Games list reset
m_gamesModel->clear();
m_gamesView->hide();
m_gamesStatus->setText(tr("Loading games…"));
m_gamesStatus->show();
// Close button — top-right corner, above everything
m_closeBtn->move(PopupWidth - m_closeBtn->width() - 6, 6);
m_closeBtn->raise();
adjustSize();
fetchGames();
}
// ── Games fetch ───────────────────────────────────────────────────────────────
void UserInfoPopup::fetchGames()
{
if (!m_client || m_currentUser.isEmpty()) {
return;
}
Command_GetGamesOfUser cmd;
cmd.set_user_name(m_currentUser.toStdString());
const QString snapshot = m_currentUser;
PendingCommand *pend = m_client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this, snapshot](const Response &r) { onGamesReceived(r, snapshot); });
m_client->sendCommand(pend);
}
void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
{
if (forUser != m_currentUser) {
return; // stale response — different user showing now
}
m_gamesModel->clear();
if (r.response_code() != Response::RespOk) {
m_gamesStatus->setText(tr("Could not load games."));
m_gamesStatus->show();
m_gamesView->hide();
return;
}
const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext);
if (resp.game_list_size() == 0) {
m_gamesStatus->setText(tr("No active games."));
m_gamesStatus->show();
m_gamesView->hide();
return;
}
for (int i = 0; i < resp.game_list_size(); ++i) {
auto *item = new QStandardItem;
item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData);
item->setEditable(false);
m_gamesModel->appendRow(item);
}
m_gamesStatus->hide();
m_gamesView->show();
// Fit exactly to the number of visible rows, scroll when more than 5
constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint
constexpr int maxRows = 5;
const int count = m_gamesModel->rowCount();
const int visible = qMin(count, maxRows);
m_gamesView->setFixedHeight(visible * rowH + 2);
m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
adjustSize();
}
void UserInfoPopup::refreshGames()
{
m_gamesModel->clear();
m_gamesView->hide();
m_gamesStatus->setText(tr("Loading games…"));
m_gamesStatus->show();
fetchGames();
}
// ── Mouse events ──────────────────────────────────────────────────────────────
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
void UserInfoPopup::enterEvent(QEnterEvent *e)
{
QFrame::enterEvent(e);
emit mouseEnteredPopup();
}
#else
void UserInfoPopup::enterEvent(QEvent *e)
{
QFrame::enterEvent(e);
emit mouseEnteredPopup();
}
#endif
void UserInfoPopup::leaveEvent(QEvent *e)
{
QFrame::leaveEvent(e);
emit mouseLeftPopup();
}

View file

@ -0,0 +1,181 @@
#ifndef COCKATRICE_USER_INFO_POPUP_H
#define COCKATRICE_USER_INFO_POPUP_H
#include "../../interface/widgets/server/game_type_map.h"
#include "user_list_painter.h"
#include <QFrame>
#include <QListView>
#include <QMap>
#include <QPixmap>
#include <QStandardItemModel>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
class AbstractClient;
class QLabel;
class QPushButton;
class TabSupervisor;
// ── Roles ─────────────────────────────────────────────────────────────────────
namespace PopupRoles
{
constexpr int GameData = Qt::UserRole + 10;
}
// ── Header widget ─────────────────────────────────────────────────────────────
/**
* @class UserInfoHeaderWidget
* @brief Paints the enlarged banner card art + circular avatar section at the
* top of the UserInfoPopup.
*
* Layout mirrors UserListPainter but at a larger scale: the card art fills the
* full width as a semi-transparent background, a bottom gradient ensures the
* avatar and username text remain legible, and the status ring colour matches
* the UserListPainter convention.
*/
class UserInfoHeaderWidget : public QWidget
{
Q_OBJECT
static constexpr int HeaderHeight = 130;
static constexpr int AvatarSize = 68;
static constexpr int AvatarPawnSize = 46;
static constexpr int LeftPad = 14;
static constexpr int AvatarToTextGap = 10;
public:
explicit UserInfoHeaderWidget(QWidget *parent = nullptr);
void setUserData(const ServerInfo_User &user,
bool online,
const QPixmap &avatar,
const QPixmap &cardArt,
const CardArtParams &params);
protected:
void paintEvent(QPaintEvent *e) override;
private:
ServerInfo_User m_user;
bool m_online = false;
QPixmap m_avatar;
QPixmap m_cardArt;
CardArtParams m_params;
};
// ── Main popup ────────────────────────────────────────────────────────────────
/**
* @class UserInfoPopup
* @brief Floating panel showing an enlarged user card, quick action buttons,
* and a live scrollable games list.
*
* Lifecycle (mirrors DeckEditorDeckDockWidget):
* - showForUser() populate, position externally, call show()
* - mouseEnteredPopup / mouseLeftPopup caller manages hide timer
* - closeRequested() emitted by the internal close button
*
* The popup is a Qt::Tool frameless child so windowOpacity animations and
* move() in screen coordinates work identically to CardInfoPictureEnlargedWidget.
*
* Action signals map 1-to-1 to UserContextMenu::exec*() methods so all action
* logic stays in one place.
*/
class UserInfoPopup : public QFrame
{
Q_OBJECT
static constexpr int PopupWidth = 316;
public:
explicit UserInfoPopup(TabSupervisor *tabSupervisor,
AbstractClient *client,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap,
QWidget *parent);
/**
* Populate the popup for @p userName and kick off a game list fetch.
* Call show() / move() externally after this.
*/
void
showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
void fetchGames();
[[nodiscard]] QString currentUser() const
{
return m_currentUser;
}
/** Called when buddy/ignore status changes externally while popup is open. */
void updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
signals:
void mouseEnteredPopup();
void mouseLeftPopup();
void closeRequested();
/** Emitted when the user requests joining or spectating a game in the list. */
void joinGameRequested(int gameId, int roomId, bool asSpectator);
// ── Action signals — connect to UserContextMenu::exec*() ──────────────────
void chatRequested(const QString &userName);
void detailsRequested(const QString &userName);
void showGamesRequested(const QString &userName);
void addBuddyRequested(const QString &userName);
void removeBuddyRequested(const QString &userName);
void addIgnoreRequested(const QString &userName);
void removeIgnoreRequested(const QString &userName);
void banRequested(const QString &userName);
void warnRequested(const QString &userName);
void banHistoryRequested(const QString &userName);
void warnHistoryRequested(const QString &userName);
void adminNotesRequested(const QString &userName);
void promoteToModRequested(const QString &userName);
void demoteFromModRequested(const QString &userName);
void promoteToJudgeRequested(const QString &userName);
void demoteFromJudgeRequested(const QString &userName);
protected:
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
void enterEvent(QEnterEvent *e) override;
#else
void enterEvent(QEvent *e) override;
#endif
void leaveEvent(QEvent *e) override;
private slots:
void refreshGames();
void onGamesReceived(const Response &r, const QString &forUser);
void onGamesContextMenu(const QPoint &pos);
private:
void buildUi();
void rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
TabSupervisor *m_ts;
AbstractClient *m_client;
const QMap<QString, QPixmap> *m_avatarCache;
const QMap<QString, QPixmap> *m_cardArtCache;
const QMap<QString, CardArtParams> *m_cardArtParamsMap;
QString m_currentUser;
ServerInfo_User m_currentUserInfo;
bool m_currentOnline = false;
UserInfoHeaderWidget *m_header;
QWidget *m_actionArea; ///< rebuilt per user
QListView *m_gamesView;
QStandardItemModel *m_gamesModel;
QLabel *m_gamesStatus;
QPushButton *m_closeBtn;
QPushButton *m_refreshBtn;
};
#endif // COCKATRICE_USER_INFO_POPUP_H

View file

@ -42,6 +42,9 @@ void UserListManager::handleDisconnect()
delete ownUserInfo;
ownUserInfo = nullptr;
// Full rebuild — all lists are gone
emit listReset();
}
void UserListManager::setOwnUserInfo(const ServerInfo_User &userInfo)
@ -63,74 +66,77 @@ void UserListManager::processListUsersResponse(const Response &response)
const int userListSize = resp.user_list_size();
for (int i = 0; i < userListSize; ++i) {
const ServerInfo_User &info = resp.user_list(i);
const QString &userName = QString::fromStdString(info.name());
onlineUsers.insert(userName, info);
onlineUsers.insert(QString::fromStdString(info.name()), info);
}
// Bulk load complete — widgets rebuild once from the now-populated map
emit listReset();
}
void UserListManager::processUserJoinedEvent(const Event_UserJoined &event)
{
const auto &info = event.user_info();
const QString &userName = QString::fromStdString(info.name());
onlineUsers.insert(userName, info);
const QString name = QString::fromStdString(info.name());
onlineUsers.insert(name, info);
emit userJoinedOnline(info);
}
void UserListManager::processUserLeftEvent(const Event_UserLeft &event)
{
const auto &userName = QString::fromStdString(event.name());
onlineUsers.remove(userName);
const QString name = QString::fromStdString(event.name());
onlineUsers.remove(name);
emit userLeftOnline(name);
}
void UserListManager::buddyListReceived(const QList<ServerInfo_User> &_buddyList)
{
for (const auto &user : _buddyList) {
const auto &userName = QString::fromStdString(user.name());
buddyUsers.insert(userName, user);
buddyUsers.insert(QString::fromStdString(user.name()), user);
}
// Bulk load — one reset covers all newly added entries
emit listReset();
}
void UserListManager::ignoreListReceived(const QList<ServerInfo_User> &_ignoreList)
{
for (const auto &user : _ignoreList) {
const auto &userName = QString::fromStdString(user.name());
ignoredUsers.insert(userName, user);
ignoredUsers.insert(QString::fromStdString(user.name()), user);
}
// Bulk load — one reset covers all newly added entries
emit listReset();
}
void UserListManager::processAddToListEvent(const Event_AddToList &event)
{
const auto &user = event.user_info();
const auto &userName = QString::fromStdString(user.name());
const QString userName = QString::fromStdString(user.name());
const QString listType = QString::fromStdString(event.list_name());
const auto &userListType = QString::fromStdString(event.list_name());
QMap<QString, ServerInfo_User> *userMap;
if (userListType == "buddy") {
userMap = &buddyUsers;
} else if (userListType == "ignore") {
userMap = &ignoredUsers;
} else {
return;
if (listType == "buddy") {
buddyUsers.insert(userName, user);
emit addedToBuddyList(user);
} else if (listType == "ignore") {
ignoredUsers.insert(userName, user);
emit addedToIgnoreList(user);
}
userMap->insert(userName, user);
}
void UserListManager::processRemoveFromListEvent(const Event_RemoveFromList &event)
{
const auto &userListType = QString::fromStdString(event.list_name());
const auto &userName = QString::fromStdString(event.user_name());
const QString listType = QString::fromStdString(event.list_name());
const QString userName = QString::fromStdString(event.user_name());
QMap<QString, ServerInfo_User> *userMap;
if (userListType == "buddy") {
userMap = &buddyUsers;
} else if (userListType == "ignore") {
userMap = &ignoredUsers;
} else {
return;
if (listType == "buddy") {
buddyUsers.remove(userName);
emit removedFromBuddyList(userName);
} else if (listType == "ignore") {
ignoredUsers.remove(userName);
emit removedFromIgnoreList(userName);
}
userMap->remove(userName);
}
bool UserListManager::isOwnUserRegistered() const
@ -155,16 +161,9 @@ bool UserListManager::isUserIgnored(const QString &userName) const
const ServerInfo_User *UserListManager::getOnlineUser(const QString &userName) const
{
const QString &userNameToMatchLower = userName.toLower();
const auto it =
std::find_if(onlineUsers.begin(), onlineUsers.end(), [&userNameToMatchLower](const ServerInfo_User &user) {
return userNameToMatchLower == QString::fromStdString(user.name()).toLower();
});
if (it != onlineUsers.end()) {
return &*it;
}
return nullptr;
const QString lower = userName.toLower();
const auto it = std::find_if(onlineUsers.begin(), onlineUsers.end(), [&lower](const ServerInfo_User &user) {
return lower == QString::fromStdString(user.name()).toLower();
});
return it != onlineUsers.end() ? &*it : nullptr;
}

View file

@ -47,15 +47,17 @@ public:
explicit UserListManager(AbstractClient *_client, QObject *parent = nullptr);
~UserListManager() override;
[[nodiscard]] QMap<QString, ServerInfo_User> getAllUsersList() const
[[nodiscard]] const QMap<QString, ServerInfo_User> &getAllUsersList() const
{
return onlineUsers;
}
[[nodiscard]] QMap<QString, ServerInfo_User> getBuddyList() const
[[nodiscard]] const QMap<QString, ServerInfo_User> &getBuddyList() const
{
return buddyUsers;
}
[[nodiscard]] QMap<QString, ServerInfo_User> getIgnoreList() const
[[nodiscard]] const QMap<QString, ServerInfo_User> &getIgnoreList() const
{
return ignoredUsers;
}
@ -71,8 +73,26 @@ public slots:
void handleDisconnect();
signals:
void userLeft(const QString &userName);
void userJoined(const ServerInfo_User &userInfo);
/**
* The entire list needs to be rebuilt from scratch.
* Fired on disconnect, reconnect, and initial bulk loads
* (Command_ListUsers response, initial buddy/ignore lists).
*/
void listReset();
// ── Online user presence ──────────────────────────────────────────────────
/** A user came online (or joined the room). Full ServerInfo_User available. */
void userJoinedOnline(const ServerInfo_User &user);
/** A user went offline (or left the room). */
void userLeftOnline(const QString &userName);
// ── Buddy list mutations (individual, post-login) ─────────────────────────
void addedToBuddyList(const ServerInfo_User &user);
void removedFromBuddyList(const QString &userName);
// ── Ignore list mutations (individual, post-login) ────────────────────────
void addedToIgnoreList(const ServerInfo_User &user);
void removedFromIgnoreList(const QString &userName);
};
#endif // COCKATRICE_USER_LIST_MANAGER_H

View file

@ -0,0 +1,342 @@
#include "user_list_painter.h"
#include "../../interface/pixel_map_generator.h"
#include <QAbstractScrollArea>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <QStyle>
#include <QStyleOptionViewItem>
static constexpr int RowHeight = 72;
static constexpr int AvatarSize = 36;
static constexpr int LeftPadding = 14;
static constexpr int TextSpacing = 10;
QSize UserListPainter::sizeHint()
{
return QSize(0, RowHeight);
}
QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool online)
{
QColor accentColor;
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
accentColor = QColor(245, 158, 11);
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
accentColor = QColor(59, 130, 246);
} else if (userLevel.testFlag(ServerInfo_User::IsJudge)) {
accentColor = QColor(168, 85, 247);
} else {
accentColor = QColor(100, 116, 139);
}
if (!online) {
accentColor = accentColor.darker(160);
}
return accentColor;
}
int UserListPainter::getCardRight(const QStyleOptionViewItem &option, const QRect &rect)
{
int scrollBarWidth = 0;
if (const auto *scrollArea = qobject_cast<const QAbstractScrollArea *>(option.widget)) {
const QScrollBar *sb = scrollArea->verticalScrollBar();
if (sb && sb->isVisible()) {
scrollBarWidth = sb->width();
}
}
const int viewportRight = option.widget ? option.widget->width() - scrollBarWidth : rect.right();
return qMin(rect.right(), viewportRight - 4);
}
void UserListPainter::drawBackground(QPainter *painter,
const QRectF &cardRect,
const QColor &accentColor,
bool selected)
{
QLinearGradient bg(cardRect.topLeft(), cardRect.topRight());
bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320));
bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30));
painter->setPen(Qt::NoPen);
painter->setBrush(bg);
painter->drawRoundedRect(cardRect, 6, 6);
painter->setBrush(accentColor);
painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
}
static QString makeKey(const QString &user, const QString &card)
{
return user + u'|' + card;
}
void UserListPainter::drawCardArt(QPainter *painter,
const QRect &rect,
int cardRight,
const QString &userName,
const QMap<QString, QPixmap> *cardArtCache,
const CardArtParams &params,
const QPixmap *overridePixmap = nullptr)
{
QPixmap art;
if (overridePixmap && !overridePixmap->isNull()) {
art = *overridePixmap;
} else {
if (!cardArtCache) {
return;
}
const QString key = makeKey(userName, params.cardName);
if (!cardArtCache->contains(key)) {
return;
}
art = cardArtCache->value(key);
}
if (art.isNull()) {
return;
}
const int cardH = rect.height() - 4;
const int totalW = cardRight - rect.left();
const int marginL = qRound(totalW * params.marginPctL);
const int marginR = qRound(totalW * params.marginPctR);
const int drawW = totalW - marginL - marginR;
const double basescale = qMax(double(drawW) / art.width(), double(cardH) / art.height());
const double scale = basescale * params.zoom;
const int scaledW = qRound(art.width() * scale);
const int scaledH = qRound(art.height() * scale);
const QPixmap scaled = art.scaled(scaledW, scaledH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
const int srcX = (scaledW - drawW) / 2;
const int srcY = qRound((scaledH - cardH) * params.verticalOffset);
// Clamp srcY so we never copy outside the pixmap bounds
const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH));
QImage img =
scaled.copy(srcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
QPainter mask(&img);
mask.setCompositionMode(QPainter::CompositionMode_DestinationIn);
QLinearGradient grad(0, 0, img.width(), 0);
grad.setColorAt(0.00, Qt::transparent);
grad.setColorAt(0.22, Qt::white);
grad.setColorAt(0.78, Qt::white);
grad.setColorAt(1.00, Qt::transparent);
mask.fillRect(img.rect(), grad);
}
painter->setOpacity(0.55);
painter->drawImage(rect.left() + marginL, rect.top() + 2, img);
painter->setOpacity(1.0);
}
QRect UserListPainter::getAvatarRect(const QRect &rect)
{
const int avatarX = rect.left() + LeftPadding;
const int avatarY = rect.top() + (rect.height() - AvatarSize) / 2;
return QRect(avatarX, avatarY, AvatarSize, AvatarSize);
}
void UserListPainter::drawAvatar(QPainter *painter,
const QRect &avatarRect,
const QString &userName,
const QColor &accentColor,
const UserLevelFlags &userLevel,
const ServerInfo_User &userInfo,
const QString &privLevel,
const QMap<QString, QPixmap> *avatarCache)
{
QPainterPath clipPath;
clipPath.addEllipse(avatarRect);
painter->save();
painter->setClipPath(clipPath);
bool drewAvatar = false;
if (avatarCache && avatarCache->contains(userName)) {
const QPixmap &avatar = avatarCache->value(userName);
if (!avatar.isNull()) {
painter->drawPixmap(
avatarRect, avatar.scaled(avatarRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
drewAvatar = true;
}
}
if (!drewAvatar) {
painter->setBrush(accentColor.darker(200));
painter->setPen(Qt::NoPen);
painter->drawEllipse(avatarRect);
const QPixmap pawn =
UserLevelPixmapGenerator::generatePixmap(24, userLevel, userInfo.pawn_colors(), false, privLevel);
painter->drawPixmap(avatarRect.center().x() - 12, avatarRect.center().y() - 12, pawn);
}
painter->restore();
}
void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online)
{
const QColor statusColor = online ? QColor(34, 197, 94) : QColor(70, 80, 95);
painter->setPen(QPen(statusColor, 2));
painter->setBrush(Qt::NoBrush);
painter->drawEllipse(avatarRect.adjusted(-1, -1, 1, 1));
}
void UserListPainter::drawUserName(QPainter *painter,
const QStyleOptionViewItem &option,
const QRect &rect,
int cardRight,
int textX,
const QString &userName,
bool online,
bool selected)
{
QFont nameFont = option.font;
nameFont.setBold(true);
painter->setFont(nameFont);
const QRect nameRect(textX, rect.top() + 8, cardRight - textX - 10, 20);
const QString elidedName = QFontMetrics(nameFont).elidedText(userName, Qt::ElideRight, cardRight - textX - 10);
painter->setPen(QColor(0, 0, 0, 200));
painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName);
painter->setPen(online ? (selected ? Qt::white : QColor(226, 232, 240)) : QColor(90, 100, 115));
painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, elidedName);
}
void UserListPainter::drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo)
{
const QPixmap flag = CountryPixmapGenerator::generatePixmap(13, QString::fromStdString(userInfo.country()));
if (!flag.isNull()) {
painter->drawPixmap(textX, rect.top() + 46, flag);
}
}
QList<UserListPainter::Badge> UserListPainter::buildBadges(const UserLevelFlags &userLevel, const QString &privLevel)
{
QList<Badge> badges;
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
badges << Badge{"ADMIN", QColor(245, 158, 11)};
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
badges << Badge{"MOD", QColor(59, 130, 246)};
} else if (userLevel.testFlag(ServerInfo_User::IsJudge)) {
badges << Badge{"JUDGE", QColor(168, 85, 247)};
}
if (privLevel == "VIP") {
badges << Badge{"VIP", QColor(20, 184, 166)};
} else if (privLevel == "DONATOR") {
badges << Badge{"DONATOR", QColor(249, 115, 22)};
}
return badges;
}
void UserListPainter::drawBadges(QPainter *painter,
const QStyleOptionViewItem &option,
const QRect &rect,
int cardRight,
const QList<Badge> &badges,
bool online)
{
if (badges.isEmpty()) {
return;
}
QFont badgeFont = option.font;
badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.68);
badgeFont.setBold(true);
painter->setFont(badgeFont);
QFontMetrics fm(badgeFont);
int totalBadgeW = 0;
for (const Badge &b : badges) {
totalBadgeW += fm.horizontalAdvance(b.text) + 8 + 4;
}
totalBadgeW -= 4;
int bx = cardRight - 6 - totalBadgeW;
for (const Badge &b : badges) {
const QColor col = online ? b.color : b.color.darker(180);
const int bw = fm.horizontalAdvance(b.text) + 8;
const QRect br(bx, rect.top() + 44, bw, 13);
painter->setPen(Qt::NoPen);
painter->setBrush(col.darker(online ? 160 : 220));
painter->drawRoundedRect(br, 3, 3);
painter->setPen(col.lighter(online ? 160 : 100));
painter->drawText(br, Qt::AlignCenter, b.text);
bx += bw + 4;
}
}
void UserListPainter::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index,
const ServerInfo_User &userInfo,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap)
{
painter->save();
painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
const QRect rect = option.rect;
const bool online = index.data(Qt::UserRole + 1).toBool();
const bool selected = option.state & QStyle::State_Selected;
const UserLevelFlags userLevel(userInfo.user_level());
const QString userName = QString::fromStdString(userInfo.name());
const QString privLevel = QString::fromStdString(userInfo.privlevel());
const QColor accentColor = getAccentColor(userLevel, online);
const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2);
const int cardRight = getCardRight(option, rect);
const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(userName))
? cardArtParamsMap->value(userName)
: CardArtParams{};
drawBackground(painter, cardRect, accentColor, selected);
drawCardArt(painter, rect, cardRight, userName, cardArtCache, params);
const QRect avatarRect = getAvatarRect(rect);
drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache);
drawStatusRing(painter, avatarRect, online);
const int textX = avatarRect.right() + TextSpacing;
drawUserName(painter, option, rect, cardRight, textX, userName, online, selected);
drawCountryFlag(painter, rect, textX, userInfo);
const QList<Badge> badges = buildBadges(userLevel, privLevel);
drawBadges(painter, option, rect, cardRight, badges, online);
painter->restore();
}

View file

@ -0,0 +1,86 @@
#ifndef COCKATRICE_USER_LIST_PAINTER_H
#define COCKATRICE_USER_LIST_PAINTER_H
#include "user_level.h"
#include <QColor>
#include <QList>
#include <QMap>
#include <QPixmap>
#include <QRect>
#include <QSize>
class QPainter;
class QModelIndex;
class QStyleOptionViewItem;
class ServerInfo_User;
struct CardArtParams
{
QString cardName = "";
double marginPctL = 0.33;
double marginPctR = 0.02;
double verticalOffset = 0.35;
double zoom = 1.0;
};
class UserListPainter
{
public:
static void paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index,
const ServerInfo_User &userInfo,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap);
static QSize sizeHint();
static void drawCardArt(QPainter *painter,
const QRect &rect,
int cardRight,
const QString &userName,
const QMap<QString, QPixmap> *cardArtCache,
const CardArtParams &params,
const QPixmap *overridePixmap);
private:
struct Badge
{
QString text;
QColor color;
};
static QColor getAccentColor(const UserLevelFlags &userLevel, bool online);
static int getCardRight(const QStyleOptionViewItem &option, const QRect &rect);
static void drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, bool selected);
static QRect getAvatarRect(const QRect &rect);
static void drawAvatar(QPainter *painter,
const QRect &avatarRect,
const QString &userName,
const QColor &accentColor,
const UserLevelFlags &userLevel,
const ServerInfo_User &userInfo,
const QString &privLevel,
const QMap<QString, QPixmap> *avatarCache);
static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online);
static void drawUserName(QPainter *painter,
const QStyleOptionViewItem &option,
const QRect &rect,
int cardRight,
int textX,
const QString &userName,
bool online,
bool selected);
static void drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo);
static QList<Badge> buildBadges(const UserLevelFlags &userLevel, const QString &privLevel);
static void drawBadges(QPainter *painter,
const QStyleOptionViewItem &option,
const QRect &rect,
int cardRight,
const QList<Badge> &badges,
bool online);
};
#endif // COCKATRICE_USER_LIST_PAINTER_H

View file

@ -1,10 +1,13 @@
#include "user_list_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/widgets/tabs/tab_account.h"
#include "../../interface/widgets/tabs/tab_supervisor.h"
#include "../game_selector.h"
#include "user_context_menu.h"
#include "user_list_painter.h"
#include <QApplication>
#include <QCheckBox>
@ -15,13 +18,18 @@
#include <QLineEdit>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QWidget>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/trice_limits.h>
BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent)
@ -308,7 +316,18 @@ QString AdminNotesDialog::getNotes() const
return notes->toPlainText();
}
UserListItemDelegate::UserListItemDelegate(QObject *const parent) : QStyledItemDelegate(parent)
namespace UserListRoles
{
constexpr int Online = Qt::UserRole + 1;
constexpr int UserInfo = Qt::UserRole + 2;
} // namespace UserListRoles
UserListItemDelegate::UserListItemDelegate(QObject *const parent,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap)
: QStyledItemDelegate(parent), avatarCache(avatarCache), cardArtCache(cardArtCache),
cardArtParamsMap(cardArtParamsMap)
{
}
@ -331,6 +350,32 @@ bool UserListItemDelegate::editorEvent(QEvent *event,
return QStyledItemDelegate::editorEvent(event, model, option, index);
}
QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (!SettingsCache::instance().getStyleUserList()) {
return QStyledItemDelegate::sizeHint(option, index);
}
return UserListPainter::sizeHint();
}
void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (!SettingsCache::instance().getStyleUserList()) {
QStyledItemDelegate::paint(painter, option, index);
return;
}
const QVariant var = index.data(UserListRoles::UserInfo);
if (!var.isValid()) {
QStyledItemDelegate::paint(painter, option, index);
return;
}
UserListPainter::paint(painter, option, index, var.value<ServerInfo_User>(), avatarCache, cardArtCache,
cardArtParamsMap);
}
UserListTWI::UserListTWI(const ServerInfo_User &_userInfo) : QTreeWidgetItem(Type)
{
setUserInfo(_userInfo);
@ -347,11 +392,12 @@ void UserListTWI::setUserInfo(const ServerInfo_User &_userInfo)
setData(2, Qt::UserRole, QString::fromStdString(userInfo.name()));
setData(2, Qt::DisplayRole, QString::fromStdString(userInfo.name()));
setData(3, Qt::InitialSortOrderRole, QString::fromStdString(userInfo.privlevel()));
setData(0, UserListRoles::UserInfo, QVariant::fromValue(userInfo));
}
void UserListTWI::setOnline(bool online)
{
setData(0, Qt::UserRole + 1, online);
setData(0, UserListRoles::Online, online);
setData(2, Qt::ForegroundRole, online ? qApp->palette().brush(QPalette::WindowText) : QBrush(Qt::gray));
}
@ -370,8 +416,8 @@ void UserListTWI::setOnline(bool online)
bool UserListTWI::operator<(const QTreeWidgetItem &other) const
{
// Sort by online/offline
if (data(0, Qt::UserRole + 1) != other.data(0, Qt::UserRole + 1)) {
return data(0, Qt::UserRole + 1).toBool();
if (data(0, UserListRoles::Online) != other.data(0, UserListRoles::Online)) {
return data(0, UserListRoles::Online).toBool();
}
const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt());
@ -418,20 +464,100 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent)
: QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0)
{
itemDelegate = new UserListItemDelegate(this);
avatarProvider = new UserAvatarProvider(client, this);
cardArtProvider = new UserCardArtProvider(this);
itemDelegate =
new UserListItemDelegate(this, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap);
userContextMenu = new UserContextMenu(tabSupervisor, this);
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog);
userTree = new QTreeWidget;
userTree->setColumnCount(3);
userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents);
userTree->setColumnCount(4); // 0=display, 1=flag(hidden), 2=name(hidden), 3=privlevel(hidden)
userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
userTree->header()->setMinimumSectionSize(0);
userTree->setHeaderHidden(true);
userTree->setRootIsDecorated(false);
userTree->setIconSize(QSize(20, 18));
userTree->setItemDelegate(itemDelegate);
userTree->setAlternatingRowColors(true);
userTree->hideColumn(1);
userTree->hideColumn(2);
userTree->hideColumn(3);
connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked);
userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
userTree->header()->setStretchLastSection(true);
// ── Hover popup ───────────────────────────────────────────────────────────
m_userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(),
&cardArtProvider->cache(), &cardArtParamsMap,
window()); // parented to main window so it floats above siblings
m_userInfoPopup->hide();
m_userInfoPopup->setWindowOpacity(0.0);
m_userInfoPopup->installEventFilter(this);
connectPopupSignals();
m_showPopupTimer = new QTimer(this);
m_showPopupTimer->setSingleShot(true);
m_showPopupTimer->setInterval(280);
connect(m_showPopupTimer, &QTimer::timeout, this, [this] {
if (!m_hoveredUser.isEmpty()) {
showPopupForUser(m_hoveredUser);
}
});
m_hidePopupTimer = new QTimer(this);
m_hidePopupTimer->setSingleShot(true);
m_hidePopupTimer->setInterval(160);
connect(m_hidePopupTimer, &QTimer::timeout, this, [this] {
if (!m_popupPinned && !m_userInfoPopup->underMouse() && !userTree->underMouse()) {
hidePopup();
}
});
userTree->setMouseTracking(true);
userTree->viewport()->setMouseTracking(true);
userTree->viewport()->installEventFilter(this);
// Pin on item click
connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) {
if (!SettingsCache::instance().getStyleUserList()) {
return;
}
const QString name = static_cast<UserListTWI *>(item)->getUserInfo().name().c_str();
m_popupPinned = false; // reset so showPopupForUser can update
showPopupForUser(name);
m_popupPinned = true; // pin after showing
});
connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this,
[this](const QItemSelection &sel, const QItemSelection &) {
// if (m_rebuildingTree) return;
if (sel.isEmpty() && m_popupPinned) {
m_popupPinned = false;
hidePopup();
}
});
// Hide popup when list scrolls (reference row has moved)
connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] {
m_showPopupTimer->stop();
hidePopup(true);
});
// Forward join requests from popup upward
connect(m_userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested);
connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this,
[this](const QString &) { userTree->viewport()->update(); });
connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this,
[this](const QString &) { userTree->viewport()->update(); });
connect(&SettingsCache::instance(), &SettingsCache::styleUserListChanged, this, &UserListWidget::applyDisplayMode);
applyDisplayMode();
QVBoxLayout *vbox = new QVBoxLayout;
vbox->addWidget(userTree);
@ -441,6 +567,280 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
retranslateUi();
}
void UserListWidget::bind(UserListManager *mgr)
{
manager = mgr;
// ── Full rebuild: disconnect / reconnect / bulk initial load ──────────────
connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild);
// ── Online users list (AllUsersList / RoomList) ───────────────────────────
if (type == AllUsersList || type == RoomList) {
connect(manager, &UserListManager::userJoinedOnline, this,
[this](const ServerInfo_User &user) { processUserInfo(user, true); });
connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); });
}
// ── Buddy list ────────────────────────────────────────────────────────────
if (type == BuddyList) {
connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) {
const QString name = QString::fromStdString(user.name());
processUserInfo(user, manager->getOnlineUser(name) != nullptr);
});
connect(manager, &UserListManager::removedFromBuddyList, this,
[this](const QString &name) { deleteUser(name); });
// Track online presence changes for buddies already in the tree
connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) {
const QString name = QString::fromStdString(user.name());
if (users.contains(name)) {
users[name]->setUserInfo(user);
setUserOnline(name, true);
}
});
connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) {
if (users.contains(name)) {
setUserOnline(name, false);
}
});
}
// ── Ignore list ───────────────────────────────────────────────────────────
if (type == IgnoreList) {
connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) {
const QString name = QString::fromStdString(user.name());
processUserInfo(user, manager->getOnlineUser(name) != nullptr);
});
connect(manager, &UserListManager::removedFromIgnoreList, this,
[this](const QString &name) { deleteUser(name); });
}
// ── Popup button refresh ──────────────────────────────────────────────────
// Any buddy/ignore mutation while the popup is open refreshes its buttons
auto refreshIfPopupOpen = [this](const QString &name) {
if (m_userInfoPopup && m_userInfoPopup->isVisible() && m_userInfoPopup->currentUser() == name) {
refreshPopupButtons(name);
}
};
auto refreshCurrentPopup = [refreshIfPopupOpen](const ServerInfo_User &u) {
refreshIfPopupOpen(QString::fromStdString(u.name()));
};
connect(manager, &UserListManager::addedToBuddyList, this, refreshCurrentPopup);
connect(manager, &UserListManager::removedFromBuddyList, this, refreshIfPopupOpen);
connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup);
connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen);
connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup);
connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen);
rebuild();
}
void UserListWidget::refreshPopupButtons(const QString &userName)
{
UserListTWI *item = users.value(userName);
if (!item) {
return;
}
const UserListProxy *proxy = tabSupervisor->getUserListManager();
const bool online = item->data(0, UserListRoles::Online).toBool();
const bool isBuddy = proxy->isUserBuddy(userName);
const bool isIgn = proxy->isUserIgnored(userName);
m_userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn);
positionPopup(userName); // height may have changed — reposition
}
void UserListWidget::hideEvent(QHideEvent *e)
{
QGroupBox::hideEvent(e);
m_showPopupTimer->stop();
m_hidePopupTimer->stop();
hidePopup(true);
}
void UserListWidget::applyDisplayMode()
{
const bool styled = SettingsCache::instance().getStyleUserList();
if (styled) {
userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
userTree->hideColumn(1);
userTree->hideColumn(2);
userTree->hideColumn(3);
} else {
userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents);
userTree->showColumn(1);
userTree->showColumn(2);
userTree->hideColumn(3);
}
userTree->viewport()->update();
}
void UserListWidget::connectPopupSignals()
{
connect(m_userInfoPopup, &UserInfoPopup::closeRequested, this, [this] {
m_popupPinned = false;
hidePopup(true);
});
connect(m_userInfoPopup, &UserInfoPopup::mouseEnteredPopup, m_hidePopupTimer, &QTimer::stop);
connect(m_userInfoPopup, &UserInfoPopup::mouseLeftPopup, this, [this] {
if (!m_popupPinned) {
m_hidePopupTimer->start();
}
});
// Wire all action signals to UserContextMenu::exec*()
connect(m_userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat);
connect(m_userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails);
connect(m_userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames);
connect(m_userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy);
connect(m_userInfoPopup, &UserInfoPopup::removeBuddyRequested, userContextMenu,
&UserContextMenu::execRemoveFromBuddy);
connect(m_userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore);
connect(m_userInfoPopup, &UserInfoPopup::removeIgnoreRequested, userContextMenu,
&UserContextMenu::execRemoveFromIgnore);
connect(m_userInfoPopup, &UserInfoPopup::banRequested, userContextMenu, &UserContextMenu::execBan);
connect(m_userInfoPopup, &UserInfoPopup::warnRequested, userContextMenu, &UserContextMenu::execWarn);
connect(m_userInfoPopup, &UserInfoPopup::banHistoryRequested, userContextMenu, &UserContextMenu::execBanHistory);
connect(m_userInfoPopup, &UserInfoPopup::warnHistoryRequested, userContextMenu, &UserContextMenu::execWarnHistory);
connect(m_userInfoPopup, &UserInfoPopup::adminNotesRequested, userContextMenu, &UserContextMenu::execAdminNotes);
connect(m_userInfoPopup, &UserInfoPopup::promoteToModRequested, this,
[this](const QString &n) { userContextMenu->execAdjustMod(n, true, false); });
connect(m_userInfoPopup, &UserInfoPopup::demoteFromModRequested, this,
[this](const QString &n) { userContextMenu->execAdjustMod(n, false, false); });
connect(m_userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this,
[this](const QString &n) { userContextMenu->execAdjustMod(n, false, true); });
connect(m_userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this,
[this](const QString &n) { userContextMenu->execAdjustMod(n, false, false); });
}
bool UserListWidget::eventFilter(QObject *obj, QEvent *event)
{
if (obj == userTree->viewport()) {
if (event->type() == QEvent::MouseMove) {
if (!SettingsCache::instance().getStyleUserList()) {
return QGroupBox::eventFilter(obj, event);
}
auto *me = static_cast<QMouseEvent *>(event);
auto *twi = static_cast<UserListTWI *>(userTree->itemAt(me->pos()));
const QString hovName = twi ? QString::fromStdString(twi->getUserInfo().name()) : QString{};
if (hovName != m_hoveredUser) {
m_hoveredUser = hovName;
if (!hovName.isEmpty()) {
m_hidePopupTimer->stop();
if (!m_popupPinned) {
m_showPopupTimer->start();
}
} else {
m_showPopupTimer->stop();
if (!m_popupPinned) {
m_hidePopupTimer->start();
}
}
}
} else if (event->type() == QEvent::Leave) {
m_hoveredUser.clear();
m_showPopupTimer->stop();
if (!m_popupPinned) {
m_hidePopupTimer->start();
}
}
}
return QGroupBox::eventFilter(obj, event);
}
void UserListWidget::showPopupForUser(const QString &userName)
{
UserListTWI *item = users.value(userName);
if (!item) {
return;
}
const ServerInfo_User &info = item->getUserInfo();
const bool online = item->data(0, UserListRoles::Online).toBool();
const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName);
const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName);
m_userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn);
positionPopup(userName);
m_userInfoPopup->show();
m_userInfoPopup->raise();
// Fade in
m_userInfoPopup->setWindowOpacity(0.0);
auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup);
fade->setDuration(120);
fade->setStartValue(0.0);
fade->setEndValue(1.0);
fade->start(QAbstractAnimation::DeleteWhenStopped);
}
void UserListWidget::positionPopup(const QString &userName)
{
UserListTWI *item = users.value(userName);
if (!item) {
return;
}
QWidget *vp = userTree->viewport();
const QRect itemR = userTree->visualItemRect(item);
const QPoint itemBR = vp->mapToGlobal(itemR.bottomRight());
const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft());
const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight());
// Force a fresh size calculation so popH is accurate
m_userInfoPopup->adjustSize();
const int popW = m_userInfoPopup->width();
const int popH = m_userInfoPopup->height();
const int margin = 12;
const QRect screen = QGuiApplication::primaryScreen()->availableGeometry();
// ── X: left of the list if there's room, otherwise right ─────────────────
int x = (vpTL.x() >= popW + margin) ? vpTL.x() - popW - margin : vpTR.x() + margin;
x = qBound(screen.left() + margin, x, screen.right() - popW - margin);
// ── Y: bottom of popup aligns with bottom of hovered row, grows upward ───
int y = itemBR.y() - popH;
// Clamp: never above the screen top
y = qMax(y, screen.top() + margin);
// Clamp: never below the screen bottom (e.g. if the popup is taller
// than the space above the row, let it spill downward rather than clip)
y = qMin(y, screen.bottom() - popH - margin);
m_userInfoPopup->move(x, y);
}
void UserListWidget::hidePopup(bool immediate)
{
m_showPopupTimer->stop();
m_hidePopupTimer->stop();
if (!m_userInfoPopup->isVisible()) {
return;
}
if (immediate) {
m_userInfoPopup->hide();
return;
}
// Fade out
auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup);
fade->setDuration(100);
fade->setStartValue(m_userInfoPopup->windowOpacity());
fade->setEndValue(0.0);
connect(fade, &QPropertyAnimation::finished, m_userInfoPopup, &QWidget::hide);
fade->start(QAbstractAnimation::DeleteWhenStopped);
}
void UserListWidget::retranslateUi()
{
userContextMenu->retranslateUi();
@ -461,9 +861,59 @@ void UserListWidget::retranslateUi()
updateCount();
}
void UserListWidget::rebuild()
{
userTree->clear();
users.clear();
cardArtParamsMap.clear();
onlineCount = 0;
if (!manager) {
return;
}
const QMap<QString, ServerInfo_User> *source = nullptr;
switch (type) {
case AllUsersList:
case RoomList:
source = &manager->getAllUsersList();
break;
case BuddyList:
source = &manager->getBuddyList();
break;
case IgnoreList:
source = &manager->getIgnoreList();
break;
}
for (auto it = source->cbegin(); it != source->cend(); ++it) {
processUserInfo(it.value(), manager->getOnlineUser(it.key()) != nullptr);
}
sortItems();
}
void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
{
const QString userName = QString::fromStdString(user.name());
// Always update params from the latest ServerInfo_User, whether the
// item is new or existing, so a live server-push refreshes the rendering.
if (user.has_card_art_params()) {
const auto &cap = user.card_art_params();
CardArtParams params;
params.cardName = QString::fromStdString(cap.card_name());
params.marginPctL = cap.margin_pct_l();
params.marginPctR = cap.margin_pct_r();
params.verticalOffset = cap.vertical_offset();
params.zoom = cap.zoom();
cardArtParamsMap.insert(userName, params);
cardArtProvider->requestCardArt(userName, params.cardName);
} else {
cardArtParamsMap.remove(userName); // clear stale params on removal
}
UserListTWI *item = users.value(userName);
if (item) {
item->setUserInfo(user);
@ -475,25 +925,28 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
++onlineCount;
}
updateCount();
avatarProvider->requestAvatar(userName);
}
item->setOnline(online);
sortItems();
userTree->viewport()->update();
}
bool UserListWidget::deleteUser(const QString &userName)
{
UserListTWI *twi = users.value(userName);
if (twi) {
users.remove(userName);
userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi));
if (twi->data(0, Qt::UserRole + 1).toBool()) {
--onlineCount;
}
delete twi;
updateCount();
return true;
if (!twi) {
return false;
}
return false;
users.remove(userName);
userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi));
if (twi->data(0, Qt::UserRole + 1).toBool()) {
--onlineCount;
}
delete twi;
updateCount();
return true;
}
void UserListWidget::setUserOnline(const QString &userName, bool online)
@ -537,5 +990,5 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index
void UserListWidget::sortItems()
{
userTree->sortItems(1, Qt::AscendingOrder);
userTree->sortItems(0, Qt::AscendingOrder);
}

View file

@ -7,9 +7,17 @@
#ifndef USERLIST_H
#define USERLIST_H
#include "../../cards/card_info_picture_art_crop_widget.h"
#include "user_avatar_provider.h"
#include "user_card_art_provider.h"
#include "user_info_popup.h"
#include "user_list_manager.h"
#include "user_list_painter.h"
#include <QComboBox>
#include <QDialog>
#include <QGroupBox>
#include <QQueue>
#include <QStyledItemDelegate>
#include <QTextEdit>
#include <QTreeWidgetItem>
@ -94,12 +102,21 @@ public:
class UserListItemDelegate : public QStyledItemDelegate
{
const QMap<QString, QPixmap> *avatarCache;
const QMap<QString, QPixmap> *cardArtCache;
const QMap<QString, CardArtParams> *cardArtParamsMap;
public:
explicit UserListItemDelegate(QObject *const parent);
explicit UserListItemDelegate(QObject *const parent,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap);
bool editorEvent(QEvent *event,
QAbstractItemModel *model,
const QStyleOptionViewItem &option,
const QModelIndex &index) override;
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
};
class UserListTWI : public QTreeWidgetItem
@ -131,6 +148,22 @@ public:
};
private:
UserListManager *manager = nullptr;
UserAvatarProvider *avatarProvider = nullptr;
UserCardArtProvider *cardArtProvider = nullptr;
QMap<QString, CardArtParams> cardArtParamsMap;
// ── Hover popup ───────────────────────────────────────────────────────────
UserInfoPopup *m_userInfoPopup = nullptr;
QTimer *m_showPopupTimer = nullptr;
QTimer *m_hidePopupTimer = nullptr;
QString m_hoveredUser;
bool m_popupPinned = false;
void showPopupForUser(const QString &userName);
void hidePopup(bool immediate = false);
void positionPopup(const QString &userName);
void connectPopupSignals();
QMap<QString, UserListTWI *> users;
TabSupervisor *tabSupervisor;
AbstractClient *client;
@ -141,6 +174,7 @@ private:
int onlineCount;
QString titleStr;
void updateCount();
void refreshPopupButtons(const QString &userName);
private slots:
void userClicked(QTreeWidgetItem *item, int column);
signals:
@ -149,13 +183,18 @@ signals:
void removeBuddy(const QString &userName);
void addIgnore(const QString &userName);
void removeIgnore(const QString &userName);
void joinGameRequested(int gameId, int roomId, bool asSpectator);
public:
UserListWidget(TabSupervisor *_tabSupervisor,
AbstractClient *_client,
UserListType _type,
QWidget *parent = nullptr);
void bind(UserListManager *mgr);
void applyDisplayMode();
bool eventFilter(QObject *obj, QEvent *event) override;
void retranslateUi();
void rebuild();
void processUserInfo(const ServerInfo_User &user, bool online);
bool deleteUser(const QString &userName);
void setUserOnline(const QString &userName, bool online);
@ -165,6 +204,9 @@ public:
}
void showContextMenu(const QPoint &pos, const QModelIndex &index);
void sortItems();
protected:
void hideEvent(QHideEvent *e) override;
};
#endif

View file

@ -111,6 +111,15 @@ AppearanceSettingsPage::AppearanceSettingsPage()
homeTabGroupBox = new QGroupBox;
homeTabGroupBox->setLayout(homeTabGrid);
styleUserListCheckBox.setChecked(settings.getStyleUserList());
connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setStyleUserList);
auto stylingTabGrid = new QGridLayout;
stylingTabGrid->addWidget(&styleUserListCheckBox, 0, 0, 1, 2);
stylingGroupBox = new QGroupBox;
stylingGroupBox->setLayout(stylingTabGrid);
// Menu settings
showShortcutsCheckBox.setChecked(settings.getShowShortcuts());
connect(&showShortcutsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::showShortcutsChanged);
@ -284,6 +293,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(themeGroupBox);
mainLayout->addWidget(homeTabGroupBox);
mainLayout->addWidget(stylingGroupBox);
mainLayout->addWidget(menuGroupBox);
mainLayout->addWidget(printingsGroupBox);
mainLayout->addWidget(cardsGroupBox);
@ -398,6 +408,9 @@ void AppearanceSettingsPage::retranslateUi()
homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled"));
homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right"));
stylingGroupBox->setTitle(tr("Styling settings"));
styleUserListCheckBox.setText(tr("Style user list"));
menuGroupBox->setTitle(tr("Menu settings"));
showShortcutsCheckBox.setText(tr("Show keyboard shortcuts in right-click menus"));
showGameSelectorFilterToolbarCheckBox.setText(tr("Show game filter toolbar above list in room tab"));

View file

@ -37,6 +37,7 @@ private:
QLabel homeTabBackgroundShuffleFrequencyLabel;
QSpinBox homeTabBackgroundShuffleFrequencySpinBox;
QCheckBox homeTabDisplayCardNameCheckBox;
QCheckBox styleUserListCheckBox;
QLabel minPlayersForMultiColumnLayoutLabel;
QLabel maxFontSizeForCardsLabel;
QCheckBox showShortcutsCheckBox;
@ -58,6 +59,7 @@ private:
QCheckBox invertVerticalCoordinateCheckBox;
QGroupBox *themeGroupBox;
QGroupBox *homeTabGroupBox;
QGroupBox *stylingGroupBox;
QGroupBox *menuGroupBox;
QGroupBox *printingsGroupBox;
QGroupBox *cardsGroupBox;

View file

@ -72,6 +72,10 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(),
[](const QT_STATE_CHANGED_T state) { SettingsCache::instance().setUseTearOffMenus(state == Qt::Checked); });
keepGameChatFocusCheckBox.setChecked(SettingsCache::instance().getKeepGameChatFocus());
connect(&keepGameChatFocusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(),
&SettingsCache::setKeepGameChatFocus);
auto *generalGrid = new QGridLayout;
generalGrid->addWidget(&doubleClickToPlayCheckBox, 0, 0);
generalGrid->addWidget(&clickPlaysAllSelectedCheckBox, 1, 0);
@ -83,6 +87,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
generalGrid->addWidget(&showDragSelectionCountCheckBox, 7, 0);
generalGrid->addWidget(&showTotalSelectionCountCheckBox, 8, 0);
generalGrid->addWidget(&useTearOffMenusCheckBox, 9, 0);
generalGrid->addWidget(&keepGameChatFocusCheckBox, 10, 0);
generalGroupBox = new QGroupBox;
generalGroupBox->setLayout(generalGrid);
@ -207,6 +212,9 @@ void UserInterfaceSettingsPage::retranslateUi()
showDragSelectionCountCheckBox.setText(tr("Show selection counter during drag selection"));
showTotalSelectionCountCheckBox.setText(tr("Show total selection counter"));
useTearOffMenusCheckBox.setText(tr("Use tear-off menus, allowing right click menus to persist on screen"));
keepGameChatFocusCheckBox.setText(
tr("Keep game chat focused when clicking in game (Note: disables card view search bar)"));
notificationsGroupBox->setTitle(tr("Notifications settings"));
notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar"));
specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating"));

View file

@ -30,6 +30,7 @@ private:
QCheckBox showDragSelectionCountCheckBox;
QCheckBox showTotalSelectionCountCheckBox;
QCheckBox useTearOffMenusCheckBox;
QCheckBox keepGameChatFocusCheckBox;
QCheckBox tapAnimationCheckBox;
QCheckBox openDeckInNewTabCheckBox;
QLabel visualDeckStoragePromptForConversionLabel;

View file

@ -0,0 +1,246 @@
#include "tab_card_art_rules.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QCompleter>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pending_command.h>
CardArtRulesModel::CardArtRulesModel(AbstractClient *client, QObject *parent)
: QAbstractTableModel(parent), client(client)
{
}
int CardArtRulesModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return static_cast<int>(entries.size());
}
int CardArtRulesModel::columnCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return 3;
}
QVariant CardArtRulesModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid()) {
return {};
}
const auto &e = entries.at(index.row());
if (role == Qt::DisplayRole) {
switch (index.column()) {
case 0:
return e.cardName;
case 1:
return e.mode;
case 2:
return e.reason;
}
}
return {};
}
QVariant CardArtRulesModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (orientation != Qt::Horizontal || role != Qt::DisplayRole) {
return {};
}
switch (section) {
case 0:
return tr("Card");
case 1:
return tr("Mode");
case 2:
return tr("Reason");
default:
return {};
}
}
void CardArtRulesModel::refresh()
{
Command_ListCardArtRules cmd;
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &CardArtRulesModel::onRefreshFinished);
client->sendCommand(pend);
}
void CardArtRulesModel::clear()
{
beginResetModel();
entries.clear();
endResetModel();
}
QString CardArtRulesModel::cardAt(int row) const
{
if (row < 0 || row >= static_cast<int>(entries.size())) {
return {};
}
return entries[row].cardName;
}
void CardArtRulesModel::onRefreshFinished(const Response &r)
{
if (r.response_code() != Response::RespOk) {
return;
}
const auto &resp = r.GetExtension(Response_ListCardArtRules::ext);
beginResetModel();
entries.clear();
for (const auto &e : resp.entries()) {
entries.push_back({QString::fromStdString(e.card_name()), QString::fromStdString(e.mode()),
QString::fromStdString(e.reason())});
}
endResetModel();
}
TabCardArtRules::TabCardArtRules(TabSupervisor *parent, AbstractClient *_client) : Tab(parent), client(_client)
{
setupUi();
refresh();
}
void TabCardArtRules::setupUi()
{
auto *central = new QWidget(this);
initSearchBar();
modeBox = new QComboBox;
reasonEdit = new QLineEdit;
addBtn = new QPushButton;
removeBtn = new QPushButton;
refreshBtn = new QPushButton;
modeBox->addItems({"ALLOW", "DENY"});
tableModel = new CardArtRulesModel(client, this);
table = new QTableView;
table->setModel(tableModel);
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
auto *form = new QFormLayout;
form->addRow(tr("Card:"), searchEdit);
form->addRow(tr("Mode:"), modeBox);
form->addRow(tr("Reason:"), reasonEdit);
auto *buttons = new QHBoxLayout;
buttons->addWidget(addBtn);
buttons->addWidget(removeBtn);
buttons->addWidget(refreshBtn);
auto *layout = new QVBoxLayout;
layout->addLayout(form);
layout->addLayout(buttons);
layout->addWidget(table);
central->setLayout(layout);
setCentralWidget(central);
connect(addBtn, &QPushButton::clicked, this, &TabCardArtRules::addRule);
connect(removeBtn, &QPushButton::clicked, this, &TabCardArtRules::removeSelected);
connect(refreshBtn, &QPushButton::clicked, this, &TabCardArtRules::refresh);
retranslateUi();
}
void TabCardArtRules::initSearchBar()
{
searchEdit = new QLineEdit;
searchEdit->setPlaceholderText(tr("Type a card name..."));
cardDbModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDbDisplayModel = new CardDatabaseDisplayModel(this);
cardDbDisplayModel->setSourceModel(cardDbModel);
cardSearchModel = new CardSearchModel(cardDbDisplayModel, this);
cardProxyModel = new CardCompleterProxyModel(this);
cardProxyModel->setSourceModel(cardSearchModel);
cardProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
searchCompleter = new QCompleter(cardProxyModel, this);
searchCompleter->setCompletionRole(Qt::DisplayRole);
searchCompleter->setCompletionMode(QCompleter::PopupCompletion);
searchCompleter->setCaseSensitivity(Qt::CaseInsensitive);
searchCompleter->setFilterMode(Qt::MatchContains);
searchCompleter->setMaxVisibleItems(15);
searchEdit->setCompleter(searchCompleter);
connect(searchEdit, &QLineEdit::textEdited, cardSearchModel, &CardSearchModel::updateSearchResults);
connect(searchEdit, &QLineEdit::textEdited, this, [this](const QString &text) {
const QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
cardProxyModel->setFilterRegularExpression(
QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
searchCompleter->complete();
}
});
connect(searchCompleter, static_cast<void (QCompleter::*)(const QString &)>(&QCompleter::activated), this,
[this](const QString &name) { searchEdit->setText(name); });
}
void TabCardArtRules::retranslateUi()
{
addBtn->setText(tr("Add rule"));
removeBtn->setText(tr("Remove rule"));
refreshBtn->setText(tr("Refresh"));
}
void TabCardArtRules::refresh()
{
tableModel->refresh();
}
void TabCardArtRules::addRule()
{
Command_AddCardArtRule cmd;
cmd.set_card_name(searchEdit->text().toStdString());
cmd.set_mode(modeBox->currentText().toStdString());
cmd.set_reason(reasonEdit->text().toStdString());
client->sendCommand(client->prepareModeratorCommand(cmd));
refresh();
}
void TabCardArtRules::removeSelected()
{
QModelIndex idx = table->currentIndex();
if (!idx.isValid()) {
return;
}
Command_RemoveCardArtRule cmd;
cmd.set_card_name(tableModel->cardAt(idx.row()).toStdString());
client->sendCommand(client->prepareModeratorCommand(cmd));
refresh();
}

View file

@ -0,0 +1,89 @@
#ifndef COCKATRICE_DLG_CARD_ART_RULES_H
#define COCKATRICE_DLG_CARD_ART_RULES_H
#include "card/card_search_model.h"
#include "tab_supervisor.h"
#include <QAbstractTableModel>
#include <QComboBox>
#include <QLineEdit>
#include <QPushButton>
#include <QTableView>
class AbstractClient;
class CardArtRulesModel : public QAbstractTableModel
{
Q_OBJECT
public:
struct Entry
{
QString cardName;
QString mode;
QString reason;
};
explicit CardArtRulesModel(AbstractClient *client, QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
void refresh();
void clear();
QString cardAt(int row) const;
private slots:
void onRefreshFinished(const Response &r);
private:
AbstractClient *client;
std::vector<Entry> entries;
};
class TabCardArtRules : public Tab
{
Q_OBJECT
public:
TabCardArtRules(TabSupervisor *parent, AbstractClient *client);
QString getTabText() const override
{
return tr("Card Art Rules");
}
void retranslateUi() override;
private:
void setupUi();
private slots:
void addRule();
void removeSelected();
void refresh();
private:
AbstractClient *client;
QLineEdit *searchEdit;
void initSearchBar();
QCompleter *searchCompleter;
CardDatabaseModel *cardDbModel;
CardDatabaseDisplayModel *cardDbDisplayModel;
CardSearchModel *cardSearchModel;
CardCompleterProxyModel *cardProxyModel;
QComboBox *modeBox;
QLineEdit *reasonEdit;
QPushButton *addBtn;
QPushButton *removeBtn;
QPushButton *refreshBtn;
QTableView *table;
CardArtRulesModel *tableModel;
};
#endif // COCKATRICE_DLG_CARD_ART_RULES_H

View file

@ -49,10 +49,25 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
QMap<int, GameTypeMap> tempMap;
tempMap.insert(info.room_id(), gameTypes);
gameSelector = new GameSelector(client, tabSupervisor, this, QMap<int, QString>(), tempMap, true, true);
auto *tabs = new QTabWidget(this);
friendsList = new UserListWidget(tabSupervisor, client, UserListWidget::BuddyList);
friendsList->bind(tabSupervisor->getUserListManager());
userList = new UserListWidget(tabSupervisor, client, UserListWidget::RoomList);
userList->bind(tabSupervisor->getUserListManager());
ignoreList = new UserListWidget(tabSupervisor, client, UserListWidget::IgnoreList);
ignoreList->bind(tabSupervisor->getUserListManager());
connect(friendsList, SIGNAL(openMessageDialog(const QString &, bool)), this,
SIGNAL(openMessageDialog(const QString &, bool)));
connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this,
SIGNAL(openMessageDialog(const QString &, bool)));
tabs->addTab(friendsList, tr("Friends"));
tabs->addTab(userList, tr("Online"));
tabs->addTab(ignoreList, tr("Ignored"));
chatView = new ChatView(tabSupervisor, nullptr, true, this);
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
@ -101,7 +116,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
auto *hbox = new QHBoxLayout;
hbox->addWidget(splitter, 3);
hbox->addWidget(userList, 1);
hbox->addWidget(tabs, 1);
aLeaveRoom = new QAction(this);
connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest);
@ -112,10 +127,8 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
const int userListSize = info.user_list_size();
for (int i = 0; i < userListSize; ++i) {
userList->processUserInfo(info.user_list(i), true);
autocompleteUserList.append("@" + QString::fromStdString(info.user_list(i).name()));
}
userList->sortItems();
const int gameListSize = info.game_list_size();
for (int i = 0; i < gameListSize; ++i) {
@ -269,8 +282,6 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event)
void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event)
{
userList->processUserInfo(event.user_info(), true);
userList->sortItems();
if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) {
autocompleteUserList << "@" + QString::fromStdString(event.user_info().name());
sayEdit->setCompletionList(autocompleteUserList);
@ -279,7 +290,6 @@ void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event)
void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event)
{
userList->deleteUser(QString::fromStdString(event.name()));
autocompleteUserList.removeOne("@" + QString::fromStdString(event.name()));
sayEdit->setCompletionList(autocompleteUserList);
}

View file

@ -56,7 +56,9 @@ private:
QMap<int, QString> gameTypes;
GameSelector *gameSelector;
UserListWidget *friendsList;
UserListWidget *userList;
UserListWidget *ignoreList;
const UserListProxy *userListProxy;
ChatView *chatView;
QLabel *sayLabel;

View file

@ -9,6 +9,7 @@
#include "api/edhrec/tab_edhrec_main.h"
#include "tab_account.h"
#include "tab_admin.h"
#include "tab_card_art_rules.h"
#include "tab_deck_editor.h"
#include "tab_deck_storage.h"
#include "tab_game.h"
@ -179,6 +180,10 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
aTabAdmin->setCheckable(true);
connect(aTabAdmin, &QAction::triggered, this, &TabSupervisor::actTabAdmin);
aTabCardArtRules = new QAction(this);
aTabCardArtRules->setCheckable(true);
connect(aTabCardArtRules, &QAction::triggered, this, &TabSupervisor::actTabCardArtRules);
aTabLog = new QAction(this);
aTabLog->setCheckable(true);
connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog);
@ -435,6 +440,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
tabsMenu->addSeparator();
tabsMenu->addAction(aTabAdmin);
tabsMenu->addAction(aTabLog);
tabsMenu->addAction(aTabCardArtRules);
if (SettingsCache::instance().getTabAdminOpen()) {
openTabAdmin();
@ -442,6 +448,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
if (SettingsCache::instance().getTabLogOpen()) {
openTabLog();
}
openTabCardArtRules();
}
retranslateUi();
@ -681,6 +688,30 @@ void TabSupervisor::openTabAdmin()
aTabAdmin->setChecked(true);
}
void TabSupervisor::actTabCardArtRules(bool checked)
{
if (checked && !tabCardArtRules) {
openTabCardArtRules();
setCurrentWidget(tabCardArtRules);
} else if (!checked && tabCardArtRules) {
tabCardArtRules->closeRequest();
}
}
void TabSupervisor::openTabCardArtRules()
{
tabCardArtRules = new TabCardArtRules(this, client);
myAddTab(tabCardArtRules, aTabCardArtRules);
connect(tabCardArtRules, &QObject::destroyed, this, [this] {
tabCardArtRules = nullptr;
aTabCardArtRules->setChecked(false);
});
aTabCardArtRules->setChecked(true);
}
void TabSupervisor::actTabLog(bool checked)
{
SettingsCache::instance().setTabLogOpen(checked);

View file

@ -24,6 +24,7 @@
#include <QProxyStyle>
#include <QTabWidget>
class TabCardArtRules;
inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor");
class UserListManager;
@ -103,6 +104,7 @@ private:
TabDeckStorage *tabDeckStorage;
TabReplays *tabReplays;
TabAdmin *tabAdmin;
TabCardArtRules *tabCardArtRules;
TabLog *tabLog;
QMap<int, TabRoom *> roomTabs;
QMap<int, TabGame *> gameTabs;
@ -112,7 +114,8 @@ private:
bool isLocalGame;
QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage,
*aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, *aTabLog;
*aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin,
*aTabCardArtRules, *aTabLog;
int myAddTab(Tab *tab, QAction *manager = nullptr);
void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager);
@ -145,7 +148,7 @@ public:
return userInfo;
}
[[nodiscard]] AbstractClient *getClient() const;
[[nodiscard]] const UserListManager *getUserListManager() const
[[nodiscard]] UserListManager *getUserListManager() const
{
return userListManager;
}
@ -197,6 +200,8 @@ private slots:
void openTabDeckStorage();
void openTabReplays();
void openTabAdmin();
void actTabCardArtRules(bool checked);
void openTabCardArtRules();
void openTabLog();
void updateCurrent(int index);

View file

@ -92,7 +92,7 @@ void CardDatabase::refreshCachedReverseRelatedCards()
}
}
void CardDatabase::addCard(CardInfoPtr card)
void CardDatabase::addCard(const CardInfoPtr &card)
{
if (card == nullptr) {
qCWarning(CardDatabaseLog) << "CardDatabase::addCard(nullptr)";
@ -118,7 +118,7 @@ void CardDatabase::addCard(CardInfoPtr card)
emit cardAdded(card);
}
void CardDatabase::removeCard(CardInfoPtr card)
void CardDatabase::removeCard(const CardInfoPtr &card)
{
if (card.isNull()) {
qCWarning(CardDatabaseLog) << "CardDatabase::removeCard(nullptr)";
@ -143,7 +143,7 @@ void CardDatabase::removeCard(CardInfoPtr card)
emit cardRemoved(card);
}
void CardDatabase::addSet(CardSetPtr set)
void CardDatabase::addSet(const CardSetPtr &set)
{
sets.insert(set->getShortName(), set);
}
@ -215,7 +215,7 @@ void CardDatabase::notifyEnabledSetsChanged()
emit cardDatabaseEnabledSetsChanged();
}
void CardDatabase::addFormat(FormatRulesPtr format)
void CardDatabase::addFormat(const FormatRulesPtr &format)
{
formats.insert(format->formatName.toLower(), format);
}
}

View file

@ -88,7 +88,7 @@ public:
* @brief Removes a card from the database.
* @param card Pointer to the card to remove.
*/
void removeCard(CardInfoPtr card);
void removeCard(const CardInfoPtr &card);
/** @brief Clears all cards, sets, and internal state. */
void clear();
@ -140,15 +140,15 @@ public slots:
* @brief Adds a card to the database.
* @param card CardInfoPtr to add.
*/
void addCard(CardInfoPtr card);
void addCard(const CardInfoPtr &card);
/**
* @brief Adds a set to the database.
* @param set Pointer to CardSet to add.
*/
void addSet(CardSetPtr set);
void addSet(const CardSetPtr &set);
void addFormat(FormatRulesPtr format);
void addFormat(const FormatRulesPtr &format);
/** @brief Loads card databases from configured paths. */
void loadCardDatabases();

View file

@ -217,27 +217,32 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml)
// NOTE: attributes must be read before readElementText()
QXmlStreamAttributes attrs = xml.attributes();
QString setName = xml.readElementText(QXmlStreamReader::IncludeChildElements);
PrintingInfo setInfo(internalAddSet(setName));
if (attrs.hasAttribute("muId")) {
setInfo.setProperty("muid", attrs.value("muId").toString());
}
auto set = internalAddSet(setName);
// Only load printings from sets the user has enabled, matching the v4 loader's
// behaviour. Without this check, disabling a set has no effect on v3 databases.
if (set->getEnabled()) {
PrintingInfo setInfo(set);
if (attrs.hasAttribute("muId")) {
setInfo.setProperty("muid", attrs.value("muId").toString());
}
if (attrs.hasAttribute("muId")) {
setInfo.setProperty("uuid", attrs.value("uuId").toString());
}
if (attrs.hasAttribute("uuId")) {
setInfo.setProperty("uuid", attrs.value("uuId").toString());
}
if (attrs.hasAttribute("picURL")) {
setInfo.setProperty("picurl", attrs.value("picURL").toString());
}
if (attrs.hasAttribute("picURL")) {
setInfo.setProperty("picurl", attrs.value("picURL").toString());
}
if (attrs.hasAttribute("num")) {
setInfo.setProperty("num", attrs.value("num").toString());
}
if (attrs.hasAttribute("num")) {
setInfo.setProperty("num", attrs.value("num").toString());
}
if (attrs.hasAttribute("rarity")) {
setInfo.setProperty("rarity", attrs.value("rarity").toString());
if (attrs.hasAttribute("rarity")) {
setInfo.setProperty("rarity", attrs.value("rarity").toString());
}
_sets[setName].append(setInfo);
}
_sets[setName].append(setInfo);
// related cards
} else if (xmlName == "related" || xmlName == "reverse-related") {
CardRelationType attach = CardRelationType::DoesNotAttach;

View file

@ -190,6 +190,25 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session,
return authState;
}
void Server::broadcastUserInfoUpdate(Server_ProtocolHandler *source)
{
Event_UserJoined event;
event.mutable_user_info()->CopyFrom(source->copyUserInfo(false));
SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event);
clientsLock.lockForRead();
for (auto &client : clients) {
if (client->getAcceptsUserListChanges()) {
client->sendProtocolItem(*se);
}
}
clientsLock.unlock();
sendIsl_SessionEvent(*se);
delete se;
}
void Server::addPersistentPlayer(const QString &userName, int roomId, int gameId, int playerId)
{
QWriteLocker locker(&persistentPlayersLock);

View file

@ -64,6 +64,7 @@ public:
QString &clientid,
QString &clientVersion,
QString &connectionType);
void broadcastUserInfoUpdate(Server_ProtocolHandler *source);
const QMap<int, Server_Room *> &getRooms()
{

View file

@ -122,6 +122,7 @@ set(PROTO_FILES
response_activate.proto
response_adjust_mod.proto
response_ban_history.proto
response_card_art_rule_entry.proto
response_deck_download.proto
response_deck_list.proto
response_deck_upload.proto

View file

@ -11,6 +11,9 @@ message ModeratorCommand {
FORCE_ACTIVATE_USER = 1007;
GET_ADMIN_NOTES = 1008;
UPDATE_ADMIN_NOTES = 1009;
ADD_CARD_ART_RULE = 1010;
REMOVE_CARD_ART_RULE = 1011;
LIST_CARD_ART_RULES = 1012;
}
extensions 100 to max;
}
@ -106,3 +109,27 @@ message Command_UpdateAdminNotes {
optional string user_name = 1;
optional string notes = 2;
}
message Command_AddCardArtRule {
extend ModeratorCommand {
optional Command_AddCardArtRule ext = 1010;
}
optional string card_name = 1;
optional string mode = 2; // "ALLOW" or "DENY"
optional string reason = 3;
}
message Command_RemoveCardArtRule {
extend ModeratorCommand {
optional Command_RemoveCardArtRule ext = 1011;
}
optional string card_name = 1;
}
message Command_ListCardArtRules {
extend ModeratorCommand {
optional Command_ListCardArtRules ext = 1012;
}
}

View file

@ -68,6 +68,7 @@ message Response {
REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101;
REPLAY_GET_CODE = 1102;
CARD_ART_RULE_LIST = 1200;
}
required uint64 cmd_id = 1;
optional ResponseCode response_code = 2;

View file

@ -0,0 +1,15 @@
syntax = "proto2";
import "response.proto";
message Response_CardArtRuleEntry {
optional string card_name = 1;
optional string mode = 2;
optional string reason = 3;
}
message Response_ListCardArtRules {
extend Response {
optional Response_ListCardArtRules ext = 1200;
}
repeated Response_CardArtRuleEntry entries = 1;
}

View file

@ -1,4 +1,5 @@
syntax = "proto2";
message ServerInfo_User {
enum UserLevelFlag {
IsNothing = 0;
@ -12,6 +13,13 @@ message ServerInfo_User {
optional string left_side = 1;
optional string right_side = 2;
};
message CardArtParams {
optional string card_name = 1;
optional double margin_pct_l = 2 [default = 0.33];
optional double margin_pct_r = 3 [default = 0.02];
optional double vertical_offset = 4 [default = 0.35];
optional double zoom = 5 [default = 1.0];
};
optional string name = 1;
optional uint32 user_level = 2;
@ -28,4 +36,5 @@ message ServerInfo_User {
optional string clientid = 13;
optional string privlevel = 14;
optional PawnColorsOverride pawn_colors = 15;
}
optional CardArtParams card_art_params = 16;
}

View file

@ -27,6 +27,7 @@ message SessionCommand {
FORGOT_PASSWORD_RESET = 1022;
FORGOT_PASSWORD_CHALLENGE = 1023;
REQUEST_PASSWORD_SALT = 1024;
SET_CARD_ART_PARAMS = 1025;
REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101;
REPLAY_MODIFY_MATCH = 1102;
@ -205,3 +206,14 @@ message Command_RequestPasswordSalt {
}
required string user_name = 1;
}
message Command_SetCardArtParams {
extend SessionCommand {
optional Command_SetCardArtParams ext = 1025;
}
optional string card_name = 1;
optional double margin_pct_l = 2;
optional double margin_pct_r = 3;
optional double vertical_offset = 4;
optional double zoom = 5;
}

View file

@ -17,6 +17,7 @@ set(UTILITY_HEADERS
libcockatrice/utility/passwordhasher.h
libcockatrice/utility/trice_limits.h
libcockatrice/utility/zone_names.h
libcockatrice/utility/days_years_between.h
)
add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS})

View file

@ -1,3 +1,6 @@
#ifndef COCKATRICE_DAYS_YEARS_BETWEEN_H
#define COCKATRICE_DAYS_YEARS_BETWEEN_H
#include <QDateTime>
inline static QPair<int, int> getDaysAndYearsBetween(const QDate &then, const QDate &now)
@ -6,3 +9,5 @@ inline static QPair<int, int> getDaysAndYearsBetween(const QDate &then, const QD
int days = then.addYears(years).daysTo(now);
return {days, years};
}
#endif // COCKATRICE_DAYS_YEARS_BETWEEN_H

View file

@ -63,7 +63,7 @@
<location filename="src/pages.cpp" line="228"/>
<source>Sets file (%1)</source>
<oldsource>Sets JSON file (%1)</oldsource>
<translation type="unfinished"/>
<translation>Archivo de ediciones (%1)</translation>
</message>
<message>
<location filename="src/pages.cpp" line="258"/>
@ -172,7 +172,7 @@
<message>
<location filename="src/pages.cpp" line="726"/>
<source>spoiler</source>
<translation type="unfinished"/>
<translation>spoiler</translation>
</message>
<message>
<location filename="src/pages.cpp" line="731"/>
@ -192,7 +192,7 @@
<message>
<location filename="src/pages.cpp" line="735"/>
<source>Local file:</source>
<translation type="unfinished"/>
<translation>Archivo local:</translation>
</message>
<message>
<location filename="src/pages.cpp" line="736"/>
@ -202,7 +202,7 @@
<message>
<location filename="src/pages.cpp" line="737"/>
<source>Choose file...</source>
<translation type="unfinished"/>
<translation>Elegir archivo...</translation>
</message>
<message>
<location filename="src/pages.cpp" line="739"/>
@ -230,7 +230,7 @@
<message>
<location filename="src/pages.cpp" line="681"/>
<source>tokens</source>
<translation type="unfinished"/>
<translation>fichas</translation>
</message>
<message>
<location filename="src/pages.cpp" line="686"/>
@ -250,7 +250,7 @@
<message>
<location filename="src/pages.cpp" line="690"/>
<source>Local file:</source>
<translation type="unfinished"/>
<translation>Archivo local:</translation>
</message>
<message>
<location filename="src/pages.cpp" line="691"/>
@ -260,7 +260,7 @@
<message>
<location filename="src/pages.cpp" line="692"/>
<source>Choose file...</source>
<translation type="unfinished"/>
<translation>Elegir archivo...</translation>
</message>
<message>
<location filename="src/pages.cpp" line="694"/>
@ -391,12 +391,12 @@
<message>
<location filename="src/pagetemplates.cpp" line="72"/>
<source>Load %1 file</source>
<translation type="unfinished"/>
<translation>Cargar archivo de %1</translation>
</message>
<message>
<location filename="src/pagetemplates.cpp" line="82"/>
<source>%1 file (%1)</source>
<translation type="unfinished"/>
<translation>archivo de %1 (%1)</translation>
</message>
<message>
<location filename="src/pagetemplates.cpp" line="111"/>
@ -420,12 +420,12 @@
<message>
<location filename="src/pagetemplates.cpp" line="129"/>
<source>Please choose a file.</source>
<translation type="unfinished"/>
<translation>Por favor elija un archivo.</translation>
</message>
<message>
<location filename="src/pagetemplates.cpp" line="134"/>
<source>Cannot open file &apos;%1&apos;.</source>
<translation type="unfinished"/>
<translation>No se puede abrir el archivo &apos;%1&apos;.</translation>
</message>
<message>
<location filename="src/pagetemplates.cpp" line="159"/>
@ -602,7 +602,7 @@
<message>
<location filename="src/main.cpp" line="63"/>
<source>Run in no-confirm background mode</source>
<translation type="unfinished"/>
<translation>Ejecutar en modo del segundo plano sin confirmación</translation>
</message>
</context>
</TS>

View file

@ -0,0 +1,18 @@
ALTER TABLE `cockatrice_users` ADD COLUMN `card_art_params` TEXT DEFAULT NULL, ALGORITHM=INSTANT;
CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`card_name` varchar(255) NOT NULL,
`mode` enum('ALLOW','DENY') NOT NULL,
`reason` varchar(255) DEFAULT NULL,
`created_by` int(7) unsigned DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_card_name` (`card_name`),
KEY `idx_mode` (`mode`),
FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE cockatrice_schema_version SET version=35 WHERE version=34;

View file

@ -0,0 +1,5 @@
# Local state - never commit these
state.json
state.json.tmp
venv/
__pycache__/

View file

@ -0,0 +1,163 @@
# Account registration monitor
Posts a Discord message whenever a new account is registered in Servatrice
(`cockatrice_users`). Each message includes the username, real name (if set),
email, and registration time.
It runs as a periodic read-only query against the production database. It does
not modify the database and does not touch the running Servatrice process.
## How it decides what is "new"
Accounts get an auto-increment `id`, so "new since last time" is just
`id > last_seen_id`. The monitor stores that single high-water-mark id in its
state file. Each run it posts every account above the mark, oldest first, then
advances the mark to the highest id it posted.
Because the mark only moves forward and an id is posted exactly once, there are
no duplicate messages and nothing is missed, even if the monitor is down for a
while. The state file holds a single number, so it never grows.
The first run (when no state file exists yet) records the current maximum id as
the baseline and posts nothing. This prevents the entire existing user base from
being dumped into the channel. Only accounts registered after that baseline are
posted.
If a post to Discord fails, the monitor stops there without advancing the mark
past it, so that account and everything after it are retried on the next run.
## Privacy note
Messages contain personal data (real name and email). Discord stores message
content on their servers, so post only to a private channel that the right
people can see, and treat the webhook URL as a secret. It lives in the config
ini alongside the database password, so keep that file readable only by the user
that runs the monitor.
## Setup
The monitor reads its database credentials and the webhook from a
servatrice-style ini file passed with `--config` (or the `CONFIG_FILE` env var).
You can point it at your existing `servatrice.ini`, or keep a small separate ini
just for the monitor.
### 1. Create a read-only database user
Run as a DB admin. Adjust the host (`'%'` allows any host; restrict it to the
machine running the monitor if you can) and the table prefix if yours is not the
default `cockatrice`.
```sql
CREATE USER 'account_monitor'@'%' IDENTIFIED BY 'a-strong-password';
GRANT SELECT (id, name, realname, email, registrationDate)
ON servatrice.cockatrice_users TO 'account_monitor'@'%';
FLUSH PRIVILEGES;
```
Using a read-only user is recommended over pointing `--config` at the real
`servatrice.ini`, because Servatrice's own DB account usually has write access
the monitor does not need.
### 2. Create the Discord webhook and add it to the config
In Discord: open the target channel, then Edit Channel -> Integrations ->
Webhooks -> New Webhook. Name it, pick the channel, and copy the webhook URL.
Add a `[discord]` section with the URL to the ini you will pass to `--config`.
If you want the read-only user above, set the `[database]` section to use it. A
small dedicated `monitor.ini` looks like this:
```ini
[database]
hostname=127.0.0.1
database=servatrice
user=account_monitor
password=a-strong-password
prefix=cockatrice
[discord]
new_user_activation_webhook=https://discord.com/api/webhooks/XXXX/YYYY
```
If you would rather use one file, add the `[discord]` section to the real
`servatrice.ini` instead. Servatrice ignores sections it does not use. Note that
Servatrice (a Qt app) rewrites ini values it touches in quoted, backslash-escaped
form, for example `"https\://..."`. The monitor strips that encoding from the
webhook automatically, so either the plain or the escaped form works.
### 3. Install
```bash
cd servatrice/scripts/account_monitor
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
```
### 4. Verify before scheduling
```bash
# Confirm the webhook works (sends one test message to the channel)
./venv/bin/python ./account_monitor.py --config /path/to/monitor.ini --test-webhook
# Confirm DB access and see what it would do, without posting or writing state
./venv/bin/python ./account_monitor.py --config /path/to/monitor.ini --dry-run --verbose
```
The first real run seeds the baseline and posts nothing:
```bash
./venv/bin/python ./account_monitor.py --config /path/to/monitor.ini
```
After that, test it end to end by registering a throwaway account and confirming
a message appears on the next run.
## Run it every 2 minutes with cron
Edit the crontab of the user that owns the script directory (`crontab -e`) and
add one line. This runs the monitor every 2 minutes, using the venv's Python and
your config ini, and appends output to a log:
```cron
*/2 * * * * cd /opt/cockatrice/servatrice/scripts/account_monitor && ./venv/bin/python ./account_monitor.py --config /etc/servatrice/servatrice.ini >> /var/log/account_monitor.log 2>&1
```
Adjust the three paths to your install: the script directory after `cd`, and the
`--config` and log paths. The `*/2` field is what makes it run every 2 minutes;
change it to `*/5` for every 5, and so on.
By default the high-water-mark is stored in `state.json` next to the script, so
the directory must be writable by the cron user. To put it elsewhere, set
`STATE_FILE`:
```cron
*/2 * * * * STATE_FILE=/var/lib/account_monitor/state.json cd /opt/cockatrice/servatrice/scripts/account_monitor && ./venv/bin/python ./account_monitor.py --config /etc/servatrice/servatrice.ini >> /var/log/account_monitor.log 2>&1
```
The interval only controls how often it checks; it is not a lookback window, so
a longer interval never causes missed accounts. The query is cheap: an indexed
range scan on the primary key for `id > last_seen`.
## Options
- `--config PATH` / `-c PATH` — read DB settings from `[database]` and the webhook from `[discord] new_user_activation_webhook` of a servatrice-style ini (falls back to the `CONFIG_FILE` env var).
- `--dry-run` — query and log what would be posted; no Discord posts, no state write.
- `--test-webhook` — send one test message to the webhook and exit (does not need DB credentials).
- `--verbose` — debug logging.
## Configuration reference
Settings come from the `--config` ini, with environment variables available as
overrides if you need them (env takes precedence over the ini).
| Setting | ini (`--config`) | Environment override |
| --- | --- | --- |
| DB host | `[database] hostname` | `DB_HOST` |
| DB port | `[database] port` (optional) | `DB_PORT` |
| DB name | `[database] database` | `DB_NAME` |
| DB user | `[database] user` | `DB_USER` |
| DB password | `[database] password` | `DB_PASSWORD` |
| Table prefix | `[database] prefix` | `DB_TABLE_PREFIX` |
| Webhook URL | `[discord] new_user_activation_webhook` | `DISCORD_WEBHOOK_URL` |
| DB TLS | — | `DB_SSL` / `DB_SSL_CA` |
| State file path | — | `STATE_FILE` (default: `state.json` next to the script) |

View file

@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""Post a Discord message when a new Servatrice account is registered.
Accounts get an auto-increment `id`, so "what is new since last time" is simply
`id > last_seen_id`. The monitor stores that single high-water-mark id in a
small state file. Each run it posts every account above the mark (oldest
first), then advances the mark. This means no duplicate posts, nothing missed
across downtime, and a state file that never grows (it holds one number).
On the very first run (no state file yet) it records the current maximum id as
the baseline and posts nothing, so existing users are not dumped into the
channel. From then on only newly-registered accounts are posted.
Intended to be run on a schedule (cron). Pass a servatrice-style ini with
--config (or CONFIG_FILE) for the database credentials and webhook; see
README.md.
"""
import argparse
import configparser
import json
import logging
import os
import re
import sys
import time
import urllib.error
import urllib.request
import pymysql
log = logging.getLogger("account_monitor")
# Columns we use. `id` drives the high-water-mark; `name` is the login/username,
# `realname` is the optional display name, `registrationDate` is when the account
# row was created.
NEW_ACCOUNTS_QUERY = (
"SELECT id, name, realname, email, registrationDate "
"FROM `{prefix}_users` WHERE id > %s ORDER BY id ASC"
)
EMBED_COLOR = 0x5865F2 # discord blurple
DISCORD_MAX_EMBED_FIELD = 1024
POST_DELAY_SECONDS = 1.0 # gap between webhook posts to stay under rate limits
MAX_RATELIMIT_RETRIES = 5
def _clean_ini_value(value):
"""Undo Qt QSettings ini encoding of a value.
Qt apps (including Servatrice) write ini values that contain special
characters wrapped in double quotes and backslash-escaped, e.g. a webhook
URL stored as "https\\://...". configparser returns that text literally, so
strip the wrapping quotes and remove the backslash escapes. This is applied
to the webhook URL only, where it is safe (URLs contain no quotes or
backslashes); DB values are left untouched so passwords are never altered.
"""
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
return re.sub(r"\\(.)", r"\1", value)
def load_config_file(path):
"""Read DB and Discord settings from a servatrice-style ini.
Pulls the [database] section (hostname/database/user/password/prefix/port)
and the Discord webhook from [discord] new_user_activation_webhook. Returns
a dict using this module's internal config keys; only keys actually present
(and non-empty) in the file are returned, so missing values fall back to
defaults or the environment.
"""
# interpolation=None so a '%' in a password is not treated as a token.
parser = configparser.ConfigParser(interpolation=None)
if not parser.read(path):
log.error("Config file not found or unreadable: %s", path)
sys.exit(2)
result = {}
if parser.has_section("database"):
db = parser["database"]
db_mapping = {
"hostname": "db_host",
"database": "db_name",
"user": "db_user",
"password": "db_password",
"prefix": "db_prefix",
"port": "db_port", # not in stock servatrice.ini, but honored if present
}
result.update({cfg_key: db[ini_key] for ini_key, cfg_key in db_mapping.items() if db.get(ini_key)})
if parser.has_section("discord") and parser["discord"].get("new_user_activation_webhook"):
result["webhook_url"] = _clean_ini_value(parser["discord"]["new_user_activation_webhook"])
return result
def get_config(config_path=None, require_db=True):
"""Build configuration from defaults, an optional ini file, then env vars.
Precedence, highest first: environment variables, the ini file, built-in
defaults. Database credentials and the Discord webhook may come from either
the ini file or the environment; the state file is environment-only.
"""
cfg = {
"db_host": "localhost",
"db_port": 3306,
"db_name": "servatrice",
"db_user": None,
"db_password": None,
"db_prefix": "cockatrice",
"db_ssl": False,
"db_ssl_ca": None,
"webhook_url": None,
"state_file": os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json"),
}
if config_path:
cfg.update(load_config_file(config_path))
env_map = {
"DB_HOST": "db_host",
"DB_PORT": "db_port",
"DB_NAME": "db_name",
"DB_USER": "db_user",
"DB_PASSWORD": "db_password",
"DB_TABLE_PREFIX": "db_prefix",
"DB_SSL_CA": "db_ssl_ca",
"DISCORD_WEBHOOK_URL": "webhook_url",
"STATE_FILE": "state_file",
}
for env_key, cfg_key in env_map.items():
if os.environ.get(env_key):
cfg[cfg_key] = os.environ[env_key]
if os.environ.get("DB_SSL"):
cfg["db_ssl"] = os.environ["DB_SSL"].lower() in ("1", "true", "yes")
cfg["db_port"] = int(cfg["db_port"])
required = {"webhook_url": "DISCORD_WEBHOOK_URL or [discord] new_user_activation_webhook"}
if require_db:
required["db_user"] = "DB_USER or [database] user"
required["db_password"] = "DB_PASSWORD or [database] password"
missing = [label for key, label in required.items() if not cfg[key]]
if missing:
log.error("Missing required configuration: %s", "; ".join(missing))
sys.exit(2)
if cfg["webhook_url"] and not cfg["webhook_url"].lower().startswith(("http://", "https://")):
log.error("Webhook URL does not look like an http(s) URL: %r", cfg["webhook_url"])
sys.exit(2)
return cfg
def connect(cfg):
"""Open a read-only connection to the Servatrice database."""
ssl = None
if cfg["db_ssl"]:
ssl = {"ca": cfg["db_ssl_ca"]} if cfg["db_ssl_ca"] else {}
return pymysql.connect(
host=cfg["db_host"],
port=cfg["db_port"],
user=cfg["db_user"],
password=cfg["db_password"],
database=cfg["db_name"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
connect_timeout=15,
read_timeout=30,
ssl=ssl,
)
def fetch_max_id(conn, prefix):
"""Return the highest account id currently in the table, or 0 if empty."""
with conn.cursor() as cur:
cur.execute("SELECT MAX(id) AS max_id FROM `{prefix}_users`".format(prefix=prefix))
row = cur.fetchone()
return int(row["max_id"]) if row and row["max_id"] is not None else 0
def fetch_new_accounts(conn, prefix, last_id):
"""Return detail rows for accounts with id > last_id, oldest first."""
with conn.cursor() as cur:
cur.execute(NEW_ACCOUNTS_QUERY.format(prefix=prefix), (last_id,))
return cur.fetchall()
def load_state(path):
"""Load the high-water-mark id. Returns (last_id, is_first_run)."""
if not os.path.exists(path):
return 0, True
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
return int(data.get("last_id", 0)), False
def save_state(path, last_id):
"""Atomically persist the high-water-mark id."""
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({"version": 2, "last_id": int(last_id)}, fh)
os.replace(tmp, path)
def build_embed(row):
"""Build a Discord embed dict for one newly-registered account."""
fields = [
{"name": "Username", "value": str(row["name"]) or "(none)", "inline": False},
]
realname = (row.get("realname") or "").strip()
if realname:
fields.append({"name": "Real name", "value": realname[:DISCORD_MAX_EMBED_FIELD], "inline": False})
fields.append({"name": "Email", "value": str(row.get("email") or "(none)"), "inline": False})
reg = row.get("registrationDate")
fields.append({"name": "Reg time", "value": str(reg) if reg is not None else "(unknown)", "inline": False})
return {
"title": "New account registered",
"color": EMBED_COLOR,
"fields": fields,
}
def post_embed(webhook_url, embed):
"""POST a single embed to the Discord webhook, honoring 429 rate limits."""
payload = json.dumps({"embeds": [embed]}).encode("utf-8")
for attempt in range(MAX_RATELIMIT_RETRIES):
req = urllib.request.Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json", "User-Agent": "servatrice-account-monitor/1.0"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
if resp.status in (200, 204):
return True
log.warning("Unexpected Discord status %s", resp.status)
return False
except urllib.error.HTTPError as err:
if err.code == 429:
retry_after = _retry_after_seconds(err)
log.warning("Rate limited by Discord; sleeping %.2fs", retry_after)
time.sleep(retry_after)
continue
log.error("Discord webhook HTTP %s: %s", err.code, err.read().decode("utf-8", "replace")[:500])
return False
except urllib.error.URLError as err:
log.error("Discord webhook connection error: %s", err)
return False
log.error("Gave up posting after %d rate-limit retries", MAX_RATELIMIT_RETRIES)
return False
def _retry_after_seconds(err):
"""Extract the retry delay (seconds) from a Discord 429 response."""
header = err.headers.get("Retry-After")
if header:
try:
return float(header)
except ValueError:
pass
try:
body = json.loads(err.read().decode("utf-8", "replace"))
return float(body.get("retry_after", 1.0))
except (ValueError, json.JSONDecodeError):
return 1.0
def main():
parser = argparse.ArgumentParser(description="Post new Servatrice account registrations to Discord.")
parser.add_argument(
"--config", "-c",
help="Path to a servatrice-style ini; reads DB settings from its [database] "
"section (hostname/database/user/password/prefix) and the webhook from "
"[discord] new_user_activation_webhook. Defaults to the CONFIG_FILE env "
"var if set.",
)
parser.add_argument("--dry-run", action="store_true", help="Log what would be posted; do not post or write state.")
parser.add_argument("--test-webhook", action="store_true", help="Send a single test message to the webhook and exit.")
parser.add_argument("--verbose", action="store_true", help="Enable debug logging.")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
config_path = args.config or os.environ.get("CONFIG_FILE")
if args.test_webhook:
cfg = get_config(config_path, require_db=False)
ok = post_embed(
cfg["webhook_url"],
{"title": "Account monitor test", "color": EMBED_COLOR,
"description": "If you can see this, the webhook is configured correctly."},
)
sys.exit(0 if ok else 1)
cfg = get_config(config_path)
try:
conn = connect(cfg)
except pymysql.MySQLError as err:
log.error("Database connection failed: %s", err)
sys.exit(1)
try:
last_id, first_run = load_state(cfg["state_file"])
if first_run:
baseline = fetch_max_id(conn, cfg["db_prefix"])
log.info("First run: seeding high-water-mark at id=%d; posting nothing.", baseline)
if not args.dry_run:
save_state(cfg["state_file"], baseline)
return
rows = fetch_new_accounts(conn, cfg["db_prefix"], last_id)
if not rows:
log.info("No new accounts since id=%d.", last_id)
return
log.info("Found %d new account(s) since id=%d.", len(rows), last_id)
# Post oldest first. Advance the mark only past accounts we successfully
# posted; on the first failure, stop so nothing after it is posted out of
# order or skipped. The failed account (and the rest) retry next run.
for row in rows:
if args.dry_run:
log.info("[dry-run] would post: id=%s name=%s email=%s reg=%s",
row["id"], row["name"], row.get("email"), row.get("registrationDate"))
continue
if not post_embed(cfg["webhook_url"], build_embed(row)):
log.error("Failed to post account id=%s; stopping. Will retry from here next run.", row["id"])
break
last_id = row["id"]
log.info("Posted account id=%s (%s)", row["id"], row["name"])
time.sleep(POST_DELAY_SECONDS)
if not args.dry_run:
save_state(cfg["state_file"], last_id)
finally:
conn.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1 @@
PyMySQL==1.2.0

View file

@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
INSERT INTO cockatrice_schema_version VALUES(34);
INSERT INTO cockatrice_schema_version VALUES(35);
-- users and user data tables
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` (
`passwordLastChangedDate` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`leftPawnColorOverride` varchar(255),
`rightPawnColorOverride` varchar(255),
`card_art_params` TEXT DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`),
KEY `token` (`token`),
@ -300,3 +301,18 @@ CREATE TABLE IF NOT EXISTS `cockatrice_audit` (
PRIMARY KEY (`id`),
KEY `user_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`card_name` varchar(255) NOT NULL,
`mode` enum('ALLOW','DENY') NOT NULL,
`reason` varchar(255) DEFAULT NULL,
`created_by` int(7) unsigned DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_card_name` (`card_name`),
KEY `idx_mode` (`mode`),
FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci;

View file

@ -7,6 +7,8 @@
#include <QChar>
#include <QDateTime>
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QSqlError>
#include <QSqlQuery>
@ -681,6 +683,30 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer
if (!clientid.isEmpty()) {
result.set_clientid(clientid.toStdString());
}
const QString cardArtParamsJson = query->value(12).toString();
if (!cardArtParamsJson.isEmpty()) {
const QJsonDocument doc = QJsonDocument::fromJson(cardArtParamsJson.toUtf8());
if (doc.isObject()) {
const QJsonObject obj = doc.object();
auto *cap = result.mutable_card_art_params();
if (obj.contains("card_name")) {
cap->set_card_name(obj["card_name"].toString().toStdString());
}
if (obj.contains("marginPctL")) {
cap->set_margin_pct_l(obj["marginPctL"].toDouble(0.33));
}
if (obj.contains("marginPctR")) {
cap->set_margin_pct_r(obj["marginPctR"].toDouble(0.02));
}
if (obj.contains("verticalOffset")) {
cap->set_vertical_offset(obj["verticalOffset"].toDouble(0.35));
}
if (obj.contains("zoom")) {
cap->set_zoom(obj["zoom"].toDouble(1.0));
}
}
}
}
return result;
}
@ -698,7 +724,7 @@ ServerInfo_User Servatrice_DatabaseInterface::getUserData(const QString &name, b
QSqlQuery *query = prepareQuery("select id, name, admin, country, privlevel, leftPawnColorOverride, "
"rightPawnColorOverride, realname, avatar_bmp, registrationDate, "
"email, clientid from {prefix}_users where "
"email, clientid, card_art_params from {prefix}_users where "
"name = :name and active = 1");
query->bindValue(":name", name);
if (!execSqlQuery(query)) {

View file

@ -10,7 +10,7 @@
#include <server.h>
#include <server_database_interface.h>
#define DATABASE_SCHEMA_VERSION 34
#define DATABASE_SCHEMA_VERSION 35
class Servatrice;

View file

@ -31,6 +31,8 @@
#include <QDateTime>
#include <QDebug>
#include <QHostAddress>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QRegularExpression>
#include <QSqlError>
@ -59,8 +61,10 @@
#include <libcockatrice/protocol/pb/event_replay_added.pb.h>
#include <libcockatrice/protocol/pb/event_server_identification.pb.h>
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
#include <libcockatrice/protocol/pb/event_user_joined.pb.h>
#include <libcockatrice/protocol/pb/event_user_message.pb.h>
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
@ -212,6 +216,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdAccountEdit(cmd.GetExtension(Command_AccountEdit::ext), rc);
case SessionCommand::ACCOUNT_IMAGE:
return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc);
case SessionCommand::SET_CARD_ART_PARAMS:
return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc);
case SessionCommand::ACCOUNT_PASSWORD:
return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc);
case SessionCommand::REQUEST_PASSWORD_SALT:
@ -247,6 +253,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo
return cmdGetAdminNotes(cmd.GetExtension(Command_GetAdminNotes::ext), rc);
case ModeratorCommand::UPDATE_ADMIN_NOTES:
return cmdUpdateAdminNotes(cmd.GetExtension(Command_UpdateAdminNotes::ext), rc);
case ModeratorCommand::ADD_CARD_ART_RULE:
return cmdAddCardArtRule(cmd.GetExtension((Command_AddCardArtRule::ext)), rc);
case ModeratorCommand::REMOVE_CARD_ART_RULE:
return cmdRemoveCardArtRule(cmd.GetExtension((Command_RemoveCardArtRule::ext)), rc);
case ModeratorCommand::LIST_CARD_ART_RULES:
return cmdListCardArtRules(cmd.GetExtension((Command_ListCardArtRules::ext)), rc);
default:
return Response::RespFunctionNotAllowed;
}
@ -1565,6 +1577,161 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm
return Response::RespOk;
}
bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName)
{
QSqlQuery *q = sqlInterface->prepareQuery("SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name");
q->bindValue(":name", cardName);
if (!sqlInterface->execSqlQuery(q)) {
qWarning() << "Card art rule lookup failed; failing open for" << cardName;
return true;
}
if (!q->next()) {
return true; // default allow
}
return q->value(0).toString() != "DENY";
}
Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd,
ResponseContainer & /* rc */)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
const QString cardName = QString::fromStdString(cmd.card_name());
if (cardName.length() > MAX_NAME_LENGTH) {
return Response::RespInvalidData;
}
if (cardName.isEmpty()) {
// Removal path
QSqlQuery *q = sqlInterface->prepareQuery("UPDATE {prefix}_users SET card_art_params = NULL WHERE id = :id");
q->bindValue(":id", userInfo->id());
if (!sqlInterface->execSqlQuery(q)) {
return Response::RespInternalError;
}
userInfo->clear_card_art_params();
server->broadcastUserInfoUpdate(this);
return Response::RespOk;
}
if (!isCardNameAllowed(cardName)) {
return Response::RespFunctionNotAllowed;
}
// Clamp everything to sane ranges server-side so a malicious client
// can't store garbage that breaks other clients' rendering.
const double marginPctL = qBound(0.0, cmd.margin_pct_l(), 0.95);
const double marginPctR = qBound(0.0, cmd.margin_pct_r(), 0.95);
const double verticalOffset = qBound(0.0, cmd.vertical_offset(), 1.0);
const double zoom = qBound(0.1, cmd.zoom(), 4.0);
QJsonObject obj;
obj["card_name"] = cardName;
obj["marginPctL"] = marginPctL;
obj["marginPctR"] = marginPctR;
obj["verticalOffset"] = verticalOffset;
obj["zoom"] = zoom;
const QString json = QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact));
QSqlQuery *query = sqlInterface->prepareQuery("update {prefix}_users set card_art_params=:params where id=:id");
query->bindValue(":params", json);
query->bindValue(":id", userInfo->id());
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespInternalError;
}
// Keep the in-memory userInfo in sync
auto *cap = userInfo->mutable_card_art_params();
cap->set_card_name(cmd.card_name());
cap->set_margin_pct_l(marginPctL);
cap->set_margin_pct_r(marginPctR);
cap->set_vertical_offset(verticalOffset);
cap->set_zoom(zoom);
const QString name = QString::fromStdString(userInfo->name());
server->broadcastUserInfoUpdate(this);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdAddCardArtRule(const Command_AddCardArtRule &cmd,
ResponseContainer &)
{
const QString cardName = QString::fromStdString(cmd.card_name());
const QString mode = QString::fromStdString(cmd.mode());
if (mode != "ALLOW" && mode != "DENY") {
return Response::RespInvalidData;
}
if (cardName.isEmpty() || cardName.length() > MAX_NAME_LENGTH) {
return Response::RespInvalidData;
}
QSqlQuery *q = sqlInterface->prepareQuery("INSERT INTO {prefix}_card_art_name_rules "
"(card_name, mode, reason, created_by) "
"VALUES (:name, :mode, :reason, :uid) "
"ON DUPLICATE KEY UPDATE mode=:mode2, reason=:reason2");
q->bindValue(":name", cardName);
q->bindValue(":mode", mode);
q->bindValue(":mode2", mode);
q->bindValue(":reason", QString::fromStdString(cmd.reason()));
q->bindValue(":reason2", QString::fromStdString(cmd.reason()));
q->bindValue(":uid", userInfo->id());
if (!sqlInterface->execSqlQuery(q)) {
return Response::RespInternalError;
}
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd,
ResponseContainer &)
{
auto cardName = QString::fromStdString(cmd.card_name());
if (cardName.length() > MAX_NAME_LENGTH) {
return Response::RespInvalidData;
}
QSqlQuery *q = sqlInterface->prepareQuery("DELETE FROM {prefix}_card_art_name_rules WHERE card_name=:name");
q->bindValue(":name", cardName);
if (!sqlInterface->execSqlQuery(q)) {
return Response::RespInternalError;
}
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdListCardArtRules(const Command_ListCardArtRules &,
ResponseContainer &rc)
{
QSqlQuery *q = sqlInterface->prepareQuery("SELECT card_name, mode, reason FROM {prefix}_card_art_name_rules");
if (!sqlInterface->execSqlQuery(q)) {
return Response::RespInternalError;
}
auto *re = new Response_ListCardArtRules;
while (q->next()) {
auto *entry = re->add_entries();
entry->set_card_name(q->value(0).toString().toStdString());
entry->set_mode(q->value(1).toString().toStdString());
entry->set_reason(q->value(2).toString().toStdString());
}
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const Command_AccountPassword &cmd,
ResponseContainer & /* rc */)
{

View file

@ -129,6 +129,11 @@ private:
Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc);
Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc);
bool isCardNameAllowed(const QString &cardName);
Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &);
Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &);
Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &);
Response::ResponseCode cmdListCardArtRules(const Command_ListCardArtRules &, ResponseContainer &rc);
Response::ResponseCode cmdAccountPassword(const Command_AccountPassword &cmd, ResponseContainer &rc);
Response::ResponseCode cmdGrantReplayAccess(const Command_GrantReplayAccess &cmd, ResponseContainer &rc);
Response::ResponseCode cmdForceActivateUser(const Command_ForceActivateUser &cmd, ResponseContainer &rc);