mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-01 11:03:54 -07:00
Merge branch 'master' into tooomm-ci_qt-install-action
This commit is contained in:
commit
2845c729c4
71 changed files with 4097 additions and 189 deletions
41
.github/workflows/desktop-build.yml
vendored
41
.github/workflows/desktop-build.yml
vendored
|
|
@ -49,24 +49,19 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Configure"
|
- name: "Configure"
|
||||||
|
env:
|
||||||
|
RESOLVED_SHA: ${{ case(github.event_name == 'pull_request', github.event.pull_request.head.sha, github.sha) }}
|
||||||
id: configure
|
id: configure
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
tag_regex='^refs/tags/'
|
if [[ "$GITHUB_REF_TYPE" == 'tag' ]]; then # release
|
||||||
if [[ $GITHUB_EVENT_NAME == pull-request ]]; then # pull request
|
echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
|
||||||
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"
|
|
||||||
fi
|
fi
|
||||||
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
if: steps.configure.outputs.tag != null
|
if: steps.configure.outputs.tag != null
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # fetch all history for all branches and tags
|
fetch-depth: 0 # fetch all history for all branches and tags
|
||||||
|
|
||||||
|
|
@ -163,7 +158,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Restore compiler cache (ccache)"
|
- name: "Restore compiler cache (ccache)"
|
||||||
id: ccache_restore
|
id: ccache_restore
|
||||||
|
|
@ -211,9 +206,10 @@ jobs:
|
||||||
if: github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
|
if: github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
|
CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
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"
|
echo "Cache deleted successfully"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -256,8 +252,9 @@ jobs:
|
||||||
if: steps.attestation.outcome == 'success'
|
if: steps.attestation.outcome == 'success'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
BUILD_PATH: ${{ steps.build.outputs.path }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
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:
|
build-vcpkg:
|
||||||
strategy:
|
strategy:
|
||||||
|
|
@ -350,7 +347,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
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
|
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
|
CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
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"
|
echo "Cache deleted successfully"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -473,18 +471,20 @@ jobs:
|
||||||
if: matrix.os == 'macOS' && matrix.make_package && needs.configure.outputs.tag != null
|
if: matrix.os == 'macOS' && matrix.make_package && needs.configure.outputs.tag != null
|
||||||
id: sign_macos
|
id: sign_macos
|
||||||
env:
|
env:
|
||||||
|
BUILD_PATH: ${{ steps.build.outputs.path }}
|
||||||
MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
|
MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
|
||||||
MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}
|
MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}
|
||||||
run: |
|
run: |
|
||||||
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]
|
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]
|
||||||
then
|
then
|
||||||
security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
|
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
|
fi
|
||||||
|
|
||||||
- name: "[macOS] Notarize app bundle"
|
- name: "[macOS] Notarize app bundle"
|
||||||
if: matrix.os == 'macOS' && steps.sign_macos.outcome == 'success'
|
if: matrix.os == 'macOS' && steps.sign_macos.outcome == 'success'
|
||||||
env:
|
env:
|
||||||
|
BUILD_PATH: ${{ steps.build.outputs.path }}
|
||||||
MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}
|
MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}
|
||||||
MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}
|
MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}
|
||||||
MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }}
|
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
|
# Therefore, we create a zip file containing our app bundle, so that we can send it to the
|
||||||
# notarization service
|
# notarization service
|
||||||
echo "Creating temp notarization archive"
|
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.
|
# 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
|
# 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
|
# 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.
|
# validated by macOS even when an internet connection is not available.
|
||||||
echo "Attach staple"
|
echo "Attach staple"
|
||||||
xcrun stapler staple "${{ steps.build.outputs.path }}"
|
xcrun stapler staple "$BUILD_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: "Upload artifact"
|
- name: "Upload artifact"
|
||||||
|
|
@ -557,5 +557,6 @@ jobs:
|
||||||
if: steps.attestation.outcome == 'success'
|
if: steps.attestation.outcome == 'success'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
BUILD_PATH: ${{ steps.build.outputs.path }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: gh attestation verify "${{ steps.build.outputs.path }}" --repo Cockatrice/Cockatrice
|
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
|
||||||
|
|
|
||||||
2
.github/workflows/desktop-lint.yml
vendored
2
.github/workflows/desktop-lint.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 20 # should be enough to find merge base
|
fetch-depth: 20 # should be enough to find merge base
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/docker-release.yml
vendored
2
.github/workflows/docker-release.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Docker metadata"
|
- name: "Docker metadata"
|
||||||
id: metadata
|
id: metadata
|
||||||
|
|
|
||||||
2
.github/workflows/documentation-build.yml
vendored
2
.github/workflows/documentation-build.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
|
|
|
||||||
16
.github/workflows/translations-pull.yml
vendored
16
.github/workflows/translations-pull.yml
vendored
|
|
@ -20,9 +20,11 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout repo"
|
- name: "Checkout repo"
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Pull translated strings from Transifex"
|
- 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
|
uses: transifex/cli-action@v2
|
||||||
with:
|
with:
|
||||||
# Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config
|
# Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config
|
||||||
|
|
@ -61,11 +63,9 @@ jobs:
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
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: |
|
run: |
|
||||||
if [[ "$STATUS" == "none" ]]; then
|
echo "PR #$PR_NUMBER $STATUS!" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} unchanged!" >> $GITHUB_STEP_SUMMARY
|
echo "URL: $PR_URL" >> "$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
|
|
||||||
|
|
|
||||||
23
.github/workflows/translations-push.yml
vendored
23
.github/workflows/translations-push.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout repo"
|
- name: "Checkout repo"
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: "Install lupdate"
|
- name: "Install lupdate"
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -29,12 +29,13 @@ jobs:
|
||||||
sudo apt-get install -y --no-install-recommends qttools5-dev-tools
|
sudo apt-get install -y --no-install-recommends qttools5-dev-tools
|
||||||
|
|
||||||
- name: "Update Cockatrice translation source"
|
- name: "Update Cockatrice translation source"
|
||||||
|
env:
|
||||||
|
FILE: cockatrice/cockatrice_en@source.ts
|
||||||
id: cockatrice
|
id: cockatrice
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: >
|
||||||
FILE="cockatrice/cockatrice_en@source.ts"
|
DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
|
||||||
export DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
|
.ci/update_translation_source_strings.sh
|
||||||
FILE="$FILE" DIRS="$DIRS" .ci/update_translation_source_strings.sh
|
|
||||||
|
|
||||||
- name: "Update Oracle translation source"
|
- name: "Update Oracle translation source"
|
||||||
id: oracle
|
id: oracle
|
||||||
|
|
@ -77,11 +78,9 @@ jobs:
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
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: |
|
run: |
|
||||||
if [[ "$STATUS" == "none" ]]; then
|
echo "PR #$PR_NUMBER $STATUS!" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} unchanged!" >> $GITHUB_STEP_SUMMARY
|
echo "URL: $PR_URL" >> "$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
|
|
||||||
|
|
|
||||||
|
|
@ -117,21 +117,22 @@ ${If} $InstDir == ""
|
||||||
; we need to set a default based on the install mode
|
; we need to set a default based on the install mode
|
||||||
StrCpy $InstDir $0
|
StrCpy $InstDir $0
|
||||||
${EndIf}
|
${EndIf}
|
||||||
Call SetModeDestinationFromInstdir
|
|
||||||
|
|
||||||
; --- Detect portable install when using /R ---
|
; --- Detect portable install when using /R (must come BEFORE SetModeDestinationFromInstdir) ---
|
||||||
${If} $ReinstallMode = 1
|
${If} $ReinstallMode = 1
|
||||||
IfFileExists "$InstDir\portable.dat" 0 not_portable
|
IfFileExists "$InstDir\portable.dat" 0 not_portable
|
||||||
StrCpy $PortableMode 1
|
StrCpy $PortableMode 1
|
||||||
Goto portable_done
|
Goto portable_done
|
||||||
|
|
||||||
not_portable:
|
not_portable:
|
||||||
StrCpy $PortableMode 0
|
StrCpy $PortableMode 0
|
||||||
|
|
||||||
portable_done:
|
portable_done:
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
|
; Now that $PortableMode reflects reality, commit InstDir into the correct slot
|
||||||
|
Call SetModeDestinationFromInstdir
|
||||||
|
|
||||||
${If} $ReinstallMode = 1
|
${If} $ReinstallMode = 1
|
||||||
|
${AndIf} $PortableMode = 0
|
||||||
Call AutoUninstallIfNeeded
|
Call AutoUninstallIfNeeded
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,10 +236,14 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/server/handle_public_servers.cpp
|
src/interface/widgets/server/handle_public_servers.cpp
|
||||||
src/interface/widgets/server/remote/remote_decklist_tree_widget.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/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_context_menu.cpp
|
||||||
src/interface/widgets/server/user/user_info_box.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_info_connection.cpp
|
||||||
src/interface/widgets/server/user/user_list_manager.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/server/user/user_list_widget.cpp
|
||||||
src/interface/widgets/settings_page/appearance_settings_page.cpp
|
src/interface/widgets/settings_page/appearance_settings_page.cpp
|
||||||
src/interface/widgets/settings_page/deck_editor_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.cpp
|
||||||
src/interface/widgets/tabs/tab_account.cpp
|
src/interface/widgets/tabs/tab_account.cpp
|
||||||
src/interface/widgets/tabs/tab_admin.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_editor.cpp
|
||||||
src/interface/widgets/tabs/tab_deck_storage.cpp
|
src/interface/widgets/tabs/tab_deck_storage.cpp
|
||||||
src/interface/widgets/tabs/tab_game.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/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.cpp
|
||||||
src/interface/widgets/utility/compact_push_button.h
|
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)
|
add_subdirectory(sounds)
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,7 @@ SettingsCache::SettingsCache()
|
||||||
cardViewExpandedRowsMax = settings->value("interface/cardViewExpandedRowsMax", 20).toInt();
|
cardViewExpandedRowsMax = settings->value("interface/cardViewExpandedRowsMax", 20).toInt();
|
||||||
closeEmptyCardView = settings->value("interface/closeEmptyCardView", true).toBool();
|
closeEmptyCardView = settings->value("interface/closeEmptyCardView", true).toBool();
|
||||||
focusCardViewSearchBar = settings->value("interface/focusCardViewSearchBar", true).toBool();
|
focusCardViewSearchBar = settings->value("interface/focusCardViewSearchBar", true).toBool();
|
||||||
|
keepGameChatFocus = settings->value("interface/keepGameChatFocus", false).toBool();
|
||||||
|
|
||||||
showDragSelectionCount = settings->value("interface/showlassoselectioncount", true).toBool();
|
showDragSelectionCount = settings->value("interface/showlassoselectioncount", true).toBool();
|
||||||
showTotalSelectionCount = settings->value("interface/showpersistentselectioncount", true).toBool();
|
showTotalSelectionCount = settings->value("interface/showpersistentselectioncount", true).toBool();
|
||||||
|
|
@ -370,6 +371,7 @@ SettingsCache::SettingsCache()
|
||||||
|
|
||||||
openDeckInNewTab = settings->value("editor/openDeckInNewTab", false).toBool();
|
openDeckInNewTab = settings->value("editor/openDeckInNewTab", false).toBool();
|
||||||
rewindBufferingMs = settings->value("replay/rewindBufferingMs", 200).toInt();
|
rewindBufferingMs = settings->value("replay/rewindBufferingMs", 200).toInt();
|
||||||
|
styleUserList = settings->value("appearance/styleUserList", true).toBool();
|
||||||
chatMention = settings->value("chat/mention", true).toBool();
|
chatMention = settings->value("chat/mention", true).toBool();
|
||||||
chatMentionCompleter = settings->value("chat/mentioncompleter", true).toBool();
|
chatMentionCompleter = settings->value("chat/mentioncompleter", true).toBool();
|
||||||
chatMentionForeground = settings->value("chat/mentionforeground", 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);
|
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)
|
void SettingsCache::setKnownMissingFeatures(const QString &_knownMissingFeatures)
|
||||||
{
|
{
|
||||||
knownMissingFeatures = _knownMissingFeatures;
|
knownMissingFeatures = _knownMissingFeatures;
|
||||||
|
|
@ -1037,6 +1046,13 @@ void SettingsCache::setRewindBufferingMs(int _rewindBufferingMs)
|
||||||
settings->setValue("replay/rewindBufferingMs", 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)
|
void SettingsCache::setChatMention(QT_STATE_CHANGED_T _chatMention)
|
||||||
{
|
{
|
||||||
chatMention = static_cast<bool>(_chatMention);
|
chatMention = static_cast<bool>(_chatMention);
|
||||||
|
|
|
||||||
|
|
@ -190,11 +190,13 @@ signals:
|
||||||
void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod);
|
void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod);
|
||||||
void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme);
|
void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme);
|
||||||
void masterVolumeChanged(int value);
|
void masterVolumeChanged(int value);
|
||||||
|
void styleUserListChanged();
|
||||||
void chatMentionCompleterChanged();
|
void chatMentionCompleterChanged();
|
||||||
void downloadSpoilerTimeIndexChanged();
|
void downloadSpoilerTimeIndexChanged();
|
||||||
void downloadSpoilerStatusChanged();
|
void downloadSpoilerStatusChanged();
|
||||||
void useTearOffMenusChanged(bool state);
|
void useTearOffMenusChanged(bool state);
|
||||||
void roundCardCornersChanged(bool roundCardCorners);
|
void roundCardCornersChanged(bool roundCardCorners);
|
||||||
|
void keepGameChatFocusChanged(bool value);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QSettings *settings;
|
QSettings *settings;
|
||||||
|
|
@ -283,6 +285,7 @@ private:
|
||||||
bool autoRotateSidewaysLayoutCards;
|
bool autoRotateSidewaysLayoutCards;
|
||||||
bool openDeckInNewTab;
|
bool openDeckInNewTab;
|
||||||
int rewindBufferingMs;
|
int rewindBufferingMs;
|
||||||
|
bool styleUserList;
|
||||||
bool chatMention;
|
bool chatMention;
|
||||||
bool chatMentionCompleter;
|
bool chatMentionCompleter;
|
||||||
QString chatMentionColor;
|
QString chatMentionColor;
|
||||||
|
|
@ -306,6 +309,7 @@ private:
|
||||||
int cardViewExpandedRowsMax;
|
int cardViewExpandedRowsMax;
|
||||||
bool closeEmptyCardView;
|
bool closeEmptyCardView;
|
||||||
bool focusCardViewSearchBar;
|
bool focusCardViewSearchBar;
|
||||||
|
bool keepGameChatFocus;
|
||||||
int pixmapCacheSize;
|
int pixmapCacheSize;
|
||||||
int networkCacheSize;
|
int networkCacheSize;
|
||||||
int redirectCacheTtl;
|
int redirectCacheTtl;
|
||||||
|
|
@ -736,6 +740,10 @@ public:
|
||||||
{
|
{
|
||||||
return rewindBufferingMs;
|
return rewindBufferingMs;
|
||||||
}
|
}
|
||||||
|
[[nodiscard]] bool getStyleUserList() const
|
||||||
|
{
|
||||||
|
return styleUserList;
|
||||||
|
}
|
||||||
[[nodiscard]] bool getChatMention() const
|
[[nodiscard]] bool getChatMention() const
|
||||||
{
|
{
|
||||||
return chatMention;
|
return chatMention;
|
||||||
|
|
@ -935,6 +943,7 @@ public:
|
||||||
void setCardViewExpandedRowsMax(int value);
|
void setCardViewExpandedRowsMax(int value);
|
||||||
void setCloseEmptyCardView(QT_STATE_CHANGED_T value);
|
void setCloseEmptyCardView(QT_STATE_CHANGED_T value);
|
||||||
void setFocusCardViewSearchBar(QT_STATE_CHANGED_T value);
|
void setFocusCardViewSearchBar(QT_STATE_CHANGED_T value);
|
||||||
|
void setKeepGameChatFocus(QT_STATE_CHANGED_T value);
|
||||||
QString getClientID() override
|
QString getClientID() override
|
||||||
{
|
{
|
||||||
return clientID;
|
return clientID;
|
||||||
|
|
@ -967,6 +976,10 @@ public:
|
||||||
{
|
{
|
||||||
return focusCardViewSearchBar;
|
return focusCardViewSearchBar;
|
||||||
}
|
}
|
||||||
|
[[nodiscard]] bool getKeepGameChatFocus() const
|
||||||
|
{
|
||||||
|
return keepGameChatFocus;
|
||||||
|
}
|
||||||
[[nodiscard]] ShortcutsSettings &shortcuts() const
|
[[nodiscard]] ShortcutsSettings &shortcuts() const
|
||||||
{
|
{
|
||||||
return *shortcutsSettings;
|
return *shortcutsSettings;
|
||||||
|
|
@ -1106,6 +1119,7 @@ public slots:
|
||||||
void setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards);
|
void setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards);
|
||||||
void setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab);
|
void setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab);
|
||||||
void setRewindBufferingMs(int _rewindBufferingMs);
|
void setRewindBufferingMs(int _rewindBufferingMs);
|
||||||
|
void setStyleUserList(QT_STATE_CHANGED_T _styleUserList);
|
||||||
void setChatMention(QT_STATE_CHANGED_T _chatMention);
|
void setChatMention(QT_STATE_CHANGED_T _chatMention);
|
||||||
void setChatMentionCompleter(QT_STATE_CHANGED_T _chatMentionCompleter);
|
void setChatMentionCompleter(QT_STATE_CHANGED_T _chatMentionCompleter);
|
||||||
void setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground);
|
void setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground);
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,10 @@ private:
|
||||||
{"TabDeckEditor/aLoadDeck", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck..."),
|
{"TabDeckEditor/aLoadDeck", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck..."),
|
||||||
parseSequenceString("Ctrl+O"),
|
parseSequenceString("Ctrl+O"),
|
||||||
ShortcutGroup::Deck_Editor)},
|
ShortcutGroup::Deck_Editor)},
|
||||||
|
{"TabDeckEditor/aLoadDeckFromWebsite",
|
||||||
|
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load deck from online service..."),
|
||||||
|
parseSequenceString("Ctrl+Shift+O"),
|
||||||
|
ShortcutGroup::Deck_Editor)},
|
||||||
{"TabDeckEditor/aLoadDeckFromClipboard",
|
{"TabDeckEditor/aLoadDeckFromClipboard",
|
||||||
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck from Clipboard..."),
|
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck from Clipboard..."),
|
||||||
parseSequenceString("Ctrl+Shift+V"),
|
parseSequenceString("Ctrl+Shift+V"),
|
||||||
|
|
@ -283,6 +287,10 @@ private:
|
||||||
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck from Clipboard..."),
|
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck from Clipboard..."),
|
||||||
parseSequenceString("Ctrl+Shift+V"),
|
parseSequenceString("Ctrl+Shift+V"),
|
||||||
ShortcutGroup::Game_Lobby)},
|
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"),
|
{"DeckViewContainer/unloadDeckButton", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Unload Deck"),
|
||||||
parseSequenceString("Ctrl+Alt+U"),
|
parseSequenceString("Ctrl+Alt+U"),
|
||||||
ShortcutGroup::Game_Lobby)},
|
ShortcutGroup::Game_Lobby)},
|
||||||
|
|
|
||||||
|
|
@ -882,7 +882,8 @@ void PlayerActions::actCreateToken(TokenInfo tokenToCreate)
|
||||||
ExactCard correctedCard = CardDatabaseManager::query()->guessCard({lastTokenInfo.name, lastTokenInfo.providerId});
|
ExactCard correctedCard = CardDatabaseManager::query()->guessCard({lastTokenInfo.name, lastTokenInfo.providerId});
|
||||||
if (correctedCard) {
|
if (correctedCard) {
|
||||||
lastTokenInfo.name = correctedCard.getName();
|
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()) {
|
if (lastTokenInfo.pt.isEmpty()) {
|
||||||
lastTokenInfo.pt = correctedCard.getInfo().getPowTough();
|
lastTokenInfo.pt = correctedCard.getInfo().getPowTough();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,16 @@ void DeckViewScene::rebuildTree()
|
||||||
return;
|
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()) {
|
for (auto *currentZone : deck->getZoneNodes()) {
|
||||||
DeckViewCardContainer *container = cardContainers.value(currentZone->getName(), 0);
|
DeckViewCardContainer *container = cardContainers.value(currentZone->getName(), 0);
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@ void DeckViewContainer::refreshShortcuts()
|
||||||
loadLocalButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadLocalButton"));
|
loadLocalButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadLocalButton"));
|
||||||
loadRemoteButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadRemoteButton"));
|
loadRemoteButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadRemoteButton"));
|
||||||
loadFromClipboardButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadFromClipboardButton"));
|
loadFromClipboardButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadFromClipboardButton"));
|
||||||
|
loadFromWebsiteButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadFromWebsiteButton"));
|
||||||
unloadDeckButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/unloadDeckButton"));
|
unloadDeckButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/unloadDeckButton"));
|
||||||
readyStartButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/readyStartButton"));
|
readyStartButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/readyStartButton"));
|
||||||
sideboardLockButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/sideboardLockButton"));
|
sideboardLockButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/sideboardLockButton"));
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
|
||||||
{
|
{
|
||||||
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
|
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
|
||||||
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
|
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
|
||||||
setFocusPolicy(Qt::ClickFocus);
|
|
||||||
setViewportUpdateMode(BoundingRectViewportUpdate);
|
setViewportUpdateMode(BoundingRectViewportUpdate);
|
||||||
|
|
||||||
connect(scene, &GameScene::sceneRectChanged, this, &GameView::updateSceneRect);
|
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, &GameScene::sigStopRubberBand, this, &GameView::stopRubberBand);
|
||||||
connect(scene, &QGraphicsScene::selectionChanged, this, [this]() { updateTotalSelectionCount(); });
|
connect(scene, &QGraphicsScene::selectionChanged, this, [this]() { updateTotalSelectionCount(); });
|
||||||
|
|
||||||
|
setFocusDisabled(SettingsCache::instance().getKeepGameChatFocus());
|
||||||
|
connect(&SettingsCache::instance(), &SettingsCache::keepGameChatFocusChanged, this, &GameView::setFocusDisabled);
|
||||||
|
|
||||||
aCloseMostRecentZoneView = new QAction(this);
|
aCloseMostRecentZoneView = new QAction(this);
|
||||||
|
|
||||||
connect(aCloseMostRecentZoneView, &QAction::triggered, scene, &GameScene::closeMostRecentZoneView);
|
connect(aCloseMostRecentZoneView, &QAction::triggered, scene, &GameScene::closeMostRecentZoneView);
|
||||||
|
|
@ -186,3 +188,12 @@ void GameView::updateTotalSelectionCount(const QSize &viewSize)
|
||||||
totalCountLabel->hide();
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ private slots:
|
||||||
void stopRubberBand();
|
void stopRubberBand();
|
||||||
void refreshShortcuts();
|
void refreshShortcuts();
|
||||||
void updateTotalSelectionCount(const QSize &viewSize = QSize());
|
void updateTotalSelectionCount(const QSize &viewSize = QSize());
|
||||||
|
void setFocusDisabled(bool disabled);
|
||||||
public slots:
|
public slots:
|
||||||
void updateSceneRect(const QRectF &rect);
|
void updateSceneRect(const QRectF &rect);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,11 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
|
||||||
searchEditProxy->setZValue(ZValues::DRAG_ITEM);
|
searchEditProxy->setZValue(ZValues::DRAG_ITEM);
|
||||||
vbox->addItem(searchEditProxy);
|
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
|
// top row
|
||||||
QGraphicsLinearLayout *hTopRow = new QGraphicsLinearLayout(Qt::Horizontal);
|
QGraphicsLinearLayout *hTopRow = new QGraphicsLinearLayout(Qt::Horizontal);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,25 @@ void DlgUpdate::downloadError(const QString &errorString)
|
||||||
void DlgUpdate::downloadSuccessful(const QUrl &filepath)
|
void DlgUpdate::downloadSuccessful(const QUrl &filepath)
|
||||||
{
|
{
|
||||||
setLabel(tr("Installing..."));
|
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
|
// Try to open the installer. If it opens, quit Cockatrice
|
||||||
if (QProcess::startDetached(
|
if (process.startDetached()) {
|
||||||
QString("\"%1\" /R /D=\"%2\"").arg(filepath.toLocalFile(), QCoreApplication::applicationDirPath()))) {
|
|
||||||
QMetaObject::invokeMethod(static_cast<MainWindow *>(parent()), "close", Qt::QueuedConnection);
|
QMetaObject::invokeMethod(static_cast<MainWindow *>(parent()), "close", Qt::QueuedConnection);
|
||||||
qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice";
|
qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice";
|
||||||
close();
|
close();
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,8 @@ void DeckEditorMenu::refreshShortcuts()
|
||||||
aEditDeckInClipboardRaw->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aEditDeckInClipboardRaw"));
|
aEditDeckInClipboardRaw->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aEditDeckInClipboardRaw"));
|
||||||
aPrintDeck->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aPrintDeck"));
|
aPrintDeck->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aPrintDeck"));
|
||||||
|
|
||||||
|
aLoadDeckFromWebsite->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aLoadDeckFromWebsite"));
|
||||||
|
|
||||||
aExportDeckDecklist->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aExportDeckDecklist"));
|
aExportDeckDecklist->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aExportDeckDecklist"));
|
||||||
aExportDeckDecklistXyz->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aExportDeckDecklistXyz"));
|
aExportDeckDecklistXyz->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aExportDeckDecklistXyz"));
|
||||||
aAnalyzeDeckDeckstats->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aAnalyzeDeck"));
|
aAnalyzeDeckDeckstats->setShortcuts(shortcuts.getShortcut("TabDeckEditor/aAnalyzeDeck"));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 ¶ms);
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -542,3 +542,113 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
||||||
|
|
||||||
delete menu;
|
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);
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,27 @@ public:
|
||||||
int playerId,
|
int playerId,
|
||||||
const QString &deckHash,
|
const QString &deckHash,
|
||||||
ChatView *chatView = nullptr);
|
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
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include "../../interface/widgets/dialogs/dlg_edit_password.h"
|
#include "../../interface/widgets/dialogs/dlg_edit_password.h"
|
||||||
#include "../../interface/widgets/dialogs/dlg_edit_user.h"
|
#include "../../interface/widgets/dialogs/dlg_edit_user.h"
|
||||||
#include "../../interface/widgets/utility/get_text_with_max.h"
|
#include "../../interface/widgets/utility/get_text_with_max.h"
|
||||||
|
#include "user_card_settings_dialog.h"
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QGridLayout>
|
#include <QGridLayout>
|
||||||
|
|
@ -61,11 +62,13 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren
|
||||||
buttonsLayout->addWidget(&editButton);
|
buttonsLayout->addWidget(&editButton);
|
||||||
buttonsLayout->addWidget(&passwordButton);
|
buttonsLayout->addWidget(&passwordButton);
|
||||||
buttonsLayout->addWidget(&avatarButton);
|
buttonsLayout->addWidget(&avatarButton);
|
||||||
|
buttonsLayout->addWidget(&bannerCardButton);
|
||||||
mainLayout->addLayout(buttonsLayout, 7, 0, 1, 3);
|
mainLayout->addLayout(buttonsLayout, 7, 0, 1, 3);
|
||||||
|
|
||||||
connect(&editButton, &QPushButton::clicked, this, &UserInfoBox::actEdit);
|
connect(&editButton, &QPushButton::clicked, this, &UserInfoBox::actEdit);
|
||||||
connect(&passwordButton, &QPushButton::clicked, this, &UserInfoBox::actPassword);
|
connect(&passwordButton, &QPushButton::clicked, this, &UserInfoBox::actPassword);
|
||||||
connect(&avatarButton, &QPushButton::clicked, this, &UserInfoBox::actAvatar);
|
connect(&avatarButton, &QPushButton::clicked, this, &UserInfoBox::actAvatar);
|
||||||
|
connect(&bannerCardButton, &QPushButton::clicked, this, &UserInfoBox::actBannerCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
setWindowTitle(tr("User Information"));
|
setWindowTitle(tr("User Information"));
|
||||||
|
|
@ -83,11 +86,15 @@ void UserInfoBox::retranslateUi()
|
||||||
editButton.setText(tr("Edit"));
|
editButton.setText(tr("Edit"));
|
||||||
passwordButton.setText(tr("Change password"));
|
passwordButton.setText(tr("Change password"));
|
||||||
avatarButton.setText(tr("Change avatar"));
|
avatarButton.setText(tr("Change avatar"));
|
||||||
|
bannerCardButton.setText(tr("Edit Banner Card"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserInfoBox::updateInfo(const ServerInfo_User &user)
|
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();
|
pawnColors = user.pawn_colors();
|
||||||
privLevel = QString::fromStdString(user.privlevel());
|
privLevel = QString::fromStdString(user.privlevel());
|
||||||
|
|
||||||
|
|
@ -306,6 +313,48 @@ void UserInfoBox::actAvatar()
|
||||||
client->sendCommand(pend);
|
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)
|
void UserInfoBox::processEditResponse(const Response &r)
|
||||||
{
|
{
|
||||||
switch (r.response_code()) {
|
switch (r.response_code()) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <libcockatrice/network/server/remote/user_level.h>
|
#include <libcockatrice/network/server/remote/user_level.h>
|
||||||
|
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||||
#include <libcockatrice/utility/days_years_between.h>
|
#include <libcockatrice/utility/days_years_between.h>
|
||||||
|
|
||||||
class AbstractClient;
|
class AbstractClient;
|
||||||
|
|
@ -25,9 +26,11 @@ private:
|
||||||
bool editable;
|
bool editable;
|
||||||
QLabel avatarPic, userLevelIcon, nameLabel, realNameLabel1, realNameLabel2, countryLabel1, countryLabel2,
|
QLabel avatarPic, userLevelIcon, nameLabel, realNameLabel1, realNameLabel2, countryLabel1, countryLabel2,
|
||||||
countryLabel3, userLevelLabel1, userLevelLabel2, accountAgeLabel1, accountAgeLabel2;
|
countryLabel3, userLevelLabel1, userLevelLabel2, accountAgeLabel1, accountAgeLabel2;
|
||||||
QPushButton editButton, passwordButton, avatarButton;
|
QPushButton editButton, passwordButton, avatarButton, bannerCardButton;
|
||||||
QPixmap avatarPixmap;
|
QPixmap avatarPixmap;
|
||||||
bool hasAvatar;
|
bool hasAvatar;
|
||||||
|
ServerInfo_User currentUserInfo;
|
||||||
|
bool hasUserInfo = false;
|
||||||
UserLevelFlags userLevel;
|
UserLevelFlags userLevel;
|
||||||
ServerInfo_User::PawnColorsOverride pawnColors;
|
ServerInfo_User::PawnColorsOverride pawnColors;
|
||||||
QString privLevel;
|
QString privLevel;
|
||||||
|
|
@ -37,6 +40,7 @@ private:
|
||||||
public:
|
public:
|
||||||
UserInfoBox(AbstractClient *_client, bool editable, QWidget *parent = nullptr, Qt::WindowFlags flags = {});
|
UserInfoBox(AbstractClient *_client, bool editable, QWidget *parent = nullptr, Qt::WindowFlags flags = {});
|
||||||
void retranslateUi();
|
void retranslateUi();
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void processResponse(const Response &r);
|
void processResponse(const Response &r);
|
||||||
void processEditResponse(const Response &r);
|
void processEditResponse(const Response &r);
|
||||||
|
|
@ -47,6 +51,7 @@ private slots:
|
||||||
void actEditInternal(const Response &r);
|
void actEditInternal(const Response &r);
|
||||||
void actPassword();
|
void actPassword();
|
||||||
void actAvatar();
|
void actAvatar();
|
||||||
|
void actBannerCard();
|
||||||
public slots:
|
public slots:
|
||||||
void updateInfo(const ServerInfo_User &user);
|
void updateInfo(const ServerInfo_User &user);
|
||||||
void updateInfo(const QString &userName);
|
void updateInfo(const QString &userName);
|
||||||
|
|
|
||||||
656
cockatrice/src/interface/widgets/server/user/user_info_popup.cpp
Normal file
656
cockatrice/src/interface/widgets/server/user/user_info_popup.cpp
Normal 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 ¶ms)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
181
cockatrice/src/interface/widgets/server/user/user_info_popup.h
Normal file
181
cockatrice/src/interface/widgets/server/user/user_info_popup.h
Normal 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 ¶ms);
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -42,6 +42,9 @@ void UserListManager::handleDisconnect()
|
||||||
|
|
||||||
delete ownUserInfo;
|
delete ownUserInfo;
|
||||||
ownUserInfo = nullptr;
|
ownUserInfo = nullptr;
|
||||||
|
|
||||||
|
// Full rebuild — all lists are gone
|
||||||
|
emit listReset();
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListManager::setOwnUserInfo(const ServerInfo_User &userInfo)
|
void UserListManager::setOwnUserInfo(const ServerInfo_User &userInfo)
|
||||||
|
|
@ -63,74 +66,77 @@ void UserListManager::processListUsersResponse(const Response &response)
|
||||||
const int userListSize = resp.user_list_size();
|
const int userListSize = resp.user_list_size();
|
||||||
for (int i = 0; i < userListSize; ++i) {
|
for (int i = 0; i < userListSize; ++i) {
|
||||||
const ServerInfo_User &info = resp.user_list(i);
|
const ServerInfo_User &info = resp.user_list(i);
|
||||||
const QString &userName = QString::fromStdString(info.name());
|
onlineUsers.insert(QString::fromStdString(info.name()), info);
|
||||||
onlineUsers.insert(userName, info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk load complete — widgets rebuild once from the now-populated map
|
||||||
|
emit listReset();
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListManager::processUserJoinedEvent(const Event_UserJoined &event)
|
void UserListManager::processUserJoinedEvent(const Event_UserJoined &event)
|
||||||
{
|
{
|
||||||
const auto &info = event.user_info();
|
const auto &info = event.user_info();
|
||||||
const QString &userName = QString::fromStdString(info.name());
|
const QString name = QString::fromStdString(info.name());
|
||||||
onlineUsers.insert(userName, info);
|
onlineUsers.insert(name, info);
|
||||||
|
|
||||||
|
emit userJoinedOnline(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListManager::processUserLeftEvent(const Event_UserLeft &event)
|
void UserListManager::processUserLeftEvent(const Event_UserLeft &event)
|
||||||
{
|
{
|
||||||
const auto &userName = QString::fromStdString(event.name());
|
const QString name = QString::fromStdString(event.name());
|
||||||
onlineUsers.remove(userName);
|
onlineUsers.remove(name);
|
||||||
|
|
||||||
|
emit userLeftOnline(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListManager::buddyListReceived(const QList<ServerInfo_User> &_buddyList)
|
void UserListManager::buddyListReceived(const QList<ServerInfo_User> &_buddyList)
|
||||||
{
|
{
|
||||||
for (const auto &user : _buddyList) {
|
for (const auto &user : _buddyList) {
|
||||||
const auto &userName = QString::fromStdString(user.name());
|
buddyUsers.insert(QString::fromStdString(user.name()), user);
|
||||||
buddyUsers.insert(userName, user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk load — one reset covers all newly added entries
|
||||||
|
emit listReset();
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListManager::ignoreListReceived(const QList<ServerInfo_User> &_ignoreList)
|
void UserListManager::ignoreListReceived(const QList<ServerInfo_User> &_ignoreList)
|
||||||
{
|
{
|
||||||
for (const auto &user : _ignoreList) {
|
for (const auto &user : _ignoreList) {
|
||||||
const auto &userName = QString::fromStdString(user.name());
|
ignoredUsers.insert(QString::fromStdString(user.name()), user);
|
||||||
ignoredUsers.insert(userName, user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk load — one reset covers all newly added entries
|
||||||
|
emit listReset();
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListManager::processAddToListEvent(const Event_AddToList &event)
|
void UserListManager::processAddToListEvent(const Event_AddToList &event)
|
||||||
{
|
{
|
||||||
const auto &user = event.user_info();
|
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());
|
if (listType == "buddy") {
|
||||||
|
buddyUsers.insert(userName, user);
|
||||||
QMap<QString, ServerInfo_User> *userMap;
|
emit addedToBuddyList(user);
|
||||||
if (userListType == "buddy") {
|
} else if (listType == "ignore") {
|
||||||
userMap = &buddyUsers;
|
ignoredUsers.insert(userName, user);
|
||||||
} else if (userListType == "ignore") {
|
emit addedToIgnoreList(user);
|
||||||
userMap = &ignoredUsers;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userMap->insert(userName, user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListManager::processRemoveFromListEvent(const Event_RemoveFromList &event)
|
void UserListManager::processRemoveFromListEvent(const Event_RemoveFromList &event)
|
||||||
{
|
{
|
||||||
const auto &userListType = QString::fromStdString(event.list_name());
|
const QString listType = QString::fromStdString(event.list_name());
|
||||||
const auto &userName = QString::fromStdString(event.user_name());
|
const QString userName = QString::fromStdString(event.user_name());
|
||||||
|
|
||||||
QMap<QString, ServerInfo_User> *userMap;
|
if (listType == "buddy") {
|
||||||
if (userListType == "buddy") {
|
buddyUsers.remove(userName);
|
||||||
userMap = &buddyUsers;
|
emit removedFromBuddyList(userName);
|
||||||
} else if (userListType == "ignore") {
|
} else if (listType == "ignore") {
|
||||||
userMap = &ignoredUsers;
|
ignoredUsers.remove(userName);
|
||||||
} else {
|
emit removedFromIgnoreList(userName);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userMap->remove(userName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool UserListManager::isOwnUserRegistered() const
|
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 ServerInfo_User *UserListManager::getOnlineUser(const QString &userName) const
|
||||||
{
|
{
|
||||||
const QString &userNameToMatchLower = userName.toLower();
|
const QString lower = userName.toLower();
|
||||||
|
const auto it = std::find_if(onlineUsers.begin(), onlineUsers.end(), [&lower](const ServerInfo_User &user) {
|
||||||
const auto it =
|
return lower == QString::fromStdString(user.name()).toLower();
|
||||||
std::find_if(onlineUsers.begin(), onlineUsers.end(), [&userNameToMatchLower](const ServerInfo_User &user) {
|
});
|
||||||
return userNameToMatchLower == QString::fromStdString(user.name()).toLower();
|
return it != onlineUsers.end() ? &*it : nullptr;
|
||||||
});
|
|
||||||
|
|
||||||
if (it != onlineUsers.end()) {
|
|
||||||
return &*it;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nullptr;
|
|
||||||
}
|
}
|
||||||
|
|
@ -47,15 +47,17 @@ public:
|
||||||
explicit UserListManager(AbstractClient *_client, QObject *parent = nullptr);
|
explicit UserListManager(AbstractClient *_client, QObject *parent = nullptr);
|
||||||
~UserListManager() override;
|
~UserListManager() override;
|
||||||
|
|
||||||
[[nodiscard]] QMap<QString, ServerInfo_User> getAllUsersList() const
|
[[nodiscard]] const QMap<QString, ServerInfo_User> &getAllUsersList() const
|
||||||
{
|
{
|
||||||
return onlineUsers;
|
return onlineUsers;
|
||||||
}
|
}
|
||||||
[[nodiscard]] QMap<QString, ServerInfo_User> getBuddyList() const
|
|
||||||
|
[[nodiscard]] const QMap<QString, ServerInfo_User> &getBuddyList() const
|
||||||
{
|
{
|
||||||
return buddyUsers;
|
return buddyUsers;
|
||||||
}
|
}
|
||||||
[[nodiscard]] QMap<QString, ServerInfo_User> getIgnoreList() const
|
|
||||||
|
[[nodiscard]] const QMap<QString, ServerInfo_User> &getIgnoreList() const
|
||||||
{
|
{
|
||||||
return ignoredUsers;
|
return ignoredUsers;
|
||||||
}
|
}
|
||||||
|
|
@ -71,8 +73,26 @@ public slots:
|
||||||
void handleDisconnect();
|
void handleDisconnect();
|
||||||
|
|
||||||
signals:
|
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
|
#endif // COCKATRICE_USER_LIST_MANAGER_H
|
||||||
|
|
|
||||||
|
|
@ -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 ¶ms,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -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 ¶ms,
|
||||||
|
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
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
#include "user_list_widget.h"
|
#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/pixel_map_generator.h"
|
||||||
#include "../../interface/widgets/tabs/tab_account.h"
|
#include "../../interface/widgets/tabs/tab_account.h"
|
||||||
#include "../../interface/widgets/tabs/tab_supervisor.h"
|
#include "../../interface/widgets/tabs/tab_supervisor.h"
|
||||||
#include "../game_selector.h"
|
#include "../game_selector.h"
|
||||||
#include "user_context_menu.h"
|
#include "user_context_menu.h"
|
||||||
|
#include "user_list_painter.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCheckBox>
|
#include <QCheckBox>
|
||||||
|
|
@ -15,13 +18,18 @@
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
#include <QPlainTextEdit>
|
#include <QPlainTextEdit>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QRadioButton>
|
#include <QRadioButton>
|
||||||
#include <QSpinBox>
|
#include <QSpinBox>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
#include <libcockatrice/card/database/card_database_manager.h>
|
||||||
#include <libcockatrice/network/client/abstract/abstract_client.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_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>
|
#include <libcockatrice/utility/trice_limits.h>
|
||||||
|
|
||||||
BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent)
|
BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent)
|
||||||
|
|
@ -308,7 +316,18 @@ QString AdminNotesDialog::getNotes() const
|
||||||
return notes->toPlainText();
|
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);
|
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)
|
UserListTWI::UserListTWI(const ServerInfo_User &_userInfo) : QTreeWidgetItem(Type)
|
||||||
{
|
{
|
||||||
setUserInfo(_userInfo);
|
setUserInfo(_userInfo);
|
||||||
|
|
@ -347,11 +392,12 @@ void UserListTWI::setUserInfo(const ServerInfo_User &_userInfo)
|
||||||
setData(2, Qt::UserRole, QString::fromStdString(userInfo.name()));
|
setData(2, Qt::UserRole, QString::fromStdString(userInfo.name()));
|
||||||
setData(2, Qt::DisplayRole, QString::fromStdString(userInfo.name()));
|
setData(2, Qt::DisplayRole, QString::fromStdString(userInfo.name()));
|
||||||
setData(3, Qt::InitialSortOrderRole, QString::fromStdString(userInfo.privlevel()));
|
setData(3, Qt::InitialSortOrderRole, QString::fromStdString(userInfo.privlevel()));
|
||||||
|
setData(0, UserListRoles::UserInfo, QVariant::fromValue(userInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListTWI::setOnline(bool online)
|
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));
|
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
|
bool UserListTWI::operator<(const QTreeWidgetItem &other) const
|
||||||
{
|
{
|
||||||
// Sort by online/offline
|
// Sort by online/offline
|
||||||
if (data(0, Qt::UserRole + 1) != other.data(0, Qt::UserRole + 1)) {
|
if (data(0, UserListRoles::Online) != other.data(0, UserListRoles::Online)) {
|
||||||
return data(0, Qt::UserRole + 1).toBool();
|
return data(0, UserListRoles::Online).toBool();
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt());
|
const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt());
|
||||||
|
|
@ -418,20 +464,100 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
QWidget *parent)
|
QWidget *parent)
|
||||||
: QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0)
|
: 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);
|
userContextMenu = new UserContextMenu(tabSupervisor, this);
|
||||||
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog);
|
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog);
|
||||||
|
|
||||||
userTree = new QTreeWidget;
|
userTree = new QTreeWidget;
|
||||||
userTree->setColumnCount(3);
|
userTree->setColumnCount(4); // 0=display, 1=flag(hidden), 2=name(hidden), 3=privlevel(hidden)
|
||||||
userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents);
|
userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
|
||||||
userTree->header()->setMinimumSectionSize(0);
|
userTree->header()->setMinimumSectionSize(0);
|
||||||
userTree->setHeaderHidden(true);
|
userTree->setHeaderHidden(true);
|
||||||
userTree->setRootIsDecorated(false);
|
userTree->setRootIsDecorated(false);
|
||||||
userTree->setIconSize(QSize(20, 18));
|
userTree->setIconSize(QSize(20, 18));
|
||||||
userTree->setItemDelegate(itemDelegate);
|
userTree->setItemDelegate(itemDelegate);
|
||||||
userTree->setAlternatingRowColors(true);
|
userTree->setAlternatingRowColors(true);
|
||||||
|
userTree->hideColumn(1);
|
||||||
|
userTree->hideColumn(2);
|
||||||
|
userTree->hideColumn(3);
|
||||||
connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked);
|
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;
|
QVBoxLayout *vbox = new QVBoxLayout;
|
||||||
vbox->addWidget(userTree);
|
vbox->addWidget(userTree);
|
||||||
|
|
@ -441,6 +567,280 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
retranslateUi();
|
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()
|
void UserListWidget::retranslateUi()
|
||||||
{
|
{
|
||||||
userContextMenu->retranslateUi();
|
userContextMenu->retranslateUi();
|
||||||
|
|
@ -461,9 +861,59 @@ void UserListWidget::retranslateUi()
|
||||||
updateCount();
|
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)
|
void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
|
||||||
{
|
{
|
||||||
const QString userName = QString::fromStdString(user.name());
|
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);
|
UserListTWI *item = users.value(userName);
|
||||||
if (item) {
|
if (item) {
|
||||||
item->setUserInfo(user);
|
item->setUserInfo(user);
|
||||||
|
|
@ -475,25 +925,28 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
|
||||||
++onlineCount;
|
++onlineCount;
|
||||||
}
|
}
|
||||||
updateCount();
|
updateCount();
|
||||||
|
avatarProvider->requestAvatar(userName);
|
||||||
}
|
}
|
||||||
item->setOnline(online);
|
item->setOnline(online);
|
||||||
|
sortItems();
|
||||||
|
userTree->viewport()->update();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool UserListWidget::deleteUser(const QString &userName)
|
bool UserListWidget::deleteUser(const QString &userName)
|
||||||
{
|
{
|
||||||
UserListTWI *twi = users.value(userName);
|
UserListTWI *twi = users.value(userName);
|
||||||
if (twi) {
|
if (!twi) {
|
||||||
users.remove(userName);
|
return false;
|
||||||
userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi));
|
|
||||||
if (twi->data(0, Qt::UserRole + 1).toBool()) {
|
|
||||||
--onlineCount;
|
|
||||||
}
|
|
||||||
delete twi;
|
|
||||||
updateCount();
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
void UserListWidget::setUserOnline(const QString &userName, bool online)
|
||||||
|
|
@ -537,5 +990,5 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index
|
||||||
|
|
||||||
void UserListWidget::sortItems()
|
void UserListWidget::sortItems()
|
||||||
{
|
{
|
||||||
userTree->sortItems(1, Qt::AscendingOrder);
|
userTree->sortItems(0, Qt::AscendingOrder);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,17 @@
|
||||||
#ifndef USERLIST_H
|
#ifndef USERLIST_H
|
||||||
#define 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 <QComboBox>
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
#include <QGroupBox>
|
#include <QGroupBox>
|
||||||
|
#include <QQueue>
|
||||||
#include <QStyledItemDelegate>
|
#include <QStyledItemDelegate>
|
||||||
#include <QTextEdit>
|
#include <QTextEdit>
|
||||||
#include <QTreeWidgetItem>
|
#include <QTreeWidgetItem>
|
||||||
|
|
@ -94,12 +102,21 @@ public:
|
||||||
|
|
||||||
class UserListItemDelegate : public QStyledItemDelegate
|
class UserListItemDelegate : public QStyledItemDelegate
|
||||||
{
|
{
|
||||||
|
const QMap<QString, QPixmap> *avatarCache;
|
||||||
|
const QMap<QString, QPixmap> *cardArtCache;
|
||||||
|
const QMap<QString, CardArtParams> *cardArtParamsMap;
|
||||||
|
|
||||||
public:
|
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,
|
bool editorEvent(QEvent *event,
|
||||||
QAbstractItemModel *model,
|
QAbstractItemModel *model,
|
||||||
const QStyleOptionViewItem &option,
|
const QStyleOptionViewItem &option,
|
||||||
const QModelIndex &index) override;
|
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
|
class UserListTWI : public QTreeWidgetItem
|
||||||
|
|
@ -131,6 +148,22 @@ public:
|
||||||
};
|
};
|
||||||
|
|
||||||
private:
|
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;
|
QMap<QString, UserListTWI *> users;
|
||||||
TabSupervisor *tabSupervisor;
|
TabSupervisor *tabSupervisor;
|
||||||
AbstractClient *client;
|
AbstractClient *client;
|
||||||
|
|
@ -141,6 +174,7 @@ private:
|
||||||
int onlineCount;
|
int onlineCount;
|
||||||
QString titleStr;
|
QString titleStr;
|
||||||
void updateCount();
|
void updateCount();
|
||||||
|
void refreshPopupButtons(const QString &userName);
|
||||||
private slots:
|
private slots:
|
||||||
void userClicked(QTreeWidgetItem *item, int column);
|
void userClicked(QTreeWidgetItem *item, int column);
|
||||||
signals:
|
signals:
|
||||||
|
|
@ -149,13 +183,18 @@ signals:
|
||||||
void removeBuddy(const QString &userName);
|
void removeBuddy(const QString &userName);
|
||||||
void addIgnore(const QString &userName);
|
void addIgnore(const QString &userName);
|
||||||
void removeIgnore(const QString &userName);
|
void removeIgnore(const QString &userName);
|
||||||
|
void joinGameRequested(int gameId, int roomId, bool asSpectator);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
UserListWidget(TabSupervisor *_tabSupervisor,
|
UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
AbstractClient *_client,
|
AbstractClient *_client,
|
||||||
UserListType _type,
|
UserListType _type,
|
||||||
QWidget *parent = nullptr);
|
QWidget *parent = nullptr);
|
||||||
|
void bind(UserListManager *mgr);
|
||||||
|
void applyDisplayMode();
|
||||||
|
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||||
void retranslateUi();
|
void retranslateUi();
|
||||||
|
void rebuild();
|
||||||
void processUserInfo(const ServerInfo_User &user, bool online);
|
void processUserInfo(const ServerInfo_User &user, bool online);
|
||||||
bool deleteUser(const QString &userName);
|
bool deleteUser(const QString &userName);
|
||||||
void setUserOnline(const QString &userName, bool online);
|
void setUserOnline(const QString &userName, bool online);
|
||||||
|
|
@ -165,6 +204,9 @@ public:
|
||||||
}
|
}
|
||||||
void showContextMenu(const QPoint &pos, const QModelIndex &index);
|
void showContextMenu(const QPoint &pos, const QModelIndex &index);
|
||||||
void sortItems();
|
void sortItems();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void hideEvent(QHideEvent *e) override;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,15 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
||||||
homeTabGroupBox = new QGroupBox;
|
homeTabGroupBox = new QGroupBox;
|
||||||
homeTabGroupBox->setLayout(homeTabGrid);
|
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
|
// Menu settings
|
||||||
showShortcutsCheckBox.setChecked(settings.getShowShortcuts());
|
showShortcutsCheckBox.setChecked(settings.getShowShortcuts());
|
||||||
connect(&showShortcutsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::showShortcutsChanged);
|
connect(&showShortcutsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::showShortcutsChanged);
|
||||||
|
|
@ -284,6 +293,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
||||||
auto *mainLayout = new QVBoxLayout;
|
auto *mainLayout = new QVBoxLayout;
|
||||||
mainLayout->addWidget(themeGroupBox);
|
mainLayout->addWidget(themeGroupBox);
|
||||||
mainLayout->addWidget(homeTabGroupBox);
|
mainLayout->addWidget(homeTabGroupBox);
|
||||||
|
mainLayout->addWidget(stylingGroupBox);
|
||||||
mainLayout->addWidget(menuGroupBox);
|
mainLayout->addWidget(menuGroupBox);
|
||||||
mainLayout->addWidget(printingsGroupBox);
|
mainLayout->addWidget(printingsGroupBox);
|
||||||
mainLayout->addWidget(cardsGroupBox);
|
mainLayout->addWidget(cardsGroupBox);
|
||||||
|
|
@ -398,6 +408,9 @@ void AppearanceSettingsPage::retranslateUi()
|
||||||
homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled"));
|
homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled"));
|
||||||
homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right"));
|
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"));
|
menuGroupBox->setTitle(tr("Menu settings"));
|
||||||
showShortcutsCheckBox.setText(tr("Show keyboard shortcuts in right-click menus"));
|
showShortcutsCheckBox.setText(tr("Show keyboard shortcuts in right-click menus"));
|
||||||
showGameSelectorFilterToolbarCheckBox.setText(tr("Show game filter toolbar above list in room tab"));
|
showGameSelectorFilterToolbarCheckBox.setText(tr("Show game filter toolbar above list in room tab"));
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ private:
|
||||||
QLabel homeTabBackgroundShuffleFrequencyLabel;
|
QLabel homeTabBackgroundShuffleFrequencyLabel;
|
||||||
QSpinBox homeTabBackgroundShuffleFrequencySpinBox;
|
QSpinBox homeTabBackgroundShuffleFrequencySpinBox;
|
||||||
QCheckBox homeTabDisplayCardNameCheckBox;
|
QCheckBox homeTabDisplayCardNameCheckBox;
|
||||||
|
QCheckBox styleUserListCheckBox;
|
||||||
QLabel minPlayersForMultiColumnLayoutLabel;
|
QLabel minPlayersForMultiColumnLayoutLabel;
|
||||||
QLabel maxFontSizeForCardsLabel;
|
QLabel maxFontSizeForCardsLabel;
|
||||||
QCheckBox showShortcutsCheckBox;
|
QCheckBox showShortcutsCheckBox;
|
||||||
|
|
@ -58,6 +59,7 @@ private:
|
||||||
QCheckBox invertVerticalCoordinateCheckBox;
|
QCheckBox invertVerticalCoordinateCheckBox;
|
||||||
QGroupBox *themeGroupBox;
|
QGroupBox *themeGroupBox;
|
||||||
QGroupBox *homeTabGroupBox;
|
QGroupBox *homeTabGroupBox;
|
||||||
|
QGroupBox *stylingGroupBox;
|
||||||
QGroupBox *menuGroupBox;
|
QGroupBox *menuGroupBox;
|
||||||
QGroupBox *printingsGroupBox;
|
QGroupBox *printingsGroupBox;
|
||||||
QGroupBox *cardsGroupBox;
|
QGroupBox *cardsGroupBox;
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
|
||||||
connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(),
|
connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(),
|
||||||
[](const QT_STATE_CHANGED_T state) { SettingsCache::instance().setUseTearOffMenus(state == Qt::Checked); });
|
[](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;
|
auto *generalGrid = new QGridLayout;
|
||||||
generalGrid->addWidget(&doubleClickToPlayCheckBox, 0, 0);
|
generalGrid->addWidget(&doubleClickToPlayCheckBox, 0, 0);
|
||||||
generalGrid->addWidget(&clickPlaysAllSelectedCheckBox, 1, 0);
|
generalGrid->addWidget(&clickPlaysAllSelectedCheckBox, 1, 0);
|
||||||
|
|
@ -83,6 +87,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
|
||||||
generalGrid->addWidget(&showDragSelectionCountCheckBox, 7, 0);
|
generalGrid->addWidget(&showDragSelectionCountCheckBox, 7, 0);
|
||||||
generalGrid->addWidget(&showTotalSelectionCountCheckBox, 8, 0);
|
generalGrid->addWidget(&showTotalSelectionCountCheckBox, 8, 0);
|
||||||
generalGrid->addWidget(&useTearOffMenusCheckBox, 9, 0);
|
generalGrid->addWidget(&useTearOffMenusCheckBox, 9, 0);
|
||||||
|
generalGrid->addWidget(&keepGameChatFocusCheckBox, 10, 0);
|
||||||
|
|
||||||
generalGroupBox = new QGroupBox;
|
generalGroupBox = new QGroupBox;
|
||||||
generalGroupBox->setLayout(generalGrid);
|
generalGroupBox->setLayout(generalGrid);
|
||||||
|
|
@ -207,6 +212,9 @@ void UserInterfaceSettingsPage::retranslateUi()
|
||||||
showDragSelectionCountCheckBox.setText(tr("Show selection counter during drag selection"));
|
showDragSelectionCountCheckBox.setText(tr("Show selection counter during drag selection"));
|
||||||
showTotalSelectionCountCheckBox.setText(tr("Show total selection counter"));
|
showTotalSelectionCountCheckBox.setText(tr("Show total selection counter"));
|
||||||
useTearOffMenusCheckBox.setText(tr("Use tear-off menus, allowing right click menus to persist on screen"));
|
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"));
|
notificationsGroupBox->setTitle(tr("Notifications settings"));
|
||||||
notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar"));
|
notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar"));
|
||||||
specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating"));
|
specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating"));
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ private:
|
||||||
QCheckBox showDragSelectionCountCheckBox;
|
QCheckBox showDragSelectionCountCheckBox;
|
||||||
QCheckBox showTotalSelectionCountCheckBox;
|
QCheckBox showTotalSelectionCountCheckBox;
|
||||||
QCheckBox useTearOffMenusCheckBox;
|
QCheckBox useTearOffMenusCheckBox;
|
||||||
|
QCheckBox keepGameChatFocusCheckBox;
|
||||||
QCheckBox tapAnimationCheckBox;
|
QCheckBox tapAnimationCheckBox;
|
||||||
QCheckBox openDeckInNewTabCheckBox;
|
QCheckBox openDeckInNewTabCheckBox;
|
||||||
QLabel visualDeckStoragePromptForConversionLabel;
|
QLabel visualDeckStoragePromptForConversionLabel;
|
||||||
|
|
|
||||||
246
cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp
Normal file
246
cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp
Normal 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();
|
||||||
|
}
|
||||||
89
cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h
Normal file
89
cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h
Normal 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
|
||||||
|
|
@ -49,10 +49,25 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
QMap<int, GameTypeMap> tempMap;
|
QMap<int, GameTypeMap> tempMap;
|
||||||
tempMap.insert(info.room_id(), gameTypes);
|
tempMap.insert(info.room_id(), gameTypes);
|
||||||
gameSelector = new GameSelector(client, tabSupervisor, this, QMap<int, QString>(), tempMap, true, true);
|
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 = 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,
|
connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this,
|
||||||
SIGNAL(openMessageDialog(const QString &, bool)));
|
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);
|
chatView = new ChatView(tabSupervisor, nullptr, true, this);
|
||||||
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
|
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
|
||||||
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
|
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
|
||||||
|
|
@ -101,7 +116,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
|
|
||||||
auto *hbox = new QHBoxLayout;
|
auto *hbox = new QHBoxLayout;
|
||||||
hbox->addWidget(splitter, 3);
|
hbox->addWidget(splitter, 3);
|
||||||
hbox->addWidget(userList, 1);
|
hbox->addWidget(tabs, 1);
|
||||||
|
|
||||||
aLeaveRoom = new QAction(this);
|
aLeaveRoom = new QAction(this);
|
||||||
connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest);
|
connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest);
|
||||||
|
|
@ -112,10 +127,8 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
|
|
||||||
const int userListSize = info.user_list_size();
|
const int userListSize = info.user_list_size();
|
||||||
for (int i = 0; i < userListSize; ++i) {
|
for (int i = 0; i < userListSize; ++i) {
|
||||||
userList->processUserInfo(info.user_list(i), true);
|
|
||||||
autocompleteUserList.append("@" + QString::fromStdString(info.user_list(i).name()));
|
autocompleteUserList.append("@" + QString::fromStdString(info.user_list(i).name()));
|
||||||
}
|
}
|
||||||
userList->sortItems();
|
|
||||||
|
|
||||||
const int gameListSize = info.game_list_size();
|
const int gameListSize = info.game_list_size();
|
||||||
for (int i = 0; i < gameListSize; ++i) {
|
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)
|
void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event)
|
||||||
{
|
{
|
||||||
userList->processUserInfo(event.user_info(), true);
|
|
||||||
userList->sortItems();
|
|
||||||
if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) {
|
if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) {
|
||||||
autocompleteUserList << "@" + QString::fromStdString(event.user_info().name());
|
autocompleteUserList << "@" + QString::fromStdString(event.user_info().name());
|
||||||
sayEdit->setCompletionList(autocompleteUserList);
|
sayEdit->setCompletionList(autocompleteUserList);
|
||||||
|
|
@ -279,7 +290,6 @@ void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event)
|
||||||
|
|
||||||
void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event)
|
void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event)
|
||||||
{
|
{
|
||||||
userList->deleteUser(QString::fromStdString(event.name()));
|
|
||||||
autocompleteUserList.removeOne("@" + QString::fromStdString(event.name()));
|
autocompleteUserList.removeOne("@" + QString::fromStdString(event.name()));
|
||||||
sayEdit->setCompletionList(autocompleteUserList);
|
sayEdit->setCompletionList(autocompleteUserList);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,9 @@ private:
|
||||||
QMap<int, QString> gameTypes;
|
QMap<int, QString> gameTypes;
|
||||||
|
|
||||||
GameSelector *gameSelector;
|
GameSelector *gameSelector;
|
||||||
|
UserListWidget *friendsList;
|
||||||
UserListWidget *userList;
|
UserListWidget *userList;
|
||||||
|
UserListWidget *ignoreList;
|
||||||
const UserListProxy *userListProxy;
|
const UserListProxy *userListProxy;
|
||||||
ChatView *chatView;
|
ChatView *chatView;
|
||||||
QLabel *sayLabel;
|
QLabel *sayLabel;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include "api/edhrec/tab_edhrec_main.h"
|
#include "api/edhrec/tab_edhrec_main.h"
|
||||||
#include "tab_account.h"
|
#include "tab_account.h"
|
||||||
#include "tab_admin.h"
|
#include "tab_admin.h"
|
||||||
|
#include "tab_card_art_rules.h"
|
||||||
#include "tab_deck_editor.h"
|
#include "tab_deck_editor.h"
|
||||||
#include "tab_deck_storage.h"
|
#include "tab_deck_storage.h"
|
||||||
#include "tab_game.h"
|
#include "tab_game.h"
|
||||||
|
|
@ -179,6 +180,10 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
|
||||||
aTabAdmin->setCheckable(true);
|
aTabAdmin->setCheckable(true);
|
||||||
connect(aTabAdmin, &QAction::triggered, this, &TabSupervisor::actTabAdmin);
|
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 = new QAction(this);
|
||||||
aTabLog->setCheckable(true);
|
aTabLog->setCheckable(true);
|
||||||
connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog);
|
connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog);
|
||||||
|
|
@ -435,6 +440,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
|
||||||
tabsMenu->addSeparator();
|
tabsMenu->addSeparator();
|
||||||
tabsMenu->addAction(aTabAdmin);
|
tabsMenu->addAction(aTabAdmin);
|
||||||
tabsMenu->addAction(aTabLog);
|
tabsMenu->addAction(aTabLog);
|
||||||
|
tabsMenu->addAction(aTabCardArtRules);
|
||||||
|
|
||||||
if (SettingsCache::instance().getTabAdminOpen()) {
|
if (SettingsCache::instance().getTabAdminOpen()) {
|
||||||
openTabAdmin();
|
openTabAdmin();
|
||||||
|
|
@ -442,6 +448,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
|
||||||
if (SettingsCache::instance().getTabLogOpen()) {
|
if (SettingsCache::instance().getTabLogOpen()) {
|
||||||
openTabLog();
|
openTabLog();
|
||||||
}
|
}
|
||||||
|
openTabCardArtRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
retranslateUi();
|
retranslateUi();
|
||||||
|
|
@ -681,6 +688,30 @@ void TabSupervisor::openTabAdmin()
|
||||||
aTabAdmin->setChecked(true);
|
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)
|
void TabSupervisor::actTabLog(bool checked)
|
||||||
{
|
{
|
||||||
SettingsCache::instance().setTabLogOpen(checked);
|
SettingsCache::instance().setTabLogOpen(checked);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
#include <QProxyStyle>
|
#include <QProxyStyle>
|
||||||
#include <QTabWidget>
|
#include <QTabWidget>
|
||||||
|
|
||||||
|
class TabCardArtRules;
|
||||||
inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor");
|
inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor");
|
||||||
|
|
||||||
class UserListManager;
|
class UserListManager;
|
||||||
|
|
@ -103,6 +104,7 @@ private:
|
||||||
TabDeckStorage *tabDeckStorage;
|
TabDeckStorage *tabDeckStorage;
|
||||||
TabReplays *tabReplays;
|
TabReplays *tabReplays;
|
||||||
TabAdmin *tabAdmin;
|
TabAdmin *tabAdmin;
|
||||||
|
TabCardArtRules *tabCardArtRules;
|
||||||
TabLog *tabLog;
|
TabLog *tabLog;
|
||||||
QMap<int, TabRoom *> roomTabs;
|
QMap<int, TabRoom *> roomTabs;
|
||||||
QMap<int, TabGame *> gameTabs;
|
QMap<int, TabGame *> gameTabs;
|
||||||
|
|
@ -112,7 +114,8 @@ private:
|
||||||
bool isLocalGame;
|
bool isLocalGame;
|
||||||
|
|
||||||
QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage,
|
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);
|
int myAddTab(Tab *tab, QAction *manager = nullptr);
|
||||||
void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager);
|
void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager);
|
||||||
|
|
@ -145,7 +148,7 @@ public:
|
||||||
return userInfo;
|
return userInfo;
|
||||||
}
|
}
|
||||||
[[nodiscard]] AbstractClient *getClient() const;
|
[[nodiscard]] AbstractClient *getClient() const;
|
||||||
[[nodiscard]] const UserListManager *getUserListManager() const
|
[[nodiscard]] UserListManager *getUserListManager() const
|
||||||
{
|
{
|
||||||
return userListManager;
|
return userListManager;
|
||||||
}
|
}
|
||||||
|
|
@ -197,6 +200,8 @@ private slots:
|
||||||
void openTabDeckStorage();
|
void openTabDeckStorage();
|
||||||
void openTabReplays();
|
void openTabReplays();
|
||||||
void openTabAdmin();
|
void openTabAdmin();
|
||||||
|
void actTabCardArtRules(bool checked);
|
||||||
|
void openTabCardArtRules();
|
||||||
void openTabLog();
|
void openTabLog();
|
||||||
|
|
||||||
void updateCurrent(int index);
|
void updateCurrent(int index);
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ void CardDatabase::refreshCachedReverseRelatedCards()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void CardDatabase::addCard(CardInfoPtr card)
|
void CardDatabase::addCard(const CardInfoPtr &card)
|
||||||
{
|
{
|
||||||
if (card == nullptr) {
|
if (card == nullptr) {
|
||||||
qCWarning(CardDatabaseLog) << "CardDatabase::addCard(nullptr)";
|
qCWarning(CardDatabaseLog) << "CardDatabase::addCard(nullptr)";
|
||||||
|
|
@ -118,7 +118,7 @@ void CardDatabase::addCard(CardInfoPtr card)
|
||||||
emit cardAdded(card);
|
emit cardAdded(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CardDatabase::removeCard(CardInfoPtr card)
|
void CardDatabase::removeCard(const CardInfoPtr &card)
|
||||||
{
|
{
|
||||||
if (card.isNull()) {
|
if (card.isNull()) {
|
||||||
qCWarning(CardDatabaseLog) << "CardDatabase::removeCard(nullptr)";
|
qCWarning(CardDatabaseLog) << "CardDatabase::removeCard(nullptr)";
|
||||||
|
|
@ -143,7 +143,7 @@ void CardDatabase::removeCard(CardInfoPtr card)
|
||||||
emit cardRemoved(card);
|
emit cardRemoved(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CardDatabase::addSet(CardSetPtr set)
|
void CardDatabase::addSet(const CardSetPtr &set)
|
||||||
{
|
{
|
||||||
sets.insert(set->getShortName(), set);
|
sets.insert(set->getShortName(), set);
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +215,7 @@ void CardDatabase::notifyEnabledSetsChanged()
|
||||||
emit cardDatabaseEnabledSetsChanged();
|
emit cardDatabaseEnabledSetsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
void CardDatabase::addFormat(FormatRulesPtr format)
|
void CardDatabase::addFormat(const FormatRulesPtr &format)
|
||||||
{
|
{
|
||||||
formats.insert(format->formatName.toLower(), format);
|
formats.insert(format->formatName.toLower(), format);
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ public:
|
||||||
* @brief Removes a card from the database.
|
* @brief Removes a card from the database.
|
||||||
* @param card Pointer to the card to remove.
|
* @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. */
|
/** @brief Clears all cards, sets, and internal state. */
|
||||||
void clear();
|
void clear();
|
||||||
|
|
@ -140,15 +140,15 @@ public slots:
|
||||||
* @brief Adds a card to the database.
|
* @brief Adds a card to the database.
|
||||||
* @param card CardInfoPtr to add.
|
* @param card CardInfoPtr to add.
|
||||||
*/
|
*/
|
||||||
void addCard(CardInfoPtr card);
|
void addCard(const CardInfoPtr &card);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Adds a set to the database.
|
* @brief Adds a set to the database.
|
||||||
* @param set Pointer to CardSet to add.
|
* @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. */
|
/** @brief Loads card databases from configured paths. */
|
||||||
void loadCardDatabases();
|
void loadCardDatabases();
|
||||||
|
|
|
||||||
|
|
@ -217,27 +217,32 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml)
|
||||||
// NOTE: attributes must be read before readElementText()
|
// NOTE: attributes must be read before readElementText()
|
||||||
QXmlStreamAttributes attrs = xml.attributes();
|
QXmlStreamAttributes attrs = xml.attributes();
|
||||||
QString setName = xml.readElementText(QXmlStreamReader::IncludeChildElements);
|
QString setName = xml.readElementText(QXmlStreamReader::IncludeChildElements);
|
||||||
PrintingInfo setInfo(internalAddSet(setName));
|
auto set = internalAddSet(setName);
|
||||||
if (attrs.hasAttribute("muId")) {
|
// Only load printings from sets the user has enabled, matching the v4 loader's
|
||||||
setInfo.setProperty("muid", attrs.value("muId").toString());
|
// 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")) {
|
if (attrs.hasAttribute("uuId")) {
|
||||||
setInfo.setProperty("uuid", attrs.value("uuId").toString());
|
setInfo.setProperty("uuid", attrs.value("uuId").toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attrs.hasAttribute("picURL")) {
|
if (attrs.hasAttribute("picURL")) {
|
||||||
setInfo.setProperty("picurl", attrs.value("picURL").toString());
|
setInfo.setProperty("picurl", attrs.value("picURL").toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attrs.hasAttribute("num")) {
|
if (attrs.hasAttribute("num")) {
|
||||||
setInfo.setProperty("num", attrs.value("num").toString());
|
setInfo.setProperty("num", attrs.value("num").toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attrs.hasAttribute("rarity")) {
|
if (attrs.hasAttribute("rarity")) {
|
||||||
setInfo.setProperty("rarity", attrs.value("rarity").toString());
|
setInfo.setProperty("rarity", attrs.value("rarity").toString());
|
||||||
|
}
|
||||||
|
_sets[setName].append(setInfo);
|
||||||
}
|
}
|
||||||
_sets[setName].append(setInfo);
|
|
||||||
// related cards
|
// related cards
|
||||||
} else if (xmlName == "related" || xmlName == "reverse-related") {
|
} else if (xmlName == "related" || xmlName == "reverse-related") {
|
||||||
CardRelationType attach = CardRelationType::DoesNotAttach;
|
CardRelationType attach = CardRelationType::DoesNotAttach;
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,25 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session,
|
||||||
return authState;
|
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)
|
void Server::addPersistentPlayer(const QString &userName, int roomId, int gameId, int playerId)
|
||||||
{
|
{
|
||||||
QWriteLocker locker(&persistentPlayersLock);
|
QWriteLocker locker(&persistentPlayersLock);
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ public:
|
||||||
QString &clientid,
|
QString &clientid,
|
||||||
QString &clientVersion,
|
QString &clientVersion,
|
||||||
QString &connectionType);
|
QString &connectionType);
|
||||||
|
void broadcastUserInfoUpdate(Server_ProtocolHandler *source);
|
||||||
|
|
||||||
const QMap<int, Server_Room *> &getRooms()
|
const QMap<int, Server_Room *> &getRooms()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ set(PROTO_FILES
|
||||||
response_activate.proto
|
response_activate.proto
|
||||||
response_adjust_mod.proto
|
response_adjust_mod.proto
|
||||||
response_ban_history.proto
|
response_ban_history.proto
|
||||||
|
response_card_art_rule_entry.proto
|
||||||
response_deck_download.proto
|
response_deck_download.proto
|
||||||
response_deck_list.proto
|
response_deck_list.proto
|
||||||
response_deck_upload.proto
|
response_deck_upload.proto
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ message ModeratorCommand {
|
||||||
FORCE_ACTIVATE_USER = 1007;
|
FORCE_ACTIVATE_USER = 1007;
|
||||||
GET_ADMIN_NOTES = 1008;
|
GET_ADMIN_NOTES = 1008;
|
||||||
UPDATE_ADMIN_NOTES = 1009;
|
UPDATE_ADMIN_NOTES = 1009;
|
||||||
|
ADD_CARD_ART_RULE = 1010;
|
||||||
|
REMOVE_CARD_ART_RULE = 1011;
|
||||||
|
LIST_CARD_ART_RULES = 1012;
|
||||||
}
|
}
|
||||||
extensions 100 to max;
|
extensions 100 to max;
|
||||||
}
|
}
|
||||||
|
|
@ -106,3 +109,27 @@ message Command_UpdateAdminNotes {
|
||||||
optional string user_name = 1;
|
optional string user_name = 1;
|
||||||
optional string notes = 2;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ message Response {
|
||||||
REPLAY_LIST = 1100;
|
REPLAY_LIST = 1100;
|
||||||
REPLAY_DOWNLOAD = 1101;
|
REPLAY_DOWNLOAD = 1101;
|
||||||
REPLAY_GET_CODE = 1102;
|
REPLAY_GET_CODE = 1102;
|
||||||
|
CARD_ART_RULE_LIST = 1200;
|
||||||
}
|
}
|
||||||
required uint64 cmd_id = 1;
|
required uint64 cmd_id = 1;
|
||||||
optional ResponseCode response_code = 2;
|
optional ResponseCode response_code = 2;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
syntax = "proto2";
|
syntax = "proto2";
|
||||||
|
|
||||||
message ServerInfo_User {
|
message ServerInfo_User {
|
||||||
enum UserLevelFlag {
|
enum UserLevelFlag {
|
||||||
IsNothing = 0;
|
IsNothing = 0;
|
||||||
|
|
@ -12,6 +13,13 @@ message ServerInfo_User {
|
||||||
optional string left_side = 1;
|
optional string left_side = 1;
|
||||||
optional string right_side = 2;
|
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 string name = 1;
|
||||||
optional uint32 user_level = 2;
|
optional uint32 user_level = 2;
|
||||||
|
|
@ -28,4 +36,5 @@ message ServerInfo_User {
|
||||||
optional string clientid = 13;
|
optional string clientid = 13;
|
||||||
optional string privlevel = 14;
|
optional string privlevel = 14;
|
||||||
optional PawnColorsOverride pawn_colors = 15;
|
optional PawnColorsOverride pawn_colors = 15;
|
||||||
|
optional CardArtParams card_art_params = 16;
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ message SessionCommand {
|
||||||
FORGOT_PASSWORD_RESET = 1022;
|
FORGOT_PASSWORD_RESET = 1022;
|
||||||
FORGOT_PASSWORD_CHALLENGE = 1023;
|
FORGOT_PASSWORD_CHALLENGE = 1023;
|
||||||
REQUEST_PASSWORD_SALT = 1024;
|
REQUEST_PASSWORD_SALT = 1024;
|
||||||
|
SET_CARD_ART_PARAMS = 1025;
|
||||||
REPLAY_LIST = 1100;
|
REPLAY_LIST = 1100;
|
||||||
REPLAY_DOWNLOAD = 1101;
|
REPLAY_DOWNLOAD = 1101;
|
||||||
REPLAY_MODIFY_MATCH = 1102;
|
REPLAY_MODIFY_MATCH = 1102;
|
||||||
|
|
@ -205,3 +206,14 @@ message Command_RequestPasswordSalt {
|
||||||
}
|
}
|
||||||
required string user_name = 1;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ set(UTILITY_HEADERS
|
||||||
libcockatrice/utility/passwordhasher.h
|
libcockatrice/utility/passwordhasher.h
|
||||||
libcockatrice/utility/trice_limits.h
|
libcockatrice/utility/trice_limits.h
|
||||||
libcockatrice/utility/zone_names.h
|
libcockatrice/utility/zone_names.h
|
||||||
|
libcockatrice/utility/days_years_between.h
|
||||||
)
|
)
|
||||||
|
|
||||||
add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS})
|
add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
#ifndef COCKATRICE_DAYS_YEARS_BETWEEN_H
|
||||||
|
#define COCKATRICE_DAYS_YEARS_BETWEEN_H
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
|
||||||
inline static QPair<int, int> getDaysAndYearsBetween(const QDate &then, const QDate &now)
|
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);
|
int days = then.addYears(years).daysTo(now);
|
||||||
return {days, years};
|
return {days, years};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif // COCKATRICE_DAYS_YEARS_BETWEEN_H
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
<location filename="src/pages.cpp" line="228"/>
|
<location filename="src/pages.cpp" line="228"/>
|
||||||
<source>Sets file (%1)</source>
|
<source>Sets file (%1)</source>
|
||||||
<oldsource>Sets JSON file (%1)</oldsource>
|
<oldsource>Sets JSON file (%1)</oldsource>
|
||||||
<translation type="unfinished"/>
|
<translation>Archivo de ediciones (%1)</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="258"/>
|
<location filename="src/pages.cpp" line="258"/>
|
||||||
|
|
@ -172,7 +172,7 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="726"/>
|
<location filename="src/pages.cpp" line="726"/>
|
||||||
<source>spoiler</source>
|
<source>spoiler</source>
|
||||||
<translation type="unfinished"/>
|
<translation>spoiler</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="731"/>
|
<location filename="src/pages.cpp" line="731"/>
|
||||||
|
|
@ -192,7 +192,7 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="735"/>
|
<location filename="src/pages.cpp" line="735"/>
|
||||||
<source>Local file:</source>
|
<source>Local file:</source>
|
||||||
<translation type="unfinished"/>
|
<translation>Archivo local:</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="736"/>
|
<location filename="src/pages.cpp" line="736"/>
|
||||||
|
|
@ -202,7 +202,7 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="737"/>
|
<location filename="src/pages.cpp" line="737"/>
|
||||||
<source>Choose file...</source>
|
<source>Choose file...</source>
|
||||||
<translation type="unfinished"/>
|
<translation>Elegir archivo...</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="739"/>
|
<location filename="src/pages.cpp" line="739"/>
|
||||||
|
|
@ -230,7 +230,7 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="681"/>
|
<location filename="src/pages.cpp" line="681"/>
|
||||||
<source>tokens</source>
|
<source>tokens</source>
|
||||||
<translation type="unfinished"/>
|
<translation>fichas</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="686"/>
|
<location filename="src/pages.cpp" line="686"/>
|
||||||
|
|
@ -250,7 +250,7 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="690"/>
|
<location filename="src/pages.cpp" line="690"/>
|
||||||
<source>Local file:</source>
|
<source>Local file:</source>
|
||||||
<translation type="unfinished"/>
|
<translation>Archivo local:</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="691"/>
|
<location filename="src/pages.cpp" line="691"/>
|
||||||
|
|
@ -260,7 +260,7 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="692"/>
|
<location filename="src/pages.cpp" line="692"/>
|
||||||
<source>Choose file...</source>
|
<source>Choose file...</source>
|
||||||
<translation type="unfinished"/>
|
<translation>Elegir archivo...</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pages.cpp" line="694"/>
|
<location filename="src/pages.cpp" line="694"/>
|
||||||
|
|
@ -391,12 +391,12 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pagetemplates.cpp" line="72"/>
|
<location filename="src/pagetemplates.cpp" line="72"/>
|
||||||
<source>Load %1 file</source>
|
<source>Load %1 file</source>
|
||||||
<translation type="unfinished"/>
|
<translation>Cargar archivo de %1</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pagetemplates.cpp" line="82"/>
|
<location filename="src/pagetemplates.cpp" line="82"/>
|
||||||
<source>%1 file (%1)</source>
|
<source>%1 file (%1)</source>
|
||||||
<translation type="unfinished"/>
|
<translation>archivo de %1 (%1)</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pagetemplates.cpp" line="111"/>
|
<location filename="src/pagetemplates.cpp" line="111"/>
|
||||||
|
|
@ -420,12 +420,12 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pagetemplates.cpp" line="129"/>
|
<location filename="src/pagetemplates.cpp" line="129"/>
|
||||||
<source>Please choose a file.</source>
|
<source>Please choose a file.</source>
|
||||||
<translation type="unfinished"/>
|
<translation>Por favor elija un archivo.</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pagetemplates.cpp" line="134"/>
|
<location filename="src/pagetemplates.cpp" line="134"/>
|
||||||
<source>Cannot open file '%1'.</source>
|
<source>Cannot open file '%1'.</source>
|
||||||
<translation type="unfinished"/>
|
<translation>No se puede abrir el archivo '%1'.</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/pagetemplates.cpp" line="159"/>
|
<location filename="src/pagetemplates.cpp" line="159"/>
|
||||||
|
|
@ -602,7 +602,7 @@
|
||||||
<message>
|
<message>
|
||||||
<location filename="src/main.cpp" line="63"/>
|
<location filename="src/main.cpp" line="63"/>
|
||||||
<source>Run in no-confirm background mode</source>
|
<source>Run in no-confirm background mode</source>
|
||||||
<translation type="unfinished"/>
|
<translation>Ejecutar en modo del segundo plano sin confirmación</translation>
|
||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
</TS>
|
</TS>
|
||||||
18
servatrice/migrations/servatrice_0034_to_0035.sql
Normal file
18
servatrice/migrations/servatrice_0034_to_0035.sql
Normal 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;
|
||||||
5
servatrice/scripts/account_monitor/.gitignore
vendored
Normal file
5
servatrice/scripts/account_monitor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Local state - never commit these
|
||||||
|
state.json
|
||||||
|
state.json.tmp
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
163
servatrice/scripts/account_monitor/README.md
Normal file
163
servatrice/scripts/account_monitor/README.md
Normal 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) |
|
||||||
350
servatrice/scripts/account_monitor/account_monitor.py
Executable file
350
servatrice/scripts/account_monitor/account_monitor.py
Executable 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()
|
||||||
1
servatrice/scripts/account_monitor/requirements.txt
Normal file
1
servatrice/scripts/account_monitor/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
PyMySQL==1.2.0
|
||||||
|
|
@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
|
||||||
PRIMARY KEY (`version`)
|
PRIMARY KEY (`version`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
) 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
|
-- users and user data tables
|
||||||
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
|
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',
|
`passwordLastChangedDate` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
`leftPawnColorOverride` varchar(255),
|
`leftPawnColorOverride` varchar(255),
|
||||||
`rightPawnColorOverride` varchar(255),
|
`rightPawnColorOverride` varchar(255),
|
||||||
|
`card_art_params` TEXT DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `name` (`name`),
|
UNIQUE KEY `name` (`name`),
|
||||||
KEY `token` (`token`),
|
KEY `token` (`token`),
|
||||||
|
|
@ -300,3 +301,18 @@ CREATE TABLE IF NOT EXISTS `cockatrice_audit` (
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `user_name` (`name`)
|
KEY `user_name` (`name`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
) 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;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
#include <QChar>
|
#include <QChar>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
#include <QSqlError>
|
#include <QSqlError>
|
||||||
#include <QSqlQuery>
|
#include <QSqlQuery>
|
||||||
|
|
@ -681,6 +683,30 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer
|
||||||
if (!clientid.isEmpty()) {
|
if (!clientid.isEmpty()) {
|
||||||
result.set_clientid(clientid.toStdString());
|
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;
|
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, "
|
QSqlQuery *query = prepareQuery("select id, name, admin, country, privlevel, leftPawnColorOverride, "
|
||||||
"rightPawnColorOverride, realname, avatar_bmp, registrationDate, "
|
"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");
|
"name = :name and active = 1");
|
||||||
query->bindValue(":name", name);
|
query->bindValue(":name", name);
|
||||||
if (!execSqlQuery(query)) {
|
if (!execSqlQuery(query)) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
#include <server.h>
|
#include <server.h>
|
||||||
#include <server_database_interface.h>
|
#include <server_database_interface.h>
|
||||||
|
|
||||||
#define DATABASE_SCHEMA_VERSION 34
|
#define DATABASE_SCHEMA_VERSION 35
|
||||||
|
|
||||||
class Servatrice;
|
class Servatrice;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QHostAddress>
|
#include <QHostAddress>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QSqlError>
|
#include <QSqlError>
|
||||||
|
|
@ -59,8 +61,10 @@
|
||||||
#include <libcockatrice/protocol/pb/event_replay_added.pb.h>
|
#include <libcockatrice/protocol/pb/event_replay_added.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_server_identification.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_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/event_user_message.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_ban_history.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_download.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
|
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_deck_upload.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);
|
return cmdAccountEdit(cmd.GetExtension(Command_AccountEdit::ext), rc);
|
||||||
case SessionCommand::ACCOUNT_IMAGE:
|
case SessionCommand::ACCOUNT_IMAGE:
|
||||||
return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc);
|
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:
|
case SessionCommand::ACCOUNT_PASSWORD:
|
||||||
return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc);
|
return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc);
|
||||||
case SessionCommand::REQUEST_PASSWORD_SALT:
|
case SessionCommand::REQUEST_PASSWORD_SALT:
|
||||||
|
|
@ -247,6 +253,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo
|
||||||
return cmdGetAdminNotes(cmd.GetExtension(Command_GetAdminNotes::ext), rc);
|
return cmdGetAdminNotes(cmd.GetExtension(Command_GetAdminNotes::ext), rc);
|
||||||
case ModeratorCommand::UPDATE_ADMIN_NOTES:
|
case ModeratorCommand::UPDATE_ADMIN_NOTES:
|
||||||
return cmdUpdateAdminNotes(cmd.GetExtension(Command_UpdateAdminNotes::ext), rc);
|
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:
|
default:
|
||||||
return Response::RespFunctionNotAllowed;
|
return Response::RespFunctionNotAllowed;
|
||||||
}
|
}
|
||||||
|
|
@ -1565,6 +1577,161 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm
|
||||||
return Response::RespOk;
|
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,
|
Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const Command_AccountPassword &cmd,
|
||||||
ResponseContainer & /* rc */)
|
ResponseContainer & /* rc */)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,11 @@ private:
|
||||||
|
|
||||||
Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdAccountImage(const Command_AccountImage &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 cmdAccountPassword(const Command_AccountPassword &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdGrantReplayAccess(const Command_GrantReplayAccess &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdGrantReplayAccess(const Command_GrantReplayAccess &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdForceActivateUser(const Command_ForceActivateUser &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdForceActivateUser(const Command_ForceActivateUser &cmd, ResponseContainer &rc);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue