diff --git a/.CI/build-installer.ps1 b/.CI/build-installer.ps1 index 756a1503f29..b60145d82e9 100644 --- a/.CI/build-installer.ps1 +++ b/.CI/build-installer.ps1 @@ -42,7 +42,7 @@ $VCRTVersion = (Get-Item "$Env:VCToolsRedistDir\vc_redist.x64.exe").VersionInfo; ISCC ` /DWORKING_DIR="$($pwd.Path)\" ` /DINSTALLER_BASE_NAME="$installerBaseName" ` - /DSHIPPED_VCRT_BUILD="$($VCRTVersion.FileBuildPart)" ` + /DSHIPPED_VCRT_MINOR="$($VCRTVersion.FileMinorPart)" ` /DSHIPPED_VCRT_VERSION="$($VCRTVersion.FileDescription)" ` $defines ` /O. ` diff --git a/.CI/chatterino-installer.iss b/.CI/chatterino-installer.iss index 2e3edbf5202..5068a3c2967 100644 --- a/.CI/chatterino-installer.iss +++ b/.CI/chatterino-installer.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "Chatterino" -#define MyAppVersion "2.4.6" +#define MyAppVersion "2.5.1" #define MyAppPublisher "Chatterino Team" #define MyAppURL "https://www.chatterino.com" #define MyAppExeName "chatterino.exe" @@ -120,15 +120,15 @@ begin Result := VCRTVersion + ' is installed'; end; -// Checks if a new VCRT is needed by comparing the builds. +// Checks if a new VCRT is needed by comparing the minor version (the major one is locked at 14). function NeedsNewVCRT(): Boolean; var VCRTBuild: Cardinal; begin Result := True; - if RegQueryDWordValue(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'Bld', VCRTBuild) then + if RegQueryDWordValue(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'Minor', VCRTBuild) then begin - if VCRTBuild >= {#SHIPPED_VCRT_BUILD} then + if VCRTBuild >= {#SHIPPED_VCRT_MINOR} then Result := False; end; end; diff --git a/.CI/deploy-crt.ps1 b/.CI/deploy-crt.ps1 new file mode 100644 index 00000000000..cab12693ef2 --- /dev/null +++ b/.CI/deploy-crt.ps1 @@ -0,0 +1,30 @@ +param ( + [string] $InstallDir = "Chatterino2" +) + +if ($null -eq $Env:VCToolsRedistDir) { + Write-Error "VCToolsRedistDir is not set. Forgot to set Visual Studio environment variables?"; + exit 1 +} + +# A path to the runtime libraries (e.g. "$Env:VCToolsRedistDir\onecore\x64\Microsoft.VC143.CRT") +$vclibs = (Get-ChildItem "$Env:VCToolsRedistDir\onecore\x64" -Filter '*.CRT')[0].FullName; + +# All executables and libraries in the installation directory +$targets = Get-ChildItem -Recurse -Include '*.dll', '*.exe' $InstallDir; +# All dependencies of the targets (with duplicates) +$all_deps = $targets | ForEach-Object { (dumpbin /DEPENDENTS $_.FullName) -match '^(?!Dump of).+\.dll$' } | ForEach-Object { $_.Trim() }; +# All dependencies without duplicates +$dependencies = $all_deps | Sort-Object -Unique; + +$n_deployed = 0; +foreach ($dll in $dependencies) { + Write-Output "Checking for $dll"; + if (Test-Path -PathType Leaf "$vclibs\$dll") { + Write-Output "Deploying $dll"; + Copy-Item "$vclibs\$dll" "$InstallDir\$dll" -Force; + $n_deployed++; + } +} + +Write-Output "Deployed $n_deployed libraries"; diff --git a/.CI/setup-clang-tidy.sh b/.CI/setup-clang-tidy.sh new file mode 100755 index 00000000000..4884285eb8e --- /dev/null +++ b/.CI/setup-clang-tidy.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +set -ev; + +# aqt installs into .qtinstall/Qt//gcc_64 +# This is doing the same as jurplel/install-qt-action +# See https://github.com/jurplel/install-qt-action/blob/74ca8cd6681420fc8894aed264644c7a76d7c8cb/action/src/main.ts#L52-L74 +qtpath=$(echo .qtinstall/Qt/[0-9]*/*/bin/qmake | sed -e s:/bin/qmake$::) +export LD_LIBRARY_PATH="$qtpath/lib" +export QT_ROOT_DIR=$qtpath +export QT_PLUGIN_PATH="$qtpath/plugins" +export PATH="$PATH:$(realpath "$qtpath/bin")" +export Qt6_DIR="$(realpath "$qtpath")" + +cmake -S. -Bbuild-clang-tidy \ + -DCMAKE_BUILD_TYPE=Debug \ + -DPAJLADA_SETTINGS_USE_BOOST_FILESYSTEM=On \ + -DUSE_PRECOMPILED_HEADERS=OFF \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=On \ + -DCHATTERINO_LTO=Off \ + -DCHATTERINO_PLUGINS=On \ + -DBUILD_WITH_QT6=On \ + -DBUILD_TESTS=On \ + -DBUILD_BENCHMARKS=On + +# Run MOC and UIC +# This will compile the dependencies +# Get the targets using `ninja -t targets | grep autogen` +cmake --build build-clang-tidy --parallel -t \ + Core_autogen \ + LibCommuni_autogen \ + Model_autogen \ + Util_autogen \ + chatterino-lib_autogen diff --git a/.cirrus.yml b/.cirrus.yml index 7d957444b18..d4ac409cd92 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -1,9 +1,9 @@ freebsd_instance: - image: freebsd-13-1-release-amd64 + image_family: freebsd-14-0 task: install_script: - - pkg install -y boost-libs git qt5-buildtools qt5-concurrent qt5-core qt5-multimedia qt5-svg qtkeychain-qt5 qt5-qmake cmake qt5-linguist + - pkg install -y boost-libs git qt6-base qt6-svg qt6-5compat qt6-imageformats qtkeychain-qt6 cmake script: | git submodule init git submodule update @@ -20,6 +20,7 @@ task: -DUSE_SYSTEM_QTKEYCHAIN="ON" \ -DCMAKE_BUILD_TYPE="release" \ -DCMAKE_EXPORT_COMPILE_COMMANDS="ON" \ + -DBUILD_WITH_QT6="ON" \ .. cat compile_commands.json make -j $(getconf _NPROCESSORS_ONLN) diff --git a/.clang-format b/.clang-format index 0feaad9dc10..cfbe49d31fe 100644 --- a/.clang-format +++ b/.clang-format @@ -50,3 +50,4 @@ PointerBindsToType: false SpacesBeforeTrailingComments: 2 Standard: Auto ReflowComments: false +InsertNewlineAtEOF: true diff --git a/.clang-tidy b/.clang-tidy index 170ad019a41..42cca83f6b7 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -22,6 +22,7 @@ Checks: "-*, -readability-magic-numbers, -performance-noexcept-move-constructor, -misc-non-private-member-variables-in-classes, + -misc-no-recursion, -cppcoreguidelines-non-private-member-variables-in-classes, -modernize-use-nodiscard, -modernize-use-trailing-return-type, @@ -29,7 +30,8 @@ Checks: "-*, -readability-function-cognitive-complexity, -bugprone-easily-swappable-parameters, -cert-err58-cpp, - -modernize-avoid-c-arrays + -modernize-avoid-c-arrays, + -misc-include-cleaner " CheckOptions: - key: readability-identifier-naming.ClassCase diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1581eddf07..6b757e885cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,6 +61,10 @@ jobs: submodules: recursive fetch-depth: 0 # allows for tags access + - name: Fix git permission error + run: | + git config --global --add safe.directory '*' + - name: Build run: | mkdir build @@ -112,24 +116,24 @@ jobs: matrix: include: # macOS - - os: macos-latest + - os: macos-13 qt-version: 5.15.2 force-lto: false - plugins: false + plugins: true skip-artifact: false skip-crashpad: false # Windows - os: windows-latest - qt-version: 6.5.0 + qt-version: 6.7.1 force-lto: false - plugins: false + plugins: true skip-artifact: false skip-crashpad: false # Windows 7/8 - os: windows-latest qt-version: 5.15.2 force-lto: false - plugins: false + plugins: true skip-artifact: false skip-crashpad: true @@ -139,6 +143,8 @@ jobs: C2_PLUGINS: ${{ matrix.plugins }} C2_ENABLE_CRASHPAD: ${{ matrix.skip-crashpad == false }} C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') }} + C2_USE_OPENSSL3: ${{ startsWith(matrix.qt-version, '6.') && 'True' || 'False' }} + C2_CONAN_CACHE_SUFFIX: ${{ startsWith(matrix.qt-version, '6.') && '-QT6' || '' }} steps: - uses: actions/checkout@v4 @@ -148,35 +154,15 @@ jobs: - name: Install Qt5 if: startsWith(matrix.qt-version, '5.') - uses: jurplel/install-qt-action@v3.3.0 + uses: jurplel/install-qt-action@v4.0.0 with: cache: true cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 version: ${{ matrix.qt-version }} - - name: Install Qt 6.5.3 imageformats - if: startsWith(matrix.qt-version, '6.') - uses: jurplel/install-qt-action@v3.3.0 - with: - cache: false - modules: qtimageformats - set-env: false - version: 6.5.3 - extra: --noarchives - - - name: Find Qt 6.5.3 Path - if: startsWith(matrix.qt-version, '6.') && startsWith(matrix.os, 'windows') - shell: pwsh - id: find-good-imageformats - run: | - cd "$Env:RUNNER_WORKSPACE/Qt/6.5.3" - cd (Get-ChildItem)[0].Name - cd plugins/imageformats - echo "PLUGIN_PATH=$(pwd)" | Out-File -Path "$Env:GITHUB_OUTPUT" -Encoding ASCII - - name: Install Qt6 if: startsWith(matrix.qt-version, '6.') - uses: jurplel/install-qt-action@v3.3.0 + uses: jurplel/install-qt-action@v4.0.0 with: cache: true cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 @@ -188,16 +174,9 @@ jobs: if: startsWith(matrix.os, 'windows') uses: ilammy/msvc-dev-cmd@v1.13.0 - - name: Setup conan variables (Windows) - if: startsWith(matrix.os, 'windows') - run: | - "C2_USE_OPENSSL3=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "True" } else { "False" })" >> "$Env:GITHUB_ENV" - "C2_CONAN_CACHE_SUFFIX=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "-QT6" } else { "`" })" >> "$Env:GITHUB_ENV" - shell: powershell - - name: Setup sccache (Windows) # sccache v0.7.4 - uses: hendrikmuhs/ccache-action@v1.2.12 + uses: hendrikmuhs/ccache-action@v1.2.13 if: startsWith(matrix.os, 'windows') with: variant: sccache @@ -276,14 +255,9 @@ jobs: cd build windeployqt bin/chatterino.exe --release --no-compiler-runtime --no-translations --no-opengl-sw --dir Chatterino2/ cp bin/chatterino.exe Chatterino2/ + ..\.CI\deploy-crt.ps1 Chatterino2 echo nightly > Chatterino2/modes - - name: Fix Qt6 (windows) - if: startsWith(matrix.qt-version, '6.') && startsWith(matrix.os, 'windows') - working-directory: build - run: | - cp ${{ steps.find-good-imageformats.outputs.PLUGIN_PATH }}/qwebp.dll Chatterino2/imageformats/qwebp.dll - - name: Package (windows) if: startsWith(matrix.os, 'windows') working-directory: build @@ -365,15 +339,15 @@ jobs: # Windows - uses: actions/download-artifact@v4 - name: Windows Qt6.5.0 + name: Windows Qt6.7.1 with: - name: chatterino-windows-x86-64-Qt-6.5.0.zip + name: chatterino-windows-x86-64-Qt-6.7.1.zip path: release-artifacts/ - uses: actions/download-artifact@v4 - name: Windows Qt6.5.0 symbols + name: Windows Qt6.7.1 symbols with: - name: chatterino-windows-x86-64-Qt-6.5.0-symbols.pdb.7z + name: chatterino-windows-x86-64-Qt-6.7.1-symbols.pdb.7z path: release-artifacts/ - uses: actions/download-artifact@v4 @@ -406,18 +380,9 @@ jobs: cp .CI/chatterino-nightly.flatpakref release-artifacts/ shell: bash - # macOS - - uses: actions/download-artifact@v4 - name: macOS x86_64 Qt5.15.2 dmg - with: - name: chatterino-macos-Qt-5.15.2.dmg - path: release-artifacts/ - - name: Rename artifacts run: | ls -l - # Rename the macos build to indicate that it's for macOS 10.15 users - mv chatterino-macos-Qt-5.15.2.dmg Chatterino-macOS-10.15.dmg # Mark all Windows Qt5 builds as old mv chatterino-windows-x86-64-Qt-5.15.2.zip chatterino-windows-old-x86-64-Qt-5.15.2.zip diff --git a/.github/workflows/check-formatting.yml b/.github/workflows/check-formatting.yml index 9a3a36685ad..4ae1f1134da 100644 --- a/.github/workflows/check-formatting.yml +++ b/.github/workflows/check-formatting.yml @@ -26,7 +26,7 @@ jobs: run: sudo apt-get -y install dos2unix - name: Check formatting - uses: DoozyX/clang-format-lint-action@v0.16.2 + uses: DoozyX/clang-format-lint-action@v0.17 with: source: "./src ./tests/src ./benchmarks/src ./mocks/include" extensions: "hpp,cpp" diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 0f072a36442..19052648b5e 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -8,141 +8,58 @@ concurrency: group: clang-tidy-${{ github.ref }} cancel-in-progress: true -env: - CHATTERINO_REQUIRE_CLEAN_GIT: On - C2_BUILD_WITH_QT6: Off - jobs: - build: + review: name: "clang-tidy ${{ matrix.os }}, Qt ${{ matrix.qt-version }})" runs-on: ${{ matrix.os }} strategy: matrix: include: - # Ubuntu 22.04, Qt 5.15 + # Ubuntu 22.04, Qt 6.6 - os: ubuntu-22.04 - qt-version: 5.15.2 - plugins: false + qt-version: 6.6.2 fail-fast: false steps: - - name: Enable plugin support - if: matrix.plugins - run: | - echo "C2_PLUGINS=ON" >> "$GITHUB_ENV" - shell: bash - - - name: Set BUILD_WITH_QT6 - if: startsWith(matrix.qt-version, '6.') - run: | - echo "C2_BUILD_WITH_QT6=ON" >> "$GITHUB_ENV" - shell: bash - - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 # allows for tags access - - name: Install Qt5 - if: startsWith(matrix.qt-version, '5.') - uses: jurplel/install-qt-action@v3.3.0 - with: - cache: true - cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 - version: ${{ matrix.qt-version }} - - - name: Install Qt 6.5.3 imageformats - if: startsWith(matrix.qt-version, '6.') - uses: jurplel/install-qt-action@v3.3.0 - with: - cache: false - modules: qtimageformats - set-env: false - version: 6.5.3 - extra: --noarchives - - name: Install Qt6 if: startsWith(matrix.qt-version, '6.') - uses: jurplel/install-qt-action@v3.3.0 + uses: jurplel/install-qt-action@v4.0.0 with: cache: true cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 modules: qt5compat qtimageformats version: ${{ matrix.qt-version }} - - # LINUX - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get -y install \ - cmake \ - virtualenv \ - rapidjson-dev \ - libfuse2 \ - libssl-dev \ - libboost-dev \ - libxcb-randr0-dev \ - libboost-system-dev \ - libboost-filesystem-dev \ - libpulse-dev \ - libxkbcommon-x11-0 \ - build-essential \ - libgl1-mesa-dev \ - libxcb-icccm4 \ - libxcb-image0 \ - libxcb-keysyms1 \ - libxcb-render-util0 \ - libxcb-xinerama0 - - - name: Apply Qt5 patches - if: startsWith(matrix.qt-version, '5.') - run: | - patch "$Qt5_DIR/include/QtConcurrent/qtconcurrentthreadengine.h" .patches/qt5-on-newer-gcc.patch - shell: bash - - - name: Build - run: | - mkdir build - cd build - CXXFLAGS=-fno-sized-deallocation cmake \ - -DCMAKE_INSTALL_PREFIX=appdir/usr/ \ - -DCMAKE_BUILD_TYPE=Release \ - -DPAJLADA_SETTINGS_USE_BOOST_FILESYSTEM=On \ - -DUSE_PRECOMPILED_HEADERS=OFF \ - -DCMAKE_EXPORT_COMPILE_COMMANDS=On \ - -DCHATTERINO_LTO="$C2_ENABLE_LTO" \ - -DCHATTERINO_PLUGINS="$C2_PLUGINS" \ - -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ - .. - shell: bash + dir: ${{ github.workspace }}/.qtinstall + set-env: false - name: clang-tidy review timeout-minutes: 20 - uses: ZedThree/clang-tidy-review@v0.17.2 + uses: ZedThree/clang-tidy-review@v0.19.0 with: build_dir: build-clang-tidy config_file: ".clang-tidy" split_workflow: true exclude: "lib/*,tools/crash-handler/*" cmake_command: >- - cmake -S. -Bbuild-clang-tidy - -DCMAKE_BUILD_TYPE=Release - -DPAJLADA_SETTINGS_USE_BOOST_FILESYSTEM=On - -DUSE_PRECOMPILED_HEADERS=OFF - -DCMAKE_EXPORT_COMPILE_COMMANDS=On - -DCHATTERINO_LTO=Off - -DCHATTERINO_PLUGINS=On - -DBUILD_WITH_QT6=Off - -DBUILD_TESTS=On - -DBUILD_BENCHMARKS=On + ./.CI/setup-clang-tidy.sh apt_packages: >- - qttools5-dev, qt5-image-formats-plugins, libqt5svg5-dev, libsecret-1-dev, libboost-dev, libboost-system-dev, libboost-filesystem-dev, libssl-dev, rapidjson-dev, - libbenchmark-dev + libbenchmark-dev, + build-essential, + libgl1-mesa-dev, libgstreamer-gl1.0-0, libpulse-dev, + libxcb-glx0, libxcb-icccm4, libxcb-image0, libxcb-keysyms1, libxcb-randr0, + libxcb-render-util0, libxcb-render0, libxcb-shape0, libxcb-shm0, libxcb-sync1, + libxcb-util1, libxcb-xfixes0, libxcb-xinerama0, libxcb1, libxkbcommon-dev, + libxkbcommon-x11-0, libxcb-xkb-dev, libxcb-cursor0 - name: clang-tidy-review upload - uses: ZedThree/clang-tidy-review/upload@v0.17.2 + uses: ZedThree/clang-tidy-review/upload@v0.19.0 diff --git a/.github/workflows/create-installer.yml b/.github/workflows/create-installer.yml index 626d3e50363..5700e3f1682 100644 --- a/.github/workflows/create-installer.yml +++ b/.github/workflows/create-installer.yml @@ -18,14 +18,14 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} strategy: matrix: - qt-version: ["6.5.0"] + qt-version: ["6.7.1"] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # allows for tags access - name: Download artifact - uses: dawidd6/action-download-artifact@v3 + uses: dawidd6/action-download-artifact@v6 with: workflow: build.yml name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index b455baaec44..6da0e71d3c0 100644 --- a/.github/workflows/homebrew.yml +++ b/.github/workflows/homebrew.yml @@ -26,4 +26,5 @@ jobs: echo "Running bump-cask-pr for cask '$C2_CASK_NAME' and version '$C2_TAGGED_VERSION'" C2_TAGGED_VERSION_STRIPPED="${C2_TAGGED_VERSION:1}" echo "Stripped version: '$C2_TAGGED_VERSION_STRIPPED'" + brew developer on brew bump-cask-pr --version "$C2_TAGGED_VERSION_STRIPPED" "$C2_CASK_NAME" diff --git a/.github/workflows/post-clang-tidy-review.yml b/.github/workflows/post-clang-tidy-review.yml index e22b264f502..c55b3a3d9f5 100644 --- a/.github/workflows/post-clang-tidy-review.yml +++ b/.github/workflows/post-clang-tidy-review.yml @@ -8,12 +8,13 @@ on: - completed jobs: - build: + post: runs-on: ubuntu-latest # Only when a build succeeds if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - - uses: ZedThree/clang-tidy-review/post@v0.17.2 + - uses: ZedThree/clang-tidy-review/post@v0.19.0 with: lgtm_comment_body: "" + num_comments_as_exitcode: false diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index d606c870880..b22766e0961 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -47,7 +47,7 @@ jobs: fetch-depth: 0 # allows for tags access - name: Install Qt - uses: jurplel/install-qt-action@v3.3.0 + uses: jurplel/install-qt-action@v4.0.0 with: cache: true cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 12ddb164cd5..052c7d81e05 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -24,7 +24,7 @@ jobs: strategy: matrix: os: [windows-latest] - qt-version: [5.15.2, 6.5.0] + qt-version: [5.15.2, 6.7.1] plugins: [false] skip-artifact: [false] skip-crashpad: [false] @@ -32,6 +32,8 @@ jobs: env: C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} QT_MODULES: ${{ startsWith(matrix.qt-version, '6.') && 'qt5compat qtimageformats' || '' }} + C2_USE_OPENSSL3: ${{ startsWith(matrix.qt-version, '6.') && 'True' || 'False' }} + C2_CONAN_CACHE_SUFFIX: ${{ startsWith(matrix.qt-version, '6.') && '-QT6' || '' }} steps: - name: Enable plugin support @@ -55,7 +57,7 @@ jobs: fetch-depth: 0 # allows for tags access - name: Install Qt - uses: jurplel/install-qt-action@v3.3.0 + uses: jurplel/install-qt-action@v4.0.0 with: cache: true cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 @@ -65,15 +67,9 @@ jobs: - name: Enable Developer Command Prompt uses: ilammy/msvc-dev-cmd@v1.13.0 - - name: Setup conan variables - if: startsWith(matrix.os, 'windows') - run: | - "C2_USE_OPENSSL3=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "True" } else { "False" })" >> "$Env:GITHUB_ENV" - "C2_CONAN_CACHE_SUFFIX=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "-QT6" } else { "`" })" >> "$Env:GITHUB_ENV" - - name: Setup sccache # sccache v0.7.4 - uses: hendrikmuhs/ccache-action@v1.2.12 + uses: hendrikmuhs/ccache-action@v1.2.13 with: variant: sccache # only save on the default (master) branch @@ -109,6 +105,12 @@ jobs: --output-folder=. ` -o with_openssl3="$Env:C2_USE_OPENSSL3" + # The Windows runners currently use an older version of the CRT + - name: Install CRT + run: | + mkdir -Force build-test/bin + cp "$((ls $Env:VCToolsRedistDir/onecore/x64 -Filter '*.CRT')[0].FullName)/*" build-test/bin + - name: Build run: | cmake ` diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml new file mode 100644 index 00000000000..7e8a5091a70 --- /dev/null +++ b/.github/workflows/winget.yml @@ -0,0 +1,14 @@ +name: Publish to WinGet +on: + release: + types: [released] +jobs: + publish: + runs-on: windows-latest + if: ${{ startsWith(github.event.release.tag_name, 'v') }} + steps: + - uses: vedantmgoyal2009/winget-releaser@v2 + with: + identifier: ChatterinoTeam.Chatterino + installers-regex: ^Chatterino.Installer.exe$ + token: ${{ secrets.WINGET_TOKEN }} diff --git a/.gitmodules b/.gitmodules index cb1235a8582..e58a5bbd49b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -41,3 +41,6 @@ [submodule "tools/crash-handler"] path = tools/crash-handler url = https://github.com/Chatterino/crash-handler +[submodule "lib/expected-lite"] + path = lib/expected-lite + url = https://github.com/martinmoene/expected-lite diff --git a/BUILDING_ON_FREEBSD.md b/BUILDING_ON_FREEBSD.md index 8a1deeebae8..38b603a2c5c 100644 --- a/BUILDING_ON_FREEBSD.md +++ b/BUILDING_ON_FREEBSD.md @@ -1,23 +1,23 @@ # FreeBSD -Note on Qt version compatibility: If you are installing Qt from a package manager, please ensure the version you are installing is at least **Qt 5.12 or newer**. +For all dependencies below we use Qt 6. Our minimum supported version is Qt 5.15.2, but you are on your own. -## FreeBSD 12.1-RELEASE +## FreeBSD 14.0-RELEASE -Note: This is known to work on FreeBSD 12.1-RELEASE amd64. Chances are +Note: This is known to work on FreeBSD 14.0-RELEASE amd64. Chances are high that this also works on older FreeBSD releases, architectures and -FreeBSD 13.0-CURRENT. +FreeBSD 15.0-SNAP. 1. Install build dependencies from package sources (or build from the - ports tree): `# pkg install qt5-core qt5-multimedia qt5-svg qt5-buildtools gstreamer-plugins-good boost-libs rapidjson cmake` + ports tree): `# pkg install boost-libs git qt6-base qt6-svg qt6-5compat qt6-imageformats qtkeychain-qt6 cmake` 1. In the project directory, create a build directory and enter it ```sh mkdir build cd build ``` -1. Generate build files +1. Generate build files. To enable Lua plugins in your build add `-DCHATTERINO_PLUGINS=ON` to this command. ```sh - cmake .. + cmake -DBUILD_WITH_QT6=ON .. ``` 1. Build the project ```sh diff --git a/BUILDING_ON_LINUX.md b/BUILDING_ON_LINUX.md index b901e8e6d7e..51317ecd1ff 100644 --- a/BUILDING_ON_LINUX.md +++ b/BUILDING_ON_LINUX.md @@ -1,6 +1,6 @@ # Linux -For all dependencies below we use Qt6. Our minimum supported version is Qt5.15, but you are on your own. +For all dependencies below we use Qt 6. Our minimum supported version is Qt 5.15.2, but you are on your own. ## Install dependencies @@ -8,11 +8,11 @@ For all dependencies below we use Qt6. Our minimum supported version is Qt5.15, Building on Ubuntu requires Docker. -Use https://github.com/Chatterino/docker/pkgs/container/chatterino2-build-ubuntu-20.04 as your base if you're on Ubuntu 20.04. +Use as your base if you're on Ubuntu 20.04. -Use https://github.com/Chatterino/docker/pkgs/container/chatterino2-build-ubuntu-22.04 if you're on Ubuntu 22.04. +Use if you're on Ubuntu 22.04. -The built binary should be exportable from the final image & able to run on your system assuming you perform a static build. See our [build.yml github workflow file](.github/workflows/build.yml) for the cmake line used for Ubuntu builds. +The built binary should be exportable from the final image & able to run on your system assuming you perform a static build. See our [build.yml GitHub workflow file](.github/workflows/build.yml) for the CMake line used for Ubuntu builds. ### Debian 12 (bookworm) or later @@ -51,7 +51,7 @@ nix-shell -p openssl boost qt6.full pkg-config cmake mkdir build cd build ``` -1. Generate build files +1. Generate build files. To enable Lua plugins in your build add `-DCHATTERINO_PLUGINS=ON` to this command. ```sh cmake -DBUILD_WITH_QT6=ON -DBUILD_WITH_QTKEYCHAIN=OFF .. ``` diff --git a/BUILDING_ON_MAC.md b/BUILDING_ON_MAC.md index 78f94c7e9ec..d1fc018b90b 100644 --- a/BUILDING_ON_MAC.md +++ b/BUILDING_ON_MAC.md @@ -1,6 +1,6 @@ # Building on macOS -Chatterino2 is built in CI on Intel on macOS 12. +Chatterino2 is built in CI on Intel on macOS 13. Local dev machines for testing are available on Apple Silicon on macOS 13. ## Installing dependencies @@ -20,7 +20,7 @@ Local dev machines for testing are available on Apple Silicon on macOS 13. 1. Go to the project directory where you cloned Chatterino2 & its submodules 1. Create a build directory and go into it: `mkdir build && cd build` -1. Run CMake: +1. Run CMake. To enable Lua plugins in your build add `-DCHATTERINO_PLUGINS=ON` to this command. `cmake -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt@5 -DOPENSSL_ROOT_DIR=/opt/homebrew/opt/openssl@1.1 ..` 1. Build: `make` diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index dc66d65c434..2449a329d00 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -24,7 +24,7 @@ Notes: Notes: -- Installing the latest **stable** Qt version is advised for new installations, but if you want to use your existing installation please ensure you are running **Qt 5.12 or later**. +- Installing the latest **stable** Qt version is advised for new installations, but if you want to use your existing installation please ensure you are running **Qt 5.15.2 or later**. #### Components @@ -33,7 +33,7 @@ When prompted which components to install, do the following: 1. Unfold the tree element that says "Qt" 2. Unfold the top most tree element (latest stable Qt version, e.g. `Qt 6.5.3`) 3. Under this version, select the following entries: - - `MSVC 2019 64-bit` (or alternative version if you are using that) + - `MSVC 2019 64-bit` (or `MSVC 2022 64-bit` from Qt 6.8 onwards) - `Qt 5 Compatibility Module` - `Additional Libraries` > `Qt Image Formats` 4. Under the "Tools" tree element (at the bottom), ensure that `Qt Creator X.X.X` and `Debugging Tools for Windows` are selected. (they should be checked by default) @@ -66,9 +66,9 @@ These dependencies are only required if you are not using a package manager - Visit the downloads list on [SourceForge](https://sourceforge.net/projects/boost/files/boost-binaries/). - Select the latest version from the list. - Download the `.exe` file appropriate to your Visual Studio installation version and system bitness (choose `-64` for 64-bit systems). - Visual Studio versions map as follows: `14.3` in the filename corresponds to MSVC 2022,`14.2` to 2019, `14.1` to 2017, `14.0` to 2015. _Anything prior to Visual Studio 2015 is unsupported. Please upgrade should you have an older installation._ + Visual Studio versions map as follows: `14.3` in the filename corresponds to MSVC 2022. _Anything prior to Visual Studio 2022 is unsupported. Please upgrade should you have an older installation._ - **Convenience link for Visual Studio 2022: [boost_1_79_0-msvc-14.3-64.exe](https://sourceforge.net/projects/boost/files/boost-binaries/1.79.0/boost_1_79_0-msvc-14.3-64.exe/download)** + **Convenience link for Visual Studio 2022: [boost_1_84_0-msvc-14.3-64.exe](https://sourceforge.net/projects/boost/files/boost-binaries/1.84.0/boost_1_84_0-msvc-14.3-64.exe/download)** 2. When prompted where to install Boost, set the location to `C:\local\boost`. 3. After the installation finishes, rename the `C:\local\boost\lib64-msvc-14.3` (or similar) directory to simply `lib` (`C:\local\boost\lib`). @@ -118,6 +118,7 @@ nmake ``` To build a debug build, you'll also need to add the `-s compiler.runtime_type=Debug` flag to the `conan install` invocation. See [this StackOverflow post](https://stackoverflow.com/questions/59828611/windeployqt-doesnt-deploy-qwindowsd-dll-for-a-debug-application/75607313#75607313) +To build with plugins add `-DCHATTERINO_PLUGINS=ON` to `cmake` command. #### Deploying Qt libraries @@ -236,7 +237,7 @@ Select the `CMake Applications > chatterino` configuration and add a new _Run Ex Now you can run the `chatterino | Debug` configuration. -If you want to run the portable version of Chatterino, create a file called `modes` inside of `build/bin` and +If you want to run the portable version of Chatterino, create a file called `modes` inside `build/bin` and write `portable` into it. #### Debugging diff --git a/BUILDING_ON_WINDOWS_WITH_VCPKG.md b/BUILDING_ON_WINDOWS_WITH_VCPKG.md index b998094311c..18748acb786 100644 --- a/BUILDING_ON_WINDOWS_WITH_VCPKG.md +++ b/BUILDING_ON_WINDOWS_WITH_VCPKG.md @@ -1,6 +1,6 @@ # Building on Windows with vcpkg -This will require more than 30GB of free space on your hard drive. +This will require more than 30 GB of free space on your hard drive. ## Prerequisites @@ -29,7 +29,7 @@ This will require more than 30GB of free space on your hard drive. See [VCPKG_ROOT documentation](https://learn.microsoft.com/en-gb/vcpkg/users/config-environment#vcpkg_root) - Append the vcpkg path to your path e.g. `setx PATH "%PATH%;"` - - For more configurations, see https://learn.microsoft.com/en-gb/vcpkg/users/config-environment + - For more configurations, see 1. You may need to restart your computer to ensure all your environment variables and what-not are loaded everywhere. ## Building @@ -50,4 +50,5 @@ This will require more than 30GB of free space on your hard drive. cmake --build . --parallel --config Release ``` When using CMD, use `-DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake` to specify the toolchain. + To build with plugins add `-DCHATTERINO_PLUGINS=ON` to `cmake -B build` command. 1. Run `.\bin\chatterino2.exe` diff --git a/CHANGELOG.md b/CHANGELOG.md index dcbf0f43067..ce2c9463e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,118 +3,186 @@ ## Unversioned - Minor: Added transparent overlay window (default keybind: `CTRL + ALT + N`). (#4746) -- Major: Allow use of Twitch follower emotes in other channels if subscribed. (#4922) -- Major: Add `/automod` split to track automod caught messages across all open channels the user moderates. (#4986, #5026) -- Major: Show restricted chat messages and suspicious treatment updates. (#5056, #5060) -- Minor: Migrate to the new Get Channel Followers Helix endpoint, fixing follower count not showing up in usercards. (#4809) -- Minor: The account switcher is now styled to match your theme. (#4817) -- Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) -- Minor: The installer now checks for the VC Runtime version and shows more info when it's outdated. (#4847) -- Minor: Allow running `/ban`, `/timeout`, `/unban`, and `/untimeout` on User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945, #4956, #4957) -- Minor: The `/usercard` command now accepts user ids. (#4934) -- Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) -- Minor: The `/reply` command now replies to the latest message of the user. (#4919) -- Minor: All sound capabilities can now be disabled by setting your "Sound backend" setting to "Null" and restarting Chatterino. (#4978) -- Minor: Add an option to use new experimental smarter emote completion. (#4987) -- Minor: Add `--safe-mode` command line option that can be used for troubleshooting when Chatterino is misbehaving or is misconfigured. It disables hiding the settings button & prevents plugins from loading. (#4985) -- Minor: Added support for FrankerFaceZ channel badges. These can be configured at https://www.frankerfacez.com/channel/mine - right now only supporting bot badges for your chat bots. (#5119) -- Minor: Updated the flatpakref link included with nightly builds to point to up-to-date flathub-beta builds. (#5008) -- Minor: Add a new completion API for experimental plugins feature. (#5000, #5047) -- Minor: Re-enabled _Restart on crash_ option on Windows. (#5012) +- Major: Release plugins alpha. (#5288) +- Major: Improve high-DPI support on Windows. (#4868, #5391) +- Minor: Add option to customise Moderation buttons with images. (#5369) +- Minor: Colored usernames now update on the fly when changing the "Color @usernames" setting. (#5300) +- Minor: Added `flags.action` filter variable, allowing you to filter on `/me` messages. (#5397) +- Minor: Added the ability for `/ban`, `/timeout`, `/unban`, and `/untimeout` to specify multiple channels to duplicate the action to. Example: `/timeout --channel id:11148817 --channel testaccount_420 forsen 7m game complaining`. (#5402) +- Minor: The size of the emote popup is now saved. (#5415) +- Minor: Added the ability to duplicate tabs. (#5277) +- Minor: Improved error messages for channel update commands. (#5429) +- Minor: Moderators can now see when users are warned. (#5441) +- Minor: Added support for Brave & google-chrome-stable browsers. (#5452) +- Minor: Added drop indicator line while dragging in tables. (#5256) +- Minor: Add channel points indication for new bits power-up redemptions. (#5471) +- Minor: Added `/warn ` command for mods. This prevents the user from chatting until they acknowledge the warning. (#5474) +- Minor: Introduce HTTP API for plugins. (#5383) +- Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426) +- Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) +- Bugfix: Fixed restricted users usernames not being clickable. (#5405) +- Bugfix: Fixed a crash that could occur when logging was enabled in IRC servers that were removed. (#5419) +- Bugfix: Fixed message history occasionally not loading after a sleep. (#5457) +- Bugfix: Fixed a crash when tab completing while having an invalid plugin loaded. (#5401) +- Bugfix: Fixed windows on Windows not saving correctly when snapping them to the edges. (#5478) +- Dev: Update Windows build from Qt 6.5.0 to Qt 6.7.1. (#5420) +- Dev: Update vcpkg build Qt from 6.5.0 to 6.7.0, boost from 1.83.0 to 1.85.0, openssl from 3.1.3 to 3.3.0. (#5422) +- Dev: Unsingletonize `ISoundController`. (#5462) +- Dev: Use Qt's high DPI scaling. (#4868, #5400) +- Dev: Add doxygen build target. (#5377) +- Dev: Make printing of strings in tests easier. (#5379) +- Dev: Refactor and document `Scrollbar`. (#5334, #5393) +- Dev: Refactor `TwitchIrcServer`, making it abstracted. (#5421, #5435) +- Dev: Reduced the amount of scale events. (#5404, #5406) +- Dev: Removed unused timegate settings. (#5361) +- Dev: Unsingletonize `Resources2`. (#5460) +- Dev: All Lua globals now show in the `c2` global in the LuaLS metadata. (#5385) +- Dev: Images are now loaded in worker threads. (#5431) +- Dev: Qt Creator now auto-configures Conan when loading the project and skips vcpkg. (#5305) +- Dev: The MSVC CRT is now bundled with Chatterino as it depends on having a recent version installed. (#5447) +- Dev: Refactor/unsingletonize `UserDataController`. (#5459) +- Dev: Cleanup `BrowserExtension`. (#5465) +- Dev: Deprecate Qt 5.12. (#5396) + +## 2.5.1 + +- Bugfix: Fixed links without a protocol not being clickable. (#5345) + +## 2.5.0 + +- Major: Twitch follower emotes can now be correctly tabbed in other channels when you are subscribed to the channel the emote is from. (#4922) +- Major: Added `/automod` split to track automod caught messages across all open channels the user moderates. (#4986, #5026) +- Major: Moderators can now see restricted chat messages and suspicious treatment updates. (#5056, #5060) +- Minor: Migrated to the new Get Channel Followers Helix endpoint, fixing follower count not showing up in usercards. (#4809) +- Minor: Moderation commands such as `/ban`, `/timeout`, `/unban`, and `/untimeout` can now be used via User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945, #4956, #4957) +- Minor: The `/usercard` command now accepts user ids. (`/usercard id:22484632`) (#4934) +- Minor: Added menu actions to reply directly to a message or the original thread root. (#4923) +- Minor: The `/reply` command now replies to the latest message from the user. Due to this change, the message you intended to reply to is now shown in the reply context, instead of the first message in a thread. (#4919) - Minor: The chatter list button is now hidden if you don't have moderator privileges. (#5245) +- Minor: Live streams that are marked as reruns now mark a tab as yellow instead of red. (#5176, #5237) +- Minor: Allowed theming of tab live and rerun indicators. (#5188) +- Minor: The _Restart on crash_ setting works again on Windows. (#5012) +- Minor: Added an option to use new experimental smarter emote completion. (#4987) +- Minor: Added support for FrankerFaceZ channel badges. These can be configured at https://www.frankerfacez.com/channel/mine - currently only supports bot badges for your chat bots. (#5119) +- Minor: Added support to send /announce[color] commands. Colored announcements only appear with the chosen color in Twitch chat. (#5250) - Minor: The whisper highlight color can now be configured through the settings. (#5053) - Minor: Added an option to always include the broadcaster in user completions. This is enabled by default. (#5193, #5244) -- Minor: Added missing periods at various moderator messages and commands. (#5061) -- Minor: Improved color selection and display. (#5057) -- Minor: Improved Streamlink documentation in the settings dialog. (#5076) -- Minor: Normalized the input padding between light & dark themes. (#5095) -- Minor: Add `--activate ` (or `-a`) command line option to activate or add a Twitch channel. (#5111) -- Minor: Chatters from recent-messages are now added to autocompletion. (#5116) -- Minor: Added a _System_ theme that updates according to the system's color scheme (requires Qt 6.5). (#5118) -- Minor: Added icons for newer versions of macOS. (#5148) -- Minor: Added the `--incognito/--no-incognito` options to the `/openurl` command, allowing you to override the "Open links in incognito/private mode" setting. (#5149, #5197) +- Minor: Added a warning message if you have multiple commands with the same trigger. (#4322) +- Minor: Chatters from message history are now added to autocompletion. (#5116) - Minor: Added support for the `{input.text}` placeholder in the **Split** -> **Run a command** hotkey. (#5130) -- Minor: Add a new Channel API for experimental plugins feature. (#5141, #5184, #5187) +- Minor: Added `--activate ` (or `-a`) command line option to focus or add a certain Twitch channel on startup. (#5111) +- Minor: Added the `--incognito/--no-incognito` options to the `/openurl` command, allowing you to override the "Open links in incognito/private mode" setting. (#5149, #5197) - Minor: Added the ability to change the top-most status of a window regardless of the _Always on top_ setting (right click the notebook). (#5135) -- Minor: Introduce `c2.later()` function to Lua API. (#5154) -- Minor: Live streams that are marked as reruns now mark a tab as yellow instead of red. (#5176, #5237) -- Minor: Updated to Emoji v15.1. Google emojis are now used as the fallback instead of Twitter emojis. (#5182) - Minor: Added the ability to show AutoMod caught messages in mentions. (#5215) - Minor: Added the ability to configure the color of highlighted AutoMod caught messages. (#5215) -- Minor: Allow theming of tab live and rerun indicators. (#5188) +- Minor: Updated to Emoji v15.1. Google emojis are now used as the fallback instead of Twitter emojis. (#5182) +- Minor: Added icons for newer versions of macOS. (#5148) +- Minor: Added more menu items in macOS menu bar. (#5266) +- Minor: Improved color selection and display. (#5057) +- Minor: Added a _System_ theme setting that updates according to the system's color scheme (requires Qt 6.5). (#5118) +- Minor: Normalized the input padding between light & dark themes. (#5095) +- Minor: The account switcher is now styled to match your theme. (#4817) - Minor: Added a fallback theme field to custom themes that will be used in case the custom theme does not contain a color Chatterino needs. If no fallback theme is specified, we'll pull the color from the included Dark or Light theme. (#5198) +- Minor: Added a new completion API for experimental plugins feature. (#5000, #5047) +- Minor: Added a new Channel API for experimental plugins feature. (#5141, #5184, #5187) +- Minor: Introduce `c2.later()` function to Lua API. (#5154) +- Minor: Added `--safe-mode` command line option that can be used for troubleshooting when Chatterino is misbehaving or is misconfigured. It disables hiding the settings button & prevents plugins from loading. (#4985) +- Minor: Added wrappers for Lua `io` library for experimental plugins feature. (#5231) +- Minor: Added permissions to experimental plugins feature. (#5231) +- Minor: Added missing periods at various moderator messages and commands. (#5061) +- Minor: Improved Streamlink documentation in the settings dialog. (#5076) +- Minor: The installer now checks for the VC Runtime version and shows more info when it's outdated. (#4847) +- Minor: All sound capabilities can now be disabled by setting your "Sound backend" setting to "Null" and restarting Chatterino. (#4978) +- Minor: Added an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) +- Minor: Updated the flatpakref link included with nightly builds to point to up-to-date flathub-beta builds. (#5008) - Minor: Image links now reflect the scale of their image instead of an internal label. (#5201) - Minor: IPC files are now stored in the Chatterino directory instead of system directories on Windows. (#5226) - Minor: 7TV emotes now have a 4x image rather than a 3x image. (#5209) -- Minor: Add wrappers for Lua `io` library for experimental plugins feature. (#5231) -- Minor: Add permissions to experimental plugins feature. (#5231) -- Minor: Added warning message if you have multiple commands with the same trigger. (#4322) -- Minor: Add support to send /announce[color] commands. (#5250) +- Minor: Add `reward.cost` `reward.id`, `reward.title` filter variables. (#5275) +- Minor: Change Lua `CompletionRequested` handler to use an event table. (#5280) +- Minor: Changed the layout of the about page. (#5287) +- Minor: Add duration to multi-month anon sub gift messages. (#5293) +- Minor: Added context menu action to toggle visibility of offline tabs. (#5318) +- Minor: Report sub duration for more multi-month gift cases. (#5319) +- Minor: Improved error reporting for the automatic streamer mode detection on Linux and macOS. (#5321) +- Bugfix: Fixed a crash that could occur on Wayland when using the image uploader. (#5314) +- Bugfix: Fixed split tooltip getting stuck in some cases. (#5309) +- Bugfix: Fixed the version string not showing up as expected in Finder on macOS. (#5311) +- Bugfix: Fixed links having `http://` added to the beginning in certain cases. (#5323) +- Bugfix: Fixed topmost windows from losing their status after opening dialogs on Windows. (#5330) +- Bugfix: Fixed a gap appearing when using filters on `/watching`. (#5329) +- Bugfix: Removed the remnant "Show chatter list" menu entry for non-moderators. (#5336) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) +- Bugfix: Fixed the `/shoutout` command not working with usernames starting with @'s (e.g. `/shoutout @forsen`). (#4800) - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) - Bugfix: Fixed a performance issue when displaying replies to certain messages. (#4807) - Bugfix: Fixed an issue where certain parts of the split input wouldn't focus the split when clicked. (#4958) -- Bugfix: Fixed a data race when disconnecting from Twitch PubSub. (#4771) -- Bugfix: Fixed `/shoutout` command not working with usernames starting with @'s (e.g. `/shoutout @forsen`). (#4800) -- Bugfix: Fixed Usercard popup not floating on tiling WMs on Linux when "Automatically close user popup when it loses focus" setting is enabled. (#3511) +- Bugfix: Fixed an issue in the `/live` split that caused some channels to not get grayed-out when they went offline. (#5172)\ +- Bugfix: User text input within watch streak notices now correctly shows up. (#5029) - Bugfix: Fixed selection of tabs after closing a tab when using "Live Tabs Only". (#4770) -- Bugfix: Fixed input in reply thread popup losing focus when dragging. (#4815) -- Bugfix: Fixed the Quick Switcher (CTRL+K) from sometimes showing up on the wrong window. (#4819) +- Bugfix: Fixed input in the reply thread popup losing focus when dragging said window. (#4815) +- Bugfix: Fixed the Quick Switcher (CTRL+K) sometimes showing up on the wrong window. (#4819) - Bugfix: Fixed the font switcher not remembering what font you had previously selected. (#5224) - Bugfix: Fixed too much text being copied when copying chat messages. (#4812, #4830, #4839) -- Bugfix: Fixed an issue where the setting `Only search for emote autocompletion at the start of emote names` wouldn't disable if it was enabled when the client started. (#4855) -- Bugfix: Fixed empty page being added when showing out of bounds dialog. (#4849) -- Bugfix: Fixed an issue preventing searching a redemption by it's title when the redemption contained text input. (#5117) - Bugfix: Fixed issue on Windows preventing the title bar from being dragged in the top left corner. (#4873) +- Bugfix: Fixed an issue where Streamer Mode did not detect that OBS was running on MacOS. (#5260) +- Bugfix: Remove ":" from the message the user is replying to if it's a /me message. (#5263) +- Bugfix: Fixed the "Cancel" button in the settings dialog only working after opening the settings dialog twice. (#5229) +- Bugfix: Fixed an issue where the setting `Only search for emote autocompletion at the start of emote names` wouldn't disable if it was enabled when the client started. (#4855) +- Bugfix: Fixed an empty page being added when showing the out of bounds dialog. (#4849) +- Bugfix: Fixed an issue preventing searching a redemption by it's title when the redemption contained user text input. (#5117) - Bugfix: Fixed an issue where reply context didn't render correctly if an emoji was touching text. (#4875, #4977, #5174) -- Bugfix: Fixed the input completion popup from disappearing when clicking on it on Windows and macOS. (#4876) +- Bugfix: Fixed the input completion popup sometimes disappearing when clicking on it on Windows and macOS. (#4876) - Bugfix: Fixed Twitch badges not loading correctly in the badge highlighting setting page. (#5223) - Bugfix: Fixed double-click text selection moving its position with each new message. (#4898) - Bugfix: Fixed an issue where notifications on Windows would contain no or an old avatar. (#4899) - Bugfix: Fixed headers of tables in the settings switching to bold text when selected. (#4913) -- Bugfix: Fixed an issue in the `/live` split that caused some channels to not get grayed-out when they went offline. (#5172) - Bugfix: Fixed tooltips appearing too large and/or away from the cursor. (#4920) -- Bugfix: Fixed a crash when clicking `More messages below` button in a usercard and closing it quickly. (#4933) - Bugfix: Fixed thread popup window missing messages for nested threads. (#4923) - Bugfix: Fixed an occasional crash for channel point redemptions with text input. (#4949) -- Bugfix: Fixed triple click on message also selecting moderation buttons. (#4961) -- Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965, #5126) +- Bugfix: Fixed triple-click on message also selecting moderation buttons. (#4961) - Bugfix: Fixed badge highlight changes not immediately being reflected. (#5110) - Bugfix: Fixed emotes being reloaded when pressing "Cancel" in the settings dialog, causing a slowdown. (#5240) +- Bugfix: Fixed double-click selection not correctly selecting words that were split onto multiple lines. (#5243) - Bugfix: Fixed some emotes not appearing when using _Ignores_. (#4965, #5126) -- Bugfix: Fixed double-click selection not selecting words that were split onto multiple lines correctly. (#5243) +- Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965, #5126) - Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965, #5126) - Bugfix: Fixed Image Uploader accidentally deleting images with some hosts when link resolver was enabled. (#4971) -- Bugfix: Fixed rare crash with Image Uploader when closing a split right after starting an upload. (#4971) -- Bugfix: Fixed an issue on macOS where the image uploader would keep prompting the user even after they clicked "Yes, don't ask again". (#5011) -- Bugfix: Hide the Usercard button in the User Info Popup when in special channels. (#4972) +- Bugfix: Fixed a rare crash with the Image Uploader when closing a split right after starting an upload. (#4971) +- Bugfix: Fixed an issue on macOS where the Image Uploader would keep prompting the user even after they clicked "Yes, don't ask again". (#5011) +- Bugfix: The usercard button is now hidden in the User Info Popup when in special channels. (#4972) - Bugfix: Fixed support for Windows 11 Snap layouts. (#4994, #5175) - Bugfix: Fixed some windows appearing between screens. (#4797) +- Bugfix: Fixed a crash that could occur when clicking `More messages below` button in a usercard and closing it quickly. (#4933) - Bugfix: Fixed a crash that could occur when using certain features in a Usercard after closing the split from which it was created. (#5034, #5051) - Bugfix: Fixed a crash that could occur when using certain features in a Reply popup after closing the split from which it was created. (#5036, #5051) - Bugfix: Fixed a bug on Wayland where tooltips would spawn as separate windows instead of behaving like tooltips. (#4998, #5040) - Bugfix: Fixes to section deletion in text input fields. (#5013) -- Bugfix: Truncated IRC messages to be at most 512 bytes. (#5246) -- Bugfix: Show user text input within watch streak notices. (#5029) - Bugfix: Fixed avatar in usercard and moderation button triggering when releasing the mouse outside their area. (#5052) -- Bugfix: Fixed moderator-only topics being subscribed to for non-moderators. (#5056) - Bugfix: Fixed a bug where buttons would remain in a hovered state after leaving them. (#5077) - Bugfix: Fixed an issue where you had to click the `reply` button twice if you already had that users @ in your input box. (#5173) - Bugfix: Fixed popup windows not persisting between restarts. (#5081) - Bugfix: Fixed splits not retaining their focus after minimizing. (#5080) - Bugfix: Fixed _Copy message_ copying the channel name in global search. (#5106) +- Bugfix: Fixed some Twitch emotes sizes being wrong at certain zoom levels. (#5279, #5291) +- Bugfix: Fixed a missing space when the image uploader provided a delete link. (#5269) - Bugfix: Reply contexts now use the color of the replied-to message. (#5145) - Bugfix: Fixed top-level window getting stuck after opening settings. (#5161, #5166) - Bugfix: Fixed link info not updating without moving the cursor. (#5178) - Bugfix: Fixed an upload sometimes failing when copying an image from a browser if it contained extra properties. (#5156) - Bugfix: Fixed tooltips getting out of bounds when loading images. (#5186) -- Bugfix: Fixed the "Cancel" button in the settings dialog only working after opening the settings dialog twice. (#5229) - Bugfix: Fixed split header tooltips showing in the wrong position on Windows. (#5230) - Bugfix: Fixed split header tooltips appearing too tall. (#5232) - Bugfix: Fixed past messages not showing in the search popup after adding a channel. (#5248) -- Bugfix: Detect when OBS is running on MacOS. (#5260) -- Bugfix: Remove ":" from the message the user is replying to if it's a /me message. (#5263) +- Bugfix: Fixed pause indicator not disappearing in some cases. (#5265) +- Bugfix: Fixed the usercard popup not floating on tiling WMs on Linux when "Automatically close user popup when it loses focus" setting is enabled. (#3511) +- Bugfix: Fixed moderator-only topics being subscribed to for non-moderators. (#5056) +- Bugfix: Truncated IRC messages to be at most 512 bytes. (#5246) +- Bugfix: Fixed a data race when disconnecting from Twitch PubSub. (#4771) +- Bugfix: Fixed messages not immediately disappearing when clearing the chat. (#5282) +- Bugfix: Fixed highlights triggering for ignored users in announcements. (#5295) +- Dev: Changed the order of the query parameters for Twitch player URLs. (#5326) - Dev: Run miniaudio in a separate thread, and simplify it to not manage the device ourselves. There's a chance the simplification is a bad idea. (#4978) - Dev: Change clang-format from v14 to v16. (#4929) - Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) @@ -174,7 +242,7 @@ - Dev: Added the ability to show `ChannelView`s without a `Split`. (#4747) - Dev: Refactor Args to be less of a singleton. (#5041) - Dev: Channels without any animated elements on screen will skip updates from the GIF timer. (#5042, #5043, #5045) -- Dev: Autogenerate docs/plugin-meta.lua. (#5055) +- Dev: Autogenerate docs/plugin-meta.lua. (#5055, #5283) - Dev: Changed Ubuntu & AppImage builders to statically link Qt. (#5151) - Dev: Refactor `NetworkPrivate`. (#5063) - Dev: Refactor `Paths` & `Updates`, focusing on reducing their singletoniability. (#5092, #5102) @@ -185,14 +253,16 @@ - Dev: Added signal to invalidate paint buffers of channel views without forcing a relayout. (#5123) - Dev: Specialize `Atomic>` if underlying standard library supports it. (#5133) - Dev: Added the `developer_name` field to the Linux AppData specification. (#5138) -- Dev: Twitch messages can be sent using Twitch's Helix API instead of IRC (disabled by default). (#5200) +- Dev: Twitch messages can be sent using Twitch's Helix API instead of IRC (disabled by default). (#5200, #5276) - Dev: Added estimation for image sizes to avoid layout shifts. (#5192) - Dev: Added the `launachable` entry to Linux AppData. (#5210) - Dev: Cleaned up and optimized resources. (#5222) - Dev: Refactor `StreamerMode`. (#5216, #5236) - Dev: Cleaned up unused code in `MessageElement` and `MessageLayoutElement`. (#5225) - Dev: Adapted `magic_enum` to Qt's Utf-16 strings. (#5258) -- Dev: `NetworkManager`'s statics are now created in its `init` method. (#5254) +- Dev: `NetworkManager`'s statics are now created in its `init` method. (#5254, #5297) +- Dev: `clang-tidy` CI now uses Qt 6. (#5273) +- Dev: Enabled `InsertNewlineAtEOF` in `clang-format`. (#5278) ## 2.4.6 diff --git a/CMakeLists.txt b/CMakeLists.txt index 14efcb0daf8..6fb323286d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,7 +28,7 @@ option(CHATTERINO_GENERATE_COVERAGE "Generate coverage files" OFF) option(BUILD_TRANSLATIONS "" OFF) option(BUILD_SHARED_LIBS "" OFF) option(CHATTERINO_LTO "Enable LTO for all targets" OFF) -option(CHATTERINO_PLUGINS "Enable EXPERIMENTAL plugin support in Chatterino" OFF) +option(CHATTERINO_PLUGINS "Enable ALPHA plugin support in Chatterino" OFF) option(CHATTERINO_UPDATER "Enable update checks" ON) mark_as_advanced(CHATTERINO_UPDATER) @@ -41,7 +41,7 @@ if(BUILD_BENCHMARKS) endif() project(chatterino - VERSION 2.4.6 + VERSION 2.5.1 DESCRIPTION "Chat client for twitch.tv" HOMEPAGE_URL "https://chatterino.com/" ) @@ -197,6 +197,7 @@ find_package(PajladaSerialize REQUIRED) find_package(PajladaSignals REQUIRED) find_package(LRUCache REQUIRED) find_package(MagicEnum REQUIRED) +find_package(Doxygen) if (USE_SYSTEM_PAJLADA_SETTINGS) find_package(PajladaSettings REQUIRED) diff --git a/QtCreatorPackageManager.cmake b/QtCreatorPackageManager.cmake new file mode 100644 index 00000000000..f26a970c81e --- /dev/null +++ b/QtCreatorPackageManager.cmake @@ -0,0 +1,5 @@ +# https://www.qt.io/blog/qt-creator-cmake-package-manager-auto-setup + +# set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON) # skip both conan and vcpkg auto-setups +# set(QT_CREATOR_SKIP_CONAN_SETUP ON) # skip conan auto-setup +set(QT_CREATOR_SKIP_VCPKG_SETUP ON) # skip vcpkg auto-setup diff --git a/benchmarks/src/RecentMessages.cpp b/benchmarks/src/RecentMessages.cpp index fd5fe0f1a14..7f2c0c7aca5 100644 --- a/benchmarks/src/RecentMessages.cpp +++ b/benchmarks/src/RecentMessages.cpp @@ -4,6 +4,7 @@ #include "messages/Emote.hpp" #include "mocks/DisabledStreamerMode.hpp" #include "mocks/EmptyApplication.hpp" +#include "mocks/LinkResolver.hpp" #include "mocks/TwitchIrcServer.hpp" #include "mocks/UserData.hpp" #include "providers/bttv/BttvEmotes.hpp" @@ -99,10 +100,16 @@ class MockApplication : mock::EmptyApplication return &this->streamerMode; } + ILinkResolver *getLinkResolver() override + { + return &this->linkResolver; + } + AccountController accounts; Emotes emotes; mock::UserDataController userData; mock::MockTwitchIrcServer twitch; + mock::EmptyLinkResolver linkResolver; ChatterinoBadges chatterinoBadges; FfzBadges ffzBadges; SeventvBadges seventvBadges; diff --git a/cmake/MacOSXBundleInfo.plist.in b/cmake/MacOSXBundleInfo.plist.in index ae08eb0d9bd..9077068dbcb 100644 --- a/cmake/MacOSXBundleInfo.plist.in +++ b/cmake/MacOSXBundleInfo.plist.in @@ -6,8 +6,6 @@ English CFBundleExecutable ${MACOSX_BUNDLE_EXECUTABLE_NAME} - CFBundleGetInfoString - ${MACOSX_BUNDLE_INFO_STRING} CFBundleIconFile ${MACOSX_BUNDLE_ICON_FILE} CFBundleIdentifier diff --git a/cmake/resources/ResourcesAutogen.hpp.in b/cmake/resources/ResourcesAutogen.hpp.in index b047192d6a5..affafc3dba0 100644 --- a/cmake/resources/ResourcesAutogen.hpp.in +++ b/cmake/resources/ResourcesAutogen.hpp.in @@ -3,15 +3,14 @@ ** WARNING! All changes made in this file will be lost! *****************************************************************************/ #include -#include "common/Singleton.hpp" namespace chatterino { -class Resources2 : public Singleton +class Resources2 { public: Resources2(); @RES_HEADER_CONTENT@ }; -} // namespace chatterino \ No newline at end of file +} // namespace chatterino diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index 9bf6f57c0fb..7929fbc3d23 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -32,6 +32,8 @@ declare module c2 { is_valid(): boolean; } + interface ISharedResource {} + class RoomModes { unique_chat: boolean; subscriber_only: boolean; @@ -69,12 +71,49 @@ declare module c2 { static by_twitch_id(id: string): null | Channel; } + enum HTTPMethod { + Get, + Post, + Put, + Delete, + Patch, + } + + class HTTPResponse implements ISharedResource { + data(): string; + status(): number | null; + error(): string; + } + + type HTTPCallback = (res: HTTPResponse) => void; + class HTTPRequest implements ISharedResource { + on_success(callback: HTTPCallback): void; + on_error(callback: HTTPCallback): void; + finally(callback: () => void): void; + + set_timeout(millis: number): void; + set_payload(data: string): void; + set_header(name: string, value: string): void; + + execute(): void; + + // might error + static create(method: HTTPMethod, url: string): HTTPRequest; + } + function log(level: LogLevel, ...data: any[]): void; function register_command( name: String, handler: (ctx: CommandContext) => void ): boolean; + class CompletionEvent { + query: string; + full_text_content: string; + cursor_position: number; + is_first_word: boolean; + } + class CompletionList { values: String[]; hide_others: boolean; @@ -84,12 +123,7 @@ declare module c2 { CompletionRequested = "CompletionRequested", } - type CbFuncCompletionsRequested = ( - query: string, - full_text_content: string, - cursor_position: number, - is_first_word: boolean - ) => CompletionList; + type CbFuncCompletionsRequested = (ev: CompletionEvent) => CompletionList; type CbFunc = T extends EventType.CompletionRequested ? CbFuncCompletionsRequested : never; diff --git a/docs/make-release.md b/docs/make-release.md index c28dead6bdb..1509289fd39 100644 --- a/docs/make-release.md +++ b/docs/make-release.md @@ -8,7 +8,9 @@ - [ ] Add a new release at the top of the `releases` key in `resources/com.chatterino.chatterino.appdata.xml` This cannot use dash to denote a pre-release identifier, you have to use a tilde instead. -- [ ] Updated version code in `.CI/chatterino-installer.iss` +- [ ] Updated version code in `.CI/chatterino-installer.iss` + This can only be "whole versions", so if you're releasing `2.4.0-beta` you'll need to condense it to `2.4.0` + - [ ] Update the changelog `## Unreleased` section to the new version `CHANGELOG.md` Make sure to leave the `## Unreleased` line unchanged for easier merges diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index 2cc56af5994..453e4f548c8 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -5,129 +5,124 @@ -- Add the folder this file is in to "Lua.workspace.library". c2 = {} - ----@class IWeakResource - ---- Returns true if the channel this object points to is valid. ---- If the object expired, returns false ---- If given a non-Channel object, it errors. ----@return boolean -function IWeakResource:is_valid() end - - ----@alias LogLevel integer ----@type { Debug: LogLevel, Info: LogLevel, Warning: LogLevel, Critical: LogLevel } +---@alias c2.LogLevel integer +---@type { Debug: c2.LogLevel, Info: c2.LogLevel, Warning: c2.LogLevel, Critical: c2.LogLevel } c2.LogLevel = {} ----@alias EventType integer ----@type { CompletionRequested: EventType } +---@alias c2.EventType integer +---@type { CompletionRequested: c2.EventType } c2.EventType = {} + ---@class CommandContext ---@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. ----@field channel Channel The channel the command was executed in. +---@field channel c2.Channel The channel the command was executed in. ---@class CompletionList ---@field values string[] The completions ---@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. --- Now including data from src/common/Channel.hpp. ----@alias ChannelType integer ----@type { None: ChannelType } -ChannelType = {} --- Back to src/controllers/plugins/LuaAPI.hpp. --- Now including data from src/controllers/plugins/api/ChannelRef.hpp. +---@class CompletionEvent +---@field query string The word being completed +---@field full_text_content string Content of the text input +---@field cursor_position integer Position of the cursor in the text input in unicode codepoints (not bytes) +---@field is_first_word boolean True if this is the first word in the input + +-- Begin src/common/Channel.hpp + +---@alias c2.ChannelType integer +---@type { None: c2.ChannelType, Direct: c2.ChannelType, Twitch: c2.ChannelType, TwitchWhispers: c2.ChannelType, TwitchWatching: c2.ChannelType, TwitchMentions: c2.ChannelType, TwitchLive: c2.ChannelType, TwitchAutomod: c2.ChannelType, TwitchEnd: c2.ChannelType, Irc: c2.ChannelType, Misc: c2.ChannelType } +c2.ChannelType = {} + +-- End src/common/Channel.hpp + +-- Begin src/controllers/plugins/api/ChannelRef.hpp + +---@alias c2.Platform integer --- This enum describes a platform for the purpose of searching for a channel. --- Currently only Twitch is supported because identifying IRC channels is tricky. +---@type { Twitch: c2.Platform } +c2.Platform = {} ----@alias Platform integer ----@type { Twitch: Platform } -Platform = {} ----@class Channel: IWeakResource +---@class c2.Channel +c2.Channel = {} --- Returns true if the channel this object points to is valid. --- If the object expired, returns false --- If given a non-Channel object, it errors. --- ---@return boolean success -function Channel:is_valid() end +function c2.Channel:is_valid() end --- Gets the channel's name. This is the lowercase login name. --- ---@return string name -function Channel:get_name() end +function c2.Channel:get_name() end --- Gets the channel's type --- ----@return ChannelType -function Channel:get_type() end +---@return c2.ChannelType +function c2.Channel:get_type() end --- Get the channel owner's display name. This may contain non-lowercase ascii characters. --- ---@return string name -function Channel:get_display_name() end +function c2.Channel:get_display_name() end --- Sends a message to the target channel. --- Note that this does not execute client-commands. --- ---@param message string ---@param execute_commands boolean Should commands be run on the text? -function Channel:send_message(message, execute_commands) end +function c2.Channel:send_message(message, execute_commands) end --- Adds a system message client-side --- ---@param message string -function Channel:add_system_message(message) end +function c2.Channel:add_system_message(message) end --- Returns true for twitch channels. --- Compares the channel Type. Note that enum values aren't guaranteed, just --- that they are equal to the exposed enum. --- ----@return bool -function Channel:is_twitch_channel() end - ---- Twitch Channel specific functions +---@return boolean +function c2.Channel:is_twitch_channel() end --- Returns a copy of the channel mode settings (subscriber only, r9k etc.) --- ---@return RoomModes -function Channel:get_room_modes() end +function c2.Channel:get_room_modes() end --- Returns a copy of the stream status. --- ---@return StreamStatus -function Channel:get_stream_status() end +function c2.Channel:get_stream_status() end --- Returns the Twitch user ID of the owner of the channel. --- ---@return string -function Channel:get_twitch_id() end +function c2.Channel:get_twitch_id() end --- Returns true if the channel is a Twitch channel and the user owns it --- ---@return boolean -function Channel:is_broadcaster() end +function c2.Channel:is_broadcaster() end --- Returns true if the channel is a Twitch channel and the user is a moderator in the channel --- Returns false for broadcaster. --- ---@return boolean -function Channel:is_mod() end +function c2.Channel:is_mod() end --- Returns true if the channel is a Twitch channel and the user is a VIP in the channel --- Returns false for broadcaster. --- ---@return boolean -function Channel:is_vip() end - ---- Misc +function c2.Channel:is_vip() end ---@return string -function Channel:__tostring() end - ---- Static functions +function c2.Channel:__tostring() end --- Finds a channel by name. ---- --- Misc channels are marked as Twitch: --- - /whispers --- - /mentions @@ -136,25 +131,21 @@ function Channel:__tostring() end --- - /automod --- ---@param name string Which channel are you looking for? ----@param platform Platform Where to search for the channel? ----@return Channel? -function Channel.by_name(name, platform) end +---@param platform c2.Platform Where to search for the channel? +---@return c2.Channel? +function c2.Channel.by_name(name, platform) end --- Finds a channel by the Twitch user ID of its owner. --- ----@param string id ID of the owner of the channel. ----@return Channel? -function Channel.by_twitch_id(string) end +---@param id string ID of the owner of the channel. +---@return c2.Channel? +function c2.Channel.by_twitch_id(id) end ---@class RoomModes ---@field unique_chat boolean You might know this as r9kbeta or robot9000. ---@field subscriber_only boolean ----@field emotes_only boolean Whether or not text is allowed in messages. - ---- Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes - ----@field unique_chat number? Time in minutes you need to follow to chat or nil. - +---@field emotes_only boolean Whether or not text is allowed in messages. Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes +---@field follower_only number? Time in minutes you need to follow to chat or nil. ---@field slow_mode number? Time in seconds you need to wait before sending messages or nil. ---@class StreamStatus @@ -164,7 +155,91 @@ function Channel.by_twitch_id(string) end ---@field title string Stream title or last stream title ---@field game_name string ---@field game_id string --- Back to src/controllers/plugins/LuaAPI.hpp. + +-- End src/controllers/plugins/api/ChannelRef.hpp + +-- Begin src/controllers/plugins/api/HTTPRequest.hpp + +---@class HTTPResponse +---@field data string Data received from the server +---@field status integer? HTTP Status code returned by the server +---@field error string A somewhat human readable description of an error if such happened + +---@alias HTTPCallback fun(result: HTTPResponse): nil +---@class HTTPRequest +HTTPRequest = {} + +--- Sets the success callback +--- +---@param callback HTTPCallback Function to call when the HTTP request succeeds +function HTTPRequest:on_success(callback) end + +--- Sets the failure callback +--- +---@param callback HTTPCallback Function to call when the HTTP request fails or returns a non-ok status +function HTTPRequest:on_error(callback) end + +--- Sets the finally callback +--- +---@param callback fun(): nil Function to call when the HTTP request finishes +function HTTPRequest:finally(callback) end + +--- Sets the timeout +--- +---@param timeout integer How long in milliseconds until the times out +function HTTPRequest:set_timeout(timeout) end + +--- Sets the request payload +--- +---@param data string +function HTTPRequest:set_payload(data) end + +--- Sets a header in the request +--- +---@param name string +---@param value string +function HTTPRequest:set_header(name, value) end + +--- Executes the HTTP request +--- +function HTTPRequest:execute() end + +--- Creates a new HTTPRequest +--- +---@param method HTTPMethod Method to use +---@param url string Where to send the request to +---@return HTTPRequest +function HTTPRequest.create(method, url) end + +-- End src/controllers/plugins/api/HTTPRequest.hpp + +-- Begin src/controllers/plugins/api/HTTPResponse.hpp + +---@class HTTPResponse +HTTPResponse = {} + +--- Returns the data. This is not guaranteed to be encoded using any +--- particular encoding scheme. It's just the bytes the server returned. +--- +function HTTPResponse:data() end + +--- Returns the status code. +--- +function HTTPResponse:status() end + +--- A somewhat human readable description of an error if such happened +--- +function HTTPResponse:error() end + +-- End src/controllers/plugins/api/HTTPResponse.hpp + +-- Begin src/common/network/NetworkCommon.hpp + +---@alias HTTPMethod integer +---@type { Get: HTTPMethod, Post: HTTPMethod, Put: HTTPMethod, Delete: HTTPMethod, Patch: HTTPMethod } +HTTPMethod = {} + +-- End src/common/network/NetworkCommon.hpp --- Registers a new command called `name` which when executed will call `handler`. --- @@ -176,12 +251,12 @@ function c2.register_command(name, handler) end --- Registers a callback to be invoked when completions for a term are requested. --- ---@param type "CompletionRequested" ----@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked. +---@param func fun(event: CompletionEvent): CompletionList The callback to be invoked. function c2.register_callback(type, func) end --- Writes a message to the Chatterino log. --- ----@param level LogLevel The desired level. +---@param level c2.LogLevel The desired level. ---@param ... any Values to log. Should be convertible to a string with `tostring()`. function c2.log(level, ...) end diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 1309d7bab0c..bf49e0f4c55 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -85,6 +85,24 @@ Example: } ``` +### Network + +Allows the plugin to send HTTP requests. + +Example: + +```json +{ + ..., + "permissions": [ + { + "type": "Network" + }, + ... + ] +} +``` + ## Plugins with Typescript If you prefer, you may use [TypescriptToLua](https://typescripttolua.github.io) @@ -167,7 +185,7 @@ Limitations/known issues: #### `register_callback("CompletionRequested", handler)` -Registers a callback (`handler`) to process completions. The callback gets the following parameters: +Registers a callback (`handler`) to process completions. The callback takes a single table with the following entries: - `query`: The queried word. - `full_text_content`: The whole input. @@ -190,8 +208,8 @@ end c2.register_callback( "CompletionRequested", - function(query, full_text_content, cursor_position, is_first_word) - if ("!join"):startswith(query) then + function(event) + if ("!join"):startswith(event.query) then ---@type CompletionList return { hide_others = true, values = { "!join" } } end @@ -370,6 +388,138 @@ Returns `true` if the channel can be moderated by the current user. Returns `true` if the current user is a VIP in the channel. +#### `HTTPMethod` enum + +This table describes HTTP methods available to Lua Plugins. The values behind +the names may change, do not count on them. It has the following keys: + +- `Get` +- `Post` +- `Put` +- `Delete` +- `Patch` + +#### `HTTPResponse` + +An `HTTPResponse` is a table you receive in the callback after a completed `HTTPRequest`. + +##### `HTTPResponse.data()` + +This function returns the data received from the server as a string. Usually +this will be UTF-8-encoded however that is not guaranteed, this could be any +binary data. + +##### `HTTPResponse.error()` + +If an error happened this function returns a human readable description of it. + +It returns something like: `"ConnectionRefusedError"`, `"401"`. + +##### `HTTPResponse.status()` + +This function returns the HTTP status code of the request or `nil` if there was +an error before a status code could be received. + +```lua +{ + data = "This is the data received from the server as a string", + status = 200, -- HTTP status code returned by the server or nil if no response was received because of an error + error = "A somewhat human readable description of an error if such happened" +} +``` + +#### `HTTPRequest` + +Allows you to send an HTTP request to a URL. Do not create requests that you +don't want to call `execute()` on. For the time being that leaks callback +functions and all their upvalues with them. + +##### `HTTPRequest.create(method, url)` + +Creates a new `HTTPRequest`. The `method` argument is an +[`HTTPMethod`](#HTTPMethod-enum). The `url` argument must be a string +containing a valid URL (ex. `https://example.com/path/to/api`). + +```lua +local req = c2.HTTPRequest.create(c2.HTTPMethod.Get, "https://example.com") +req:on_success(function (res) + print(res.data) +end) +req:execute() +``` + +##### `HTTPRequest:on_success(callback)` + +Sets the success callback. It accepts a function that takes a single parameter +of type `HTTPResponse`. The callback will be called on success. This function +returns nothing. + +##### `HTTPRequest:on_error(callback)` + +Sets the error callback. It accepts a function that takes a single parameter of +type `HTTPResponse`. The callback will be called if the request fails. To see why +it failed check the `error` field of the result. This function returns nothing. + +##### `HTTPRequest:finally(callback)` + +Sets the finally callback. It accepts a function that takes no parameters and +returns nothing. It will be always called after `success` or `error`. This +function returns nothing. + +##### `HTTPRequest:set_timeout(timeout)` + +Sets how long the request will take before it times out. The `timeout` +parameter is in milliseconds. This function returns nothing. + +##### `HTTPRequest:set_payload(data)` + +Sets the data that will be sent with the request. The `data` parameter must be +a string. This function returns nothing. + +##### `HTTPRequest:set_header(name, value)` + +Adds or overwrites a header in the request. Both `name` and `value` should be +strings. If they are not strings they will be converted to strings. This +function returns nothing. + +##### `HTTPRequest:execute()` + +Sends the request. This function returns nothing. + +```lua +local url = "http://localhost:8080/thing" +local request = c2.HTTPRequest.create("Post", url) +request:set_timeout(1000) +request:set_payload("TEST!") +request:set_header("X-Test", "Testing!") +request:set_header("Content-Type", "text/plain") +request:on_success(function (res) + print('Success!') + -- Data is in res.data + print(res.status) +end) +request:on_error(function (res) + print('Error!') + print(res.status) + print(res.error) +end) +request:finally(function () + print('Finally') +end) +request:execute() + +-- This prints: +-- Success! +-- [content of /thing] +-- 200 +-- Finally + +-- Or: +-- Error! +-- nil +-- ConnectionRefusedError +``` + ### Input/Output API These functions are wrappers for Lua's I/O library. Functions on file pointer diff --git a/lib/expected-lite b/lib/expected-lite new file mode 160000 index 00000000000..182165b584d --- /dev/null +++ b/lib/expected-lite @@ -0,0 +1 @@ +Subproject commit 182165b584dad130afaf4bcd25b8629799baea38 diff --git a/lib/lua/src b/lib/lua/src index e288c5a9188..0897c0a4289 160000 --- a/lib/lua/src +++ b/lib/lua/src @@ -1 +1 @@ -Subproject commit e288c5a91883793d14ed9e9d93464f6ee0b08915 +Subproject commit 0897c0a4289ef3a8d45761266124613f364bef60 diff --git a/lib/settings b/lib/settings index ceac9c7e97d..f3e9161ee61 160000 --- a/lib/settings +++ b/lib/settings @@ -1 +1 @@ -Subproject commit ceac9c7e97d2d2b97f40ecd0b421e358d7525cbc +Subproject commit f3e9161ee61b47da71c7858e24efee9b0053357f diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 5d5ea1064ea..233211bfa81 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -19,6 +19,11 @@ class EmptyApplication : public IApplication virtual ~EmptyApplication() = default; + bool isTest() const override + { + return true; + } + const Paths &getPaths() override { return this->paths_; @@ -118,6 +123,13 @@ class EmptyApplication : public IApplication return nullptr; } + IAbstractIrcServer *getTwitchAbstract() override + { + assert(false && "EmptyApplication::getTwitchAbstract was called " + "without being initialized"); + return nullptr; + } + PubSub *getTwitchPubSub() override { assert(false && "getTwitchPubSub was called without being initialized"); @@ -130,7 +142,7 @@ class EmptyApplication : public IApplication return nullptr; } - Logging *getChatLogger() override + ILogging *getChatLogger() override { assert(!"getChatLogger was called without being initialized"); return nullptr; diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index f53f62dda6d..14ec3976eb8 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -119,12 +119,12 @@ class Helix : public IHelix HelixFailureCallback failureCallback), (override)); - MOCK_METHOD(void, updateChannel, - (QString broadcasterId, QString gameId, QString language, - QString title, - std::function successCallback, - HelixFailureCallback failureCallback), - (override)); + MOCK_METHOD( + void, updateChannel, + (QString broadcasterId, QString gameId, QString language, QString title, + std::function successCallback, + (FailureCallback failureCallback)), + (override)); MOCK_METHOD(void, manageAutoModMessages, (QString userID, QString msgID, QString action, @@ -326,6 +326,16 @@ class Helix : public IHelix (FailureCallback failureCallback)), (override)); // /timeout, /ban + // /warn + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, warnUser, + (QString broadcasterID, QString moderatorID, QString userID, + QString reason, ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /warn + // /w // The extra parenthesis around the failure callback is because its type // contains a comma diff --git a/mocks/include/mocks/LinkResolver.hpp b/mocks/include/mocks/LinkResolver.hpp new file mode 100644 index 00000000000..8a5682a3d1c --- /dev/null +++ b/mocks/include/mocks/LinkResolver.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "providers/links/LinkResolver.hpp" + +#include +#include +#include + +namespace chatterino::mock { + +class LinkResolver : public ILinkResolver +{ +public: + LinkResolver() = default; + ~LinkResolver() override = default; + + MOCK_METHOD(void, resolve, (LinkInfo * info), (override)); +}; + +class EmptyLinkResolver : public ILinkResolver +{ +public: + EmptyLinkResolver() = default; + ~EmptyLinkResolver() override = default; + + void resolve(LinkInfo *info) override + { + // + } +}; + +} // namespace chatterino::mock diff --git a/mocks/include/mocks/Logging.hpp b/mocks/include/mocks/Logging.hpp new file mode 100644 index 00000000000..8d444142bb6 --- /dev/null +++ b/mocks/include/mocks/Logging.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "singletons/Logging.hpp" + +#include +#include +#include + +namespace chatterino::mock { + +class Logging : public ILogging +{ +public: + Logging() = default; + ~Logging() override = default; + + MOCK_METHOD(void, addMessage, + (const QString &channelName, MessagePtr message, + const QString &platformName), + (override)); +}; + +class EmptyLogging : public ILogging +{ +public: + EmptyLogging() = default; + ~EmptyLogging() override = default; + + void addMessage(const QString &channelName, MessagePtr message, + const QString &platformName) override + { + // + } +}; + +} // namespace chatterino::mock diff --git a/mocks/include/mocks/TwitchIrcServer.hpp b/mocks/include/mocks/TwitchIrcServer.hpp index 976d6f2addd..42abc30ec5b 100644 --- a/mocks/include/mocks/TwitchIrcServer.hpp +++ b/mocks/include/mocks/TwitchIrcServer.hpp @@ -2,8 +2,13 @@ #include "mocks/Channel.hpp" #include "providers/bttv/BttvEmotes.hpp" +#include "providers/bttv/BttvLiveUpdates.hpp" #include "providers/ffz/FfzEmotes.hpp" +#include "providers/seventv/eventapi/Client.hpp" +#include "providers/seventv/eventapi/Dispatch.hpp" +#include "providers/seventv/eventapi/Message.hpp" #include "providers/seventv/SeventvEmotes.hpp" +#include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/TwitchIrcServer.hpp" namespace chatterino::mock { @@ -16,22 +21,91 @@ class MockTwitchIrcServer : public ITwitchIrcServer std::shared_ptr(new MockChannel("testaccount_420"))) , watchingChannel(this->watchingChannelInner, Channel::Type::TwitchWatching) + , whispersChannel(std::shared_ptr(new MockChannel("whispers"))) + , mentionsChannel(std::shared_ptr(new MockChannel("forsen3"))) + , liveChannel(std::shared_ptr(new MockChannel("forsen"))) + , automodChannel(std::shared_ptr(new MockChannel("forsen2"))) { } + void forEachChannelAndSpecialChannels( + std::function func) override + { + // + } + + std::shared_ptr getChannelOrEmptyByID( + const QString &channelID) override + { + return {}; + } + + void dropSeventvChannel(const QString &userID, + const QString &emoteSetID) override + { + // + } + + std::unique_ptr &getBTTVLiveUpdates() override + { + return this->bttvLiveUpdates; + } + + std::unique_ptr &getSeventvEventAPI() override + { + return this->seventvEventAPI; + } + const IndirectChannel &getWatchingChannel() const override { return this->watchingChannel; } + void setWatchingChannel(ChannelPtr newWatchingChannel) override + { + this->watchingChannel.reset(newWatchingChannel); + } + QString getLastUserThatWhisperedMe() const override { return this->lastUserThatWhisperedMe; } + void setLastUserThatWhisperedMe(const QString &user) override + { + this->lastUserThatWhisperedMe = user; + } + + ChannelPtr getWhispersChannel() const override + { + return this->whispersChannel; + } + + ChannelPtr getMentionsChannel() const override + { + return this->mentionsChannel; + } + + ChannelPtr getLiveChannel() const override + { + return this->liveChannel; + } + + ChannelPtr getAutomodChannel() const override + { + return this->automodChannel; + } + ChannelPtr watchingChannelInner; IndirectChannel watchingChannel; + ChannelPtr whispersChannel; + ChannelPtr mentionsChannel; + ChannelPtr liveChannel; + ChannelPtr automodChannel; QString lastUserThatWhisperedMe{"forsen"}; + + std::unique_ptr bttvLiveUpdates; + std::unique_ptr seventvEventAPI; }; } // namespace chatterino::mock diff --git a/resources/avatars/anon.png b/resources/avatars/anon.png new file mode 100644 index 00000000000..b7993edcbbb Binary files /dev/null and b/resources/avatars/anon.png differ diff --git a/resources/avatars/jakeryw.png b/resources/avatars/jakeryw.png new file mode 100644 index 00000000000..fd256af642d Binary files /dev/null and b/resources/avatars/jakeryw.png differ diff --git a/resources/avatars/nealxm.png b/resources/avatars/nealxm.png new file mode 100644 index 00000000000..fcba4918984 Binary files /dev/null and b/resources/avatars/nealxm.png differ diff --git a/resources/avatars/niller2005.png b/resources/avatars/niller2005.png new file mode 100644 index 00000000000..1a2b5bab128 Binary files /dev/null and b/resources/avatars/niller2005.png differ diff --git a/resources/avatars/pajlada.png b/resources/avatars/pajlada.png index c596bfb8edf..e8ff6160d64 100644 Binary files a/resources/avatars/pajlada.png and b/resources/avatars/pajlada.png differ diff --git a/resources/com.chatterino.chatterino.appdata.xml b/resources/com.chatterino.chatterino.appdata.xml index ce9e25db88d..a2d09fecbdc 100644 --- a/resources/com.chatterino.chatterino.appdata.xml +++ b/resources/com.chatterino.chatterino.appdata.xml @@ -34,6 +34,15 @@ chatterino + + https://github.com/Chatterino/chatterino2/releases/tag/v2.5.1 + + + https://github.com/Chatterino/chatterino2/releases/tag/v2.5.0 + + + https://github.com/Chatterino/chatterino2/releases/tag/v2.5.0-beta.1 + https://github.com/Chatterino/chatterino2/releases/tag/v2.4.6 diff --git a/resources/contributors.txt b/resources/contributors.txt index b5907812ea8..6267887e35f 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -3,80 +3,92 @@ # TODO: Parse this into a CONTRIBUTORS.md too # Adding yourself? Copy and paste this template at the bottom of this file and fill in the fields in a PR! -# Name | Link | Avatar (Loaded as a resource, avatars are not required) | Title (description of work done). +# Name | Link | Avatar (Loaded as a resource, avatars are not required). # Avatar should be located in avatars/ directory. Its size should be 128x128 (get it from https://github.com/username.png?size=128). # Make sure to reduce avatar's size as much as possible with tool like pngcrush or optipng (if the file is in png format). # Contributor is what we use for someone who has contributed in general (like sent a programming-related PR). -fourtf | https://fourtf.com | :/avatars/fourtf.png | Author, main developer -pajlada | https://pajlada.se | :/avatars/pajlada.png | Collaborator, co-developer -zneix | https://github.com/zneix | :/avatars/zneix.png | Collaborator -Mm2PL | https://github.com/mm2pl | :/avatars/mm2pl.png | Collaborator -YungLPR | https://github.com/leon-richardt | | Collaborator -dnsge | https://github.com/dnsge | | Collaborator -Felanbird | https://github.com/Felanbird | | Collaborator -kornes | https://github.com/kornes | | Collaborator +@header Maintainers -Cranken | https://github.com/Cranken | | Contributor -hemirt | https://github.com/hemirt | | Contributor -LajamerrMittesdine | https://github.com/LajamerrMittesdine | | Contributor -coral | https://github.com/coral | | Contributor, design -apa420 | https://github.com/apa420 | | Contributor -DatGuy1 | https://github.com/DatGuy1 | | Contributor -Confuseh | https://github.com/Confuseh | | Contributor -ch-ems | https://github.com/ch-ems | | Contributor -Bur0k | https://github.com/Bur0k | | Contributor -nuuls | https://github.com/nuuls | | Contributor -Chronophylos | https://github.com/Chronophylos | | Contributor -Ckath | https://github.com/Ckath | | Contributor -matijakevic | https://github.com/matijakevic | | Contributor -nforro | https://github.com/nforro | | Contributor -vanolpfan | https://github.com/vanolpfan | | Contributor -23rd | https://github.com/23rd | | Contributor -machgo | https://github.com/machgo | | Contributor -TranRed | https://github.com/TranRed | | Contributor -RAnders00 | https://github.com/RAnders00 | | Contributor -gempir | https://github.com/gempir | | Contributor -mfmarlow | https://github.com/mfmarlow | | Contributor -y0dax | https://github.com/y0dax | | Contributor -Iulian Onofrei | https://github.com/revolter | :/avatars/revolter.jpg | Contributor -matthewde | https://github.com/m4tthewde | :/avatars/matthewde.jpg | Contributor -Karar Al-Remahy | https://github.com/KararTY | :/avatars/kararty.png | Contributor -Talen | https://github.com/talneoran | | Contributor -SLCH | https://github.com/SLCH | :/avatars/slch.png | Contributor -ALazyMeme | https://github.com/alazymeme | :/avatars/alazymeme.png | Contributor -xHeaveny_ | https://github.com/xHeaveny | :/avatars/xheaveny.png | Contributor -1xelerate | https://github.com/xel86 | :/avatars/_1xelerate.png | Contributor -acdvs | https://github.com/acdvs | | Contributor -karl-police | https://github.com/karl-police | :/avatars/karlpolice.png | Contributor -brian6932 | https://github.com/brian6932 | :/avatars/brian6932.png | Contributor -hicupalot | https://github.com/hicupalot | :/avatars/hicupalot.png | Contributor -iProdigy | https://github.com/iProdigy | :/avatars/iprodigy.png | Contributor -Jaxkey | https://github.com/Jaxkey | :/avatars/jaxkey.png | Contributor -Explooosion | https://github.com/Explooosion-code | :/avatars/explooosion_code.png | Contributor -mohad12211 | https://github.com/mohad12211 | :/avatars/mohad12211.png | Contributor -Wissididom | https://github.com/Wissididom | :/avatars/wissididom.png | Contributor -03y | https://github.com/03y | | Contributor -ScrubN | https://github.com/ScrubN | | Contributor -Cyclone | https://github.com/PsycloneTM | :/avatars/cyclone.png | Contributor -2547techno | https://github.com/2547techno | :/avatars/techno.png | Contributor -ZonianMidian | https://github.com/ZonianMidian | :/avatars/zonianmidian.png | Contributor -olafyang | https://github.com/olafyang | | Contributor -chrrs | https://github.com/chrrs | | Contributor -4rneee | https://github.com/4rneee | | Contributor -crazysmc | https://github.com/crazysmc | :/avatars/crazysmc.png | Contributor -SputNikPlop | https://github.com/SputNikPlop | | Contributor -fraxx | https://github.com/fraxxio | :/avatars/fraxx.png | Contributor -KleberPF | https://github.com/KleberPF | | Contributor +fourtf | https://fourtf.com | :/avatars/fourtf.png +pajlada | https://pajlada.se | :/avatars/pajlada.png + +@header Collaborators + +zneix | https://github.com/zneix | :/avatars/zneix.png +Mm2PL | https://github.com/mm2pl | :/avatars/mm2pl.png +YungLPR | https://github.com/leon-richardt | +dnsge | https://github.com/dnsge | +Felanbird | https://github.com/Felanbird | +kornes | https://github.com/kornes | + +@header Contributors + +Cranken | https://github.com/Cranken | +hemirt | https://github.com/hemirt | +LajamerrMittesdine | https://github.com/LajamerrMittesdine | +coral | https://github.com/coral | +apa420 | https://github.com/apa420 | +DatGuy1 | https://github.com/DatGuy1 | +Confuseh | https://github.com/Confuseh | +ch-ems | https://github.com/ch-ems | +Bur0k | https://github.com/Bur0k | +nuuls | https://github.com/nuuls | +Chronophylos | https://github.com/Chronophylos | +Ckath | https://github.com/Ckath | +matijakevic | https://github.com/matijakevic | +nforro | https://github.com/nforro | +vanolpfan | https://github.com/vanolpfan | +23rd | https://github.com/23rd | +machgo | https://github.com/machgo | +TranRed | https://github.com/TranRed | +RAnders00 | https://github.com/RAnders00 | +gempir | https://github.com/gempir | +mfmarlow | https://github.com/mfmarlow | +y0dax | https://github.com/y0dax | +Iulian Onofrei | https://github.com/revolter | :/avatars/revolter.jpg +matthewde | https://github.com/m4tthewde | :/avatars/matthewde.jpg +Karar Al-Remahy | https://github.com/KararTY | :/avatars/kararty.png +Talen | https://github.com/talneoran | +SLCH | https://github.com/SLCH | :/avatars/slch.png +ALazyMeme | https://github.com/alazymeme | :/avatars/alazymeme.png +xHeaveny_ | https://github.com/xHeaveny | :/avatars/xheaveny.png +1xelerate | https://github.com/xel86 | :/avatars/_1xelerate.png +acdvs | https://github.com/acdvs | +karl-police | https://github.com/karl-police | :/avatars/karlpolice.png +brian6932 | https://github.com/brian6932 | :/avatars/brian6932.png +hicupalot | https://github.com/hicupalot | :/avatars/hicupalot.png +iProdigy | https://github.com/iProdigy | :/avatars/iprodigy.png +Jaxkey | https://github.com/Jaxkey | :/avatars/jaxkey.png +Explooosion | https://github.com/Explooosion-code | :/avatars/explooosion_code.png +mohad12211 | https://github.com/mohad12211 | :/avatars/mohad12211.png +Wissididom | https://github.com/Wissididom | :/avatars/wissididom.png +03y | https://github.com/03y | +ScrubN | https://github.com/ScrubN | +Cyclone | https://github.com/PsycloneTM | :/avatars/cyclone.png +2547techno | https://github.com/2547techno | :/avatars/techno.png +ZonianMidian | https://github.com/ZonianMidian | :/avatars/zonianmidian.png +olafyang | https://github.com/olafyang | +chrrs | https://github.com/chrrs | +4rneee | https://github.com/4rneee | +crazysmc | https://github.com/crazysmc | :/avatars/crazysmc.png +SputNikPlop | https://github.com/SputNikPlop | +fraxx | https://github.com/fraxxio | :/avatars/fraxx.png +KleberPF | https://github.com/KleberPF | +nealxm | https://github.com/nealxm | :/avatars/nealxm.png +Niller2005 | https://github.com/Niller2005 | :/avatars/niller2005.png +JakeRYW | https://github.com/JakeRYW | :/avatars/jakeryw.png # If you are a contributor add yourself above this line -Defman21 | https://github.com/Defman21 | | Documentation -vilgotf | https://github.com/vilgotf | | Documentation -Ian321 | https://github.com/Ian321 | | Documentation -Yardanico | https://github.com/Yardanico | | Documentation -huti26 | https://github.com/huti26 | | Documentation -chrisduerr | https://github.com/chrisduerr | | Documentation +@header Documentation + +Defman21 | https://github.com/Defman21 | +vilgotf | https://github.com/vilgotf | +Ian321 | https://github.com/Ian321 | +Yardanico | https://github.com/Yardanico | +huti26 | https://github.com/huti26 | +chrisduerr | https://github.com/chrisduerr | # Otherwise add yourself right above this one diff --git a/resources/licenses/expected-lite.txt b/resources/licenses/expected-lite.txt new file mode 100644 index 00000000000..36b7cd93cdf --- /dev/null +++ b/resources/licenses/expected-lite.txt @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/resources/qss/settings.qss b/resources/qss/settings.qss index 6d5114423b0..93c69b603ed 100644 --- a/resources/qss/settings.qss +++ b/resources/qss/settings.qss @@ -1,11 +1,11 @@ * { - font-size: px; + font-size: 14px; font-family: "Segoe UI"; } QCheckBox::indicator { - width: px; - height: px; + width: 14px; + height: 14px; } chatterino--ComboBox { diff --git a/scripts/get-vcpkg-package-versions.sh b/scripts/get-vcpkg-package-versions.sh new file mode 100755 index 00000000000..f726a6cbb1a --- /dev/null +++ b/scripts/get-vcpkg-package-versions.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +dependencies="$(jq -r -c '.dependencies[] | if type == "string" then . else .name end' vcpkg.json)" +dependencies+=" openssl" +baseline="$(jq -r -c '."builtin-baseline"' vcpkg.json)" + +for dependency_name in $dependencies; do + dependency_url="https://raw.githubusercontent.com/microsoft/vcpkg/${baseline}/ports/${dependency_name}/vcpkg.json" + dependency_version="$(curl -s "$dependency_url" | jq -rc '.version')" + echo "Dependency $dependency_name is at version '$dependency_version' in baseline $baseline" +done diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py index 22240da1984..f9f44e7ed20 100644 --- a/scripts/make_luals_meta.py +++ b/scripts/make_luals_meta.py @@ -12,25 +12,26 @@ - Do not have any useful info on '/**' and '*/' lines. - Class members are not allowed to have non-@command lines and commands different from @lua@field -When this scripts sees "@brief", any further lines of the comment will be ignored +Only entire comment blocks are used. One comment block can describe at most one +entity (function/class/enum). Blocks without commands are ignored. Valid commands are: 1. @exposeenum [dotted.name.in_lua.last_part] Define a table with keys of the enum. Values behind those keys aren't written on purpose. - This generates three lines: - - An type alias of [last_part] to integer, - - A type description that describes available values of the enum, - - A global table definition for the num -2. @lua[@command] +2. @exposed [c2.name] + Generates a function definition line from the last `@lua@param`s. +3. @lua[@command] Writes [@command] to the file as a comment, usually this is @class, @param, @return, ... @lua@class and @lua@field have special treatment when it comes to generation of spacing new lines -3. @exposed [c2.name] - Generates a function definition line from the last `@lua@param`s. Non-command lines of comments are written with a space after '---' """ + +from io import TextIOWrapper from pathlib import Path +import re +from typing import Optional BOILERPLATE = """ ---@meta Chatterino2 @@ -40,15 +41,6 @@ -- Add the folder this file is in to "Lua.workspace.library". c2 = {} - ----@class IWeakResource - ---- Returns true if the channel this object points to is valid. ---- If the object expired, returns false ---- If given a non-Channel object, it errors. ----@return boolean -function IWeakResource:is_valid() end - """ repo_root = Path(__file__).parent.parent @@ -58,116 +50,279 @@ print("Writing to", lua_meta.relative_to(repo_root)) -def process_file(target, out): - print("Reading from", target.relative_to(repo_root)) - with target.open("r") as f: +def strip_line(line: str): + return re.sub(r"^/\*\*|^\*|\*/$", "", line).strip() + + +def is_comment_start(line: str): + return line.startswith("/**") + + +def is_enum_class(line: str): + return line.startswith("enum class") + + +def is_class(line: str): + return line.startswith(("class", "struct")) + + +class Reader: + lines: list[str] + line_idx: int + + def __init__(self, lines: list[str]) -> None: + self.lines = lines + self.line_idx = 0 + + def line_no(self) -> int: + """Returns the current line number (starting from 1)""" + return self.line_idx + 1 + + def has_next(self) -> bool: + """Returns true if there are lines left to read""" + return self.line_idx < len(self.lines) + + def peek_line(self) -> Optional[str]: + """Reads the line the cursor is at""" + if self.has_next(): + return self.lines[self.line_idx].strip() + return None + + def next_line(self) -> Optional[str]: + """Consumes and returns one line""" + if self.has_next(): + self.line_idx += 1 + return self.lines[self.line_idx - 1].strip() + return None + + def next_doc_comment(self) -> Optional[list[str]]: + """Reads a documentation comment (/** ... */) and advances the cursor""" + lines = [] + # find the start + while (line := self.next_line()) is not None and not is_comment_start(line): + pass + if line is None: + return None + + stripped = strip_line(line) + if stripped: + lines.append(stripped) + + if stripped.endswith("*/"): + return lines if lines else None + + while (line := self.next_line()) is not None: + if line.startswith("*/"): + break + + stripped = strip_line(line) + if not stripped: + continue + + if stripped.startswith("@"): + lines.append(stripped) + continue + + if not lines: + lines.append(stripped) + else: + lines[-1] += "\n--- " + stripped + + return lines if lines else None + + def read_class_body(self) -> list[list[str]]: + """The reader must be at the first line of the class/struct body. All comments inside the class are returned.""" + items = [] + nesting = -1 # for the opening brace + while (line := self.peek_line()) is not None: + if line.startswith("};") and nesting == 0: + self.next_line() + break + if not is_comment_start(line): + nesting += line.count("{") - line.count("}") + self.next_line() + continue + doc = self.next_doc_comment() + if not doc: + break + items.append(doc) + return items + + def read_enum_variants(self) -> list[str]: + """The reader must be before an enum class definition (possibly with some comments before). It returns all variants.""" + items = [] + is_comment = False + while (line := self.peek_line()) is not None and not line.startswith("};"): + self.next_line() + if is_comment: + if line.endswith("*/"): + is_comment = False + continue + if line.startswith("/*"): + is_comment = True + continue + if line.startswith("//"): + continue + if line.endswith("};"): # oneline declaration + opener = line.find("{") + 1 + closer = line.find("}") + items = [ + line.split("=", 1)[0].strip() + for line in line[opener:closer].split(",") + ] + break + if line.startswith("enum class"): + continue + + items.append(line.rstrip(",")) + + return items + + +def finish_class(out, name): + out.write(f"{name} = {{}}\n") + + +def printmsg(path: Path, line: int, message: str): + print(f"{path.relative_to(repo_root)}:{line} {message}") + + +def panic(path: Path, line: int, message: str): + printmsg(path, line, message) + exit(1) + + +def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper): + if not comments[0].startswith("@"): + out.write(f"--- {comments[0]}\n---\n") + comments = comments[1:] + params = [] + for comment in comments[:-1]: + if not comment.startswith("@lua"): + panic(path, line, f"Invalid function specification - got '{comment}'") + if comment.startswith("@lua@param"): + params.append(comment.split(" ", 2)[1]) + + out.write(f"---{comment.removeprefix('@lua')}\n") + + if not comments[-1].startswith("@exposed "): + panic(path, line, f"Invalid function exposure - got '{comments[-1]}'") + name = comments[-1].split(" ", 1)[1] + printmsg(path, line, f"function {name}") + lua_params = ", ".join(params) + out.write(f"function {name}({lua_params}) end\n\n") + + +def read_file(path: Path, out: TextIOWrapper): + print("Reading", path.relative_to(repo_root)) + with path.open("r") as f: lines = f.read().splitlines() - # Are we in a doc comment? - comment: bool = False - # This is set when @brief is encountered, making the rest of the comment be - # ignored - ignore_this_comment: bool = False - - # Last `@lua@param`s seen - for @exposed generation - last_params_names: list[str] = [] - # Are we in a `@lua@class` definition? - makes newlines around @lua@class and @lua@field prettier - is_class = False - - # The name of the next enum in lua world - expose_next_enum_as: str | None = None - # Name of the current enum in c++ world, used to generate internal typenames for - current_enum_name: str | None = None - for line_num, line in enumerate(lines): - line = line.strip() - loc = f'{target.relative_to(repo_root)}:{line_num}' - if line.startswith("enum class "): - line = line.removeprefix("enum class ") - temp = line.split(" ", 2) - current_enum_name = temp[0] - if not expose_next_enum_as: - print( - f"{loc} Skipping enum {current_enum_name}, there wasn't a @exposeenum command" - ) - current_enum_name = None + reader = Reader(lines) + while reader.has_next(): + doc_comment = reader.next_doc_comment() + if not doc_comment: + break + header_comment = None + if not doc_comment[0].startswith("@"): + if len(doc_comment) == 1: continue - current_enum_name = expose_next_enum_as.split(".", 1)[-1] - out.write("---@alias " + current_enum_name + " integer\n") + header_comment = doc_comment[0] + header = doc_comment[1:] + else: + header = doc_comment + + # enum + if header[0].startswith("@exposeenum "): + if len(header) > 1: + panic( + path, + reader.line_no(), + f"Invalid enum exposure - one command expected, got {len(header)}", + ) + name = header[0].split(" ", 1)[1] + printmsg(path, reader.line_no(), f"enum {name}") + out.write(f"---@alias {name} integer\n") + if header_comment: + out.write(f"--- {header_comment}\n") out.write("---@type { ") - # temp[1] is '{' - if len(temp) == 2: # no values on this line - continue - line = temp[2] - - if current_enum_name is not None: - for i, tok in enumerate(line.split(" ")): - if tok == "};": - break - entry = tok.removesuffix(",") - if i != 0: - out.write(", ") - out.write(entry + ": " + current_enum_name) - out.write(" }\n" f"{expose_next_enum_as} = {{}}\n") - print(f"{loc} Wrote enum {expose_next_enum_as} => {current_enum_name}") - current_enum_name = None - expose_next_enum_as = None + out.write( + ", ".join( + [f"{variant}: {name}" for variant in reader.read_enum_variants()] + ) + ) + out.write(" }\n") + out.write(f"{name} = {{}}\n\n") continue - if line.startswith("/**"): - comment = True - continue - elif "*/" in line: - comment = False - ignore_this_comment = False + # class + elif header[0].startswith("@lua@class "): + name = header[0].split(" ", 1)[1] + classname = name.split(":")[0].strip() + printmsg(path, reader.line_no(), f"class {classname}") - if not is_class: + if header_comment: + out.write(f"--- {header_comment}\n") + out.write(f"---@class {name}\n") + # inline class + if len(header) > 1: + for field in header[1:]: + if not field.startswith("@lua@field "): + panic( + path, + reader.line_no(), + f"Invalid inline class exposure - all lines must be fields, got '{field}'", + ) + out.write(f"---{field.removeprefix('@lua')}\n") out.write("\n") + continue + + # class definition + # save functions for later (print fields first) + funcs = [] + for comment in reader.read_class_body(): + if comment[-1].startswith("@exposed "): + funcs.append(comment) + continue + if len(comment) > 1 or not comment[0].startswith("@lua"): + continue + out.write(f"---{comment[0].removeprefix('@lua')}\n") + + if funcs: + # only define global if there are functions on the class + out.write(f"{classname} = {{}}\n\n") + else: + out.write("\n") + + for func in funcs: + write_func(path, reader.line_no(), func, out) continue - if not comment: - continue - if ignore_this_comment: + # global function + elif header[-1].startswith("@exposed "): + write_func(path, reader.line_no(), doc_comment, out) continue - line = line.replace("*", "", 1).lstrip() - if line == "": - out.write("---\n") - elif line.startswith('@brief '): - # Doxygen comment, on a C++ only method - ignore_this_comment = True - elif line.startswith("@exposeenum "): - expose_next_enum_as = line.split(" ", 1)[1] - elif line.startswith("@exposed "): - exp = line.replace("@exposed ", "", 1) - params = ", ".join(last_params_names) - out.write(f"function {exp}({params}) end\n") - print(f"{loc} Wrote function {exp}(...)") - last_params_names = [] - elif line.startswith("@includefile "): - filename = line.replace("@includefile ", "", 1) - output.write(f"-- Now including data from src/{filename}.\n") - process_file(repo_root / 'src' / filename, output) - output.write(f'-- Back to {target.relative_to(repo_root)}.\n') - elif line.startswith("@lua"): - command = line.replace("@lua", "", 1) - if command.startswith("@param"): - last_params_names.append(command.split(" ", 2)[1]) - elif command.startswith("@class"): - print(f"{loc} Writing {command}") - if is_class: - out.write("\n") - is_class = True - elif not command.startswith("@field"): - is_class = False - - out.write("---" + command + "\n") else: - if is_class: - is_class = False - out.write("\n") + for comment in header: + inline_command(path, reader.line_no(), comment, out) + - # note the space difference from the branch above - out.write("--- " + line + "\n") +def inline_command(path: Path, line: int, comment: str, out: TextIOWrapper): + if comment.startswith("@includefile "): + filename = comment.split(" ", 1)[1] + out.write(f"-- Begin src/{filename}\n\n") + read_file(repo_root / "src" / filename, out) + out.write(f"-- End src/{filename}\n\n") + elif comment.startswith("@lua@class"): + panic( + path, + line, + "Unexpected @lua@class command. @lua@class must be placed at the start of the comment block!", + ) + elif comment.startswith("@lua@"): + out.write(f'---{comment.replace("@lua", "", 1)}\n') -with lua_meta.open("w") as output: - output.write(BOILERPLATE[1:]) # skip the newline after triple quote - process_file(lua_api_file, output) +if __name__ == "__main__": + with lua_meta.open("w") as output: + output.write(BOILERPLATE[1:]) # skip the newline after triple quote + read_file(lua_api_file, output) diff --git a/src/Application.cpp b/src/Application.cpp index e412fe8f4cf..529b2481b44 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -13,6 +13,7 @@ #include "controllers/sound/ISoundController.hpp" #include "providers/bttv/BttvEmotes.hpp" #include "providers/ffz/FfzEmotes.hpp" +#include "providers/irc/AbstractIrcServer.hpp" #include "providers/links/LinkResolver.hpp" #include "providers/seventv/SeventvAPI.hpp" #include "providers/seventv/SeventvEmotes.hpp" @@ -131,11 +132,11 @@ Application::Application(Settings &_settings, const Paths &paths, , commands(&this->emplace()) , notifications(&this->emplace()) , highlights(&this->emplace()) - , twitch(&this->emplace()) + , twitch(new TwitchIrcServer) , ffzBadges(&this->emplace()) , seventvBadges(&this->emplace()) - , userData(&this->emplace(new UserDataController(paths))) - , sound(&this->emplace(makeSoundController(_settings))) + , userData(new UserDataController(paths)) + , sound(makeSoundController(_settings)) , twitchLiveController(&this->emplace()) , twitchPubSub(new PubSub(TWITCH_PUBSUB_URL)) , twitchBadges(new TwitchBadges) @@ -170,7 +171,10 @@ void Application::fakeDtor() this->bttvEmotes.reset(); this->ffzEmotes.reset(); this->seventvEmotes.reset(); + // this->twitch.reset(); this->fonts.reset(); + this->sound.reset(); + this->userData.reset(); } void Application::initialize(Settings &settings, const Paths &paths) @@ -209,6 +213,8 @@ void Application::initialize(Settings &settings, const Paths &paths) singleton->initialize(settings, paths); } + this->twitch->initialize(); + // XXX: Loading Twitch badges after Helix has been initialized, which only happens after // the AccountController initialize has been called this->twitchBadges->loadTwitchBadges(); @@ -423,14 +429,14 @@ IUserDataController *Application::getUserData() { assertInGuiThread(); - return this->userData; + return this->userData.get(); } ISoundController *Application::getSound() { assertInGuiThread(); - return this->sound; + return this->sound.get(); } ITwitchLiveController *Application::getTwitchLiveController() @@ -483,7 +489,14 @@ ITwitchIrcServer *Application::getTwitch() { assertInGuiThread(); - return this->twitch; + return this->twitch.get(); +} + +IAbstractIrcServer *Application::getTwitchAbstract() +{ + assertInGuiThread(); + + return this->twitch.get(); } PubSub *Application::getTwitchPubSub() @@ -493,7 +506,7 @@ PubSub *Application::getTwitchPubSub() return this->twitchPubSub.get(); } -Logging *Application::getChatLogger() +ILogging *Application::getChatLogger() { assertInGuiThread(); @@ -639,6 +652,24 @@ void Application::initPubSub() chan->addOrReplaceTimeout(msg.release()); }); }); + + std::ignore = this->twitchPubSub->moderation.userWarned.connect( + [&](const auto &action) { + auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); + + if (chan->isEmpty()) + { + return; + } + + // TODO: Resolve the moderator's user ID into a full user here, so message can look better + postToThread([chan, action] { + MessageBuilder msg(action); + msg->flags.set(MessageFlag::PubSub); + chan->addMessage(msg.release()); + }); + }); + std::ignore = this->twitchPubSub->moderation.messageDeleted.connect( [&](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); @@ -865,17 +896,25 @@ void Application::initPubSub() chan->addMessage(p.first); chan->addMessage(p.second); - getApp()->twitch->automodChannel->addMessage( - p.first); - getApp()->twitch->automodChannel->addMessage( - p.second); + getIApp() + ->getTwitch() + ->getAutomodChannel() + ->addMessage(p.first); + getIApp() + ->getTwitch() + ->getAutomodChannel() + ->addMessage(p.second); if (getSettings()->showAutomodInMentions) { - getApp()->twitch->mentionsChannel->addMessage( - p.first); - getApp()->twitch->mentionsChannel->addMessage( - p.second); + getIApp() + ->getTwitch() + ->getMentionsChannel() + ->addMessage(p.first); + getIApp() + ->getTwitch() + ->getMentionsChannel() + ->addMessage(p.second); } }); } @@ -984,7 +1023,9 @@ void Application::initPubSub() void Application::initBttvLiveUpdates() { - if (!this->twitch->bttvLiveUpdates) + auto &bttvLiveUpdates = this->twitch->getBTTVLiveUpdates(); + + if (!bttvLiveUpdates) { qCDebug(chatterinoBttv) << "Skipping initialization of Live Updates as it's disabled"; @@ -993,8 +1034,8 @@ void Application::initBttvLiveUpdates() // We can safely ignore these signal connections since the twitch object will always // be destroyed before the Application - std::ignore = this->twitch->bttvLiveUpdates->signals_.emoteAdded.connect( - [&](const auto &data) { + std::ignore = + bttvLiveUpdates->signals_.emoteAdded.connect([&](const auto &data) { auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); postToThread([chan, data] { @@ -1004,8 +1045,8 @@ void Application::initBttvLiveUpdates() } }); }); - std::ignore = this->twitch->bttvLiveUpdates->signals_.emoteUpdated.connect( - [&](const auto &data) { + std::ignore = + bttvLiveUpdates->signals_.emoteUpdated.connect([&](const auto &data) { auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); postToThread([chan, data] { @@ -1015,8 +1056,8 @@ void Application::initBttvLiveUpdates() } }); }); - std::ignore = this->twitch->bttvLiveUpdates->signals_.emoteRemoved.connect( - [&](const auto &data) { + std::ignore = + bttvLiveUpdates->signals_.emoteRemoved.connect([&](const auto &data) { auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); postToThread([chan, data] { @@ -1026,12 +1067,14 @@ void Application::initBttvLiveUpdates() } }); }); - this->twitch->bttvLiveUpdates->start(); + bttvLiveUpdates->start(); } void Application::initSeventvEventAPI() { - if (!this->twitch->seventvEventAPI) + auto &seventvEventAPI = this->twitch->getSeventvEventAPI(); + + if (!seventvEventAPI) { qCDebug(chatterinoSeventvEventAPI) << "Skipping initialization as the EventAPI is disabled"; @@ -1040,8 +1083,8 @@ void Application::initSeventvEventAPI() // We can safely ignore these signal connections since the twitch object will always // be destroyed before the Application - std::ignore = this->twitch->seventvEventAPI->signals_.emoteAdded.connect( - [&](const auto &data) { + std::ignore = + seventvEventAPI->signals_.emoteAdded.connect([&](const auto &data) { postToThread([this, data] { this->twitch->forEachSeventvEmoteSet( data.emoteSetID, [data](TwitchChannel &chan) { @@ -1049,8 +1092,8 @@ void Application::initSeventvEventAPI() }); }); }); - std::ignore = this->twitch->seventvEventAPI->signals_.emoteUpdated.connect( - [&](const auto &data) { + std::ignore = + seventvEventAPI->signals_.emoteUpdated.connect([&](const auto &data) { postToThread([this, data] { this->twitch->forEachSeventvEmoteSet( data.emoteSetID, [data](TwitchChannel &chan) { @@ -1058,8 +1101,8 @@ void Application::initSeventvEventAPI() }); }); }); - std::ignore = this->twitch->seventvEventAPI->signals_.emoteRemoved.connect( - [&](const auto &data) { + std::ignore = + seventvEventAPI->signals_.emoteRemoved.connect([&](const auto &data) { postToThread([this, data] { this->twitch->forEachSeventvEmoteSet( data.emoteSetID, [data](TwitchChannel &chan) { @@ -1067,15 +1110,15 @@ void Application::initSeventvEventAPI() }); }); }); - std::ignore = this->twitch->seventvEventAPI->signals_.userUpdated.connect( - [&](const auto &data) { + std::ignore = + seventvEventAPI->signals_.userUpdated.connect([&](const auto &data) { this->twitch->forEachSeventvUser(data.userID, [data](TwitchChannel &chan) { chan.updateSeventvUser(data); }); }); - this->twitch->seventvEventAPI->start(); + seventvEventAPI->start(); } Application *getApp() diff --git a/src/Application.hpp b/src/Application.hpp index f846ff00b07..af1c563a8e6 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -35,6 +35,7 @@ class PluginController; class Theme; class WindowManager; +class ILogging; class Logging; class Paths; class Emotes; @@ -54,6 +55,7 @@ class FfzEmotes; class SeventvEmotes; class ILinkResolver; class IStreamerMode; +class IAbstractIrcServer; class IApplication { @@ -63,6 +65,8 @@ class IApplication static IApplication *instance; + virtual bool isTest() const = 0; + virtual const Paths &getPaths() = 0; virtual const Args &getArgs() = 0; virtual Theme *getThemes() = 0; @@ -77,8 +81,9 @@ class IApplication virtual HighlightController *getHighlights() = 0; virtual NotificationController *getNotifications() = 0; virtual ITwitchIrcServer *getTwitch() = 0; + virtual IAbstractIrcServer *getTwitchAbstract() = 0; virtual PubSub *getTwitchPubSub() = 0; - virtual Logging *getChatLogger() = 0; + virtual ILogging *getChatLogger() = 0; virtual IChatterinoBadges *getChatterinoBadges() = 0; virtual FfzBadges *getFfzBadges() = 0; virtual SeventvBadges *getSeventvBadges() = 0; @@ -119,6 +124,11 @@ class Application : public IApplication Application &operator=(const Application &) = delete; Application &operator=(Application &&) = delete; + bool isTest() const override + { + return false; + } + /** * In the interim, before we remove _exit(0); from RunGui.cpp, * this will destroy things we know can be destroyed @@ -147,15 +157,11 @@ class Application : public IApplication CommandController *const commands{}; NotificationController *const notifications{}; HighlightController *const highlights{}; - -public: - TwitchIrcServer *const twitch{}; - -private: + std::unique_ptr twitch; FfzBadges *const ffzBadges{}; SeventvBadges *const seventvBadges{}; - UserDataController *const userData{}; - ISoundController *const sound{}; + std::unique_ptr userData; + std::unique_ptr sound; TwitchLiveController *const twitchLiveController{}; std::unique_ptr twitchPubSub; std::unique_ptr twitchBadges; @@ -191,8 +197,9 @@ class Application : public IApplication NotificationController *getNotifications() override; HighlightController *getHighlights() override; ITwitchIrcServer *getTwitch() override; + IAbstractIrcServer *getTwitchAbstract() override; PubSub *getTwitchPubSub() override; - Logging *getChatLogger() override; + ILogging *getChatLogger() override; FfzBadges *getFfzBadges() override; SeventvBadges *getSeventvBadges() override; IUserDataController *getUserData() override; diff --git a/src/BrowserExtension.cpp b/src/BrowserExtension.cpp index 3ac8dc2dded..8511cbcb078 100644 --- a/src/BrowserExtension.cpp +++ b/src/BrowserExtension.cpp @@ -2,13 +2,6 @@ #include "singletons/NativeMessaging.hpp" -#include -#include -#include -#include - -#include -#include #include #include #include @@ -16,69 +9,88 @@ #ifdef Q_OS_WIN # include # include -# include -#endif -namespace chatterino { +# include + +#endif namespace { - void initFileMode() - { + +using namespace chatterino; + +void initFileMode() +{ #ifdef Q_OS_WIN - _setmode(_fileno(stdin), _O_BINARY); - _setmode(_fileno(stdout), _O_BINARY); + _setmode(_fileno(stdin), _O_BINARY); + _setmode(_fileno(stdout), _O_BINARY); #endif - } - - void runLoop() - { - auto received_message = std::make_shared(true); +} - auto thread = std::thread([=]() { - while (true) - { - using namespace std::chrono_literals; - if (!received_message->exchange(false)) - { - _Exit(1); - } - std::this_thread::sleep_for(5s); - } - }); +// TODO(Qt6): Use QUtf8String +void sendToBrowser(QLatin1String str) +{ + auto len = static_cast(str.size()); + std::cout.write(reinterpret_cast(&len), sizeof(len)); + std::cout.write(str.data(), str.size()); + std::cout.flush(); +} - while (true) - { - char size_c[4]; - std::cin.read(size_c, 4); +QByteArray receiveFromBrowser() +{ + uint32_t size = 0; + std::cin.read(reinterpret_cast(&size), sizeof(size)); - if (std::cin.eof()) - { - break; - } + if (std::cin.eof()) + { + return {}; + } - auto size = *reinterpret_cast(size_c); + QByteArray buffer{static_cast(size), + Qt::Uninitialized}; + std::cin.read(buffer.data(), size); - std::unique_ptr buffer(new char[size + 1]); - std::cin.read(buffer.get(), size); - *(buffer.get() + size) = '\0'; + return buffer; +} - auto data = QByteArray::fromRawData(buffer.get(), - static_cast(size)); - auto doc = QJsonDocument(); +void runLoop() +{ + auto receivedMessage = std::make_shared(true); - if (doc.object().value("type") == "nm_pong") + auto thread = std::thread([=]() { + while (true) + { + using namespace std::chrono_literals; + if (!receivedMessage->exchange(false)) { - received_message->store(true); + sendToBrowser(QLatin1String{ + R"({"type":"status","status":"exiting-host","reason":"no message was received in 10s"})"}); + _Exit(1); } + std::this_thread::sleep_for(10s); + } + }); - received_message->store(true); - - nm::client::sendMessage(data); + while (true) + { + auto buffer = receiveFromBrowser(); + if (buffer.isNull()) + { + break; } - _Exit(0); + + receivedMessage->store(true); + + nm::client::sendMessage(buffer); } + + sendToBrowser(QLatin1String{ + R"({"type":"status","status":"exiting-host","reason":"received EOF"})"}); + _Exit(0); +} } // namespace +namespace chatterino { + void runBrowserExtensionHost() { initFileMode(); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index faef0a3a801..73d97a18abc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -107,6 +107,10 @@ set(SOURCE_FILES controllers/commands/builtin/twitch/UpdateChannel.hpp controllers/commands/builtin/twitch/UpdateColor.cpp controllers/commands/builtin/twitch/UpdateColor.hpp + controllers/commands/builtin/twitch/Warn.cpp + controllers/commands/builtin/twitch/Warn.hpp + controllers/commands/common/ChannelAction.cpp + controllers/commands/common/ChannelAction.hpp controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp @@ -230,6 +234,10 @@ set(SOURCE_FILES controllers/plugins/api/ChannelRef.hpp controllers/plugins/api/IOWrapper.cpp controllers/plugins/api/IOWrapper.hpp + controllers/plugins/api/HTTPRequest.cpp + controllers/plugins/api/HTTPRequest.hpp + controllers/plugins/api/HTTPResponse.cpp + controllers/plugins/api/HTTPResponse.hpp controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.hpp controllers/plugins/PluginPermission.cpp @@ -505,6 +513,8 @@ set(SOURCE_FILES util/IpcQueue.hpp util/LayoutHelper.cpp util/LayoutHelper.hpp + util/LoadPixmap.cpp + util/LoadPixmap.hpp util/RapidjsonHelpers.cpp util/RapidjsonHelpers.hpp util/RatelimitBucket.cpp @@ -638,6 +648,8 @@ set(SOURCE_FILES widgets/helper/EditableModelView.hpp widgets/helper/EffectLabel.cpp widgets/helper/EffectLabel.hpp + widgets/helper/IconDelegate.cpp + widgets/helper/IconDelegate.hpp widgets/helper/InvisibleSizeGrip.cpp widgets/helper/InvisibleSizeGrip.hpp widgets/helper/NotebookButton.cpp @@ -646,8 +658,6 @@ set(SOURCE_FILES widgets/helper/NotebookTab.hpp widgets/helper/RegExpItemDelegate.cpp widgets/helper/RegExpItemDelegate.hpp - widgets/helper/TrimRegExpValidator.cpp - widgets/helper/TrimRegExpValidator.hpp widgets/helper/ResizingTextEdit.cpp widgets/helper/ResizingTextEdit.hpp widgets/helper/ScrollbarHighlight.cpp @@ -658,10 +668,17 @@ set(SOURCE_FILES widgets/helper/SettingsDialogTab.hpp widgets/helper/SignalLabel.cpp widgets/helper/SignalLabel.hpp + widgets/helper/TableStyles.cpp + widgets/helper/TableStyles.hpp widgets/helper/TitlebarButton.cpp widgets/helper/TitlebarButton.hpp widgets/helper/TitlebarButtons.cpp widgets/helper/TitlebarButtons.hpp + widgets/helper/TrimRegExpValidator.cpp + widgets/helper/TrimRegExpValidator.hpp + + widgets/layout/FlowLayout.cpp + widgets/layout/FlowLayout.hpp widgets/listview/GenericItemDelegate.cpp widgets/listview/GenericItemDelegate.hpp @@ -872,6 +889,7 @@ if (BUILD_APP) endif() get_filename_component(QT_BIN_DIR ${QT_CORE_LOC} DIRECTORY) + # This assumes the installed CRT is up-to-date (see .CI/deploy-crt.ps1) set(WINDEPLOYQT_COMMAND_ARGV "${WINDEPLOYQT_PATH}" "$" ${WINDEPLOYQT_MODE} --no-compiler-runtime --no-translations --no-opengl-sw) string(REPLACE ";" " " WINDEPLOYQT_COMMAND "${WINDEPLOYQT_COMMAND_ARGV}") @@ -997,7 +1015,6 @@ if (APPLE AND BUILD_APP) PROPERTIES MACOSX_BUNDLE_BUNDLE_NAME "Chatterino" MACOSX_BUNDLE_GUI_IDENTIFIER "com.chatterino" - MACOSX_BUNDLE_INFO_STRING "Chat client for Twitch" MACOSX_BUNDLE_LONG_VERSION_STRING "${PROJECT_VERSION}" MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" @@ -1010,6 +1027,9 @@ target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} # semver dependency https://github.com/Neargye/semver target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/semver/include) +# expected-lite dependency https://github.com/martinmoene/expected-lite +target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/expected-lite/include) + # miniaudio dependency https://github.com/mackron/miniaudio if (USE_SYSTEM_MINIAUDIO) message(STATUS "Building with system miniaudio") @@ -1153,3 +1173,14 @@ if(NOT CHATTERINO_UPDATER) message(STATUS "Disabling the updater.") target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO_DISABLE_UPDATER) endif() + +if (DOXYGEN_FOUND) + message(STATUS "Doxygen found, adding doxygen target") + # output will be in docs/html + set(DOXYGEN_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/docs") + + doxygen_add_docs( + doxygen + ${CMAKE_CURRENT_LIST_DIR} + ) +endif () diff --git a/src/RunGui.cpp b/src/RunGui.cpp index 13012957dc2..6fba9c6aff8 100644 --- a/src/RunGui.cpp +++ b/src/RunGui.cpp @@ -86,10 +86,6 @@ namespace { QApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings); #endif -#if defined(Q_OS_WIN32) && QT_VERSION < QT_VERSION_CHECK(6, 0, 0) - QApplication::setAttribute(Qt::AA_DisableHighDpiScaling, true); -#endif - QApplication::setStyle(QStyleFactory::create("Fusion")); #ifndef Q_OS_MAC diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index 872ba246209..7141dbe7efc 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -92,8 +92,16 @@ void Channel::addMessage(MessagePtr message, auto *irc = dynamic_cast(this); if (irc != nullptr) { - channelPlatform = QString("irc-%1").arg( - irc->server()->userFriendlyIdentifier()); + auto *ircServer = irc->server(); + if (ircServer != nullptr) + { + channelPlatform = QString("irc-%1").arg( + irc->server()->userFriendlyIdentifier()); + } + else + { + channelPlatform = "irc-unknown"; + } } } else if (this->isTwitchChannel()) diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 6adac6a76f4..106d6cabc8f 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -4,6 +4,7 @@ #include "controllers/completion/TabCompletionModel.hpp" #include "messages/LimitedQueue.hpp" +#include #include #include #include @@ -32,7 +33,7 @@ class Channel : public std::enable_shared_from_this public: // This is for Lua. See scripts/make_luals_meta.py /** - * @exposeenum ChannelType + * @exposeenum c2.ChannelType */ enum class Type { None, @@ -45,7 +46,7 @@ class Channel : public std::enable_shared_from_this TwitchAutomod, TwitchEnd, Irc, - Misc + Misc, }; explicit Channel(const QString &name, Type type); @@ -151,3 +152,32 @@ class IndirectChannel }; } // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t + magic_enum::customize::enum_name( + chatterino::Channel::Type value) noexcept +{ + using Type = chatterino::Channel::Type; + switch (value) + { + case Type::Twitch: + return "twitch"; + case Type::TwitchWhispers: + return "whispers"; + case Type::TwitchWatching: + return "watching"; + case Type::TwitchMentions: + return "mentions"; + case Type::TwitchLive: + return "live"; + case Type::TwitchAutomod: + return "automod"; + case Type::Irc: + return "irc"; + case Type::Misc: + return "misc"; + default: + return default_tag; + } +} diff --git a/src/common/Common.hpp b/src/common/Common.hpp index b0315a8aaf2..8d6097473bc 100644 --- a/src/common/Common.hpp +++ b/src/common/Common.hpp @@ -8,8 +8,15 @@ #include #include +#define LINK_CHATTERINO_WIKI "https://wiki.chatterino.com" +#define LINK_CHATTERINO_DISCORD "https://discord.gg/7Y5AYhAK4z" +#define LINK_CHATTERINO_SOURCE "https://github.com/Chatterino/chatterino2" + namespace chatterino { +const inline auto TWITCH_PLAYER_URL = + QStringLiteral("https://player.twitch.tv/?channel=%1&parent=twitch.tv"); + enum class HighlightState { None, Highlighted, diff --git a/src/common/LinkParser.cpp b/src/common/LinkParser.cpp index 8ef1dc232b7..d64abed31cf 100644 --- a/src/common/LinkParser.cpp +++ b/src/common/LinkParser.cpp @@ -124,15 +124,6 @@ LinkParser::LinkParser(const QString &unparsedString) QStringView remaining(unparsedString); QStringView protocol(remaining); -#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0) - QStringView wholeString(unparsedString); - const auto refFromView = [&](QStringView view) { - return QStringRef(&unparsedString, - static_cast(view.begin() - wholeString.begin()), - static_cast(view.size())); - }; -#endif - // Check protocol for https?:// if (remaining.startsWith(QStringLiteral("http"), Qt::CaseInsensitive) && remaining.length() >= 4 + 3 + 1) // 'http' + '://' + [any] @@ -149,12 +140,7 @@ LinkParser::LinkParser(const QString &unparsedString) { // there's really a protocol => consume it remaining = withProto.mid(3); -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) result.protocol = {protocol.begin(), remaining.begin()}; -#else - result.protocol = - refFromView({protocol.begin(), remaining.begin()}); -#endif } } @@ -219,13 +205,8 @@ LinkParser::LinkParser(const QString &unparsedString) if ((nDots == 3 && isValidIpv4(host)) || isValidTld(host.mid(lastDotPos + 1))) { -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) result.host = host; result.rest = rest; -#else - result.host = refFromView(host); - result.rest = refFromView(rest); -#endif result.source = unparsedString; this->result_ = std::move(result); } diff --git a/src/common/LinkParser.hpp b/src/common/LinkParser.hpp index 16bfe235e52..2ef1183181c 100644 --- a/src/common/LinkParser.hpp +++ b/src/common/LinkParser.hpp @@ -7,14 +7,28 @@ namespace chatterino { struct ParsedLink { -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - using StringView = QStringView; -#else - using StringView = QStringRef; -#endif - StringView protocol; - StringView host; - StringView rest; + /// The parsed protocol of the link. Can be empty. + /// + /// https://www.forsen.tv/commands + /// ^------^ + QStringView protocol; + + /// The parsed host of the link. Can not be empty. + /// + /// https://www.forsen.tv/commands + /// ^-----------^ + QStringView host; + + /// The remainder of the link. Can be empty. + /// + /// https://www.forsen.tv/commands + /// ^-------^ + QStringView rest; + + /// The original unparsed link. + /// + /// https://www.forsen.tv/commands + /// ^----------------------------^ QString source; }; diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index de4ef056c0c..a8cd8285d55 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -11,6 +11,7 @@ Q_LOGGING_CATEGORY(chatterinoArgs, "chatterino.args", logThreshold); Q_LOGGING_CATEGORY(chatterinoBenchmark, "chatterino.benchmark", logThreshold); Q_LOGGING_CATEGORY(chatterinoBttv, "chatterino.bttv", logThreshold); Q_LOGGING_CATEGORY(chatterinoCache, "chatterino.cache", logThreshold); +Q_LOGGING_CATEGORY(chatterinoCommands, "chatterino.commands", logThreshold); Q_LOGGING_CATEGORY(chatterinoCommon, "chatterino.common", logThreshold); Q_LOGGING_CATEGORY(chatterinoCrashhandler, "chatterino.crashhandler", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 36daa0e1e92..b814bb33246 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -7,6 +7,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoArgs); Q_DECLARE_LOGGING_CATEGORY(chatterinoBenchmark); Q_DECLARE_LOGGING_CATEGORY(chatterinoBttv); Q_DECLARE_LOGGING_CATEGORY(chatterinoCache); +Q_DECLARE_LOGGING_CATEGORY(chatterinoCommands); Q_DECLARE_LOGGING_CATEGORY(chatterinoCommon); Q_DECLARE_LOGGING_CATEGORY(chatterinoCrashhandler); Q_DECLARE_LOGGING_CATEGORY(chatterinoEmoji); @@ -17,6 +18,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoHighlights); Q_DECLARE_LOGGING_CATEGORY(chatterinoHotkeys); Q_DECLARE_LOGGING_CATEGORY(chatterinoHTTP); Q_DECLARE_LOGGING_CATEGORY(chatterinoImage); +Q_DECLARE_LOGGING_CATEGORY(chatterinoImageuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoIrc); Q_DECLARE_LOGGING_CATEGORY(chatterinoIvr); Q_DECLARE_LOGGING_CATEGORY(chatterinoLiveupdates); @@ -26,7 +28,6 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNetwork); Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification); -Q_DECLARE_LOGGING_CATEGORY(chatterinoImageuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); diff --git a/src/common/Version.hpp b/src/common/Version.hpp index 5d978b19a88..fbe536a69b8 100644 --- a/src/common/Version.hpp +++ b/src/common/Version.hpp @@ -24,7 +24,7 @@ * - 2.4.0-alpha.2 * - 2.4.0-alpha **/ -#define CHATTERINO_VERSION "2.4.6" +#define CHATTERINO_VERSION "2.5.1" #if defined(Q_OS_WIN) # define CHATTERINO_OS "win" diff --git a/src/common/WindowDescriptors.cpp b/src/common/WindowDescriptors.cpp index e08069b2cc4..6c02d5c9522 100644 --- a/src/common/WindowDescriptors.cpp +++ b/src/common/WindowDescriptors.cpp @@ -219,9 +219,15 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) } // Load emote popup position - QJsonObject emote_popup_obj = windowObj.value("emotePopup").toObject(); - layout.emotePopupPos_ = QPoint(emote_popup_obj.value("x").toInt(), - emote_popup_obj.value("y").toInt()); + { + auto emotePopup = windowObj["emotePopup"].toObject(); + layout.emotePopupBounds_ = QRect{ + emotePopup["x"].toInt(), + emotePopup["y"].toInt(), + emotePopup["width"].toInt(), + emotePopup["height"].toInt(), + }; + } layout.windows_.emplace_back(std::move(window)); } diff --git a/src/common/WindowDescriptors.hpp b/src/common/WindowDescriptors.hpp index b35edf1554e..9964940ca39 100644 --- a/src/common/WindowDescriptors.hpp +++ b/src/common/WindowDescriptors.hpp @@ -98,7 +98,7 @@ class WindowLayout { public: // A complete window layout has a single emote popup position that is shared among all windows - QPoint emotePopupPos_; + QRect emotePopupBounds_; std::vector windows_; diff --git a/src/common/network/NetworkCommon.hpp b/src/common/network/NetworkCommon.hpp index a5a44430e95..215b828a75f 100644 --- a/src/common/network/NetworkCommon.hpp +++ b/src/common/network/NetworkCommon.hpp @@ -15,6 +15,9 @@ using NetworkSuccessCallback = std::function; using NetworkErrorCallback = std::function; using NetworkFinallyCallback = std::function; +/** + * @exposeenum HTTPMethod + */ enum class NetworkRequestType { Get, Post, diff --git a/src/common/network/NetworkManager.cpp b/src/common/network/NetworkManager.cpp index 956c2e79f31..eb1b7ec5229 100644 --- a/src/common/network/NetworkManager.cpp +++ b/src/common/network/NetworkManager.cpp @@ -24,15 +24,19 @@ void NetworkManager::deinit() assert(NetworkManager::workerThread); assert(NetworkManager::accessManager); + // delete the access manager first: + // - put the event on the worker thread + // - wait for it to process + NetworkManager::accessManager->deleteLater(); + NetworkManager::accessManager = nullptr; + if (NetworkManager::workerThread) { NetworkManager::workerThread->quit(); NetworkManager::workerThread->wait(); } - delete NetworkManager::accessManager; - NetworkManager::accessManager = nullptr; - delete NetworkManager::workerThread; + NetworkManager::workerThread->deleteLater(); NetworkManager::workerThread = nullptr; } diff --git a/src/common/network/NetworkRequest.cpp b/src/common/network/NetworkRequest.cpp index b436216ae06..f46da079a07 100644 --- a/src/common/network/NetworkRequest.cpp +++ b/src/common/network/NetworkRequest.cpp @@ -98,6 +98,13 @@ NetworkRequest NetworkRequest::header(QNetworkRequest::KnownHeaders header, return std::move(*this); } +NetworkRequest NetworkRequest::header(const QByteArray &headerName, + const QByteArray &value) && +{ + this->data->request.setRawHeader(headerName, value); + return std::move(*this); +} + NetworkRequest NetworkRequest::headerList( const std::vector> &headers) && { diff --git a/src/common/network/NetworkRequest.hpp b/src/common/network/NetworkRequest.hpp index 4da4c7a9e9e..1308fb023b1 100644 --- a/src/common/network/NetworkRequest.hpp +++ b/src/common/network/NetworkRequest.hpp @@ -57,6 +57,8 @@ class NetworkRequest final NetworkRequest header(const char *headerName, const char *value) &&; NetworkRequest header(const char *headerName, const QByteArray &value) &&; NetworkRequest header(const char *headerName, const QString &value) &&; + NetworkRequest header(const QByteArray &headerName, + const QByteArray &value) &&; NetworkRequest header(QNetworkRequest::KnownHeaders header, const QVariant &value) &&; NetworkRequest headerList( diff --git a/src/common/network/NetworkResult.cpp b/src/common/network/NetworkResult.cpp index 177d2ae6f97..c6544eaad80 100644 --- a/src/common/network/NetworkResult.cpp +++ b/src/common/network/NetworkResult.cpp @@ -67,7 +67,9 @@ const QByteArray &NetworkResult::getData() const QString NetworkResult::formatError() const { - if (this->status_) + // Print the status for errors that mirror HTTP status codes (=0 || >99) + if (this->status_ && (this->error_ == QNetworkReply::NoError || + this->error_ > QNetworkReply::UnknownNetworkError)) { return QString::number(*this->status_); } @@ -77,6 +79,13 @@ QString NetworkResult::formatError() const this->error_); if (name == nullptr) { + if (this->status_) + { + return QStringLiteral("unknown error (status: %1, error: %2)") + .arg(QString::number(*this->status_), + QString::number(this->error_)); + } + return QStringLiteral("unknown error (%1)").arg(this->error_); } return name; diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a5554570a87..ac4dca9e846 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -26,6 +26,7 @@ #include "controllers/commands/builtin/twitch/Unban.hpp" #include "controllers/commands/builtin/twitch/UpdateChannel.hpp" #include "controllers/commands/builtin/twitch/UpdateColor.hpp" +#include "controllers/commands/builtin/twitch/Warn.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" @@ -39,7 +40,6 @@ #include "singletons/Paths.hpp" #include "util/CombinePath.hpp" #include "util/QStringHash.hpp" -#include "util/Qt.hpp" #include @@ -439,6 +439,8 @@ void CommandController::initialize(Settings &, const Paths &paths) this->registerCommand("/ban", &commands::sendBan); this->registerCommand("/banid", &commands::sendBanById); + this->registerCommand("/warn", &commands::sendWarn); + for (const auto &cmd : TWITCH_WHISPER_COMMANDS) { this->registerCommand(cmd, &commands::sendWhisper); diff --git a/src/controllers/commands/builtin/Misc.cpp b/src/controllers/commands/builtin/Misc.cpp index c0b62c2d9f9..e1940271a36 100644 --- a/src/controllers/commands/builtin/Misc.cpp +++ b/src/controllers/commands/builtin/Misc.cpp @@ -390,9 +390,9 @@ QString popup(const CommandContext &ctx) } // Open channel passed as argument in a popup - auto *app = getApp(); - auto targetChannel = app->twitch->getOrAddChannel(target); - app->getWindows()->openInPopup(targetChannel); + auto targetChannel = + getIApp()->getTwitchAbstract()->getOrAddChannel(target); + getIApp()->getWindows()->openInPopup(targetChannel); return ""; } @@ -533,7 +533,8 @@ QString sendRawMessage(const CommandContext &ctx) if (ctx.channel->isTwitchChannel()) { - getApp()->twitch->sendRawMessage(ctx.words.mid(1).join(" ")); + getIApp()->getTwitchAbstract()->sendRawMessage( + ctx.words.mid(1).join(" ")); } else { @@ -566,7 +567,7 @@ QString injectFakeMessage(const CommandContext &ctx) } auto ircText = ctx.words.mid(1).join(" "); - getApp()->twitch->addFakeMessage(ircText); + getIApp()->getTwitchAbstract()->addFakeMessage(ircText); return ""; } @@ -667,7 +668,7 @@ QString openUsercard(const CommandContext &ctx) stripChannelName(channelName); ChannelPtr channelTemp = - getApp()->twitch->getChannelOrEmpty(channelName); + getIApp()->getTwitchAbstract()->getChannelOrEmpty(channelName); if (channelTemp->isEmpty()) { diff --git a/src/controllers/commands/builtin/twitch/Ban.cpp b/src/controllers/commands/builtin/twitch/Ban.cpp index 27b3d5a462d..bce3001f4b0 100644 --- a/src/controllers/commands/builtin/twitch/Ban.cpp +++ b/src/controllers/commands/builtin/twitch/Ban.cpp @@ -1,13 +1,14 @@ #include "controllers/commands/builtin/twitch/Ban.hpp" #include "Application.hpp" +#include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/CommandContext.hpp" +#include "controllers/commands/common/ChannelAction.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "util/Twitch.hpp" namespace { @@ -80,13 +81,12 @@ QString formatBanTimeoutError(const char *operation, HelixBanUserError error, return errorMessage; } -void banUserByID(const ChannelPtr &channel, const TwitchChannel *twitchChannel, +void banUserByID(const ChannelPtr &channel, const QString &channelID, const QString &sourceUserID, const QString &targetUserID, const QString &reason, const QString &displayName) { getHelix()->banUser( - twitchChannel->roomId(), sourceUserID, targetUserID, std::nullopt, - reason, + channelID, sourceUserID, targetUserID, std::nullopt, reason, [] { // No response for bans, they're emitted over pubsub/IRC instead }, @@ -97,14 +97,13 @@ void banUserByID(const ChannelPtr &channel, const TwitchChannel *twitchChannel, }); } -void timeoutUserByID(const ChannelPtr &channel, - const TwitchChannel *twitchChannel, +void timeoutUserByID(const ChannelPtr &channel, const QString &channelID, const QString &sourceUserID, const QString &targetUserID, int duration, const QString &reason, const QString &displayName) { getHelix()->banUser( - twitchChannel->roomId(), sourceUserID, targetUserID, duration, reason, + channelID, sourceUserID, targetUserID, duration, reason, [] { // No response for timeouts, they're emitted over pubsub/IRC instead }, @@ -121,63 +120,108 @@ namespace chatterino::commands { QString sendBan(const CommandContext &ctx) { - const auto &words = ctx.words; - const auto &channel = ctx.channel; - const auto *twitchChannel = ctx.twitchChannel; + const auto command = QStringLiteral("/ban"); + const auto usage = QStringLiteral( + R"(Usage: "/ban [options...] [reason]" - Permanently prevent a user from chatting via their username. Reason is optional and will be shown to the target user and other moderators. Options: --channel to override which channel the ban takes place in (can be specified multiple times).)"); + const auto actions = parseChannelAction(ctx, command, usage, false, true); - if (channel == nullptr) + if (!actions.has_value()) { - return ""; - } + if (ctx.channel != nullptr) + { + ctx.channel->addMessage(makeSystemMessage(actions.error())); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << actions.error(); + } - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The /ban command only works in Twitch channels."))); return ""; } - const auto *usageStr = - "Usage: \"/ban [reason]\" - Permanently prevent a user " - "from chatting. Reason is optional and will be shown to the target " - "user and other moderators. Use \"/unban\" to remove a ban."; - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; - } + assert(!actions.value().empty()); auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { - channel->addMessage( + ctx.channel->addMessage( makeSystemMessage("You must be logged in to ban someone!")); return ""; } - const auto &rawTarget = words.at(1); - auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); - auto reason = words.mid(2).join(' '); - - if (!targetUserID.isEmpty()) - { - banUserByID(channel, twitchChannel, currentUser->getUserId(), - targetUserID, reason, targetUserID); - } - else + for (const auto &action : actions.value()) { - getHelix()->getUserByName( - targetUserName, - [channel, currentUser, twitchChannel, - reason](const auto &targetUser) { - banUserByID(channel, twitchChannel, currentUser->getUserId(), - targetUser.id, reason, targetUser.displayName); - }, - [channel, targetUserName{targetUserName}] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(targetUserName))); - }); + const auto &reason = action.reason; + + QStringList userLoginsToFetch; + QStringList userIDs; + if (action.target.id.isEmpty()) + { + assert(!action.target.login.isEmpty() && + "Ban Action target username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.target.login); + } + else + { + // For hydration + userIDs.append(action.target.id); + } + if (action.channel.id.isEmpty()) + { + assert(!action.channel.login.isEmpty() && + "Ban Action channel username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.channel.login); + } + else + { + // For hydration + userIDs.append(action.channel.id); + } + + if (!userLoginsToFetch.isEmpty()) + { + // At least 1 user ID needs to be resolved before we can take action + // userIDs is filled up with the data we already have to hydrate the action channel & action target + getHelix()->fetchUsers( + userIDs, userLoginsToFetch, + [channel{ctx.channel}, actionChannel{action.channel}, + actionTarget{action.target}, currentUser, reason, + userLoginsToFetch](const auto &users) mutable { + if (!actionChannel.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to ban, bad channel name: %1") + .arg(actionChannel.login))); + return; + } + if (!actionTarget.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to ban, bad target name: %1") + .arg(actionTarget.login))); + return; + } + + banUserByID(channel, actionChannel.id, + currentUser->getUserId(), actionTarget.id, + reason, actionTarget.displayName); + }, + [channel{ctx.channel}, userLoginsToFetch] { + channel->addMessage(makeSystemMessage( + QString("Failed to ban, bad username(s): %1") + .arg(userLoginsToFetch.join(", ")))); + }); + } + else + { + // If both IDs are available, we do no hydration & just use the id as the display name + banUserByID(ctx.channel, action.channel.id, + currentUser->getUserId(), action.target.id, reason, + action.target.id); + } } return ""; @@ -221,87 +265,117 @@ QString sendBanById(const CommandContext &ctx) auto target = words.at(1); auto reason = words.mid(2).join(' '); - banUserByID(channel, twitchChannel, currentUser->getUserId(), target, - reason, target); + banUserByID(channel, twitchChannel->roomId(), currentUser->getUserId(), + target, reason, target); return ""; } QString sendTimeout(const CommandContext &ctx) { - const auto &words = ctx.words; - const auto &channel = ctx.channel; - const auto *twitchChannel = ctx.twitchChannel; + const auto command = QStringLiteral("/timeout"); + const auto usage = QStringLiteral( + R"(Usage: "/timeout [options...] [duration][time unit] [reason]" - Temporarily prevent a user from chatting. Duration (optional, default=10 minutes) must be a positive integer; time unit (optional, default=s) must be one of s, m, h, d, w; maximum duration is 2 weeks. Combinations like 1d2h are also allowed. Reason is optional and will be shown to the target user and other moderators. Use "/untimeout" to remove a timeout. Options: --channel to override which channel the timeout takes place in (can be specified multiple times).)"); + const auto actions = parseChannelAction(ctx, command, usage, true, true); - if (channel == nullptr) + if (!actions.has_value()) { - return ""; - } + if (ctx.channel != nullptr) + { + ctx.channel->addMessage(makeSystemMessage(actions.error())); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << actions.error(); + } - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The /timeout command only works in Twitch channels."))); - return ""; - } - const auto *usageStr = - "Usage: \"/timeout [duration][time unit] [reason]\" - " - "Temporarily prevent a user from chatting. Duration (optional, " - "default=10 minutes) must be a positive integer; time unit " - "(optional, default=s) must be one of s, m, h, d, w; maximum " - "duration is 2 weeks. Combinations like 1d2h are also allowed. " - "Reason is optional and will be shown to the target user and other " - "moderators. Use \"/untimeout\" to remove a timeout."; - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage(usageStr)); return ""; } + assert(!actions.value().empty()); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { - channel->addMessage( + ctx.channel->addMessage( makeSystemMessage("You must be logged in to timeout someone!")); return ""; } - const auto &rawTarget = words.at(1); - auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); - - int duration = 10 * 60; // 10min - if (words.size() >= 3) + for (const auto &action : actions.value()) { - duration = (int)parseDurationToSeconds(words.at(2)); - if (duration <= 0) + const auto &reason = action.reason; + + QStringList userLoginsToFetch; + QStringList userIDs; + if (action.target.id.isEmpty()) { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; + assert(!action.target.login.isEmpty() && + "Timeout Action target username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.target.login); + } + else + { + // For hydration + userIDs.append(action.target.id); + } + if (action.channel.id.isEmpty()) + { + assert(!action.channel.login.isEmpty() && + "Timeout Action channel username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.channel.login); + } + else + { + // For hydration + userIDs.append(action.channel.id); } - } - auto reason = words.mid(3).join(' '); - if (!targetUserID.isEmpty()) - { - timeoutUserByID(channel, twitchChannel, currentUser->getUserId(), - targetUserID, duration, reason, targetUserID); - } - else - { - getHelix()->getUserByName( - targetUserName, - [channel, currentUser, twitchChannel, - targetUserName{targetUserName}, duration, - reason](const auto &targetUser) { - timeoutUserByID(channel, twitchChannel, - currentUser->getUserId(), targetUser.id, - duration, reason, targetUser.displayName); - }, - [channel, targetUserName{targetUserName}] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(targetUserName))); - }); + if (!userLoginsToFetch.isEmpty()) + { + // At least 1 user ID needs to be resolved before we can take action + // userIDs is filled up with the data we already have to hydrate the action channel & action target + getHelix()->fetchUsers( + userIDs, userLoginsToFetch, + [channel{ctx.channel}, duration{action.duration}, + actionChannel{action.channel}, actionTarget{action.target}, + currentUser, reason, + userLoginsToFetch](const auto &users) mutable { + if (!actionChannel.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad channel name: %1") + .arg(actionChannel.login))); + return; + } + if (!actionTarget.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad target name: %1") + .arg(actionTarget.login))); + return; + } + + timeoutUserByID(channel, actionChannel.id, + currentUser->getUserId(), actionTarget.id, + duration, reason, actionTarget.displayName); + }, + [channel{ctx.channel}, userLoginsToFetch] { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad username(s): %1") + .arg(userLoginsToFetch.join(", ")))); + }); + } + else + { + // If both IDs are available, we do no hydration & just use the id as the display name + timeoutUserByID(ctx.channel, action.channel.id, + currentUser->getUserId(), action.target.id, + action.duration, reason, action.target.id); + } } return ""; diff --git a/src/controllers/commands/builtin/twitch/SendWhisper.cpp b/src/controllers/commands/builtin/twitch/SendWhisper.cpp index 6b4cc25bfbc..1d804604624 100644 --- a/src/controllers/commands/builtin/twitch/SendWhisper.cpp +++ b/src/controllers/commands/builtin/twitch/SendWhisper.cpp @@ -92,7 +92,7 @@ QString formatWhisperError(HelixWhisperError error, const QString &message) bool appendWhisperMessageWordsLocally(const QStringList &words) { - auto *app = getApp(); + auto *app = getIApp(); MessageBuilder b; @@ -177,7 +177,7 @@ bool appendWhisperMessageWordsLocally(const QStringList &words) b->flags.set(MessageFlag::Whisper); auto messagexD = b.release(); - app->twitch->whispersChannel->addMessage(messagexD); + getIApp()->getTwitch()->getWhispersChannel()->addMessage(messagexD); auto overrideFlags = std::optional(messagexD->flags); overrideFlags->set(MessageFlag::DoNotLog); @@ -186,7 +186,7 @@ bool appendWhisperMessageWordsLocally(const QStringList &words) !(getSettings()->streamerModeSuppressInlineWhispers && getIApp()->getStreamerMode()->isEnabled())) { - app->twitch->forEachChannel( + app->getTwitchAbstract()->forEachChannel( [&messagexD, overrideFlags](ChannelPtr _channel) { _channel->addMessage(messagexD, overrideFlags); }); diff --git a/src/controllers/commands/builtin/twitch/Unban.cpp b/src/controllers/commands/builtin/twitch/Unban.cpp index e88008e8427..f47d701683f 100644 --- a/src/controllers/commands/builtin/twitch/Unban.cpp +++ b/src/controllers/commands/builtin/twitch/Unban.cpp @@ -1,24 +1,25 @@ +#include "controllers/commands/builtin/twitch/Unban.hpp" + #include "Application.hpp" +#include "common/Channel.hpp" +#include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" -#include "controllers/commands/builtin/twitch/Ban.hpp" #include "controllers/commands/CommandContext.hpp" +#include "controllers/commands/common/ChannelAction.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "util/Twitch.hpp" namespace { using namespace chatterino; -void unbanUserByID(const ChannelPtr &channel, - const TwitchChannel *twitchChannel, +void unbanUserByID(const ChannelPtr &channel, const QString &channelID, const QString &sourceUserID, const QString &targetUserID, const QString &displayName) { getHelix()->unbanUser( - twitchChannel->roomId(), sourceUserID, targetUserID, + channelID, sourceUserID, targetUserID, [] { // No response for unbans, they're emitted over pubsub/IRC instead }, @@ -85,27 +86,29 @@ namespace chatterino::commands { QString unbanUser(const CommandContext &ctx) { - if (ctx.channel == nullptr) + const auto command = ctx.words.at(0).toLower(); + const auto usage = + QStringLiteral( + R"(Usage: "%1 - Removes a ban on a user. Options: --channel to override which channel the unban takes place in (can be specified multiple times).)") + .arg(command); + const auto actions = parseChannelAction(ctx, command, usage, false, false); + if (!actions.has_value()) { - return ""; - } + if (ctx.channel != nullptr) + { + ctx.channel->addMessage(makeSystemMessage(actions.error())); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << actions.error(); + } - auto commandName = ctx.words.at(0).toLower(); - if (ctx.twitchChannel == nullptr) - { - ctx.channel->addMessage(makeSystemMessage( - QString("The %1 command only works in Twitch channels.") - .arg(commandName))); - return ""; - } - if (ctx.words.size() < 2) - { - ctx.channel->addMessage(makeSystemMessage( - QString("Usage: \"%1 \" - Removes a ban on a user.") - .arg(commandName))); return ""; } + assert(!actions.value().empty()); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { @@ -114,29 +117,78 @@ QString unbanUser(const CommandContext &ctx) return ""; } - const auto &rawTarget = ctx.words.at(1); - auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); - - if (!targetUserID.isEmpty()) - { - unbanUserByID(ctx.channel, ctx.twitchChannel, currentUser->getUserId(), - targetUserID, targetUserID); - } - else + for (const auto &action : actions.value()) { - getHelix()->getUserByName( - targetUserName, - [channel{ctx.channel}, currentUser, - twitchChannel{ctx.twitchChannel}, - targetUserName{targetUserName}](const auto &targetUser) { - unbanUserByID(channel, twitchChannel, currentUser->getUserId(), - targetUser.id, targetUser.displayName); - }, - [channel{ctx.channel}, targetUserName{targetUserName}] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(targetUserName))); - }); + const auto &reason = action.reason; + + QStringList userLoginsToFetch; + QStringList userIDs; + if (action.target.id.isEmpty()) + { + assert(!action.target.login.isEmpty() && + "Unban Action target username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.target.login); + } + else + { + // For hydration + userIDs.append(action.target.id); + } + if (action.channel.id.isEmpty()) + { + assert(!action.channel.login.isEmpty() && + "Unban Action channel username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.channel.login); + } + else + { + // For hydration + userIDs.append(action.channel.id); + } + + if (!userLoginsToFetch.isEmpty()) + { + // At least 1 user ID needs to be resolved before we can take action + // userIDs is filled up with the data we already have to hydrate the action channel & action target + getHelix()->fetchUsers( + userIDs, userLoginsToFetch, + [channel{ctx.channel}, actionChannel{action.channel}, + actionTarget{action.target}, currentUser, reason, + userLoginsToFetch](const auto &users) mutable { + if (!actionChannel.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad channel name: %1") + .arg(actionChannel.login))); + return; + } + if (!actionTarget.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad target name: %1") + .arg(actionTarget.login))); + return; + } + + unbanUserByID(channel, actionChannel.id, + currentUser->getUserId(), actionTarget.id, + actionTarget.displayName); + }, + [channel{ctx.channel}, userLoginsToFetch] { + channel->addMessage(makeSystemMessage( + QString("Failed to timeout, bad username(s): %1") + .arg(userLoginsToFetch.join(", ")))); + }); + } + else + { + // If both IDs are available, we do no hydration & just use the id as the display name + unbanUserByID(ctx.channel, action.channel.id, + currentUser->getUserId(), action.target.id, + action.target.id); + } } return ""; diff --git a/src/controllers/commands/builtin/twitch/UpdateChannel.cpp b/src/controllers/commands/builtin/twitch/UpdateChannel.cpp index bcda86f384d..bb026bf4d50 100644 --- a/src/controllers/commands/builtin/twitch/UpdateChannel.cpp +++ b/src/controllers/commands/builtin/twitch/UpdateChannel.cpp @@ -7,6 +7,58 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchChannel.hpp" +namespace { + +using namespace chatterino; + +QString formatUpdateChannelError(const char *updateType, + HelixUpdateChannelError error, + const QString &message) +{ + using Error = HelixUpdateChannelError; + + QString errorMessage = QString("Failed to set %1 - ").arg(updateType); + + switch (error) + { + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += QString("You must be the broadcaster " + "to set the %1.") + .arg(updateType); + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + QString("An unknown error has occurred (%1).").arg(message); + } + break; + } + + return errorMessage; +} + +} // namespace + namespace chatterino::commands { QString setTitle(const CommandContext &ctx) @@ -30,8 +82,8 @@ QString setTitle(const CommandContext &ctx) return ""; } - auto status = ctx.twitchChannel->accessStreamStatus(); auto title = ctx.words.mid(1).join(" "); + getHelix()->updateChannel( ctx.twitchChannel->roomId(), "", "", title, [channel{ctx.channel}, title](const auto &result) { @@ -40,10 +92,10 @@ QString setTitle(const CommandContext &ctx) channel->addMessage( makeSystemMessage(QString("Updated title to %1").arg(title))); }, - [channel{ctx.channel}] { - channel->addMessage( - makeSystemMessage("Title update failed! Are you " - "missing the required scope?")); + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = + formatUpdateChannelError("title", error, message); + channel->addMessage(makeSystemMessage(errorMessage)); }); return ""; @@ -105,10 +157,10 @@ QString setGame(const CommandContext &ctx) channel->addMessage(makeSystemMessage( QString("Updated game to %1").arg(matchedGame.name))); }, - [channel] { - channel->addMessage( - makeSystemMessage("Game update failed! Are you " - "missing the required scope?")); + [channel](auto error, auto message) { + auto errorMessage = + formatUpdateChannelError("game", error, message); + channel->addMessage(makeSystemMessage(errorMessage)); }); }, [channel{ctx.channel}] { diff --git a/src/controllers/commands/builtin/twitch/Warn.cpp b/src/controllers/commands/builtin/twitch/Warn.cpp new file mode 100644 index 00000000000..30cc54e6ffe --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Warn.cpp @@ -0,0 +1,199 @@ +#include "controllers/commands/builtin/twitch/Warn.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "controllers/commands/common/ChannelAction.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace { + +using namespace chatterino; + +void warnUserByID(const ChannelPtr &channel, const QString &channelID, + const QString &sourceUserID, const QString &targetUserID, + const QString &reason, const QString &displayName) +{ + using Error = HelixWarnUserError; + + getHelix()->warnUser( + channelID, sourceUserID, targetUserID, reason, + [] { + // No response for warns, they're emitted over pubsub instead + }, + [channel, displayName](auto error, auto message) { + QString errorMessage = QString("Failed to warn user - "); + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += "There was a conflicting warn operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::CannotWarnUser: { + errorMessage += + QString("You cannot warn %1.").arg(displayName); + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} + +} // namespace + +namespace chatterino::commands { + +QString sendWarn(const CommandContext &ctx) +{ + const auto command = QStringLiteral("/warn"); + const auto usage = QStringLiteral( + R"(Usage: "/warn [options...] " - Warn a user via their username. Reason is required and will be shown to the target user and other moderators. Options: --channel to override which channel the warn takes place in (can be specified multiple times).)"); + const auto actions = parseChannelAction(ctx, command, usage, false, true); + + if (!actions.has_value()) + { + if (ctx.channel != nullptr) + { + ctx.channel->addMessage(makeSystemMessage(actions.error())); + } + else + { + qCWarning(chatterinoCommands) + << "Error parsing command:" << actions.error(); + } + + return ""; + } + + assert(!actions.value().empty()); + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to warn someone!")); + return ""; + } + + for (const auto &action : actions.value()) + { + const auto &reason = action.reason; + if (reason.isEmpty()) + { + ctx.channel->addMessage( + makeSystemMessage("Failed to warn, you must specify a reason")); + break; + } + + QStringList userLoginsToFetch; + QStringList userIDs; + if (action.target.id.isEmpty()) + { + assert(!action.target.login.isEmpty() && + "Warn Action target username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.target.login); + } + else + { + // For hydration + userIDs.append(action.target.id); + } + if (action.channel.id.isEmpty()) + { + assert(!action.channel.login.isEmpty() && + "Warn Action channel username AND user ID may not be " + "empty at the same time"); + userLoginsToFetch.append(action.channel.login); + } + else + { + // For hydration + userIDs.append(action.channel.id); + } + + if (!userLoginsToFetch.isEmpty()) + { + // At least 1 user ID needs to be resolved before we can take action + // userIDs is filled up with the data we already have to hydrate the action channel & action target + getHelix()->fetchUsers( + userIDs, userLoginsToFetch, + [channel{ctx.channel}, actionChannel{action.channel}, + actionTarget{action.target}, currentUser, reason, + userLoginsToFetch](const auto &users) mutable { + if (!actionChannel.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to warn, bad channel name: %1") + .arg(actionChannel.login))); + return; + } + if (!actionTarget.hydrateFrom(users)) + { + channel->addMessage(makeSystemMessage( + QString("Failed to warn, bad target name: %1") + .arg(actionTarget.login))); + return; + } + + warnUserByID(channel, actionChannel.id, + currentUser->getUserId(), actionTarget.id, + reason, actionTarget.displayName); + }, + [channel{ctx.channel}, userLoginsToFetch] { + channel->addMessage(makeSystemMessage( + QString("Failed to warn, bad username(s): %1") + .arg(userLoginsToFetch.join(", ")))); + }); + } + else + { + // If both IDs are available, we do no hydration & just use the id as the display name + warnUserByID(ctx.channel, action.channel.id, + currentUser->getUserId(), action.target.id, reason, + action.target.id); + } + } + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Warn.hpp b/src/controllers/commands/builtin/twitch/Warn.hpp new file mode 100644 index 00000000000..42c78f564dc --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Warn.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /warn +QString sendWarn(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/common/ChannelAction.cpp b/src/controllers/commands/common/ChannelAction.cpp new file mode 100644 index 00000000000..4873859207d --- /dev/null +++ b/src/controllers/commands/common/ChannelAction.cpp @@ -0,0 +1,184 @@ +#include "controllers/commands/common/ChannelAction.hpp" + +#include "controllers/commands/CommandContext.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Helpers.hpp" +#include "util/Twitch.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace chatterino::commands { + +bool IncompleteHelixUser::hydrateFrom(const std::vector &users) +{ + // Find user in list based on our id or login + auto resolvedIt = + std::find_if(users.begin(), users.end(), [this](const auto &user) { + if (!this->login.isEmpty()) + { + return user.login.compare(this->login, Qt::CaseInsensitive) == + 0; + } + if (!this->id.isEmpty()) + { + return user.id.compare(this->id, Qt::CaseInsensitive) == 0; + } + return false; + }); + if (resolvedIt == users.end()) + { + return false; + } + const auto &resolved = *resolvedIt; + this->id = resolved.id; + this->login = resolved.login; + this->displayName = resolved.displayName; + return true; +} + +std::ostream &operator<<(std::ostream &os, const IncompleteHelixUser &u) +{ + os << "{id:" << u.id.toStdString() << ", login:" << u.login.toStdString() + << ", displayName:" << u.displayName.toStdString() << '}'; + return os; +} + +void PrintTo(const PerformChannelAction &a, std::ostream *os) +{ + *os << "{channel:" << a.channel << ", target:" << a.target + << ", reason:" << a.reason.toStdString() + << ", duration:" << std::to_string(a.duration) << '}'; +} + +nonstd::expected, QString> parseChannelAction( + const CommandContext &ctx, const QString &command, const QString &usage, + bool withDuration, bool withReason) +{ + if (ctx.channel == nullptr) + { + // A ban action must be performed with a channel as a context + return nonstd::make_unexpected( + "A " % command % + " action must be performed with a channel as a context"); + } + + QCommandLineParser parser; + parser.setOptionsAfterPositionalArgumentsMode( + QCommandLineParser::ParseAsPositionalArguments); + parser.addPositionalArgument("username", "The name of the user to ban"); + if (withDuration) + { + parser.addPositionalArgument("duration", "Duration of the action"); + } + if (withReason) + { + parser.addPositionalArgument("reason", "The optional ban reason"); + } + QCommandLineOption channelOption( + "channel", "Override which channel(s) to perform the action in", + "channel"); + parser.addOptions({ + channelOption, + }); + parser.parse(ctx.words); + + auto positionalArguments = parser.positionalArguments(); + if (positionalArguments.isEmpty()) + { + return nonstd::make_unexpected("Missing target - " % usage); + } + + auto [targetUserName, targetUserID] = + parseUserNameOrID(positionalArguments.takeFirst()); + + PerformChannelAction base{ + .target = + IncompleteHelixUser{ + .id = targetUserID, + .login = targetUserName, + .displayName = "", + }, + .duration = 0, + }; + + if (withDuration) + { + if (positionalArguments.isEmpty()) + { + base.duration = 10 * 60; // 10 min + } + else + { + auto durationStr = positionalArguments.takeFirst(); + base.duration = (int)parseDurationToSeconds(durationStr); + if (base.duration <= 0) + { + return nonstd::make_unexpected("Invalid duration - " % usage); + } + if (withReason) + { + base.reason = positionalArguments.join(' '); + } + } + } + else + { + if (withReason) + { + base.reason = positionalArguments.join(' '); + } + } + + std::vector actions; + + auto overrideChannels = parser.values(channelOption); + if (overrideChannels.isEmpty()) + { + if (ctx.twitchChannel == nullptr) + { + return nonstd::make_unexpected( + "The " % command % " command only works in Twitch channels"); + } + + actions.push_back(PerformChannelAction{ + .channel = + { + .id = ctx.twitchChannel->roomId(), + .login = ctx.twitchChannel->getName(), + .displayName = ctx.twitchChannel->getDisplayName(), + }, + .target = base.target, + .reason = base.reason, + .duration = base.duration, + }); + } + else + { + for (const auto &overrideChannelTarget : overrideChannels) + { + auto [channelUserName, channelUserID] = + parseUserNameOrID(overrideChannelTarget); + actions.push_back(PerformChannelAction{ + .channel = + { + .id = channelUserID, + .login = channelUserName, + }, + .target = base.target, + .reason = base.reason, + .duration = base.duration, + }); + } + } + + return actions; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/common/ChannelAction.hpp b/src/controllers/commands/common/ChannelAction.hpp new file mode 100644 index 00000000000..fd80eeb7954 --- /dev/null +++ b/src/controllers/commands/common/ChannelAction.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace chatterino { + +struct CommandContext; +struct HelixUser; + +} // namespace chatterino + +namespace chatterino::commands { + +struct IncompleteHelixUser { + QString id; + QString login; + QString displayName; + + bool hydrateFrom(const std::vector &users); + + bool operator==(const IncompleteHelixUser &other) const + { + return std::tie(this->id, this->login, this->displayName) == + std::tie(other.id, other.login, other.displayName); + } +}; + +struct PerformChannelAction { + // Channel to perform the action in + IncompleteHelixUser channel; + // Target to perform the action on + IncompleteHelixUser target; + QString reason; + int duration{}; + + bool operator==(const PerformChannelAction &other) const + { + return std::tie(this->channel, this->target, this->reason, + this->duration) == std::tie(other.channel, other.target, + other.reason, + other.duration); + } +}; + +std::ostream &operator<<(std::ostream &os, const IncompleteHelixUser &u); +// gtest printer +// NOLINTNEXTLINE(readability-identifier-naming) +void PrintTo(const PerformChannelAction &a, std::ostream *os); + +nonstd::expected, QString> parseChannelAction( + const CommandContext &ctx, const QString &command, const QString &usage, + bool withDuration, bool withReason); + +} // namespace chatterino::commands diff --git a/src/controllers/filters/lang/Filter.cpp b/src/controllers/filters/lang/Filter.cpp index 7ae61991a90..1a246166198 100644 --- a/src/controllers/filters/lang/Filter.cpp +++ b/src/controllers/filters/lang/Filter.cpp @@ -50,6 +50,9 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) * message.content * message.length * + * reward.title + * reward.cost + * reward.id */ using MessageFlag = chatterino::MessageFlag; @@ -90,6 +93,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) {"channel.name", m->channelName}, {"channel.watching", watching}, + {"flags.action", m->flags.has(MessageFlag::Action)}, {"flags.highlighted", m->flags.has(MessageFlag::Highlighted)}, {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, {"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, @@ -120,6 +124,18 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) vars["channel.live"] = false; } } + if (m->reward != nullptr) + { + vars["reward.title"] = m->reward->title; + vars["reward.cost"] = m->reward->cost; + vars["reward.id"] = m->reward->id; + } + else + { + vars["reward.title"] = ""; + vars["reward.cost"] = -1; + vars["reward.id"] = ""; + } return vars; } diff --git a/src/controllers/filters/lang/Filter.hpp b/src/controllers/filters/lang/Filter.hpp index c8afbd76916..7a0f44805a1 100644 --- a/src/controllers/filters/lang/Filter.hpp +++ b/src/controllers/filters/lang/Filter.hpp @@ -32,6 +32,7 @@ static const QMap MESSAGE_TYPING_CONTEXT = { {"channel.name", Type::String}, {"channel.watching", Type::Bool}, {"channel.live", Type::Bool}, + {"flags.action", Type::Bool}, {"flags.highlighted", Type::Bool}, {"flags.points_redeemed", Type::Bool}, {"flags.sub_message", Type::Bool}, @@ -48,6 +49,9 @@ static const QMap MESSAGE_TYPING_CONTEXT = { {"flags.monitored", Type::Bool}, {"message.content", Type::String}, {"message.length", Type::Int}, + {"reward.title", Type::String}, + {"reward.cost", Type::Int}, + {"reward.id", Type::String}, }; ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); diff --git a/src/controllers/filters/lang/Tokenizer.hpp b/src/controllers/filters/lang/Tokenizer.hpp index 2fbc5fd9536..ced78c5d2e7 100644 --- a/src/controllers/filters/lang/Tokenizer.hpp +++ b/src/controllers/filters/lang/Tokenizer.hpp @@ -18,6 +18,7 @@ static const QMap validIdentifiersMap = { {"channel.name", "channel name"}, {"channel.watching", "/watching channel?"}, {"channel.live", "channel live?"}, + {"flags.action", "action/me message?"}, {"flags.highlighted", "highlighted?"}, {"flags.points_redeemed", "redeemed points?"}, {"flags.sub_message", "sub/resub message?"}, @@ -35,7 +36,11 @@ static const QMap validIdentifiersMap = { {"flags.restricted", "restricted message?"}, {"flags.monitored", "monitored message?"}, {"message.content", "message text"}, - {"message.length", "message length"}}; + {"message.length", "message length"}, + {"reward.title", "point reward title"}, + {"reward.cost", "point reward cost"}, + {"reward.id", "point reward id"}, +}; // clang-format off static const QRegularExpression tokenRegex( diff --git a/src/controllers/moderationactions/ModerationAction.cpp b/src/controllers/moderationactions/ModerationAction.cpp index 2b3a95b0642..a82d1848ccb 100644 --- a/src/controllers/moderationactions/ModerationAction.cpp +++ b/src/controllers/moderationactions/ModerationAction.cpp @@ -6,28 +6,11 @@ #include "singletons/Resources.hpp" #include +#include namespace chatterino { -// ModerationAction::ModerationAction(Image *_image, const QString &_action) -// : _isImage(true) -// , image(_image) -// , action(_action) -//{ -//} - -// ModerationAction::ModerationAction(const QString &_line1, const QString -// &_line2, -// const QString &_action) -// : _isImage(false) -// , image(nullptr) -// , line1(_line1) -// , line2(_line2) -// , action(_action) -//{ -//} - -ModerationAction::ModerationAction(const QString &action) +ModerationAction::ModerationAction(const QString &action, const QUrl &iconPath) : action_(action) { static QRegularExpression replaceRegex("[!/.]"); @@ -37,6 +20,8 @@ ModerationAction::ModerationAction(const QString &action) if (timeoutMatch.hasMatch()) { + this->type_ = Type::Timeout; + // if (multipleTimeouts > 1) { // QString line1; // QString line2; @@ -99,24 +84,19 @@ ModerationAction::ModerationAction(const QString &action) } this->line2_ = "w"; } - - // line1 = this->line1_; - // line2 = this->line2_; - // } else { - // this->_moderationActions.emplace_back(getResources().buttonTimeout, - // str); - // } } else if (action.startsWith("/ban ")) { - this->imageToLoad_ = 1; + this->type_ = Type::Ban; } else if (action.startsWith("/delete ")) { - this->imageToLoad_ = 2; + this->type_ = Type::Delete; } else { + this->type_ = Type::Custom; + QString xD = action; xD.replace(replaceRegex, ""); @@ -124,6 +104,11 @@ ModerationAction::ModerationAction(const QString &action) this->line1_ = xD.mid(0, 2); this->line2_ = xD.mid(2, 2); } + + if (iconPath.isValid()) + { + this->iconPath_ = iconPath; + } } bool ModerationAction::operator==(const ModerationAction &other) const @@ -139,19 +124,23 @@ bool ModerationAction::isImage() const const std::optional &ModerationAction::getImage() const { assertInGuiThread(); + if (this->image_.has_value()) + { + return this->image_; + } - if (this->imageToLoad_ != 0) + if (this->iconPath_.isValid()) { - if (this->imageToLoad_ == 1) - { - this->image_ = - Image::fromResourcePixmap(getResources().buttons.ban); - } - else if (this->imageToLoad_ == 2) - { - this->image_ = - Image::fromResourcePixmap(getResources().buttons.trashCan); - } + this->image_ = Image::fromUrl({this->iconPath_.toString()}); + } + else if (this->type_ == Type::Ban) + { + this->image_ = Image::fromResourcePixmap(getResources().buttons.ban); + } + else if (this->type_ == Type::Delete) + { + this->image_ = + Image::fromResourcePixmap(getResources().buttons.trashCan); } return this->image_; @@ -172,4 +161,14 @@ const QString &ModerationAction::getAction() const return this->action_; } +const QUrl &ModerationAction::iconPath() const +{ + return this->iconPath_; +} + +ModerationAction::Type ModerationAction::getType() const +{ + return this->type_; +} + } // namespace chatterino diff --git a/src/controllers/moderationactions/ModerationAction.hpp b/src/controllers/moderationactions/ModerationAction.hpp index 8fa4c9be8a2..643eaf06d62 100644 --- a/src/controllers/moderationactions/ModerationAction.hpp +++ b/src/controllers/moderationactions/ModerationAction.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -16,7 +17,32 @@ using ImagePtr = std::shared_ptr; class ModerationAction { public: - ModerationAction(const QString &action); + /** + * Type of the action, parsed from the input `action` + */ + enum class Type { + /** + * /ban + */ + Ban, + + /** + * /delete + */ + Delete, + + /** + * /timeout + */ + Timeout, + + /** + * Anything not matching the action types above + */ + Custom, + }; + + ModerationAction(const QString &action, const QUrl &iconPath = {}); bool operator==(const ModerationAction &other) const; @@ -25,13 +51,18 @@ class ModerationAction const QString &getLine1() const; const QString &getLine2() const; const QString &getAction() const; + const QUrl &iconPath() const; + Type getType() const; private: mutable std::optional image_; QString line1_; QString line2_; QString action_; - int imageToLoad_{}; + + Type type_{}; + + QUrl iconPath_; }; } // namespace chatterino @@ -46,6 +77,7 @@ struct Serialize { rapidjson::Value ret(rapidjson::kObjectType); chatterino::rj::set(ret, "pattern", value.getAction(), a); + chatterino::rj::set(ret, "icon", value.iconPath().toString(), a); return ret; } @@ -63,10 +95,12 @@ struct Deserialize { } QString pattern; - chatterino::rj::getSafe(value, "pattern", pattern); - return chatterino::ModerationAction(pattern); + QString icon; + chatterino::rj::getSafe(value, "icon", icon); + + return chatterino::ModerationAction(pattern, QUrl(icon)); } }; diff --git a/src/controllers/moderationactions/ModerationActionModel.cpp b/src/controllers/moderationactions/ModerationActionModel.cpp index d6595556d51..f7160b5896a 100644 --- a/src/controllers/moderationactions/ModerationActionModel.cpp +++ b/src/controllers/moderationactions/ModerationActionModel.cpp @@ -1,13 +1,19 @@ #include "controllers/moderationactions/ModerationActionModel.hpp" #include "controllers/moderationactions/ModerationAction.hpp" +#include "messages/Image.hpp" +#include "util/LoadPixmap.hpp" +#include "util/PostToThread.hpp" #include "util/StandardItemHelper.hpp" +#include +#include + namespace chatterino { // commandmodel ModerationActionModel ::ModerationActionModel(QObject *parent) - : SignalVectorModel(1, parent) + : SignalVectorModel(2, parent) { } @@ -15,14 +21,31 @@ ModerationActionModel ::ModerationActionModel(QObject *parent) ModerationAction ModerationActionModel::getItemFromRow( std::vector &row, const ModerationAction &original) { - return ModerationAction(row[0]->data(Qt::DisplayRole).toString()); + return ModerationAction( + row[Column::Command]->data(Qt::DisplayRole).toString(), + row[Column::Icon]->data(Qt::UserRole).toString()); } // turns a row in the model into a vector item void ModerationActionModel::getRowFromItem(const ModerationAction &item, std::vector &row) { - setStringItem(row[0], item.getAction()); + setStringItem(row[Column::Command], item.getAction()); + setFilePathItem(row[Column::Icon], item.iconPath()); + if (!item.iconPath().isEmpty()) + { + auto oImage = item.getImage(); + assert(oImage.has_value()); + if (oImage.has_value()) + { + auto url = oImage->get()->url(); + loadPixmapFromUrl(url, [row](const QPixmap &pixmap) { + postToThread([row, pixmap]() { + row[Column::Icon]->setData(pixmap, Qt::DecorationRole); + }); + }); + } + } } } // namespace chatterino diff --git a/src/controllers/moderationactions/ModerationActionModel.hpp b/src/controllers/moderationactions/ModerationActionModel.hpp index e8e51db037c..3382b437803 100644 --- a/src/controllers/moderationactions/ModerationActionModel.hpp +++ b/src/controllers/moderationactions/ModerationActionModel.hpp @@ -13,6 +13,11 @@ class ModerationActionModel : public SignalVectorModel public: explicit ModerationActionModel(QObject *parent); + enum Column { + Command = 0, + Icon = 1, + }; + protected: // turn a vector item into a model row ModerationAction getItemFromRow(std::vector &row, diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index 745cf383e24..cfab7242fb3 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -124,7 +124,7 @@ void NotificationController::fetchFakeChannels() for (std::vector::size_type i = 0; i < channelMap[Platform::Twitch].raw().size(); i++) { - auto chan = getApp()->twitch->getChannelOrEmpty( + auto chan = getIApp()->getTwitchAbstract()->getChannelOrEmpty( channelMap[Platform::Twitch].raw()[i]); if (chan->isEmpty()) { @@ -202,7 +202,7 @@ void NotificationController::checkStream(bool live, QString channelName) } MessageBuilder builder; TwitchMessageBuilder::liveMessage(channelName, &builder); - getApp()->twitch->liveChannel->addMessage(builder.release()); + getIApp()->getTwitch()->getLiveChannel()->addMessage(builder.release()); // Indicate that we have pushed notifications for this stream fakeTwitchChannels.push_back(channelName); @@ -217,7 +217,7 @@ void NotificationController::removeFakeChannel(const QString channelName) fakeTwitchChannels.erase(it); // "delete" old 'CHANNEL is live' message LimitedQueueSnapshot snapshot = - getApp()->twitch->liveChannel->getMessageSnapshot(); + getIApp()->getTwitch()->getLiveChannel()->getMessageSnapshot(); int snapshotLength = snapshot.size(); // MSVC hates this code if the parens are not there diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index df042b24f5c..dd3ee83062b 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -5,6 +5,8 @@ extern "C" { # include } +# include "controllers/plugins/LuaUtilities.hpp" + # include # include @@ -37,7 +39,7 @@ enum class EventType { /** * @lua@class CommandContext * @lua@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. - * @lua@field channel Channel The channel the command was executed in. + * @lua@field channel c2.Channel The channel the command was executed in. */ /** @@ -55,9 +57,34 @@ struct CompletionList { bool hideOthers{}; }; +/** + * @lua@class CompletionEvent + */ +struct CompletionEvent { + /** + * @lua@field query string The word being completed + */ + QString query; + /** + * @lua@field full_text_content string Content of the text input + */ + QString full_text_content; + /** + * @lua@field cursor_position integer Position of the cursor in the text input in unicode codepoints (not bytes) + */ + int cursor_position{}; + /** + * @lua@field is_first_word boolean True if this is the first word in the input + */ + bool is_first_word{}; +}; + /** * @includefile common/Channel.hpp * @includefile controllers/plugins/api/ChannelRef.hpp + * @includefile controllers/plugins/api/HTTPRequest.hpp + * @includefile controllers/plugins/api/HTTPResponse.hpp + * @includefile common/network/NetworkCommon.hpp */ /** @@ -74,7 +101,7 @@ int c2_register_command(lua_State *L); * Registers a callback to be invoked when completions for a term are requested. * * @lua@param type "CompletionRequested" - * @lua@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked. + * @lua@param func fun(event: CompletionEvent): CompletionList The callback to be invoked. * @exposed c2.register_callback */ int c2_register_callback(lua_State *L); @@ -82,7 +109,7 @@ int c2_register_callback(lua_State *L); /** * Writes a message to the Chatterino log. * - * @lua@param level LogLevel The desired level. + * @lua@param level c2.LogLevel The desired level. * @lua@param ... any Values to log. Should be convertible to a string with `tostring()`. * @exposed c2.log */ @@ -109,7 +136,11 @@ int searcherRelative(lua_State *L); // This is a fat pointer that allows us to type check values given to functions needing a userdata. // Ensure ALL userdata given to Lua are a subclass of this! Otherwise we garbage as a pointer! struct UserData { - enum class Type { Channel }; + enum class Type { + Channel, + HTTPRequest, + HTTPResponse, + }; Type type; bool isWeak; }; diff --git a/src/controllers/plugins/LuaUtilities.cpp b/src/controllers/plugins/LuaUtilities.cpp index 9361cd1ff3e..64af18c0133 100644 --- a/src/controllers/plugins/LuaUtilities.cpp +++ b/src/controllers/plugins/LuaUtilities.cpp @@ -142,6 +142,20 @@ StackIdx push(lua_State *L, const int &b) return lua_gettop(L); } +StackIdx push(lua_State *L, const api::CompletionEvent &ev) +{ + auto idx = pushEmptyTable(L, 4); +# define PUSH(field) \ + lua::push(L, ev.field); \ + lua_setfield(L, idx, #field) + PUSH(query); + PUSH(full_text_content); + PUSH(cursor_position); + PUSH(is_first_word); +# undef PUSH + return idx; +} + bool peek(lua_State *L, int *out, StackIdx idx) { StackGuard guard(L); diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index 4c78d6edc9a..5443a751f79 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -28,6 +28,7 @@ namespace chatterino::lua { namespace api { struct CompletionList; + struct CompletionEvent; } // namespace api constexpr int ERROR_BAD_PEEK = LUA_OK - 1; @@ -66,6 +67,7 @@ StackIdx push(lua_State *L, const QString &str); StackIdx push(lua_State *L, const std::string &str); StackIdx push(lua_State *L, const bool &b); StackIdx push(lua_State *L, const int &b); +StackIdx push(lua_State *L, const api::CompletionEvent &ev); // returns OK? bool peek(lua_State *L, int *out, StackIdx idx = -1); diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp index 4609fee7c17..4a8f89f0912 100644 --- a/src/controllers/plugins/Plugin.cpp +++ b/src/controllers/plugins/Plugin.cpp @@ -1,8 +1,10 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/Plugin.hpp" +# include "common/network/NetworkCommon.hpp" # include "common/QLogging.hpp" # include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/PluginPermission.hpp" # include "util/QMagicEnum.hpp" extern "C" { @@ -11,6 +13,8 @@ extern "C" { # include # include # include +# include +# include # include # include @@ -258,16 +262,25 @@ bool Plugin::hasFSPermissionFor(bool write, const QString &path) using PType = PluginPermission::Type; auto typ = write ? PType::FilesystemWrite : PType::FilesystemRead; - // XXX: Older compilers don't have support for std::ranges - // NOLINTNEXTLINE(readability-use-anyofallof) - for (const auto &p : this->meta.permissions) + return std::ranges::any_of(this->meta.permissions, [=](const auto &p) { + return p.type == typ; + }); +} + +bool Plugin::hasHTTPPermissionFor(const QUrl &url) +{ + auto proto = url.scheme(); + if (proto != "http" && proto != "https") { - if (p.type == typ) - { - return true; - } + qCWarning(chatterinoLua).nospace() + << "Plugin " << this->id << " (" << this->meta.name + << ") is trying to use a non-http protocol"; + return false; } - return false; + + return std::ranges::any_of(this->meta.permissions, [](const auto &p) { + return p.type == PluginPermission::Type::Network; + }); } } // namespace chatterino diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp index 4450b2a0192..f8375247f5d 100644 --- a/src/controllers/plugins/Plugin.hpp +++ b/src/controllers/plugins/Plugin.hpp @@ -2,12 +2,14 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "Application.hpp" +# include "common/network/NetworkCommon.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/PluginPermission.hpp" # include # include +# include # include # include @@ -98,8 +100,8 @@ class Plugin // Note: The CallbackFunction object's destructor will remove the function from the lua stack using LuaCompletionCallback = - lua::CallbackFunction; + lua::CallbackFunction; std::optional getCompletionCallback() { if (this->state_ == nullptr || !this->error_.isNull()) @@ -123,7 +125,7 @@ class Plugin // move return std::make_optional>( + lua::api::CompletionList, lua::api::CompletionEvent>>( this->state_, lua_gettop(this->state_)); } @@ -139,6 +141,7 @@ class Plugin void removeTimeout(QTimer *timer); bool hasFSPermissionFor(bool write, const QString &path); + bool hasHTTPPermissionFor(const QUrl &url); private: QDir loadDirectory_; diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 0f23df3430d..2fa2865f4eb 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -3,10 +3,13 @@ # include "Application.hpp" # include "common/Args.hpp" +# include "common/network/NetworkCommon.hpp" # include "common/QLogging.hpp" # include "controllers/commands/CommandContext.hpp" # include "controllers/commands/CommandController.hpp" # include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/api/HTTPRequest.hpp" +# include "controllers/plugins/api/HTTPResponse.hpp" # include "controllers/plugins/api/IOWrapper.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" @@ -174,10 +177,19 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, lua::pushEnumTable(L); lua_setfield(L, c2libIdx, "ChannelType"); + lua::pushEnumTable(L); + lua_setfield(L, c2libIdx, "HTTPMethod"); + // Initialize metatables for objects lua::api::ChannelRef::createMetatable(L); lua_setfield(L, c2libIdx, "Channel"); + lua::api::HTTPRequest::createMetatable(L); + lua_setfield(L, c2libIdx, "HTTPRequest"); + + lua::api::HTTPResponse::createMetatable(L); + lua_setfield(L, c2libIdx, "HTTPResponse"); + lua_setfield(L, gtable, "c2"); // ban functions @@ -420,7 +432,7 @@ std::pair PluginController::updateCustomCompletions( for (const auto &[name, pl] : this->plugins()) { - if (!pl->error().isNull()) + if (!pl->error().isNull() || pl->state_ == nullptr) { continue; } @@ -433,8 +445,12 @@ std::pair PluginController::updateCustomCompletions( qCDebug(chatterinoLua) << "Processing custom completions from plugin" << name; auto &cb = *opt; - auto errOrList = - cb(query, fullTextContent, cursorPosition, isFirstWord); + auto errOrList = cb(lua::api::CompletionEvent{ + .query = query, + .full_text_content = fullTextContent, + .cursor_position = cursorPosition, + .is_first_word = isFirstWord, + }); if (std::holds_alternative(errOrList)) { guard.handled(); diff --git a/src/controllers/plugins/PluginPermission.cpp b/src/controllers/plugins/PluginPermission.cpp index 09204f93df4..9a4bc925e9c 100644 --- a/src/controllers/plugins/PluginPermission.cpp +++ b/src/controllers/plugins/PluginPermission.cpp @@ -37,6 +37,8 @@ QString PluginPermission::toHtml() const return "Read files in its data directory"; case PluginPermission::Type::FilesystemWrite: return "Write to or create files in its data directory"; + case PluginPermission::Type::Network: + return "Make requests over the internet to third party websites"; default: assert(false && "invalid PluginPermission type in toHtml()"); return "shut up compiler, this never happens"; diff --git a/src/controllers/plugins/PluginPermission.hpp b/src/controllers/plugins/PluginPermission.hpp index 5867b7b63d6..ffa728e7b43 100644 --- a/src/controllers/plugins/PluginPermission.hpp +++ b/src/controllers/plugins/PluginPermission.hpp @@ -14,6 +14,7 @@ struct PluginPermission { enum class Type { FilesystemRead, FilesystemWrite, + Network, }; Type type; std::vector errors; diff --git a/src/controllers/plugins/api/ChannelRef.cpp b/src/controllers/plugins/api/ChannelRef.cpp index 986fbbac359..1e592d0db1c 100644 --- a/src/controllers/plugins/api/ChannelRef.cpp +++ b/src/controllers/plugins/api/ChannelRef.cpp @@ -300,7 +300,7 @@ int ChannelRef::get_by_name(lua_State *L) lua_pushnil(L); return 1; } - auto chn = getApp()->twitch->getChannelOrEmpty(name); + auto chn = getIApp()->getTwitchAbstract()->getChannelOrEmpty(name); lua::push(L, chn); return 1; } @@ -324,7 +324,7 @@ int ChannelRef::get_by_twitch_id(lua_State *L) lua_pushnil(L); return 1; } - auto chn = getApp()->twitch->getChannelOrEmptyByID(id); + auto chn = getIApp()->getTwitch()->getChannelOrEmptyByID(id); lua::push(L, chn); return 1; diff --git a/src/controllers/plugins/api/ChannelRef.hpp b/src/controllers/plugins/api/ChannelRef.hpp index 29f5173d28e..32e1946abcb 100644 --- a/src/controllers/plugins/api/ChannelRef.hpp +++ b/src/controllers/plugins/api/ChannelRef.hpp @@ -13,7 +13,7 @@ namespace chatterino::lua::api { /** * This enum describes a platform for the purpose of searching for a channel. * Currently only Twitch is supported because identifying IRC channels is tricky. - * @exposeenum Platform + * @exposeenum c2.Platform */ enum class LPlatform { Twitch, @@ -21,7 +21,7 @@ enum class LPlatform { }; /** - * @lua@class Channel: IWeakResource + * @lua@class c2.Channel */ struct ChannelRef { static void createMetatable(lua_State *L); @@ -49,7 +49,7 @@ struct ChannelRef { * If given a non-Channel object, it errors. * * @lua@return boolean success - * @exposed Channel:is_valid + * @exposed c2.Channel:is_valid */ static int is_valid(lua_State *L); @@ -57,15 +57,15 @@ struct ChannelRef { * Gets the channel's name. This is the lowercase login name. * * @lua@return string name - * @exposed Channel:get_name + * @exposed c2.Channel:get_name */ static int get_name(lua_State *L); /** * Gets the channel's type * - * @lua@return ChannelType - * @exposed Channel:get_type + * @lua@return c2.ChannelType + * @exposed c2.Channel:get_type */ static int get_type(lua_State *L); @@ -73,7 +73,7 @@ struct ChannelRef { * Get the channel owner's display name. This may contain non-lowercase ascii characters. * * @lua@return string name - * @exposed Channel:get_display_name + * @exposed c2.Channel:get_display_name */ static int get_display_name(lua_State *L); @@ -83,7 +83,7 @@ struct ChannelRef { * * @lua@param message string * @lua@param execute_commands boolean Should commands be run on the text? - * @exposed Channel:send_message + * @exposed c2.Channel:send_message */ static int send_message(lua_State *L); @@ -91,7 +91,7 @@ struct ChannelRef { * Adds a system message client-side * * @lua@param message string - * @exposed Channel:add_system_message + * @exposed c2.Channel:add_system_message */ static int add_system_message(lua_State *L); @@ -100,8 +100,8 @@ struct ChannelRef { * Compares the channel Type. Note that enum values aren't guaranteed, just * that they are equal to the exposed enum. * - * @lua@return bool - * @exposed Channel:is_twitch_channel + * @lua@return boolean + * @exposed c2.Channel:is_twitch_channel */ static int is_twitch_channel(lua_State *L); @@ -113,7 +113,7 @@ struct ChannelRef { * Returns a copy of the channel mode settings (subscriber only, r9k etc.) * * @lua@return RoomModes - * @exposed Channel:get_room_modes + * @exposed c2.Channel:get_room_modes */ static int get_room_modes(lua_State *L); @@ -121,7 +121,7 @@ struct ChannelRef { * Returns a copy of the stream status. * * @lua@return StreamStatus - * @exposed Channel:get_stream_status + * @exposed c2.Channel:get_stream_status */ static int get_stream_status(lua_State *L); @@ -129,7 +129,7 @@ struct ChannelRef { * Returns the Twitch user ID of the owner of the channel. * * @lua@return string - * @exposed Channel:get_twitch_id + * @exposed c2.Channel:get_twitch_id */ static int get_twitch_id(lua_State *L); @@ -137,7 +137,7 @@ struct ChannelRef { * Returns true if the channel is a Twitch channel and the user owns it * * @lua@return boolean - * @exposed Channel:is_broadcaster + * @exposed c2.Channel:is_broadcaster */ static int is_broadcaster(lua_State *L); @@ -146,7 +146,7 @@ struct ChannelRef { * Returns false for broadcaster. * * @lua@return boolean - * @exposed Channel:is_mod + * @exposed c2.Channel:is_mod */ static int is_mod(lua_State *L); @@ -155,7 +155,7 @@ struct ChannelRef { * Returns false for broadcaster. * * @lua@return boolean - * @exposed Channel:is_vip + * @exposed c2.Channel:is_vip */ static int is_vip(lua_State *L); @@ -165,7 +165,7 @@ struct ChannelRef { /** * @lua@return string - * @exposed Channel:__tostring + * @exposed c2.Channel:__tostring */ static int to_string(lua_State *L); @@ -184,18 +184,18 @@ struct ChannelRef { * - /automod * * @lua@param name string Which channel are you looking for? - * @lua@param platform Platform Where to search for the channel? - * @lua@return Channel? - * @exposed Channel.by_name + * @lua@param platform c2.Platform Where to search for the channel? + * @lua@return c2.Channel? + * @exposed c2.Channel.by_name */ static int get_by_name(lua_State *L); /** * Finds a channel by the Twitch user ID of its owner. * - * @lua@param string id ID of the owner of the channel. - * @lua@return Channel? - * @exposed Channel.by_twitch_id + * @lua@param id string ID of the owner of the channel. + * @lua@return c2.Channel? + * @exposed c2.Channel.by_twitch_id */ static int get_by_twitch_id(lua_State *L); }; @@ -216,13 +216,12 @@ struct LuaRoomModes { bool subscriber_only = false; /** - * @lua@field emotes_only boolean Whether or not text is allowed in messages. - * Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes + * @lua@field emotes_only boolean Whether or not text is allowed in messages. Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes */ bool emotes_only = false; /** - * @lua@field unique_chat number? Time in minutes you need to follow to chat or nil. + * @lua@field follower_only number? Time in minutes you need to follow to chat or nil. */ std::optional follower_only; /** diff --git a/src/controllers/plugins/api/HTTPRequest.cpp b/src/controllers/plugins/api/HTTPRequest.cpp new file mode 100644 index 00000000000..6defabb38d4 --- /dev/null +++ b/src/controllers/plugins/api/HTTPRequest.cpp @@ -0,0 +1,451 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/api/HTTPRequest.hpp" + +# include "Application.hpp" +# include "common/network/NetworkCommon.hpp" +# include "common/network/NetworkRequest.hpp" +# include "common/network/NetworkResult.hpp" +# include "controllers/plugins/api/HTTPResponse.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "util/DebugCount.hpp" + +extern "C" { +# include +# include +} +# include +# include + +# include +# include + +namespace chatterino::lua::api { +// NOLINTBEGIN(*vararg) +// NOLINTNEXTLINE(*-avoid-c-arrays) +static const luaL_Reg HTTP_REQUEST_METHODS[] = { + {"on_success", &HTTPRequest::on_success_wrap}, + {"on_error", &HTTPRequest::on_error_wrap}, + {"finally", &HTTPRequest::finally_wrap}, + + {"execute", &HTTPRequest::execute_wrap}, + {"set_timeout", &HTTPRequest::set_timeout_wrap}, + {"set_payload", &HTTPRequest::set_payload_wrap}, + {"set_header", &HTTPRequest::set_header_wrap}, + // static + {"create", &HTTPRequest::create}, + {nullptr, nullptr}, +}; + +std::shared_ptr HTTPRequest::getOrError(lua_State *L, + StackIdx where) +{ + if (lua_gettop(L) < 1) + { + // The nullptr is there just to appease the compiler, luaL_error is no return + luaL_error(L, "Called c2.HTTPRequest method without a request object"); + return nullptr; + } + if (lua_isuserdata(L, where) == 0) + { + luaL_error( + L, + "Called c2.HTTPRequest method with a non-userdata 'self' argument"); + return nullptr; + } + // luaL_checkudata is no-return if check fails + auto *checked = luaL_checkudata(L, where, "c2.HTTPRequest"); + auto *data = + SharedPtrUserData::from( + checked); + if (data == nullptr) + { + luaL_error(L, "Called c2.HTTPRequest method with an invalid pointer"); + return nullptr; + } + lua_remove(L, where); + if (data->target == nullptr) + { + luaL_error( + L, "Internal error: SharedPtrUserData::target was null. This is a Chatterino bug!"); + return nullptr; + } + if (data->target->done) + { + luaL_error(L, "This c2.HTTPRequest has already been executed!"); + return nullptr; + } + return data->target; +} + +void HTTPRequest::createMetatable(lua_State *L) +{ + lua::StackGuard guard(L, 1); + + luaL_newmetatable(L, "c2.HTTPRequest"); + lua_pushstring(L, "__index"); + lua_pushvalue(L, -2); // clone metatable + lua_settable(L, -3); // metatable.__index = metatable + + // Generic ISharedResource stuff + lua_pushstring(L, "__gc"); + lua_pushcfunction(L, (&SharedPtrUserData::destroy)); + lua_settable(L, -3); // metatable.__gc = SharedPtrUserData<...>::destroy + + luaL_setfuncs(L, HTTP_REQUEST_METHODS, 0); +} + +int HTTPRequest::on_success_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->on_success(L); +} + +int HTTPRequest::on_success(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error( + L, "HTTPRequest:on_success needs 1 argument (a callback " + "that takes an HTTPResult and doesn't return anything)"); + } + if (!lua_isfunction(L, top)) + { + return luaL_error( + L, "HTTPRequest:on_success needs 1 argument (a callback " + "that takes an HTTPResult and doesn't return anything)"); + } + auto shared = this->pushPrivate(L); + lua_pushvalue(L, -2); + lua_setfield(L, shared, "success"); // this deletes the function copy + lua_pop(L, 2); // delete the table and function original + return 0; +} + +int HTTPRequest::on_error_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->on_error(L); +} + +int HTTPRequest::on_error(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error( + L, "HTTPRequest:on_error needs 1 argument (a callback " + "that takes an HTTPResult and doesn't return anything)"); + } + if (!lua_isfunction(L, top)) + { + return luaL_error( + L, "HTTPRequest:on_error needs 1 argument (a callback " + "that takes an HTTPResult and doesn't return anything)"); + } + auto shared = this->pushPrivate(L); + lua_pushvalue(L, -2); + lua_setfield(L, shared, "error"); // this deletes the function copy + lua_pop(L, 2); // delete the table and function original + return 0; +} + +int HTTPRequest::set_timeout_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->set_timeout(L); +} + +int HTTPRequest::set_timeout(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error( + L, "HTTPRequest:set_timeout needs 1 argument (a number of " + "milliseconds after which the request will time out)"); + } + + int temporary = -1; + if (!lua::pop(L, &temporary)) + { + return luaL_error( + L, "HTTPRequest:set_timeout failed to get timeout, expected a " + "positive integer"); + } + if (temporary <= 0) + { + return luaL_error( + L, "HTTPRequest:set_timeout failed to get timeout, expected a " + "positive integer"); + } + this->timeout_ = temporary; + return 0; +} + +int HTTPRequest::finally_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->finally(L); +} + +int HTTPRequest::finally(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error(L, "HTTPRequest:finally needs 1 argument (a callback " + "that takes nothing and doesn't return anything)"); + } + if (!lua_isfunction(L, top)) + { + return luaL_error(L, "HTTPRequest:finally needs 1 argument (a callback " + "that takes nothing and doesn't return anything)"); + } + auto shared = this->pushPrivate(L); + lua_pushvalue(L, -2); + lua_setfield(L, shared, "finally"); // this deletes the function copy + lua_pop(L, 2); // delete the table and function original + return 0; +} + +int HTTPRequest::set_payload_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->set_payload(L); +} + +int HTTPRequest::set_payload(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error( + L, "HTTPRequest:set_payload needs 1 argument (a string payload)"); + } + + std::string temporary; + if (!lua::pop(L, &temporary)) + { + return luaL_error( + L, "HTTPRequest:set_payload failed to get payload, expected a " + "string"); + } + this->req_ = + std::move(this->req_).payload(QByteArray::fromStdString(temporary)); + return 0; +} + +int HTTPRequest::set_header_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -3); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->set_header(L); +} + +int HTTPRequest::set_header(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 2) + { + return luaL_error( + L, "HTTPRequest:set_header needs 2 arguments (a header name " + "and a value)"); + } + + std::string value; + if (!lua::pop(L, &value)) + { + return luaL_error( + L, "cannot get value (2nd argument of HTTPRequest:set_header)"); + } + std::string name; + if (!lua::pop(L, &name)) + { + return luaL_error( + L, "cannot get name (1st argument of HTTPRequest:set_header)"); + } + this->req_ = std::move(this->req_) + .header(QByteArray::fromStdString(name), + QByteArray::fromStdString(value)); + return 0; +} + +int HTTPRequest::create(lua_State *L) +{ + lua::StackGuard guard(L, -1); + if (lua_gettop(L) != 2) + { + return luaL_error( + L, "HTTPRequest.create needs exactly 2 arguments (method " + "and url)"); + } + QString url; + if (!lua::pop(L, &url)) + { + return luaL_error(L, + "cannot get url (2nd argument of HTTPRequest.create, " + "expected a string)"); + } + auto parsedurl = QUrl(url); + if (!parsedurl.isValid()) + { + return luaL_error( + L, "cannot parse url (2nd argument of HTTPRequest.create, " + "got invalid url in argument)"); + } + NetworkRequestType method{}; + if (!lua::pop(L, &method)) + { + return luaL_error( + L, "cannot get method (1st argument of HTTPRequest.create, " + "expected a string)"); + } + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + if (!pl->hasHTTPPermissionFor(parsedurl)) + { + return luaL_error( + L, "Plugin does not have permission to send HTTP requests " + "to this URL"); + } + NetworkRequest r(parsedurl, method); + lua::push( + L, std::make_shared(ConstructorAccessTag{}, std::move(r))); + return 1; +} + +int HTTPRequest::execute_wrap(lua_State *L) +{ + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->execute(L); +} + +int HTTPRequest::execute(lua_State *L) +{ + auto shared = this->shared_from_this(); + this->done = true; + std::move(this->req_) + .onSuccess([shared, L](const NetworkResult &res) { + lua::StackGuard guard(L); + auto *thread = lua_newthread(L); + + auto priv = shared->pushPrivate(thread); + lua_getfield(thread, priv, "success"); + auto cb = lua_gettop(thread); + if (lua_isfunction(thread, cb)) + { + lua::push(thread, std::make_shared(res)); + // one arg, no return, no msgh + lua_pcall(thread, 1, 0, 0); + } + else + { + lua_pop(thread, 1); // remove callback + } + lua_closethread(thread, nullptr); + lua_pop(L, 1); // remove thread from L + }) + .onError([shared, L](const NetworkResult &res) { + lua::StackGuard guard(L); + auto *thread = lua_newthread(L); + + auto priv = shared->pushPrivate(thread); + lua_getfield(thread, priv, "error"); + auto cb = lua_gettop(thread); + if (lua_isfunction(thread, cb)) + { + lua::push(thread, std::make_shared(res)); + // one arg, no return, no msgh + lua_pcall(thread, 1, 0, 0); + } + else + { + lua_pop(thread, 1); // remove callback + } + lua_closethread(thread, nullptr); + lua_pop(L, 1); // remove thread from L + }) + .finally([shared, L]() { + lua::StackGuard guard(L); + auto *thread = lua_newthread(L); + + auto priv = shared->pushPrivate(thread); + lua_getfield(thread, priv, "finally"); + auto cb = lua_gettop(thread); + if (lua_isfunction(thread, cb)) + { + // no args, no return, no msgh + lua_pcall(thread, 0, 0, 0); + } + else + { + lua_pop(thread, 1); // remove callback + } + // remove our private data + lua_pushnil(thread); + lua_setfield(thread, LUA_REGISTRYINDEX, + shared->privateKey.toStdString().c_str()); + lua_closethread(thread, nullptr); + lua_pop(L, 1); // remove thread from L + + // we removed our private table, forget the key for it + shared->privateKey = QString(); + }) + .timeout(this->timeout_) + .execute(); + return 0; +} + +HTTPRequest::HTTPRequest(HTTPRequest::ConstructorAccessTag /*ignored*/, + NetworkRequest req) + : req_(std::move(req)) +{ + DebugCount::increase("lua::api::HTTPRequest"); +} + +HTTPRequest::~HTTPRequest() +{ + DebugCount::decrease("lua::api::HTTPRequest"); + // We might leak a Lua function or two here if the request isn't executed + // but that's better than accessing a possibly invalid lua_State pointer. +} + +StackIdx HTTPRequest::pushPrivate(lua_State *L) +{ + if (this->privateKey.isEmpty()) + { + this->privateKey = QString("HTTPRequestPrivate%1") + .arg(QRandomGenerator::system()->generate()); + pushEmptyTable(L, 4); + lua_setfield(L, LUA_REGISTRYINDEX, + this->privateKey.toStdString().c_str()); + } + lua_getfield(L, LUA_REGISTRYINDEX, this->privateKey.toStdString().c_str()); + return lua_gettop(L); +} + +// NOLINTEND(*vararg) +} // namespace chatterino::lua::api + +namespace chatterino::lua { + +StackIdx push(lua_State *L, std::shared_ptr request) +{ + using namespace chatterino::lua::api; + + SharedPtrUserData::create( + L, std::move(request)); + luaL_getmetatable(L, "c2.HTTPRequest"); + lua_setmetatable(L, -2); + return lua_gettop(L); +} +} // namespace chatterino::lua +#endif diff --git a/src/controllers/plugins/api/HTTPRequest.hpp b/src/controllers/plugins/api/HTTPRequest.hpp new file mode 100644 index 00000000000..c373dcec14b --- /dev/null +++ b/src/controllers/plugins/api/HTTPRequest.hpp @@ -0,0 +1,162 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include "common/network/NetworkRequest.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginController.hpp" + +# include + +namespace chatterino::lua::api { +// NOLINTBEGIN(readability-identifier-naming) + +/** + * @lua@class HTTPResponse + * @lua@field data string Data received from the server + * @lua@field status integer? HTTP Status code returned by the server + * @lua@field error string A somewhat human readable description of an error if such happened + */ + +/** + * @lua@alias HTTPCallback fun(result: HTTPResponse): nil + */ + +/** + * @lua@class HTTPRequest + */ +class HTTPRequest : public std::enable_shared_from_this +{ + // This type is private to prevent the accidental construction of HTTPRequest without a shared pointer + struct ConstructorAccessTag { + }; + +public: + HTTPRequest(HTTPRequest::ConstructorAccessTag, NetworkRequest req); + HTTPRequest(HTTPRequest &&other) = default; + HTTPRequest &operator=(HTTPRequest &&) = default; + HTTPRequest &operator=(HTTPRequest &) = delete; + HTTPRequest(const HTTPRequest &other) = delete; + ~HTTPRequest(); + +private: + NetworkRequest req_; + + static void createMetatable(lua_State *L); + friend class chatterino::PluginController; + + /** + * @brief Get the content of the top object on Lua stack, usually the first argument as an HTTPRequest + * + * If the object given is not a userdatum or the pointer inside that + * userdatum doesn't point to a HTTPRequest, a lua error is thrown. + * + * This function always returns a non-null pointer. + */ + static std::shared_ptr getOrError(lua_State *L, + StackIdx where = -1); + /** + * Pushes the private table onto the lua stack. + * + * This might create it if it doesn't exist. + */ + StackIdx pushPrivate(lua_State *L); + + // This is the key in the registry the private table it held at (if it exists) + // This might be a null QString if the request has already been executed or + // the table wasn't created yet. + QString privateKey; + int timeout_ = 10'000; + bool done = false; + +public: + // These functions are wrapped so data can be accessed more easily. When a call from Lua comes in: + // - the static wrapper function is called + // - it calls getOrError + // - and then the wrapped method + + /** + * Sets the success callback + * + * @lua@param callback HTTPCallback Function to call when the HTTP request succeeds + * @exposed HTTPRequest:on_success + */ + static int on_success_wrap(lua_State *L); + int on_success(lua_State *L); + + /** + * Sets the failure callback + * + * @lua@param callback HTTPCallback Function to call when the HTTP request fails or returns a non-ok status + * @exposed HTTPRequest:on_error + */ + static int on_error_wrap(lua_State *L); + int on_error(lua_State *L); + + /** + * Sets the finally callback + * + * @lua@param callback fun(): nil Function to call when the HTTP request finishes + * @exposed HTTPRequest:finally + */ + static int finally_wrap(lua_State *L); + int finally(lua_State *L); + + /** + * Sets the timeout + * + * @lua@param timeout integer How long in milliseconds until the times out + * @exposed HTTPRequest:set_timeout + */ + static int set_timeout_wrap(lua_State *L); + int set_timeout(lua_State *L); + + /** + * Sets the request payload + * + * @lua@param data string + * @exposed HTTPRequest:set_payload + */ + static int set_payload_wrap(lua_State *L); + int set_payload(lua_State *L); + + /** + * Sets a header in the request + * + * @lua@param name string + * @lua@param value string + * @exposed HTTPRequest:set_header + */ + static int set_header_wrap(lua_State *L); + int set_header(lua_State *L); + + /** + * Executes the HTTP request + * + * @exposed HTTPRequest:execute + */ + static int execute_wrap(lua_State *L); + int execute(lua_State *L); + + /** + * Static functions + */ + + /** + * Creates a new HTTPRequest + * + * @lua@param method HTTPMethod Method to use + * @lua@param url string Where to send the request to + * + * @lua@return HTTPRequest + * @exposed HTTPRequest.create + */ + static int create(lua_State *L); +}; + +// NOLINTEND(readability-identifier-naming) +} // namespace chatterino::lua::api + +namespace chatterino::lua { +StackIdx push(lua_State *L, std::shared_ptr request); +} // namespace chatterino::lua + +#endif diff --git a/src/controllers/plugins/api/HTTPResponse.cpp b/src/controllers/plugins/api/HTTPResponse.cpp new file mode 100644 index 00000000000..f6d6ea1dfe4 --- /dev/null +++ b/src/controllers/plugins/api/HTTPResponse.cpp @@ -0,0 +1,144 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/api/HTTPResponse.hpp" + +# include "common/network/NetworkResult.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "util/DebugCount.hpp" + +extern "C" { +# include +} +# include + +namespace chatterino::lua::api { +// NOLINTBEGIN(*vararg) +// NOLINTNEXTLINE(*-avoid-c-arrays) +static const luaL_Reg HTTP_RESPONSE_METHODS[] = { + {"data", &HTTPResponse::data_wrap}, + {"status", &HTTPResponse::status_wrap}, + {"error", &HTTPResponse::error_wrap}, + {nullptr, nullptr}, +}; + +void HTTPResponse::createMetatable(lua_State *L) +{ + lua::StackGuard guard(L, 1); + + luaL_newmetatable(L, "c2.HTTPResponse"); + lua_pushstring(L, "__index"); + lua_pushvalue(L, -2); // clone metatable + lua_settable(L, -3); // metatable.__index = metatable + + // Generic ISharedResource stuff + lua_pushstring(L, "__gc"); + lua_pushcfunction(L, (&SharedPtrUserData::destroy)); + lua_settable(L, -3); // metatable.__gc = SharedPtrUserData<...>::destroy + + luaL_setfuncs(L, HTTP_RESPONSE_METHODS, 0); +} + +std::shared_ptr HTTPResponse::getOrError(lua_State *L, + StackIdx where) +{ + if (lua_gettop(L) < 1) + { + // The nullptr is there just to appease the compiler, luaL_error is no return + luaL_error(L, "Called c2.HTTPResponse method without a request object"); + return nullptr; + } + if (lua_isuserdata(L, where) == 0) + { + luaL_error(L, "Called c2.HTTPResponse method with a non-userdata " + "'self' argument"); + return nullptr; + } + // luaL_checkudata is no-return if check fails + auto *checked = luaL_checkudata(L, where, "c2.HTTPResponse"); + auto *data = + SharedPtrUserData::from( + checked); + if (data == nullptr) + { + luaL_error(L, "Called c2.HTTPResponse method with an invalid pointer"); + return nullptr; + } + lua_remove(L, where); + if (data->target == nullptr) + { + luaL_error( + L, + "Internal error: SharedPtrUserData::target was null. This is a Chatterino bug!"); + return nullptr; + } + return data->target; +} + +HTTPResponse::HTTPResponse(NetworkResult res) + : result_(std::move(res)) +{ + DebugCount::increase("lua::api::HTTPResponse"); +} +HTTPResponse::~HTTPResponse() +{ + DebugCount::decrease("lua::api::HTTPResponse"); +} + +int HTTPResponse::data_wrap(lua_State *L) +{ + lua::StackGuard guard(L, 0); // 1 in, 1 out + auto ptr = HTTPResponse::getOrError(L, 1); + return ptr->data(L); +} + +int HTTPResponse::data(lua_State *L) +{ + lua::push(L, this->result_.getData().toStdString()); + return 1; +} + +int HTTPResponse::status_wrap(lua_State *L) +{ + lua::StackGuard guard(L, 0); // 1 in, 1 out + auto ptr = HTTPResponse::getOrError(L, 1); + return ptr->status(L); +} + +int HTTPResponse::status(lua_State *L) +{ + lua::push(L, this->result_.status()); + return 1; +} + +int HTTPResponse::error_wrap(lua_State *L) +{ + lua::StackGuard guard(L, 0); // 1 in, 1 out + auto ptr = HTTPResponse::getOrError(L, 1); + return ptr->error(L); +} + +int HTTPResponse::error(lua_State *L) +{ + lua::push(L, this->result_.formatError()); + return 1; +} + +// NOLINTEND(*vararg) +} // namespace chatterino::lua::api + +namespace chatterino::lua { +StackIdx push(lua_State *L, std::shared_ptr request) +{ + using namespace chatterino::lua::api; + + // Prepare table + SharedPtrUserData::create( + L, std::move(request)); + luaL_getmetatable(L, "c2.HTTPResponse"); + lua_setmetatable(L, -2); + + return lua_gettop(L); +} +} // namespace chatterino::lua +#endif diff --git a/src/controllers/plugins/api/HTTPResponse.hpp b/src/controllers/plugins/api/HTTPResponse.hpp new file mode 100644 index 00000000000..205aae01e69 --- /dev/null +++ b/src/controllers/plugins/api/HTTPResponse.hpp @@ -0,0 +1,80 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include "common/network/NetworkResult.hpp" +# include "controllers/plugins/LuaUtilities.hpp" + +# include +extern "C" { +# include +} + +namespace chatterino { +class PluginController; +} // namespace chatterino + +namespace chatterino::lua::api { +// NOLINTBEGIN(readability-identifier-naming) + +/** + * @lua@class HTTPResponse + */ +class HTTPResponse : public std::enable_shared_from_this +{ + NetworkResult result_; + +public: + HTTPResponse(NetworkResult res); + HTTPResponse(HTTPResponse &&other) = default; + HTTPResponse &operator=(HTTPResponse &&) = default; + HTTPResponse &operator=(HTTPResponse &) = delete; + HTTPResponse(const HTTPResponse &other) = delete; + ~HTTPResponse(); + +private: + static void createMetatable(lua_State *L); + friend class chatterino::PluginController; + + /** + * @brief Get the content of the top object on Lua stack, usually the first argument as an HTTPResponse + * + * If the object given is not a userdatum or the pointer inside that + * userdatum doesn't point to a HTTPResponse, a lua error is thrown. + * + * This function always returns a non-null pointer. + */ + static std::shared_ptr getOrError(lua_State *L, + StackIdx where = -1); + +public: + /** + * Returns the data. This is not guaranteed to be encoded using any + * particular encoding scheme. It's just the bytes the server returned. + * + * @exposed HTTPResponse:data + */ + static int data_wrap(lua_State *L); + int data(lua_State *L); + + /** + * Returns the status code. + * + * @exposed HTTPResponse:status + */ + static int status_wrap(lua_State *L); + int status(lua_State *L); + + /** + * A somewhat human readable description of an error if such happened + * @exposed HTTPResponse:error + */ + + static int error_wrap(lua_State *L); + int error(lua_State *L); +}; + +// NOLINTEND(readability-identifier-naming) +} // namespace chatterino::lua::api +namespace chatterino::lua { +StackIdx push(lua_State *L, std::shared_ptr request); +} // namespace chatterino::lua +#endif diff --git a/src/controllers/sound/ISoundController.hpp b/src/controllers/sound/ISoundController.hpp index ebf7e3425b6..10e8c6c7332 100644 --- a/src/controllers/sound/ISoundController.hpp +++ b/src/controllers/sound/ISoundController.hpp @@ -1,14 +1,9 @@ #pragma once -#include "common/Singleton.hpp" - #include namespace chatterino { -class Settings; -class Paths; - enum class SoundBackend { Miniaudio, Null, @@ -17,11 +12,11 @@ enum class SoundBackend { /** * @brief Handles sound loading & playback **/ -class ISoundController : public Singleton +class ISoundController { public: ISoundController() = default; - ~ISoundController() override = default; + virtual ~ISoundController() = default; ISoundController(const ISoundController &) = delete; ISoundController(ISoundController &&) = delete; ISoundController &operator=(const ISoundController &) = delete; diff --git a/src/controllers/sound/MiniaudioBackend.cpp b/src/controllers/sound/MiniaudioBackend.cpp index f84ee8991c2..63b7efcf0c5 100644 --- a/src/controllers/sound/MiniaudioBackend.cpp +++ b/src/controllers/sound/MiniaudioBackend.cpp @@ -68,10 +68,13 @@ namespace chatterino { // NUM_SOUNDS specifies how many simultaneous default ping sounds & decoders to create constexpr const auto NUM_SOUNDS = 4; -void MiniaudioBackend::initialize(Settings &settings, const Paths &paths) +MiniaudioBackend::MiniaudioBackend() + : context(std::make_unique()) + , engine(std::make_unique()) + , workGuard(boost::asio::make_work_guard(this->ioContext)) + , sleepTimer(this->ioContext) { - (void)(settings); - (void)(paths); + qCInfo(chatterinoSound) << "Initializing miniaudio sound backend"; boost::asio::post(this->ioContext, [this] { ma_result result{}; @@ -192,15 +195,6 @@ void MiniaudioBackend::initialize(Settings &settings, const Paths &paths) }); } -MiniaudioBackend::MiniaudioBackend() - : context(std::make_unique()) - , engine(std::make_unique()) - , workGuard(boost::asio::make_work_guard(this->ioContext)) - , sleepTimer(this->ioContext) -{ - qCInfo(chatterinoSound) << "Initializing miniaudio sound backend"; -} - MiniaudioBackend::~MiniaudioBackend() { // NOTE: This destructor is never called because the `runGui` function calls _exit before that happens diff --git a/src/controllers/sound/MiniaudioBackend.hpp b/src/controllers/sound/MiniaudioBackend.hpp index 18ef9ed00e3..b8f359c3ce7 100644 --- a/src/controllers/sound/MiniaudioBackend.hpp +++ b/src/controllers/sound/MiniaudioBackend.hpp @@ -25,8 +25,6 @@ namespace chatterino { **/ class MiniaudioBackend : public ISoundController { - void initialize(Settings &settings, const Paths &paths) override; - public: MiniaudioBackend(); ~MiniaudioBackend() override; diff --git a/src/controllers/userdata/UserDataController.cpp b/src/controllers/userdata/UserDataController.cpp index 3f8029cbefa..dc79f16ae22 100644 --- a/src/controllers/userdata/UserDataController.cpp +++ b/src/controllers/userdata/UserDataController.cpp @@ -37,11 +37,6 @@ UserDataController::UserDataController(const Paths &paths) this->users = this->setting.getValue(); } -void UserDataController::save() -{ - this->sm->save(); -} - std::optional UserDataController::getUser(const QString &userID) const { std::shared_lock lock(this->usersMutex); diff --git a/src/controllers/userdata/UserDataController.hpp b/src/controllers/userdata/UserDataController.hpp index 3be1f260adc..49edbde75f6 100644 --- a/src/controllers/userdata/UserDataController.hpp +++ b/src/controllers/userdata/UserDataController.hpp @@ -1,6 +1,5 @@ #pragma once -#include "common/Singleton.hpp" #include "controllers/userdata/UserData.hpp" #include "util/QStringHash.hpp" #include "util/RapidjsonHelpers.hpp" @@ -30,7 +29,7 @@ class IUserDataController const QString &colorString) = 0; }; -class UserDataController : public IUserDataController, public Singleton +class UserDataController : public IUserDataController { public: explicit UserDataController(const Paths &paths); @@ -43,9 +42,6 @@ class UserDataController : public IUserDataController, public Singleton void setUserColor(const QString &userID, const QString &colorString) override; -protected: - void save() override; - private: void update(std::unordered_map &&newUsers); diff --git a/src/main.cpp b/src/main.cpp index ef59af0c529..0e4d4996871 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -26,11 +27,6 @@ using namespace chatterino; int main(int argc, char **argv) { - // TODO: This is a temporary fix (see #4552). -#if defined(Q_OS_WINDOWS) && QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - qputenv("QT_ENABLE_HIGHDPI_SCALING", "0"); -#endif - QApplication a(argc, argv); QCoreApplication::setApplicationName("chatterino"); @@ -95,6 +91,25 @@ int main(int argc, char **argv) attachToConsole(); } + qCInfo(chatterinoApp).noquote() + << "Chatterino Qt SSL library build version:" + << QSslSocket::sslLibraryBuildVersionString(); + qCInfo(chatterinoApp).noquote() + << "Chatterino Qt SSL library version:" + << QSslSocket::sslLibraryVersionString(); +#if QT_VERSION >= QT_VERSION_CHECK(6, 1, 0) + qCInfo(chatterinoApp).noquote() + << "Chatterino Qt SSL active backend:" + << QSslSocket::activeBackend() << "of" + << QSslSocket::availableBackends().join(", "); +# if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + qCInfo(chatterinoApp) << "Chatterino Qt SSL active backend features:" + << QSslSocket::supportedFeatures(); +# endif + qCInfo(chatterinoApp) << "Chatterino Qt SSL active backend protocols:" + << QSslSocket::supportedProtocols(); +#endif + Updates updates(*paths); NetworkConfigurationProvider::applyFromEnv(Env::get()); diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index f39485d5fd8..30706969105 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -21,276 +21,238 @@ #include #include -#include -#include -#include +#include // Duration between each check of every Image instance const auto IMAGE_POOL_CLEANUP_INTERVAL = std::chrono::minutes(1); // Duration since last usage of Image pixmap before expiration of frames const auto IMAGE_POOL_IMAGE_LIFETIME = std::chrono::minutes(10); -namespace chatterino { -namespace detail { - // Frames - Frames::Frames() +namespace chatterino::detail { + +Frames::Frames() +{ + DebugCount::increase("images"); +} + +Frames::Frames(QList &&frames) + : items_(std::move(frames)) +{ + assertInGuiThread(); + DebugCount::increase("images"); + if (!this->empty()) { - DebugCount::increase("images"); + DebugCount::increase("loaded images"); } - Frames::Frames(QVector> &&frames) - : items_(std::move(frames)) + if (this->animated()) { - assertInGuiThread(); - DebugCount::increase("images"); - if (!this->empty()) - { - DebugCount::increase("loaded images"); - } - - if (this->animated()) - { - DebugCount::increase("animated images"); + DebugCount::increase("animated images"); - this->gifTimerConnection_ = - getIApp()->getEmotes()->getGIFTimer().signal.connect([this] { - this->advance(); - }); - } + this->gifTimerConnection_ = + getIApp()->getEmotes()->getGIFTimer().signal.connect([this] { + this->advance(); + }); + } - auto totalLength = - std::accumulate(this->items_.begin(), this->items_.end(), 0UL, - [](auto init, auto &&frame) { - return init + frame.duration; - }); + auto totalLength = std::accumulate(this->items_.begin(), this->items_.end(), + 0UL, [](auto init, auto &&frame) { + return init + frame.duration; + }); - if (totalLength == 0) - { - this->durationOffset_ = 0; - } - else - { - this->durationOffset_ = std::min( - int(getIApp()->getEmotes()->getGIFTimer().position() % - totalLength), - 60000); - } - this->processOffset(); - DebugCount::increase("image bytes", this->memoryUsage()); - DebugCount::increase("image bytes (ever loaded)", this->memoryUsage()); + if (totalLength == 0) + { + this->durationOffset_ = 0; } - - Frames::~Frames() + else { - assertInGuiThread(); - DebugCount::decrease("images"); - if (!this->empty()) - { - DebugCount::decrease("loaded images"); - } + this->durationOffset_ = std::min( + int(getIApp()->getEmotes()->getGIFTimer().position() % totalLength), + 60000); + } + this->processOffset(); + DebugCount::increase("image bytes", this->memoryUsage()); + DebugCount::increase("image bytes (ever loaded)", this->memoryUsage()); +} - if (this->animated()) - { - DebugCount::decrease("animated images"); - } - DebugCount::decrease("image bytes", this->memoryUsage()); - DebugCount::increase("image bytes (ever unloaded)", - this->memoryUsage()); +Frames::~Frames() +{ + assertInGuiThread(); + DebugCount::decrease("images"); + if (!this->empty()) + { + DebugCount::decrease("loaded images"); + } - this->gifTimerConnection_.disconnect(); + if (this->animated()) + { + DebugCount::decrease("animated images"); } + DebugCount::decrease("image bytes", this->memoryUsage()); + DebugCount::increase("image bytes (ever unloaded)", this->memoryUsage()); + + this->gifTimerConnection_.disconnect(); +} - int64_t Frames::memoryUsage() const +int64_t Frames::memoryUsage() const +{ + int64_t usage = 0; + for (const auto &frame : this->items_) { - int64_t usage = 0; - for (const auto &frame : this->items_) - { - auto sz = frame.image.size(); - auto area = sz.width() * sz.height(); - auto memory = area * frame.image.depth() / 8; + auto sz = frame.image.size(); + auto area = sz.width() * sz.height(); + auto memory = area * frame.image.depth() / 8; - usage += memory; - } - return usage; + usage += memory; } + return usage; +} + +void Frames::advance() +{ + this->durationOffset_ += GIF_FRAME_LENGTH; + this->processOffset(); +} - void Frames::advance() +void Frames::processOffset() +{ + if (this->items_.isEmpty()) { - this->durationOffset_ += GIF_FRAME_LENGTH; - this->processOffset(); + return; } - void Frames::processOffset() + while (true) { - if (this->items_.isEmpty()) - { - return; - } + this->index_ %= this->items_.size(); - while (true) + if (this->durationOffset_ > this->items_[this->index_].duration) { - this->index_ %= this->items_.size(); - - if (this->durationOffset_ > this->items_[this->index_].duration) - { - this->durationOffset_ -= this->items_[this->index_].duration; - this->index_ = (this->index_ + 1) % this->items_.size(); - } - else - { - break; - } + this->durationOffset_ -= this->items_[this->index_].duration; + this->index_ = (this->index_ + 1) % this->items_.size(); } - } - - void Frames::clear() - { - assertInGuiThread(); - if (!this->empty()) + else { - DebugCount::decrease("loaded images"); + break; } - DebugCount::decrease("image bytes", this->memoryUsage()); - DebugCount::increase("image bytes (ever unloaded)", - this->memoryUsage()); - - this->items_.clear(); - this->index_ = 0; - this->durationOffset_ = 0; - this->gifTimerConnection_.disconnect(); } +} - bool Frames::empty() const +void Frames::clear() +{ + assertInGuiThread(); + if (!this->empty()) { - return this->items_.empty(); + DebugCount::decrease("loaded images"); } + DebugCount::decrease("image bytes", this->memoryUsage()); + DebugCount::increase("image bytes (ever unloaded)", this->memoryUsage()); + + this->items_.clear(); + this->index_ = 0; + this->durationOffset_ = 0; + this->gifTimerConnection_.disconnect(); +} + +bool Frames::empty() const +{ + return this->items_.empty(); +} + +bool Frames::animated() const +{ + return this->items_.size() > 1; +} - bool Frames::animated() const +std::optional Frames::current() const +{ + if (this->items_.empty()) { - return this->items_.size() > 1; + return std::nullopt; } - std::optional Frames::current() const - { - if (this->items_.empty()) - { - return std::nullopt; - } + return this->items_[this->index_].image; +} - return this->items_[this->index_].image; +std::optional Frames::first() const +{ + if (this->items_.empty()) + { + return std::nullopt; } - std::optional Frames::first() const - { - if (this->items_.empty()) - { - return std::nullopt; - } + return this->items_.front().image; +} - return this->items_.front().image; - } +QList readFrames(QImageReader &reader, const Url &url) +{ + QList frames; + frames.reserve(reader.imageCount()); - // functions - QVector> readFrames(QImageReader &reader, const Url &url) + for (int index = 0; index < reader.imageCount(); ++index) { - QVector> frames; - frames.reserve(reader.imageCount()); - - QImage image; - for (int index = 0; index < reader.imageCount(); ++index) + auto pixmap = QPixmap::fromImageReader(&reader); + if (!pixmap.isNull()) { - if (reader.read(&image)) + // It seems that browsers have special logic for fast animations. + // This implements Chrome and Firefox's behavior which uses + // a duration of 100 ms for any frames that specify a duration of <= 10 ms. + // See http://webkit.org/b/36082 for more information. + // https://github.com/SevenTV/chatterino7/issues/46#issuecomment-1010595231 + int duration = reader.nextImageDelay(); + if (duration <= 10) { - // It seems that browsers have special logic for fast animations. - // This implements Chrome and Firefox's behavior which uses - // a duration of 100 ms for any frames that specify a duration of <= 10 ms. - // See http://webkit.org/b/36082 for more information. - // https://github.com/SevenTV/chatterino7/issues/46#issuecomment-1010595231 - int duration = reader.nextImageDelay(); - if (duration <= 10) - { - duration = 100; - } - duration = std::max(20, duration); - frames.push_back(Frame{std::move(image), duration}); + duration = 100; } + duration = std::max(20, duration); + frames.append(Frame{ + .image = std::move(pixmap), + .duration = duration, + }); } - - if (frames.empty()) - { - qCDebug(chatterinoImage) - << "Error while reading image" << url.string << ": '" - << reader.errorString() << "'"; - } - - return frames; } - // parsed - template - void assignDelayed( - std::queue>>> &queued, - std::mutex &mutex, std::atomic_bool &loadedEventQueued) + if (frames.empty()) { - std::lock_guard lock(mutex); - int i = 0; + qCDebug(chatterinoImage) << "Error while reading image" << url.string + << ": '" << reader.errorString() << "'"; + } - while (!queued.empty()) - { - auto front = std::move(queued.front()); - queued.pop(); + return frames; +} - // Call Assign with the vector of frames - front.first(std::move(front.second)); +void assignFrames(std::weak_ptr weak, QList parsed) +{ + static bool isPushQueued; - if (++i > 50) - { - QTimer::singleShot(3, [&] { - assignDelayed(queued, mutex, loadedEventQueued); - }); - return; - } + auto cb = [parsed = std::move(parsed), weak = std::move(weak)]() mutable { + auto shared = weak.lock(); + if (!shared) + { + return; } + shared->frames_ = std::make_unique(std::move(parsed)); + + // Avoid too many layouts in one event-loop iteration + // + // This callback is called for every image, so there might be multiple + // callbacks queued on the event-loop in this iteration, but we only + // want to generate one invalidation. + if (!isPushQueued) + { + isPushQueued = true; + postToThread([] { + isPushQueued = false; + getIApp()->getWindows()->forceLayoutChannelViews(); + }); + } + }; - getIApp()->getWindows()->forceLayoutChannelViews(); + postToGuiThread(cb); +} - loadedEventQueued = false; - } +} // namespace chatterino::detail - template - auto makeConvertCallback(const QVector> &parsed, - Assign assign) - { - static std::queue>>> queued; - static std::mutex mutex; - static std::atomic_bool loadedEventQueued{false}; - - return [parsed, assign] { - // convert to pixmap - QVector> frames; - frames.reserve(parsed.size()); - std::transform(parsed.begin(), parsed.end(), - std::back_inserter(frames), [](auto &frame) { - return Frame{ - QPixmap::fromImage(frame.image), - frame.duration}; - }); - - // put into stack - std::lock_guard lock(mutex); - queued.emplace(assign, frames); - - if (!loadedEventQueued) - { - loadedEventQueued = true; - - QTimer::singleShot(100, [=] { - assignDelayed(queued, mutex, loadedEventQueued); - }); - } - }; - } -} // namespace detail +namespace chatterino { // IMAGE2 Image::~Image() @@ -402,7 +364,7 @@ void Image::setPixmap(const QPixmap &pixmap) { auto setFrames = [shared = this->shared_from_this(), pixmap]() { shared->frames_ = std::make_unique( - QVector>{detail::Frame{pixmap, 1}}); + QList{detail::Frame{pixmap, 1}}); }; if (isGuiThread()) @@ -512,11 +474,8 @@ void Image::actuallyLoad() return; } - auto data = result.getData(); - - // const cast since we are only reading from it - QBuffer buffer(const_cast(&data)); - buffer.open(QIODevice::ReadOnly); + QBuffer buffer; + buffer.setData(result.getData()); QImageReader reader(&buffer); if (!reader.canRead()) @@ -557,14 +516,7 @@ void Image::actuallyLoad() auto parsed = detail::readFrames(reader, shared->url()); - postToThread(makeConvertCallback( - parsed, [weak = std::weak_ptr(shared)](auto &&frames) { - if (auto shared = weak.lock()) - { - shared->frames_ = std::make_unique( - std::forward(frames)); - } - })); + assignFrames(shared, parsed); }) .onError([weak](auto /*result*/) { auto shared = weak.lock(); diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index 2eb0fcf04ca..31351ab78dc 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -1,15 +1,14 @@ #pragma once #include "common/Aliases.hpp" -#include "common/Common.hpp" #include #include +#include #include #include #include #include -#include #include #include @@ -19,41 +18,53 @@ #include namespace chatterino { -namespace detail { - template - struct Frame { - Image image; - int duration; - }; - class Frames - { - public: - Frames(); - Frames(QVector> &&frames); - ~Frames(); - - Frames(const Frames &) = delete; - Frames &operator=(const Frames &) = delete; - - Frames(Frames &&) = delete; - Frames &operator=(Frames &&) = delete; - - void clear(); - bool empty() const; - bool animated() const; - void advance(); - std::optional current() const; - std::optional first() const; - - private: - int64_t memoryUsage() const; - void processOffset(); - QVector> items_; - int index_{0}; - int durationOffset_{0}; - pajlada::Signals::Connection gifTimerConnection_; - }; -} // namespace detail + +class Image; + +} // namespace chatterino + +namespace chatterino::detail { + +struct Frame { + QPixmap image; + int duration; +}; + +class Frames +{ +public: + Frames(); + Frames(QList &&frames); + ~Frames(); + + Frames(const Frames &) = delete; + Frames &operator=(const Frames &) = delete; + + Frames(Frames &&) = delete; + Frames &operator=(Frames &&) = delete; + + void clear(); + bool empty() const; + bool animated() const; + void advance(); + std::optional current() const; + std::optional first() const; + +private: + int64_t memoryUsage() const; + void processOffset(); + QList items_; + QList::size_type index_{0}; + int durationOffset_{0}; + pajlada::Signals::Connection gifTimerConnection_; +}; + +QList readFrames(QImageReader &reader, const Url &url); +void assignFrames(std::weak_ptr weak, QList parsed); + +} // namespace chatterino::detail + +namespace chatterino { class Image; using ImagePtr = std::shared_ptr; @@ -116,9 +127,11 @@ class Image : public std::enable_shared_from_this mutable std::chrono::time_point lastUsed_; // gui thread only - std::unique_ptr frames_{}; + std::unique_ptr frames_; friend class ImageExpirationPool; + friend void detail::assignFrames(std::weak_ptr, + QList); }; // forward-declarable function that calls Image::getEmpty() under the hood. diff --git a/src/messages/LimitedQueue.hpp b/src/messages/LimitedQueue.hpp index 62fd025278d..e06e5a0f2b4 100644 --- a/src/messages/LimitedQueue.hpp +++ b/src/messages/LimitedQueue.hpp @@ -24,14 +24,6 @@ class LimitedQueue private: /// Property Accessors - /** - * @brief Return the limit of the internal buffer - */ - [[nodiscard]] size_t limit() const - { - return this->limit_; - } - /** * @brief Return the amount of space left in the buffer * @@ -43,6 +35,14 @@ class LimitedQueue } public: + /** + * @brief Return the limit of the queue + */ + [[nodiscard]] size_t limit() const + { + return this->limit_; + } + /** * @brief Return true if the buffer is empty */ diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index b9e0b2321ec..bdbe120ddaa 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -1,6 +1,7 @@ #pragma once #include "common/FlagsEnum.hpp" +#include "providers/twitch/ChannelPointReward.hpp" #include "util/QStringHash.hpp" #include @@ -107,6 +108,8 @@ struct Message { std::vector> elements; ScrollbarHighlight getScrollBarHighlight() const; + + std::shared_ptr reward = nullptr; }; } // namespace chatterino diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index af5dadcf707..64af873b9f9 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -15,7 +15,6 @@ #include "singletons/Resources.hpp" #include "singletons/Theme.hpp" #include "util/FormatTime.hpp" -#include "util/Qt.hpp" #include @@ -323,6 +322,31 @@ MessageBuilder::MessageBuilder(const UnbanAction &action) this->message().searchText = text; } +MessageBuilder::MessageBuilder(const WarnAction &action) + : MessageBuilder() +{ + this->emplace(); + this->message().flags.set(MessageFlag::System); + + QString text; + + // TODO: Use MentionElement here, once WarnAction includes username/displayname + this->emplaceSystemTextAndUpdate("A moderator", text) + ->setLink({Link::UserInfo, "id:" + action.source.id}); + this->emplaceSystemTextAndUpdate("warned", text); + this->emplaceSystemTextAndUpdate( + action.target.login + (action.reasons.isEmpty() ? "." : ":"), text) + ->setLink({Link::UserInfo, action.target.login}); + + if (!action.reasons.isEmpty()) + { + this->emplaceSystemTextAndUpdate(action.reasons.join(", "), text); + } + + this->message().messageText = text; + this->message().searchText = text; +} + MessageBuilder::MessageBuilder(const AutomodUserAction &action) : MessageBuilder() { @@ -543,7 +567,7 @@ MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/, // This also ensures that the LinkResolver doesn't get these links. addText(imageLink, MessageColor::Link) ->setLink({Link::Url, imageLink}) - ->setTrailingSpace(false); + ->setTrailingSpace(!deletionLink.isEmpty()); if (!deletionLink.isEmpty()) { @@ -617,16 +641,16 @@ void MessageBuilder::addLink(const ParsedLink &parsedLink) { QString lowercaseLinkString; QString origLink = parsedLink.source; - QString matchedLink; + QString fullUrl; if (parsedLink.protocol.isNull()) { - matchedLink = QStringLiteral("http://") + parsedLink.source; + fullUrl = QStringLiteral("http://") + parsedLink.source; } else { lowercaseLinkString += parsedLink.protocol; - matchedLink = parsedLink.source; + fullUrl = parsedLink.source; } lowercaseLinkString += parsedLink.host.toString().toLower(); @@ -635,9 +659,8 @@ void MessageBuilder::addLink(const ParsedLink &parsedLink) auto textColor = MessageColor(MessageColor::Link); auto *el = this->emplace( LinkElement::Parsed{.lowercase = lowercaseLinkString, - .original = matchedLink}, - MessageElementFlag::Text, textColor); - el->setLink({Link::Url, matchedLink}); + .original = origLink}, + fullUrl, MessageElementFlag::Text, textColor); getIApp()->getLinkResolver()->resolve(el->linkInfo()); } @@ -764,10 +787,7 @@ void MessageBuilder::addTextOrEmoji(const QString &string_) auto &&textColor = this->textColor_; if (string.startsWith('@')) { - this->emplace(string, MessageElementFlag::BoldUsername, - textColor, FontStyle::ChatMediumBold); - this->emplace(string, MessageElementFlag::NonBoldUsername, - textColor); + this->emplace(string, "", textColor, textColor); } else { diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index c7277997a27..63b9fd8cb95 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -12,6 +12,7 @@ namespace chatterino { struct BanAction; struct UnbanAction; +struct WarnAction; struct AutomodAction; struct AutomodUserAction; struct AutomodInfoAction; @@ -78,6 +79,7 @@ class MessageBuilder const QTime &time = QTime::currentTime()); MessageBuilder(const BanAction &action, uint32_t count = 1); MessageBuilder(const UnbanAction &action); + MessageBuilder(const WarnAction &action); MessageBuilder(const AutomodUserAction &action); MessageBuilder(LiveUpdatesAddEmoteMessageTag, const QString &platform, diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 56d1e2ed3d3..0fb47cd0e33 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -155,8 +155,8 @@ void EmoteElement::addToContainer(MessageLayoutContainer &container, { if (flags.has(MessageElementFlag::EmoteImages)) { - auto image = - this->emote_->images.getImageOrLoaded(container.getScale()); + auto image = this->emote_->images.getImageOrLoaded( + container.getImageScale()); if (image->isEmpty()) { return; @@ -210,7 +210,7 @@ void LayeredEmoteElement::addToContainer(MessageLayoutContainer &container, { if (flags.has(MessageElementFlag::EmoteImages)) { - auto images = this->getLoadedImages(container.getScale()); + auto images = this->getLoadedImages(container.getImageScale()); if (images.empty()) { return; @@ -364,7 +364,7 @@ void BadgeElement::addToContainer(MessageLayoutContainer &container, if (flags.hasAny(this->getFlags())) { auto image = - this->emote_->images.getImageOrLoaded(container.getScale()); + this->emote_->images.getImageOrLoaded(container.getImageScale()); if (image->isEmpty()) { return; @@ -679,10 +679,11 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, } } -LinkElement::LinkElement(const Parsed &parsed, MessageElementFlags flags, - const MessageColor &color, FontStyle style) +LinkElement::LinkElement(const Parsed &parsed, const QString &fullUrl, + MessageElementFlags flags, const MessageColor &color, + FontStyle style) : TextElement({}, flags, color, style) - , linkInfo_(parsed.original) + , linkInfo_(fullUrl) , lowercase_({parsed.lowercase}) , original_({parsed.original}) { @@ -702,6 +703,61 @@ Link LinkElement::getLink() const return {Link::Url, this->linkInfo_.url()}; } +MentionElement::MentionElement(const QString &displayName, QString loginName_, + MessageColor fallbackColor_, + MessageColor userColor_) + : TextElement(displayName, + {MessageElementFlag::Text, MessageElementFlag::Mention}) + , fallbackColor(fallbackColor_) + , userColor(userColor_) + , userLoginName(std::move(loginName_)) +{ +} + +void MentionElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + if (getSettings()->colorUsernames) + { + this->color_ = this->userColor; + } + else + { + this->color_ = this->fallbackColor; + } + + if (getSettings()->boldUsernames) + { + this->style_ = FontStyle::ChatMediumBold; + } + else + { + this->style_ = FontStyle::ChatMedium; + } + + TextElement::addToContainer(container, flags); +} + +MessageElement *MentionElement::setLink(const Link &link) +{ + assert(false && "MentionElement::setLink should not be called. Pass " + "through a valid login name in the constructor and it will " + "automatically be a UserInfo link"); + + return TextElement::setLink(link); +} + +Link MentionElement::getLink() const +{ + if (this->userLoginName.isEmpty()) + { + // Some rare mention elements don't have the knowledge of the login name + return {}; + } + + return {Link::UserInfo, this->userLoginName}; +} + // TIMESTAMP TimestampElement::TimestampElement(QTime time) : MessageElement(MessageElementFlag::Timestamp) @@ -797,7 +853,7 @@ void ScalingImageElement::addToContainer(MessageLayoutContainer &container, if (flags.hasAny(this->getFlags())) { const auto &image = - this->images_.getImageOrLoaded(container.getScale()); + this->images_.getImageOrLoaded(container.getImageScale()); if (image->isEmpty()) { return; diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 68f90e9b57e..49ce762cb91 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -133,9 +133,10 @@ enum class MessageElementFlag : int64_t { // needed Collapsed = (1LL << 26), - // used for dynamic bold usernames - BoldUsername = (1LL << 27), - NonBoldUsername = (1LL << 28), + // A mention of a username that isn't the author of the message + Mention = (1LL << 27), + + // Unused = (1LL << 28), // used to check if links should be lowercased LowercaseLinks = (1LL << 29), @@ -168,7 +169,7 @@ class MessageElement MessageElement(MessageElement &&) = delete; MessageElement &operator=(MessageElement &&) = delete; - MessageElement *setLink(const Link &link); + virtual MessageElement *setLink(const Link &link); MessageElement *setTooltip(const QString &tooltip); MessageElement *setTrailingSpace(bool value); @@ -236,7 +237,6 @@ class TextElement : public MessageElement protected: QStringList words_; -private: MessageColor color_; FontStyle style_; }; @@ -272,7 +272,10 @@ class LinkElement : public TextElement QString original; }; - LinkElement(const Parsed &parsed, MessageElementFlags flags, + /// @param parsed The link as it appeared in the message + /// @param fullUrl A full URL (notably with a protocol) + LinkElement(const Parsed &parsed, const QString &fullUrl, + MessageElementFlags flags, const MessageColor &color = MessageColor::Text, FontStyle style = FontStyle::ChatMedium); ~LinkElement() override = default; @@ -298,6 +301,47 @@ class LinkElement : public TextElement QStringList original_; }; +/** + * @brief Contains a username mention. + * + * Examples of mentions: + * V + * 13:37 pajlada: hello @forsen + * + * V V + * 13:37 The moderators of this channel are: forsen, nuuls + */ +class MentionElement : public TextElement +{ +public: + MentionElement(const QString &displayName, QString loginName_, + MessageColor fallbackColor_, MessageColor userColor_); + ~MentionElement() override = default; + MentionElement(const MentionElement &) = delete; + MentionElement(MentionElement &&) = delete; + MentionElement &operator=(const MentionElement &) = delete; + MentionElement &operator=(MentionElement &&) = delete; + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + + MessageElement *setLink(const Link &link) override; + Link getLink() const override; + +private: + /** + * The color of the element in case the "Colorize @usernames" is disabled + **/ + MessageColor fallbackColor; + + /** + * The color of the element in case the "Colorize @usernames" is enabled + **/ + MessageColor userColor; + + QString userLoginName; +}; + // contains emote data and will pick the emote based on : // a) are images for the emote type enabled // b) which size it wants diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 87a3ae9b458..52349107f2a 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -14,7 +14,6 @@ #include "singletons/StreamerMode.hpp" #include "singletons/WindowManager.hpp" #include "util/Helpers.hpp" -#include "util/Qt.hpp" #include @@ -150,7 +149,7 @@ void SharedMessageBuilder::parseUsername() void SharedMessageBuilder::parseHighlights() { - if (getSettings()->isBlacklistedUser(this->ircMessage->nick())) + if (getSettings()->isBlacklistedUser(this->message().loginName)) { // Do nothing. We ignore highlights from this user. return; @@ -158,7 +157,7 @@ void SharedMessageBuilder::parseHighlights() auto badges = SharedMessageBuilder::parseBadgeTag(this->tags); auto [highlighted, highlightResult] = getIApp()->getHighlights()->check( - this->args, badges, this->ircMessage->nick(), this->originalMessage_, + this->args, badges, this->message().loginName, this->originalMessage_, this->message().flags); if (!highlighted) diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index ef21ac8ad28..a9ff0f56c31 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -74,7 +74,8 @@ int MessageLayout::getWidth() const // Layout // return true if redraw is required -bool MessageLayout::layout(int width, float scale, MessageElementFlags flags, +bool MessageLayout::layout(int width, float scale, float imageScale, + MessageElementFlags flags, bool shouldInvalidateBuffer) { // BenchmarkGuard benchmark("MessageLayout::layout()"); @@ -106,6 +107,8 @@ bool MessageLayout::layout(int width, float scale, MessageElementFlags flags, // check if dpi changed layoutRequired |= this->scale_ != scale; this->scale_ = scale; + layoutRequired |= this->imageScale_ != imageScale; + this->imageScale_ = imageScale; if (!layoutRequired) { @@ -148,7 +151,8 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) bool hideSimilar = getSettings()->hideSimilar; bool hideReplies = !flags.has(MessageElementFlag::RepliedMessage); - this->container_.beginLayout(width, this->scale_, messageFlags); + this->container_.beginLayout(width, this->scale_, this->imageScale_, + messageFlags); for (const auto &element : this->message_->elements) { @@ -293,17 +297,12 @@ QPixmap *MessageLayout::ensureBuffer(QPainter &painter, int width, bool clear) } // Create new buffer -#if defined(Q_OS_MACOS) || defined(Q_OS_LINUX) this->buffer_ = std::make_unique( int(width * painter.device()->devicePixelRatioF()), int(this->container_.getHeight() * painter.device()->devicePixelRatioF())); this->buffer_->setDevicePixelRatio(painter.device()->devicePixelRatioF()); -#else - Q_UNUSED(painter); - this->buffer_ = std::make_unique( - width, std::max(16, this->container_.getHeight())); -#endif + if (clear) { this->buffer_->fill(Qt::transparent); diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index 29131b128e7..c96062c57e6 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -56,8 +56,8 @@ class MessageLayout MessageLayoutFlags flags; - bool layout(int width, float scale_, MessageElementFlags flags, - bool shouldInvalidateBuffer); + bool layout(int width, float scale_, float imageScale, + MessageElementFlags flags, bool shouldInvalidateBuffer); // Painting MessagePaintResult paint(const MessagePaintContext &ctx); @@ -128,6 +128,7 @@ class MessageLayout int currentLayoutWidth_ = -1; int layoutState_ = -1; float scale_ = -1; + float imageScale_ = -1.F; MessageElementFlags currentWordFlags_; #ifdef FOURTF diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index 29d70e0a193..e5e53f360e0 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -30,7 +30,7 @@ constexpr const QMargins MARGIN{8, 4, 8, 4}; namespace chatterino { void MessageLayoutContainer::beginLayout(int width, float scale, - MessageFlags flags) + float imageScale, MessageFlags flags) { this->elements_.clear(); this->lines_.clear(); @@ -45,6 +45,7 @@ void MessageLayoutContainer::beginLayout(int width, float scale, this->width_ = width; this->height_ = 0; this->scale_ = scale; + this->imageScale_ = imageScale; this->flags_ = flags; auto mediumFontMetrics = getIApp()->getFonts()->getFontMetrics(FontStyle::ChatMedium, scale); @@ -526,6 +527,11 @@ float MessageLayoutContainer::getScale() const return this->scale_; } +float MessageLayoutContainer::getImageScale() const +{ + return this->imageScale_; +} + bool MessageLayoutContainer::isCollapsed() const { return this->isCollapsed_; @@ -750,9 +756,7 @@ void MessageLayoutContainer::reorderRTL(int firstTextIndex) const auto neutral = isNeutral(element->getText()); const auto neutralOrUsername = - neutral || - element->getFlags().hasAny({MessageElementFlag::BoldUsername, - MessageElementFlag::NonBoldUsername}); + neutral || element->getFlags().has(MessageElementFlag::Mention); if (neutral && ((this->first == FirstWord::RTL && !this->wasPrevReversed_) || diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index ed3c1a7a6b7..dde3f4d452f 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -32,7 +32,8 @@ struct MessageLayoutContainer { * This will reset all line calculations, and will be considered incomplete * until the accompanying end function has been called */ - void beginLayout(int width_, float scale_, MessageFlags flags_); + void beginLayout(int width, float scale, float imageScale, + MessageFlags flags); /** * Finish the layout process of this message @@ -146,6 +147,11 @@ struct MessageLayoutContainer { */ float getScale() const; + /** + * Returns the image scale + */ + float getImageScale() const; + /** * Returns true if this message is collapsed */ @@ -270,6 +276,10 @@ struct MessageLayoutContainer { // variables float scale_ = 1.F; + /** + * Scale factor for images + */ + float imageScale_ = 1.F; int width_ = 0; MessageFlags flags_{}; /** diff --git a/src/messages/search/AuthorPredicate.cpp b/src/messages/search/AuthorPredicate.cpp index adc3655778e..ae951b8cddf 100644 --- a/src/messages/search/AuthorPredicate.cpp +++ b/src/messages/search/AuthorPredicate.cpp @@ -1,7 +1,6 @@ #include "messages/search/AuthorPredicate.hpp" #include "messages/Message.hpp" -#include "util/Qt.hpp" namespace chatterino { diff --git a/src/messages/search/BadgePredicate.cpp b/src/messages/search/BadgePredicate.cpp index dc3fbafa8cd..218ede8e1af 100644 --- a/src/messages/search/BadgePredicate.cpp +++ b/src/messages/search/BadgePredicate.cpp @@ -2,7 +2,6 @@ #include "messages/Message.hpp" #include "providers/twitch/TwitchBadge.hpp" -#include "util/Qt.hpp" namespace chatterino { diff --git a/src/messages/search/ChannelPredicate.cpp b/src/messages/search/ChannelPredicate.cpp index 665f96a973e..47df247fa80 100644 --- a/src/messages/search/ChannelPredicate.cpp +++ b/src/messages/search/ChannelPredicate.cpp @@ -1,7 +1,6 @@ #include "messages/search/ChannelPredicate.hpp" #include "messages/Message.hpp" -#include "util/Qt.hpp" namespace chatterino { diff --git a/src/messages/search/LinkPredicate.cpp b/src/messages/search/LinkPredicate.cpp index 69442069c9a..8430f84ba11 100644 --- a/src/messages/search/LinkPredicate.cpp +++ b/src/messages/search/LinkPredicate.cpp @@ -2,7 +2,6 @@ #include "common/LinkParser.hpp" #include "messages/Message.hpp" -#include "util/Qt.hpp" namespace chatterino { diff --git a/src/messages/search/MessageFlagsPredicate.cpp b/src/messages/search/MessageFlagsPredicate.cpp index 76e32de7209..d36fc72bb1d 100644 --- a/src/messages/search/MessageFlagsPredicate.cpp +++ b/src/messages/search/MessageFlagsPredicate.cpp @@ -1,7 +1,5 @@ #include "messages/search/MessageFlagsPredicate.hpp" -#include "util/Qt.hpp" - namespace chatterino { MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags, bool negate) diff --git a/src/messages/search/SubtierPredicate.cpp b/src/messages/search/SubtierPredicate.cpp index 2dc79fc3518..70d5b7148cf 100644 --- a/src/messages/search/SubtierPredicate.cpp +++ b/src/messages/search/SubtierPredicate.cpp @@ -2,7 +2,6 @@ #include "messages/Message.hpp" #include "providers/twitch/TwitchBadge.hpp" -#include "util/Qt.hpp" namespace chatterino { diff --git a/src/providers/irc/AbstractIrcServer.hpp b/src/providers/irc/AbstractIrcServer.hpp index 7a0e1ee639a..0b626f9e0be 100644 --- a/src/providers/irc/AbstractIrcServer.hpp +++ b/src/providers/irc/AbstractIrcServer.hpp @@ -17,7 +17,24 @@ class Channel; using ChannelPtr = std::shared_ptr; class RatelimitBucket; -class AbstractIrcServer : public QObject +class IAbstractIrcServer +{ +public: + virtual void connect() = 0; + + virtual void sendRawMessage(const QString &rawMessage) = 0; + + virtual ChannelPtr getOrAddChannel(const QString &dirtyChannelName) = 0; + virtual ChannelPtr getChannelOrEmpty(const QString &dirtyChannelName) = 0; + + virtual void addFakeMessage(const QString &data) = 0; + + virtual void addGlobalSystemMessage(const QString &messageText) = 0; + + virtual void forEachChannel(std::function func) = 0; +}; + +class AbstractIrcServer : public IAbstractIrcServer, public QObject { public: enum ConnectionType { Read = 1, Write = 2, Both = 3 }; @@ -33,27 +50,27 @@ class AbstractIrcServer : public QObject void initializeIrc(); // connection - void connect(); + void connect() final; void disconnect(); void sendMessage(const QString &channelName, const QString &message); - virtual void sendRawMessage(const QString &rawMessage); + void sendRawMessage(const QString &rawMessage) override; // channels - ChannelPtr getOrAddChannel(const QString &dirtyChannelName); - ChannelPtr getChannelOrEmpty(const QString &dirtyChannelName); + ChannelPtr getOrAddChannel(const QString &dirtyChannelName) final; + ChannelPtr getChannelOrEmpty(const QString &dirtyChannelName) final; std::vector> getChannels(); // signals pajlada::Signals::NoArgSignal connected; pajlada::Signals::NoArgSignal disconnected; - void addFakeMessage(const QString &data); + void addFakeMessage(const QString &data) final; - void addGlobalSystemMessage(const QString &messageText); + void addGlobalSystemMessage(const QString &messageText) final; // iteration - void forEachChannel(std::function func); + void forEachChannel(std::function func) final; protected: AbstractIrcServer(); diff --git a/src/providers/irc/IrcConnection2.cpp b/src/providers/irc/IrcConnection2.cpp index 9f97e6db641..19f327c24d0 100644 --- a/src/providers/irc/IrcConnection2.cpp +++ b/src/providers/irc/IrcConnection2.cpp @@ -3,6 +3,10 @@ #include "common/QLogging.hpp" #include "common/Version.hpp" +#include + +using namespace std::chrono_literals; + namespace chatterino { namespace { @@ -56,6 +60,7 @@ IrcConnection::IrcConnection(QObject *parent) // Send ping every x seconds this->pingTimer_.setInterval(5000); this->pingTimer_.start(); + this->lastPing_ = std::chrono::system_clock::now(); QObject::connect(&this->pingTimer_, &QTimer::timeout, [this] { if (this->isConnected()) { @@ -64,7 +69,25 @@ IrcConnection::IrcConnection(QObject *parent) // If we're still receiving messages, all is well this->recentlyReceivedMessage_ = false; this->waitingForPong_ = false; - this->heartbeat.invoke(); + + // Check if we got invoked too late (e.g. due to a sleep) + auto now = std::chrono::system_clock::now(); + auto elapsed = now - this->lastPing_; + if (elapsed < 3 * 5000ms) + { + this->heartbeat.invoke(); + } + else + { + qCDebug(chatterinoIrc).nospace() + << "Got late ping (skipping heartbeat): " + << std::chrono::duration_cast< + std::chrono::milliseconds>(elapsed) + .count() + << "ms"; + } + this->lastPing_ = now; + return; } diff --git a/src/providers/irc/IrcConnection2.hpp b/src/providers/irc/IrcConnection2.hpp index 150793ec8a7..0e269397c4c 100644 --- a/src/providers/irc/IrcConnection2.hpp +++ b/src/providers/irc/IrcConnection2.hpp @@ -6,6 +6,8 @@ #include #include +#include + namespace chatterino { class IrcConnection : public Communi::IrcConnection @@ -33,6 +35,7 @@ class IrcConnection : public Communi::IrcConnection QTimer pingTimer_; QTimer reconnectTimer_; std::atomic recentlyReceivedMessage_{true}; + std::chrono::time_point lastPing_; // Reconnect with a base delay of 1 second and max out at 1 second * (2^(5-1)) (i.e. 16 seconds) ExponentialBackoff<5> reconnectBackoff_{std::chrono::milliseconds{1000}}; diff --git a/src/providers/irc/IrcServer.cpp b/src/providers/irc/IrcServer.cpp index 8d54d79f7b0..c90211fcb1a 100644 --- a/src/providers/irc/IrcServer.cpp +++ b/src/providers/irc/IrcServer.cpp @@ -244,7 +244,7 @@ void IrcServer::privateMessageReceived(Communi::IrcPrivateMessage *message) if (highlighted && showInMentions) { - getApp()->twitch->mentionsChannel->addMessage(msg); + getIApp()->getTwitch()->getMentionsChannel()->addMessage(msg); } } else diff --git a/src/providers/twitch/ChannelPointReward.cpp b/src/providers/twitch/ChannelPointReward.cpp index 62d93e3f0c2..658d498ff68 100644 --- a/src/providers/twitch/ChannelPointReward.cpp +++ b/src/providers/twitch/ChannelPointReward.cpp @@ -14,6 +14,47 @@ ChannelPointReward::ChannelPointReward(const QJsonObject &redemption) this->title = reward.value("title").toString(); this->cost = reward.value("cost").toInt(); this->isUserInputRequired = reward.value("is_user_input_required").toBool(); + this->isBits = reward.value("pricing_type").toString() == "BITS"; + + // accommodate idiosyncrasies of automatic reward redemptions + const auto rewardType = reward.value("reward_type").toString(); + if (rewardType == "SEND_ANIMATED_MESSAGE") + { + this->id = "animated-message"; + this->isUserInputRequired = true; + this->title = "Message Effects"; + } + else if (rewardType == "SEND_GIGANTIFIED_EMOTE") + { + this->id = "gigantified-emote-message"; + this->isUserInputRequired = true; + this->title = "Gigantify an Emote"; + } + else if (rewardType == "CELEBRATION") + { + this->id = rewardType; + this->title = "On-Screen Celebration"; + const auto metadata = + redemption.value("redemption_metadata").toObject(); + const auto emote = metadata.value("celebration_emote_metadata") + .toObject() + .value("emote") + .toObject(); + this->emoteId = emote.value("id").toString(); + this->emoteName = emote.value("token").toString(); + } + + // use bits cost when channel points were not used + if (cost == 0) + { + this->cost = reward.value("bits_cost").toInt(); + } + + // workaround twitch bug where bits_cost is always 0 in practice + if (cost == 0) + { + this->cost = reward.value("default_bits_cost").toInt(); + } // We don't need to store user information for rewards with user input // because we will get the user info from a corresponding IRC message @@ -27,6 +68,13 @@ ChannelPointReward::ChannelPointReward(const QJsonObject &redemption) } auto imageValue = reward.value("image"); + + // automatic reward redemptions have specialized default images + if (imageValue.isNull() && this->isBits) + { + imageValue = reward.value("default_image"); + } + // From Twitch docs // The size is only an estimation, the actual size might vary. constexpr QSize baseSize(28, 28); diff --git a/src/providers/twitch/ChannelPointReward.hpp b/src/providers/twitch/ChannelPointReward.hpp index f9f9b6316e6..d4f428e92f5 100644 --- a/src/providers/twitch/ChannelPointReward.hpp +++ b/src/providers/twitch/ChannelPointReward.hpp @@ -19,6 +19,9 @@ struct ChannelPointReward { int cost; ImageSet image; bool isUserInputRequired = false; + bool isBits = false; + QString emoteId; // currently only for celebrations + QString emoteName; // currently only for celebrations struct { QString id; diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 8a9cc9e6c2c..4657438ad2b 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -13,6 +13,7 @@ #include "messages/MessageColor.hpp" #include "messages/MessageElement.hpp" #include "messages/MessageThread.hpp" +#include "providers/irc/AbstractIrcServer.hpp" #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchAccountManager.hpp" @@ -53,6 +54,8 @@ const QSet SPECIAL_MESSAGE_TYPES{ "viewermilestone", // watch streak, but other categories possible in future }; +const QString ANONYMOUS_GIFTER_ID = "274598607"; + MessagePtr generateBannedMessage(bool confirmedBan) { const auto linkColor = MessageColor(MessageColor::Link); @@ -167,7 +170,7 @@ void updateReplyParticipatedStatus(const QVariantMap &tags, } ChannelPtr channelOrEmptyByTarget(const QString &target, - TwitchIrcServer &server) + IAbstractIrcServer &server) { QString channelName; if (!trimChannelName(target, channelName)) @@ -433,19 +436,8 @@ std::vector parseNoticeMessage(Communi::IrcNoticeMessage *message) // default case std::vector builtMessages; - auto content = message->content(); - if (content.startsWith( - "Your settings prevent you from sending this whisper", - Qt::CaseInsensitive) && - getSettings()->helixTimegateWhisper.getValue() == - HelixTimegateOverride::Timegate) - { - content = content + - " Consider setting \"Helix timegate /w behaviour\" " - "to \"Always use Helix\" in your Chatterino settings."; - } - builtMessages.emplace_back( - makeSystemMessage(content, calculateMessageTime(message).time())); + builtMessages.emplace_back(makeSystemMessage( + message->content(), calculateMessageTime(message).time())); return builtMessages; } @@ -516,6 +508,41 @@ std::vector parseUserNoticeMessage(Channel *channel, { messageText = "Announcement"; } + else if (msgType == "subgift") + { + if (auto monthsIt = tags.find("msg-param-gift-months"); + monthsIt != tags.end()) + { + int months = monthsIt.value().toInt(); + if (months > 1) + { + auto plan = tags.value("msg-param-sub-plan").toString(); + QString name = + ANONYMOUS_GIFTER_ID == tags.value("user-id").toString() + ? "An anonymous user" + : tags.value("display-name").toString(); + messageText = + QString("%1 gifted %2 months of a Tier %3 sub to %4!") + .arg(name, QString::number(months), + plan.isEmpty() ? '1' : plan.at(0), + tags.value("msg-param-recipient-display-name") + .toString()); + + if (auto countIt = tags.find("msg-param-sender-count"); + countIt != tags.end()) + { + int count = countIt.value().toInt(); + if (count > months) + { + messageText += + QString( + " They've gifted %1 months in the channel.") + .arg(QString::number(count)); + } + } + } + } + } auto b = MessageBuilder(systemMessage, parseTagString(messageText), calculateMessageTime(message).time()); @@ -651,9 +678,10 @@ std::vector IrcMessageHandler::parseMessageWithReply( } void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, - TwitchIrcServer &server) + ITwitchIrcServer &twitchServer, + IAbstractIrcServer &abstractIrcServer) { - auto chan = channelOrEmptyByTarget(message->target(), server); + auto chan = channelOrEmptyByTarget(message->target(), abstractIrcServer); if (chan->isEmpty()) { return; @@ -684,8 +712,8 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, // https://mm2pl.github.io/emoji_rfc.pdf for more details this->addMessage( message, chan, - message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, - false, message->isAction()); + message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), + twitchServer, false, message->isAction()); if (message->tags().contains(u"pinned-chat-paid-amount"_s)) { @@ -707,7 +735,7 @@ void IrcMessageHandler::handleRoomStateMessage(Communi::IrcMessage *message) { return; } - auto chan = getApp()->twitch->getChannelOrEmpty(chanName); + auto chan = getIApp()->getTwitchAbstract()->getChannelOrEmpty(chanName); auto *twitchChannel = dynamic_cast(chan.get()); if (!twitchChannel) @@ -769,7 +797,7 @@ void IrcMessageHandler::handleClearChatMessage(Communi::IrcMessage *message) } // get channel - auto chan = getApp()->twitch->getChannelOrEmpty(chanName); + auto chan = getIApp()->getTwitchAbstract()->getChannelOrEmpty(chanName); if (chan->isEmpty()) { @@ -813,7 +841,7 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message) } // get channel - auto chan = getApp()->twitch->getChannelOrEmpty(chanName); + auto chan = getIApp()->getTwitchAbstract()->getChannelOrEmpty(chanName); if (chan->isEmpty()) { @@ -862,7 +890,7 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) return; } - auto c = getApp()->twitch->getChannelOrEmpty(channelName); + auto c = getIApp()->getTwitchAbstract()->getChannelOrEmpty(channelName); if (c->isEmpty()) { return; @@ -917,7 +945,7 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) args.isReceivedWhisper = true; - auto *c = getApp()->twitch->whispersChannel.get(); + auto *c = getIApp()->getTwitch()->getWhispersChannel().get(); TwitchMessageBuilder builder( c, ircMessage, args, @@ -933,11 +961,11 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) MessagePtr message = builder.build(); builder.triggerHighlights(); - getApp()->twitch->lastUserThatWhisperedMe.set(builder.userName); + getIApp()->getTwitch()->setLastUserThatWhisperedMe(builder.userName); if (message->flags.has(MessageFlag::ShowInMentions)) { - getApp()->twitch->mentionsChannel->addMessage(message); + getIApp()->getTwitch()->getMentionsChannel()->addMessage(message); } c->addMessage(message); @@ -950,15 +978,16 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) !(getSettings()->streamerModeSuppressInlineWhispers && getIApp()->getStreamerMode()->isEnabled())) { - getApp()->twitch->forEachChannel( + getIApp()->getTwitchAbstract()->forEachChannel( [&message, overrideFlags](ChannelPtr channel) { channel->addMessage(message, overrideFlags); }); } } -void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, - TwitchIrcServer &server) +void IrcMessageHandler::handleUserNoticeMessage( + Communi::IrcMessage *message, ITwitchIrcServer &twitchServer, + IAbstractIrcServer &abstractIrcServer) { auto tags = message->tags(); auto parameters = message->parameters(); @@ -971,7 +1000,7 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, content = parameters[1]; } - auto chn = server.getChannelOrEmpty(target); + auto chn = abstractIrcServer.getChannelOrEmpty(target); if (isIgnoredMessage({ .message = content, .twitchUserID = tags.value("user-id").toString(), @@ -987,7 +1016,7 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, // Messages are not required, so they might be empty if (!content.isEmpty()) { - this->addMessage(message, chn, content, server, true, false); + this->addMessage(message, chn, content, twitchServer, true, false); } } @@ -1010,6 +1039,41 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, { messageText = "Announcement"; } + else if (msgType == "subgift") + { + if (auto monthsIt = tags.find("msg-param-gift-months"); + monthsIt != tags.end()) + { + int months = monthsIt.value().toInt(); + if (months > 1) + { + auto plan = tags.value("msg-param-sub-plan").toString(); + QString name = + ANONYMOUS_GIFTER_ID == tags.value("user-id").toString() + ? "An anonymous user" + : tags.value("display-name").toString(); + messageText = + QString("%1 gifted %2 months of a Tier %3 sub to %4!") + .arg(name, QString::number(months), + plan.isEmpty() ? '1' : plan.at(0), + tags.value("msg-param-recipient-display-name") + .toString()); + + if (auto countIt = tags.find("msg-param-sender-count"); + countIt != tags.end()) + { + int count = countIt.value().toInt(); + if (count > months) + { + messageText += + QString( + " They've gifted %1 months in the channel.") + .arg(QString::number(count)); + } + } + } + } + } auto b = MessageBuilder(systemMessage, parseTagString(messageText), calculateMessageTime(message).time()); @@ -1029,7 +1093,7 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, return; } - auto chan = server.getChannelOrEmpty(channelName); + auto chan = abstractIrcServer.getChannelOrEmpty(channelName); if (!chan->isEmpty()) { @@ -1050,7 +1114,7 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) { // Notice wasn't targeted at a single channel, send to all twitch // channels - getApp()->twitch->forEachChannelAndSpecialChannels( + getIApp()->getTwitch()->forEachChannelAndSpecialChannels( [msg](const auto &c) { c->addMessage(msg); }); @@ -1058,7 +1122,8 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) return; } - auto channel = getApp()->twitch->getChannelOrEmpty(channelName); + auto channel = + getIApp()->getTwitchAbstract()->getChannelOrEmpty(channelName); if (channel->isEmpty()) { @@ -1141,8 +1206,8 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) void IrcMessageHandler::handleJoinMessage(Communi::IrcMessage *message) { - auto channel = - getApp()->twitch->getChannelOrEmpty(message->parameter(0).remove(0, 1)); + auto channel = getIApp()->getTwitchAbstract()->getChannelOrEmpty( + message->parameter(0).remove(0, 1)); auto *twitchChannel = dynamic_cast(channel.get()); if (!twitchChannel) @@ -1164,8 +1229,8 @@ void IrcMessageHandler::handleJoinMessage(Communi::IrcMessage *message) void IrcMessageHandler::handlePartMessage(Communi::IrcMessage *message) { - auto channel = - getApp()->twitch->getChannelOrEmpty(message->parameter(0).remove(0, 1)); + auto channel = getIApp()->getTwitchAbstract()->getChannelOrEmpty( + message->parameter(0).remove(0, 1)); auto *twitchChannel = dynamic_cast(channel.get()); if (!twitchChannel) @@ -1250,7 +1315,7 @@ void IrcMessageHandler::setSimilarityFlags(const MessagePtr &message, void IrcMessageHandler::addMessage(Communi::IrcMessage *message, const ChannelPtr &chan, const QString &originalContent, - TwitchIrcServer &server, bool isSub, + ITwitchIrcServer &server, bool isSub, bool isAction) { if (chan->isEmpty()) @@ -1273,21 +1338,30 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message, auto *channel = dynamic_cast(chan.get()); const auto &tags = message->tags(); + QString rewardId; if (const auto it = tags.find("custom-reward-id"); it != tags.end()) { - const auto rewardId = it.value().toString(); - if (!rewardId.isEmpty() && - !channel->isChannelPointRewardKnown(rewardId)) + rewardId = it.value().toString(); + } + else if (const auto typeIt = tags.find("msg-id"); typeIt != tags.end()) + { + // slight hack to treat bits power-ups as channel point redemptions + const auto msgId = typeIt.value().toString(); + if (msgId == "animated-message" || msgId == "gigantified-emote-message") { - // Need to wait for pubsub reward notification - qCDebug(chatterinoTwitch) << "TwitchChannel reward added ADD " - "callback since reward is not known:" - << rewardId; - channel->addQueuedRedemption(rewardId, originalContent, message); - return; + rewardId = msgId; } - args.channelPointRewardId = rewardId; } + if (!rewardId.isEmpty() && !channel->isChannelPointRewardKnown(rewardId)) + { + // Need to wait for pubsub reward notification + qCDebug(chatterinoTwitch) << "TwitchChannel reward added ADD " + "callback since reward is not known:" + << rewardId; + channel->addQueuedRedemption(rewardId, originalContent, message); + return; + } + args.channelPointRewardId = rewardId; QString content = originalContent; int messageOffset = stripLeadingReplyMention(tags, content); @@ -1385,7 +1459,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message, if (highlighted && showInMentions) { - server.mentionsChannel->addMessage(msg); + server.getMentionsChannel()->addMessage(msg); } chan->addMessage(msg); diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp index 26c21f6da64..60dbacaf356 100644 --- a/src/providers/twitch/IrcMessageHandler.hpp +++ b/src/providers/twitch/IrcMessageHandler.hpp @@ -9,7 +9,8 @@ namespace chatterino { -class TwitchIrcServer; +class IAbstractIrcServer; +class ITwitchIrcServer; class Channel; using ChannelPtr = std::shared_ptr; struct Message; @@ -38,7 +39,8 @@ class IrcMessageHandler std::vector &otherLoaded); void handlePrivMessage(Communi::IrcPrivateMessage *message, - TwitchIrcServer &server); + ITwitchIrcServer &twitchServer, + IAbstractIrcServer &abstractIrcServer); void handleRoomStateMessage(Communi::IrcMessage *message); void handleClearChatMessage(Communi::IrcMessage *message); @@ -48,7 +50,8 @@ class IrcMessageHandler void handleWhisperMessage(Communi::IrcMessage *ircMessage); void handleUserNoticeMessage(Communi::IrcMessage *message, - TwitchIrcServer &server); + ITwitchIrcServer &twitchServer, + IAbstractIrcServer &abstractIrcServer); void handleNoticeMessage(Communi::IrcNoticeMessage *message); @@ -56,7 +59,7 @@ class IrcMessageHandler void handlePartMessage(Communi::IrcMessage *message); void addMessage(Communi::IrcMessage *message, const ChannelPtr &chan, - const QString &originalContent, TwitchIrcServer &server, + const QString &originalContent, ITwitchIrcServer &server, bool isSub, bool isAction); private: diff --git a/src/providers/twitch/PubSubActions.hpp b/src/providers/twitch/PubSubActions.hpp index 89abdb24464..82c94da3004 100644 --- a/src/providers/twitch/PubSubActions.hpp +++ b/src/providers/twitch/PubSubActions.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -171,4 +172,12 @@ struct AutomodInfoAction : PubSubAction { } type; }; +struct WarnAction : PubSubAction { + using PubSubAction::PubSubAction; + + ActionUser target; + + QStringList reasons; +}; + } // namespace chatterino diff --git a/src/providers/twitch/PubSubManager.cpp b/src/providers/twitch/PubSubManager.cpp index acb75cb59ba..447909812b9 100644 --- a/src/providers/twitch/PubSubManager.cpp +++ b/src/providers/twitch/PubSubManager.cpp @@ -286,6 +286,37 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) this->moderation.userUnbanned.invoke(action); }; + this->moderationActionHandlers["warn"] = [this](const auto &data, + const auto &roomID) { + WarnAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = + data.value("created_by").toString(); // currently always empty + + action.target.id = data.value("target_user_id").toString(); + action.target.login = data.value("target_user_login").toString(); + + const auto reasons = data.value("args").toArray(); + bool firstArg = true; + for (const auto &reasonValue : reasons) + { + if (firstArg) + { + // Skip first arg in the reasons array since it's not a reason + firstArg = false; + continue; + } + const auto &reason = reasonValue.toString(); + if (!reason.isEmpty()) + { + action.reasons.append(reason); + } + } + + this->moderation.userWarned.invoke(action); + }; + /* // This handler is no longer required as we use the automod-queue topic now this->moderationActionHandlers["automod_rejected"] = @@ -1131,8 +1162,8 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message) case PubSubChatModeratorActionMessage::Type::INVALID: default: { - qCDebug(chatterinoPubSub) - << "Invalid whisper type:" << innerMessage.typeString; + qCDebug(chatterinoPubSub) << "Invalid moderator action type:" + << innerMessage.typeString; } break; } @@ -1150,6 +1181,8 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message) switch (innerMessage.type) { + case PubSubCommunityPointsChannelV1Message::Type:: + AutomaticRewardRedeemed: case PubSubCommunityPointsChannelV1Message::Type::RewardRedeemed: { auto redemption = innerMessage.data.value("redemption").toObject(); diff --git a/src/providers/twitch/PubSubManager.hpp b/src/providers/twitch/PubSubManager.hpp index 6ddd98369aa..eb27e32f8ee 100644 --- a/src/providers/twitch/PubSubManager.hpp +++ b/src/providers/twitch/PubSubManager.hpp @@ -34,6 +34,7 @@ struct PubSubAutoModQueueMessage; struct AutomodAction; struct AutomodUserAction; struct AutomodInfoAction; +struct WarnAction; struct PubSubLowTrustUsersMessage; struct PubSubWhisperMessage; @@ -97,6 +98,7 @@ class PubSub Signal userBanned; Signal userUnbanned; + Signal userWarned; Signal suspiciousMessageReceived; Signal suspiciousTreatmentUpdated; diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index 14c7475e0b9..6e2b4c4aad8 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -7,6 +7,7 @@ #include "messages/Image.hpp" #include "providers/twitch/api/Helix.hpp" #include "util/DisplayBadge.hpp" +#include "util/LoadPixmap.hpp" #include #include @@ -239,48 +240,20 @@ void TwitchBadges::getBadgeIcons(const QList &badges, } } -void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, +void TwitchBadges::loadEmoteImage(const QString &name, const ImagePtr &image, BadgeIconCallback &&callback) { - auto url = image->url().string; - NetworkRequest(url) - .concurrent() - .cache() - .onSuccess([this, name, callback, url](auto result) { - auto data = result.getData(); - - // const cast since we are only reading from it - QBuffer buffer(const_cast(&data)); - buffer.open(QIODevice::ReadOnly); - QImageReader reader(&buffer); - - if (!reader.canRead() || reader.size().isEmpty()) - { - qCWarning(chatterinoTwitch) - << "Can't read badge image at" << url << "for" << name - << reader.errorString(); - return; - } - - QImage image = reader.read(); - if (image.isNull()) - { - qCWarning(chatterinoTwitch) - << "Failed reading badge image at" << url << "for" << name - << reader.errorString(); - return; - } + loadPixmapFromUrl(image->url(), + [this, name, callback{std::move(callback)}](auto pixmap) { + auto icon = std::make_shared(pixmap); - auto icon = std::make_shared(QPixmap::fromImage(image)); - - { - std::unique_lock lock(this->badgesMutex_); - this->badgesMap_[name] = icon; - } + { + std::unique_lock lock(this->badgesMutex_); + this->badgesMap_[name] = icon; + } - callback(name, icon); - }) - .execute(); + callback(name, icon); + }); } } // namespace chatterino diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp index 9964030f079..fff0f5aff0b 100644 --- a/src/providers/twitch/TwitchBadges.hpp +++ b/src/providers/twitch/TwitchBadges.hpp @@ -48,7 +48,7 @@ class TwitchBadges private: void parseTwitchBadges(QJsonObject root); void loaded(); - void loadEmoteImage(const QString &name, ImagePtr image, + void loadEmoteImage(const QString &name, const ImagePtr &image, BadgeIconCallback &&callback); std::shared_mutex badgesMutex_; diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 39350b69c12..bb982c8fe3e 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -84,21 +84,13 @@ TwitchChannel::TwitchChannel(const QString &name) , nameOptions{name, name, name} , subscriptionUrl_("https://www.twitch.tv/subs/" + name) , channelUrl_("https://twitch.tv/" + name) - , popoutPlayerUrl_("https://player.twitch.tv/?parent=twitch.tv&channel=" + - name) + , popoutPlayerUrl_(TWITCH_PLAYER_URL.arg(name)) , bttvEmotes_(std::make_shared()) , ffzEmotes_(std::make_shared()) , seventvEmotes_(std::make_shared()) { qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened"; - if (!getApp()) - { - // This is intended for tests and benchmarks. - // Irc, Pubsub, live-updates, and live-notifications aren't mocked there. - return; - } - this->bSignals_.emplace_back( getIApp()->getAccounts()->twitch.currentUserChanged.connect([this] { this->setMod(false); @@ -177,7 +169,8 @@ TwitchChannel::TwitchChannel(const QString &name) TwitchMessageBuilder::liveMessage(this->getDisplayName(), &builder2); builder2.message().id = this->roomId(); - getApp()->twitch->liveChannel->addMessage(builder2.release()); + getIApp()->getTwitch()->getLiveChannel()->addMessage( + builder2.release()); // Notify on all channels with a ping sound if (getSettings()->notificationOnAnyChannel && @@ -199,7 +192,7 @@ TwitchChannel::TwitchChannel(const QString &name) // "delete" old 'CHANNEL is live' message LimitedQueueSnapshot snapshot = - getApp()->twitch->liveChannel->getMessageSnapshot(); + getIApp()->getTwitch()->getLiveChannel()->getMessageSnapshot(); int snapshotLength = snapshot.size(); // MSVC hates this code if the parens are not there @@ -231,24 +224,18 @@ TwitchChannel::TwitchChannel(const QString &name) TwitchChannel::~TwitchChannel() { - if (!getApp()) - { - // This is for tests and benchmarks, where live-updates aren't mocked - // see comment in constructor. - return; - } - - getApp()->twitch->dropSeventvChannel(this->seventvUserID_, - this->seventvEmoteSetID_); + getIApp()->getTwitch()->dropSeventvChannel(this->seventvUserID_, + this->seventvEmoteSetID_); - if (getApp()->twitch->bttvLiveUpdates) + if (getIApp()->getTwitch()->getBTTVLiveUpdates()) { - getApp()->twitch->bttvLiveUpdates->partChannel(this->roomId()); + getIApp()->getTwitch()->getBTTVLiveUpdates()->partChannel( + this->roomId()); } - if (getApp()->twitch->seventvEventAPI) + if (getIApp()->getTwitch()->getSeventvEventAPI()) { - getApp()->twitch->seventvEventAPI->unsubscribeTwitchChannel( + getIApp()->getTwitch()->getSeventvEventAPI()->unsubscribeTwitchChannel( this->roomId()); } } @@ -426,7 +413,7 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) << "] Channel point reward added:" << reward.id << "," << reward.title << "," << reward.isUserInputRequired; - auto *server = getApp()->twitch; + auto *server = getIApp()->getTwitch(); auto it = std::remove_if( this->waitingRedemptions_.begin(), this->waitingRedemptions_.end(), [&](const QueuedRedemption &msg) { @@ -585,6 +572,10 @@ void TwitchChannel::showLoginMessage() void TwitchChannel::roomIdChanged() { + if (getIApp()->isTest()) + { + return; + } this->refreshPubSub(); this->refreshBadges(); this->refreshCheerEmotes(); @@ -777,7 +768,7 @@ bool TwitchChannel::canReconnect() const void TwitchChannel::reconnect() { - getApp()->twitch->connect(); + getIApp()->getTwitchAbstract()->connect(); } QString TwitchChannel::roomId() const @@ -791,7 +782,7 @@ void TwitchChannel::setRoomId(const QString &id) { *this->roomID_.access() = id; // This is intended for tests and benchmarks. See comment in constructor. - if (getApp()) + if (!getIApp()->isTest()) { this->roomIdChanged(); this->loadRecentMessages(); @@ -892,7 +883,7 @@ const QString &TwitchChannel::seventvEmoteSetID() const void TwitchChannel::joinBttvChannel() const { - if (getApp()->twitch->bttvLiveUpdates) + if (getIApp()->getTwitch()->getBTTVLiveUpdates()) { const auto currentAccount = getIApp()->getAccounts()->twitch.getCurrent(); @@ -901,8 +892,8 @@ void TwitchChannel::joinBttvChannel() const { userName = currentAccount->getUserName(); } - getApp()->twitch->bttvLiveUpdates->joinChannel(this->roomId(), - userName); + getIApp()->getTwitch()->getBTTVLiveUpdates()->joinChannel( + this->roomId(), userName); } } @@ -1049,14 +1040,14 @@ void TwitchChannel::updateSeventvData(const QString &newUserID, this->seventvUserID_ = newUserID; this->seventvEmoteSetID_ = newEmoteSetID; runInGuiThread([this, oldUserID, oldEmoteSetID]() { - if (getApp()->twitch->seventvEventAPI) + if (getIApp()->getTwitch()->getSeventvEventAPI()) { - getApp()->twitch->seventvEventAPI->subscribeUser( + getIApp()->getTwitch()->getSeventvEventAPI()->subscribeUser( this->seventvUserID_, this->seventvEmoteSetID_); if (oldUserID || oldEmoteSetID) { - getApp()->twitch->dropSeventvChannel( + getIApp()->getTwitch()->dropSeventvChannel( oldUserID.value_or(QString()), oldEmoteSetID.value_or(QString())); } @@ -1252,7 +1243,8 @@ void TwitchChannel::loadRecentMessages() tc->addRecentChatter(msg->displayName); } - getApp()->twitch->mentionsChannel->fillInMissingMessages(msgs); + getIApp()->getTwitch()->getMentionsChannel()->fillInMissingMessages( + msgs); }, [weak]() { auto shared = weak.lock(); @@ -1339,6 +1331,11 @@ void TwitchChannel::loadRecentMessagesReconnect() void TwitchChannel::refreshPubSub() { + if (getIApp()->isTest()) + { + return; + } + auto roomId = this->roomId(); if (roomId.isEmpty()) { @@ -1842,9 +1839,9 @@ void TwitchChannel::updateSevenTVActivity() void TwitchChannel::listenSevenTVCosmetics() const { - if (getApp()->twitch->seventvEventAPI) + if (getIApp()->getTwitch()->getSeventvEventAPI()) { - getApp()->twitch->seventvEventAPI->subscribeTwitchChannel( + getIApp()->getTwitch()->getSeventvEventAPI()->subscribeTwitchChannel( this->roomId()); } } diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 2add5430213..611fea1f9a0 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -463,6 +463,7 @@ class TwitchChannel final : public Channel, public ChannelChatters friend class TwitchIrcServer; friend class TwitchMessageBuilder; friend class IrcMessageHandler; + friend class Commands_E2E_Test; }; } // namespace chatterino diff --git a/src/providers/twitch/TwitchCommon.hpp b/src/providers/twitch/TwitchCommon.hpp index 19f538c2a4f..a5c38824500 100644 --- a/src/providers/twitch/TwitchCommon.hpp +++ b/src/providers/twitch/TwitchCommon.hpp @@ -77,6 +77,7 @@ static const QStringList TWITCH_DEFAULT_COMMANDS{ "delete", "announce", "requests", + "warn", }; static const QStringList TWITCH_WHISPER_COMMANDS{"/w", ".w"}; diff --git a/src/providers/twitch/TwitchEmotes.cpp b/src/providers/twitch/TwitchEmotes.cpp index 4c87e472b61..4baa13f2027 100644 --- a/src/providers/twitch/TwitchEmotes.cpp +++ b/src/providers/twitch/TwitchEmotes.cpp @@ -5,6 +5,400 @@ #include "messages/Image.hpp" #include "util/QStringHash.hpp" +namespace { + +using namespace chatterino; + +Url getEmoteLink(const EmoteId &id, const QString &emoteScale) +{ + return {QString(TWITCH_EMOTE_TEMPLATE) + .replace("{id}", id.string) + .replace("{scale}", emoteScale)}; +} + +QSize getEmoteExpectedBaseSize(const EmoteId &id) +{ + // From Twitch docs - expected size for an emote (1x) + constexpr QSize defaultBaseSize(28, 28); + static std::unordered_map outliers{ + {"555555635", {21, 18}}, /* ;p */ + {"555555636", {21, 18}}, /* ;-p */ + {"555555614", {21, 18}}, /* O_o */ + {"555555641", {21, 18}}, /* :z */ + {"555555604", {21, 18}}, /* :\\ */ + {"444", {21, 18}}, /* :| */ + {"555555634", {21, 18}}, /* ;-P */ + {"439", {21, 18}}, /* ;) */ + {"555555642", {21, 18}}, /* :-z */ + {"555555613", {21, 18}}, /* :-o */ + {"555555625", {21, 18}}, /* :-p */ + {"433", {21, 18}}, /* :/ */ + {"555555622", {21, 18}}, /* :P */ + {"555555640", {21, 18}}, /* :-| */ + {"555555623", {21, 18}}, /* :-P */ + {"555555628", {21, 18}}, /* :) */ + {"555555632", {21, 18}}, /* 8-) */ + {"555555667", {20, 18}}, /* ;p */ + {"445", {21, 18}}, /* <3 */ + {"555555668", {20, 18}}, /* ;-p */ + {"555555679", {20, 18}}, /* :z */ + {"483", {20, 18}}, /* <3 */ + {"555555666", {20, 18}}, /* ;-P */ + {"497", {20, 18}}, /* O_o */ + {"555555664", {20, 18}}, /* :-p */ + {"555555671", {20, 18}}, /* :o */ + {"555555681", {20, 18}}, /* :Z */ + {"555555672", {20, 18}}, /* :-o */ + {"555555676", {20, 18}}, /* :-\\ */ + {"555555611", {21, 18}}, /* :-O */ + {"555555670", {20, 18}}, /* :-O */ + {"555555688", {20, 18}}, /* :-D */ + {"441", {21, 18}}, /* B) */ + {"555555601", {21, 18}}, /* >( */ + {"491", {20, 18}}, /* ;P */ + {"496", {20, 18}}, /* :D */ + {"492", {20, 18}}, /* :O */ + {"555555573", {24, 18}}, /* o_O */ + {"555555643", {21, 18}}, /* :Z */ + {"1898", {26, 28}}, /* ThunBeast */ + {"555555682", {20, 18}}, /* :-Z */ + {"1896", {20, 30}}, /* WholeWheat */ + {"1906", {24, 30}}, /* SoBayed */ + {"555555607", {21, 18}}, /* :-( */ + {"555555660", {20, 18}}, /* :-( */ + {"489", {20, 18}}, /* :( */ + {"495", {20, 18}}, /* :s */ + {"555555638", {21, 18}}, /* :-D */ + {"357", {28, 30}}, /* HotPokket */ + {"555555624", {21, 18}}, /* :p */ + {"73", {21, 30}}, /* DBstyle */ + {"555555674", {20, 18}}, /* :-/ */ + {"555555629", {21, 18}}, /* :-) */ + {"555555600", {24, 18}}, /* R-) */ + {"41", {19, 27}}, /* Kreygasm */ + {"555555612", {21, 18}}, /* :o */ + {"488", {29, 24}}, /* :7 */ + {"69", {41, 28}}, /* BloodTrail */ + {"555555608", {21, 18}}, /* R) */ + {"501", {20, 18}}, /* ;) */ + {"50", {18, 27}}, /* ArsonNoSexy */ + {"443", {21, 18}}, /* :D */ + {"1904", {24, 30}}, /* BigBrother */ + {"555555595", {24, 18}}, /* ;P */ + {"555555663", {20, 18}}, /* :p */ + {"555555576", {24, 18}}, /* o.o */ + {"360", {22, 30}}, /* FailFish */ + {"500", {20, 18}}, /* B) */ + {"3", {24, 18}}, /* :D */ + {"484", {20, 22}}, /* R) */ + {"555555678", {20, 18}}, /* :-| */ + {"7", {24, 18}}, /* B) */ + {"52", {32, 32}}, /* SMOrc */ + {"555555644", {21, 18}}, /* :-Z */ + {"18", {20, 27}}, /* TheRinger */ + {"49106", {27, 28}}, /* CorgiDerp */ + {"6", {24, 18}}, /* O_o */ + {"10", {24, 18}}, /* :/ */ + {"47", {24, 24}}, /* PunchTrees */ + {"555555561", {24, 18}}, /* :-D */ + {"555555564", {24, 18}}, /* :-| */ + {"13", {24, 18}}, /* ;P */ + {"555555593", {24, 18}}, /* :p */ + {"555555589", {24, 18}}, /* ;) */ + {"555555590", {24, 18}}, /* ;-) */ + {"486", {27, 42}}, /* :> */ + {"40", {21, 27}}, /* KevinTurtle */ + {"555555558", {24, 18}}, /* :( */ + {"555555597", {24, 18}}, /* ;p */ + {"555555580", {24, 18}}, /* :O */ + {"555555567", {24, 18}}, /* :Z */ + {"1", {24, 18}}, /* :) */ + {"11", {24, 18}}, /* ;) */ + {"33", {25, 32}}, /* DansGame */ + {"555555586", {24, 18}}, /* :-/ */ + {"4", {24, 18}}, /* >( */ + {"555555588", {24, 18}}, /* :-\\ */ + {"12", {24, 18}}, /* :P */ + {"555555563", {24, 18}}, /* :| */ + {"555555581", {24, 18}}, /* :-O */ + {"555555598", {24, 18}}, /* ;-p */ + {"555555596", {24, 18}}, /* ;-P */ + {"555555557", {24, 18}}, /* :-) */ + {"498", {20, 18}}, /* >( */ + {"555555680", {20, 18}}, /* :-z */ + {"555555587", {24, 18}}, /* :\\ */ + {"5", {24, 18}}, /* :| */ + {"354", {20, 30}}, /* 4Head */ + {"555555562", {24, 18}}, /* >( */ + {"555555594", {24, 18}}, /* :-p */ + {"490", {20, 18}}, /* :P */ + {"555555662", {20, 18}}, /* :-P */ + {"2", {24, 18}}, /* :( */ + {"1902", {27, 29}}, /* Keepo */ + {"555555627", {21, 18}}, /* ;-) */ + {"555555566", {24, 18}}, /* :-z */ + {"555555559", {24, 18}}, /* :-( */ + {"555555592", {24, 18}}, /* :-P */ + {"28", {39, 27}}, /* MrDestructoid */ + {"8", {24, 18}}, /* :O */ + {"244", {24, 30}}, /* FUNgineer */ + {"555555591", {24, 18}}, /* :P */ + {"555555585", {24, 18}}, /* :/ */ + {"494", {20, 18}}, /* :| */ + {"9", {24, 18}}, /* <3 */ + {"555555584", {24, 18}}, /* <3 */ + {"555555579", {24, 18}}, /* 8-) */ + {"14", {24, 18}}, /* R) */ + {"485", {27, 18}}, /* #/ */ + {"555555560", {24, 18}}, /* :D */ + {"86", {36, 30}}, /* BibleThump */ + {"555555578", {24, 18}}, /* B-) */ + {"17", {20, 27}}, /* StoneLightning */ + {"436", {21, 18}}, /* :O */ + {"555555675", {20, 18}}, /* :\\ */ + {"22", {19, 27}}, /* RedCoat */ + {"555555574", {24, 18}}, /* o.O */ + {"555555603", {21, 18}}, /* :-/ */ + {"1901", {24, 28}}, /* Kippa */ + {"15", {21, 27}}, /* JKanStyle */ + {"555555605", {21, 18}}, /* :-\\ */ + {"555555701", {20, 18}}, /* ;-) */ + {"487", {20, 42}}, /* <] */ + {"555555572", {24, 18}}, /* O.O */ + {"65", {40, 30}}, /* FrankerZ */ + {"25", {25, 28}}, /* Kappa */ + {"36", {36, 30}}, /* PJSalt */ + {"499", {20, 18}}, /* :) */ + {"555555565", {24, 18}}, /* :z */ + {"434", {21, 18}}, /* :( */ + {"555555577", {24, 18}}, /* B) */ + {"34", {21, 28}}, /* SwiftRage */ + {"555555575", {24, 18}}, /* o_o */ + {"92", {23, 30}}, /* PMSTwin */ + {"555555570", {24, 18}}, /* O.o */ + {"555555569", {24, 18}}, /* O_o */ + {"493", {20, 18}}, /* :/ */ + {"26", {20, 27}}, /* JonCarnage */ + {"66", {20, 27}}, /* OneHand */ + {"555555568", {24, 18}}, /* :-Z */ + {"555555599", {24, 18}}, /* R) */ + {"1900", {33, 30}}, /* RalpherZ */ + {"555555582", {24, 18}}, /* :o */ + {"1899", {22, 30}}, /* TF2John */ + {"555555633", {21, 18}}, /* ;P */ + {"16", {22, 27}}, /* OptimizePrime */ + {"30", {29, 27}}, /* BCWarrior */ + {"555555583", {24, 18}}, /* :-o */ + {"32", {21, 27}}, /* GingerPower */ + {"87", {24, 30}}, /* ShazBotstix */ + {"74", {24, 30}}, /* AsianGlow */ + {"555555571", {24, 18}}, /* O_O */ + {"46", {24, 24}}, /* SSSsss */ + }; + + auto it = outliers.find(id.string); + if (it != outliers.end()) + { + return it->second; + } + + return defaultBaseSize; +} + +qreal getEmote3xScaleFactor(const EmoteId &id) +{ + // From Twitch docs - expected size for an emote (1x) + constexpr qreal default3xScaleFactor = 0.25; + static std::unordered_map outliers{ + {"555555635", 0.3333333333333333}, /* ;p */ + {"555555636", 0.3333333333333333}, /* ;-p */ + {"555555614", 0.3333333333333333}, /* O_o */ + {"555555641", 0.3333333333333333}, /* :z */ + {"555555604", 0.3333333333333333}, /* :\\ */ + {"444", 0.3333333333333333}, /* :| */ + {"555555634", 0.3333333333333333}, /* ;-P */ + {"439", 0.3333333333333333}, /* ;) */ + {"555555642", 0.3333333333333333}, /* :-z */ + {"555555613", 0.3333333333333333}, /* :-o */ + {"555555625", 0.3333333333333333}, /* :-p */ + {"433", 0.3333333333333333}, /* :/ */ + {"555555622", 0.3333333333333333}, /* :P */ + {"555555640", 0.3333333333333333}, /* :-| */ + {"555555623", 0.3333333333333333}, /* :-P */ + {"555555628", 0.3333333333333333}, /* :) */ + {"555555632", 0.3333333333333333}, /* 8-) */ + {"555555667", 0.3333333333333333}, /* ;p */ + {"445", 0.3333333333333333}, /* <3 */ + {"555555668", 0.3333333333333333}, /* ;-p */ + {"555555679", 0.3333333333333333}, /* :z */ + {"483", 0.3333333333333333}, /* <3 */ + {"555555666", 0.3333333333333333}, /* ;-P */ + {"497", 0.3333333333333333}, /* O_o */ + {"555555664", 0.3333333333333333}, /* :-p */ + {"555555671", 0.3333333333333333}, /* :o */ + {"555555681", 0.3333333333333333}, /* :Z */ + {"555555672", 0.3333333333333333}, /* :-o */ + {"555555676", 0.3333333333333333}, /* :-\\ */ + {"555555611", 0.3333333333333333}, /* :-O */ + {"555555670", 0.3333333333333333}, /* :-O */ + {"555555688", 0.3333333333333333}, /* :-D */ + {"441", 0.3333333333333333}, /* B) */ + {"555555601", 0.3333333333333333}, /* >( */ + {"491", 0.3333333333333333}, /* ;P */ + {"496", 0.3333333333333333}, /* :D */ + {"492", 0.3333333333333333}, /* :O */ + {"555555573", 0.3333333333333333}, /* o_O */ + {"555555643", 0.3333333333333333}, /* :Z */ + {"1898", 0.3333333333333333}, /* ThunBeast */ + {"555555682", 0.3333333333333333}, /* :-Z */ + {"1896", 0.3333333333333333}, /* WholeWheat */ + {"1906", 0.3333333333333333}, /* SoBayed */ + {"555555607", 0.3333333333333333}, /* :-( */ + {"555555660", 0.3333333333333333}, /* :-( */ + {"489", 0.3333333333333333}, /* :( */ + {"495", 0.3333333333333333}, /* :s */ + {"555555638", 0.3333333333333333}, /* :-D */ + {"357", 0.3333333333333333}, /* HotPokket */ + {"555555624", 0.3333333333333333}, /* :p */ + {"73", 0.3333333333333333}, /* DBstyle */ + {"555555674", 0.3333333333333333}, /* :-/ */ + {"555555629", 0.3333333333333333}, /* :-) */ + {"555555600", 0.3333333333333333}, /* R-) */ + {"41", 0.3333333333333333}, /* Kreygasm */ + {"555555612", 0.3333333333333333}, /* :o */ + {"488", 0.3333333333333333}, /* :7 */ + {"69", 0.3333333333333333}, /* BloodTrail */ + {"555555608", 0.3333333333333333}, /* R) */ + {"501", 0.3333333333333333}, /* ;) */ + {"50", 0.3333333333333333}, /* ArsonNoSexy */ + {"443", 0.3333333333333333}, /* :D */ + {"1904", 0.3333333333333333}, /* BigBrother */ + {"555555595", 0.3333333333333333}, /* ;P */ + {"555555663", 0.3333333333333333}, /* :p */ + {"555555576", 0.3333333333333333}, /* o.o */ + {"360", 0.3333333333333333}, /* FailFish */ + {"500", 0.3333333333333333}, /* B) */ + {"3", 0.3333333333333333}, /* :D */ + {"484", 0.3333333333333333}, /* R) */ + {"555555678", 0.3333333333333333}, /* :-| */ + {"7", 0.3333333333333333}, /* B) */ + {"52", 0.3333333333333333}, /* SMOrc */ + {"555555644", 0.3333333333333333}, /* :-Z */ + {"18", 0.3333333333333333}, /* TheRinger */ + {"49106", 0.3333333333333333}, /* CorgiDerp */ + {"6", 0.3333333333333333}, /* O_o */ + {"10", 0.3333333333333333}, /* :/ */ + {"47", 0.3333333333333333}, /* PunchTrees */ + {"555555561", 0.3333333333333333}, /* :-D */ + {"555555564", 0.3333333333333333}, /* :-| */ + {"13", 0.3333333333333333}, /* ;P */ + {"555555593", 0.3333333333333333}, /* :p */ + {"555555589", 0.3333333333333333}, /* ;) */ + {"555555590", 0.3333333333333333}, /* ;-) */ + {"486", 0.3333333333333333}, /* :> */ + {"40", 0.3333333333333333}, /* KevinTurtle */ + {"555555558", 0.3333333333333333}, /* :( */ + {"555555597", 0.3333333333333333}, /* ;p */ + {"555555580", 0.3333333333333333}, /* :O */ + {"555555567", 0.3333333333333333}, /* :Z */ + {"1", 0.3333333333333333}, /* :) */ + {"11", 0.3333333333333333}, /* ;) */ + {"33", 0.3333333333333333}, /* DansGame */ + {"555555586", 0.3333333333333333}, /* :-/ */ + {"4", 0.3333333333333333}, /* >( */ + {"555555588", 0.3333333333333333}, /* :-\\ */ + {"12", 0.3333333333333333}, /* :P */ + {"555555563", 0.3333333333333333}, /* :| */ + {"555555581", 0.3333333333333333}, /* :-O */ + {"555555598", 0.3333333333333333}, /* ;-p */ + {"555555596", 0.3333333333333333}, /* ;-P */ + {"555555557", 0.3333333333333333}, /* :-) */ + {"498", 0.3333333333333333}, /* >( */ + {"555555680", 0.3333333333333333}, /* :-z */ + {"555555587", 0.3333333333333333}, /* :\\ */ + {"5", 0.3333333333333333}, /* :| */ + {"354", 0.3333333333333333}, /* 4Head */ + {"555555562", 0.3333333333333333}, /* >( */ + {"555555594", 0.3333333333333333}, /* :-p */ + {"490", 0.3333333333333333}, /* :P */ + {"555555662", 0.3333333333333333}, /* :-P */ + {"2", 0.3333333333333333}, /* :( */ + {"1902", 0.3333333333333333}, /* Keepo */ + {"555555627", 0.3333333333333333}, /* ;-) */ + {"555555566", 0.3333333333333333}, /* :-z */ + {"555555559", 0.3333333333333333}, /* :-( */ + {"555555592", 0.3333333333333333}, /* :-P */ + {"28", 0.3333333333333333}, /* MrDestructoid */ + {"8", 0.3333333333333333}, /* :O */ + {"244", 0.3333333333333333}, /* FUNgineer */ + {"555555591", 0.3333333333333333}, /* :P */ + {"555555585", 0.3333333333333333}, /* :/ */ + {"494", 0.3333333333333333}, /* :| */ + {"9", 0.21428571428571427}, /* <3 */ + {"555555584", 0.21428571428571427}, /* <3 */ + {"555555579", 0.3333333333333333}, /* 8-) */ + {"14", 0.3333333333333333}, /* R) */ + {"485", 0.3333333333333333}, /* #/ */ + {"555555560", 0.3333333333333333}, /* :D */ + {"86", 0.3333333333333333}, /* BibleThump */ + {"555555578", 0.3333333333333333}, /* B-) */ + {"17", 0.3333333333333333}, /* StoneLightning */ + {"436", 0.3333333333333333}, /* :O */ + {"555555675", 0.3333333333333333}, /* :\\ */ + {"22", 0.3333333333333333}, /* RedCoat */ + {"245", 0.3333333333333333}, /* ResidentSleeper */ + {"555555574", 0.3333333333333333}, /* o.O */ + {"555555603", 0.3333333333333333}, /* :-/ */ + {"1901", 0.3333333333333333}, /* Kippa */ + {"15", 0.3333333333333333}, /* JKanStyle */ + {"555555605", 0.3333333333333333}, /* :-\\ */ + {"555555701", 0.3333333333333333}, /* ;-) */ + {"487", 0.3333333333333333}, /* <] */ + {"22639", 0.3333333333333333}, /* BabyRage */ + {"555555572", 0.3333333333333333}, /* O.O */ + {"65", 0.3333333333333333}, /* FrankerZ */ + {"25", 0.3333333333333333}, /* Kappa */ + {"36", 0.3333333333333333}, /* PJSalt */ + {"499", 0.3333333333333333}, /* :) */ + {"555555565", 0.3333333333333333}, /* :z */ + {"434", 0.3333333333333333}, /* :( */ + {"555555577", 0.3333333333333333}, /* B) */ + {"34", 0.3333333333333333}, /* SwiftRage */ + {"555555575", 0.3333333333333333}, /* o_o */ + {"92", 0.3333333333333333}, /* PMSTwin */ + {"555555570", 0.3333333333333333}, /* O.o */ + {"555555569", 0.3333333333333333}, /* O_o */ + {"493", 0.3333333333333333}, /* :/ */ + {"26", 0.3333333333333333}, /* JonCarnage */ + {"66", 0.3333333333333333}, /* OneHand */ + {"973", 0.3333333333333333}, /* DAESuppy */ + {"555555568", 0.3333333333333333}, /* :-Z */ + {"555555599", 0.3333333333333333}, /* R) */ + {"1900", 0.3333333333333333}, /* RalpherZ */ + {"555555582", 0.3333333333333333}, /* :o */ + {"1899", 0.3333333333333333}, /* TF2John */ + {"555555633", 0.3333333333333333}, /* ;P */ + {"16", 0.3333333333333333}, /* OptimizePrime */ + {"30", 0.3333333333333333}, /* BCWarrior */ + {"555555583", 0.3333333333333333}, /* :-o */ + {"32", 0.3333333333333333}, /* GingerPower */ + {"87", 0.3333333333333333}, /* ShazBotstix */ + {"74", 0.3333333333333333}, /* AsianGlow */ + {"555555571", 0.3333333333333333}, /* O_O */ + {"46", 0.3333333333333333}, /* SSSsss */ + }; + + auto it = outliers.find(id.string); + if (it != outliers.end()) + { + return it->second; + } + + return default3xScaleFactor; +} + +} // namespace + namespace chatterino { QString TwitchEmotes::cleanUpEmoteCode(const QString &dirtyEmoteCode) @@ -44,14 +438,15 @@ EmotePtr TwitchEmotes::getOrCreateEmote(const EmoteId &id, if (!shared) { - // From Twitch docs - expected size for an emote (1x) - constexpr QSize baseSize(28, 28); + auto baseSize = getEmoteExpectedBaseSize(id); + auto emote3xScaleFactor = getEmote3xScaleFactor(id); (*cache)[id] = shared = std::make_shared(Emote{ EmoteName{name}, ImageSet{ Image::fromUrl(getEmoteLink(id, "1.0"), 1, baseSize), Image::fromUrl(getEmoteLink(id, "2.0"), 0.5, baseSize * 2), - Image::fromUrl(getEmoteLink(id, "3.0"), 0.25, baseSize * 4), + Image::fromUrl(getEmoteLink(id, "3.0"), emote3xScaleFactor, + baseSize * (1.0 / emote3xScaleFactor)), }, Tooltip{name.toHtmlEscaped() + "
Twitch Emote"}, }); @@ -60,11 +455,4 @@ EmotePtr TwitchEmotes::getOrCreateEmote(const EmoteId &id, return shared; } -Url TwitchEmotes::getEmoteLink(const EmoteId &id, const QString &emoteScale) -{ - return {QString(TWITCH_EMOTE_TEMPLATE) - .replace("{id}", id.string) - .replace("{scale}", emoteScale)}; -} - } // namespace chatterino diff --git a/src/providers/twitch/TwitchEmotes.hpp b/src/providers/twitch/TwitchEmotes.hpp index d793ce72338..17e50b11fcb 100644 --- a/src/providers/twitch/TwitchEmotes.hpp +++ b/src/providers/twitch/TwitchEmotes.hpp @@ -52,7 +52,6 @@ class TwitchEmotes : public ITwitchEmotes const EmoteName &name) override; private: - Url getEmoteLink(const EmoteId &id, const QString &emoteScale); UniqueAccess>> twitchEmotesCache_; }; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 9a71c89ac77..7012cdb2192 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -39,9 +39,17 @@ const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3"; void sendHelixMessage(const std::shared_ptr &channel, const QString &message, const QString &replyParentId = {}) { + auto broadcasterID = channel->roomId(); + if (broadcasterID.isEmpty()) + { + channel->addMessage(makeSystemMessage( + "Sending messages in this channel isn't possible.")); + return; + } + getHelix()->sendChatMessage( { - .broadcasterID = channel->roomId(), + .broadcasterID = broadcasterID, .senderID = getIApp()->getAccounts()->twitch.getCurrent()->getUserId(), .message = message, @@ -68,13 +76,18 @@ void sendHelixMessage(const std::shared_ptr &channel, }(); chan->addMessage(errorMessage); }, - [weak = std::weak_ptr(channel)](auto error, const auto &message) { + [weak = std::weak_ptr(channel)](auto error, auto message) { auto chan = weak.lock(); if (!chan) { return; } + if (message.isEmpty()) + { + message = "(empty message)"; + } + using Error = decltype(error); auto errorMessage = [&]() -> QString { @@ -152,7 +165,7 @@ TwitchIrcServer::TwitchIrcServer() // false); } -void TwitchIrcServer::initialize(Settings &settings, const Paths &paths) +void TwitchIrcServer::initialize() { getIApp()->getAccounts()->twitch.currentUserChanged.connect([this]() { postToThread([this] { @@ -250,7 +263,7 @@ std::shared_ptr TwitchIrcServer::createChannel( void TwitchIrcServer::privateMessageReceived( Communi::IrcPrivateMessage *message) { - IrcMessageHandler::instance().handlePrivMessage(message, *this); + IrcMessageHandler::instance().handlePrivMessage(message, *this, *this); } void TwitchIrcServer::readConnectionMessageReceived( @@ -297,7 +310,7 @@ void TwitchIrcServer::readConnectionMessageReceived( } else if (command == "USERNOTICE") { - handler.handleUserNoticeMessage(message, *this); + handler.handleUserNoticeMessage(message, *this, *this); } else if (command == "NOTICE") { @@ -632,16 +645,56 @@ void TwitchIrcServer::onReplySendRequested( sent = true; } +std::unique_ptr &TwitchIrcServer::getBTTVLiveUpdates() +{ + return this->bttvLiveUpdates; +} + +std::unique_ptr &TwitchIrcServer::getSeventvEventAPI() +{ + return this->seventvEventAPI; +} + const IndirectChannel &TwitchIrcServer::getWatchingChannel() const { return this->watchingChannel; } +void TwitchIrcServer::setWatchingChannel(ChannelPtr newWatchingChannel) +{ + this->watchingChannel.reset(newWatchingChannel); +} + +ChannelPtr TwitchIrcServer::getWhispersChannel() const +{ + return this->whispersChannel; +} + +ChannelPtr TwitchIrcServer::getMentionsChannel() const +{ + return this->mentionsChannel; +} + +ChannelPtr TwitchIrcServer::getLiveChannel() const +{ + return this->liveChannel; +} + +ChannelPtr TwitchIrcServer::getAutomodChannel() const +{ + return this->automodChannel; +} + QString TwitchIrcServer::getLastUserThatWhisperedMe() const { return this->lastUserThatWhisperedMe.get(); } +void TwitchIrcServer::setLastUserThatWhisperedMe(const QString &user) +{ + this->lastUserThatWhisperedMe.set(user); +} + void TwitchIrcServer::reloadBTTVGlobalEmotes() { getIApp()->getBttvEmotes()->loadEmotes(); diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index 5fef4908492..1f3dbe73028 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -2,7 +2,6 @@ #include "common/Atomic.hpp" #include "common/Channel.hpp" -#include "common/Singleton.hpp" #include "providers/irc/AbstractIrcServer.hpp" #include @@ -27,26 +26,49 @@ class ITwitchIrcServer public: virtual ~ITwitchIrcServer() = default; + virtual void forEachChannelAndSpecialChannels( + std::function func) = 0; + + virtual std::shared_ptr getChannelOrEmptyByID( + const QString &channelID) = 0; + + virtual void dropSeventvChannel(const QString &userID, + const QString &emoteSetID) = 0; + + virtual std::unique_ptr &getBTTVLiveUpdates() = 0; + virtual std::unique_ptr &getSeventvEventAPI() = 0; + virtual const IndirectChannel &getWatchingChannel() const = 0; + virtual void setWatchingChannel(ChannelPtr newWatchingChannel) = 0; + virtual ChannelPtr getWhispersChannel() const = 0; + virtual ChannelPtr getMentionsChannel() const = 0; + virtual ChannelPtr getLiveChannel() const = 0; + virtual ChannelPtr getAutomodChannel() const = 0; virtual QString getLastUserThatWhisperedMe() const = 0; + virtual void setLastUserThatWhisperedMe(const QString &user) = 0; // Update this interface with TwitchIrcServer methods as needed }; -class TwitchIrcServer final : public AbstractIrcServer, - public Singleton, - public ITwitchIrcServer +class TwitchIrcServer final : public AbstractIrcServer, public ITwitchIrcServer { public: TwitchIrcServer(); ~TwitchIrcServer() override = default; - void initialize(Settings &settings, const Paths &paths) override; + TwitchIrcServer(const TwitchIrcServer &) = delete; + TwitchIrcServer(TwitchIrcServer &&) = delete; + TwitchIrcServer &operator=(const TwitchIrcServer &) = delete; + TwitchIrcServer &operator=(TwitchIrcServer &&) = delete; - void forEachChannelAndSpecialChannels(std::function func); + void initialize(); - std::shared_ptr getChannelOrEmptyByID(const QString &channelID); + void forEachChannelAndSpecialChannels( + std::function func) override; + + std::shared_ptr getChannelOrEmptyByID( + const QString &channelID) override; void reloadBTTVGlobalEmotes(); void reloadAllBTTVChannelEmotes(); @@ -68,8 +90,10 @@ class TwitchIrcServer final : public AbstractIrcServer, * It's currently not possible to share emote sets among users, * but it's a commonly requested feature. */ - void dropSeventvChannel(const QString &userID, const QString &emoteSetID); + void dropSeventvChannel(const QString &userID, + const QString &emoteSetID) override; +private: Atomic lastUserThatWhisperedMe; const ChannelPtr whispersChannel; @@ -81,9 +105,19 @@ class TwitchIrcServer final : public AbstractIrcServer, std::unique_ptr bttvLiveUpdates; std::unique_ptr seventvEventAPI; +public: + std::unique_ptr &getBTTVLiveUpdates() override; + std::unique_ptr &getSeventvEventAPI() override; + const IndirectChannel &getWatchingChannel() const override; + void setWatchingChannel(ChannelPtr newWatchingChannel) override; + ChannelPtr getWhispersChannel() const override; + ChannelPtr getMentionsChannel() const override; + ChannelPtr getLiveChannel() const override; + ChannelPtr getAutomodChannel() const override; QString getLastUserThatWhisperedMe() const override; + void setLastUserThatWhisperedMe(const QString &user) override; protected: void initializeConnection(IrcConnection *connection, diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 88c0f671e74..fe7e449909b 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -37,7 +37,6 @@ #include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" #include "util/QStringHash.hpp" -#include "util/Qt.hpp" #include "widgets/Window.hpp" #include @@ -51,6 +50,8 @@ using namespace chatterino::literals; namespace { +const QColor AUTOMOD_USER_COLOR{"blue"}; + using namespace std::chrono_literals; const QString regexHelpString("(\\w+)[.,!?;:]*?$"); @@ -380,13 +381,7 @@ namespace { dst.reserve(newLength); for (const QStringView &chunk : std::as_const(chunks)) { -#if QT_VERSION < QT_VERSION_CHECK(5, 15, 2) - static_assert(sizeof(QChar) == sizeof(decltype(*chunk.utf16()))); - dst.append(reinterpret_cast(chunk.utf16()), - chunk.length()); -#else dst += chunk; -#endif } return dst; } @@ -756,7 +751,7 @@ void TwitchMessageBuilder::addTextOrEmoji(const QString &string_) QString username = match.captured(1); auto originalTextColor = textColor; - if (this->twitchChannel != nullptr && getSettings()->colorUsernames) + if (this->twitchChannel != nullptr) { if (auto userColor = this->twitchChannel->getUserColor(username); @@ -767,21 +762,16 @@ void TwitchMessageBuilder::addTextOrEmoji(const QString &string_) } auto prefixedUsername = '@' + username; - this->emplace(prefixedUsername, - MessageElementFlag::BoldUsername, - textColor, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, username}) - ->setTrailingSpace(false); - - this->emplace(prefixedUsername, - MessageElementFlag::NonBoldUsername, - textColor) - ->setLink({Link::UserInfo, username}) - ->setTrailingSpace(false); - - this->emplace(string.remove(prefixedUsername), - MessageElementFlag::Text, - originalTextColor); + auto remainder = string.remove(prefixedUsername); + this->emplace(prefixedUsername, username, + originalTextColor, textColor) + ->setTrailingSpace(remainder.isEmpty()); + + if (!remainder.isEmpty()) + { + this->emplace(remainder, MessageElementFlag::Text, + originalTextColor); + } return; } @@ -797,30 +787,22 @@ void TwitchMessageBuilder::addTextOrEmoji(const QString &string_) { auto originalTextColor = textColor; - if (getSettings()->colorUsernames) + if (auto userColor = this->twitchChannel->getUserColor(username); + userColor.isValid()) { - if (auto userColor = - this->twitchChannel->getUserColor(username); - userColor.isValid()) - { - textColor = userColor; - } + textColor = userColor; } - this->emplace(username, - MessageElementFlag::BoldUsername, - textColor, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, username}) - ->setTrailingSpace(false); - - this->emplace( - username, MessageElementFlag::NonBoldUsername, textColor) - ->setLink({Link::UserInfo, username}) - ->setTrailingSpace(false); + auto remainder = string.remove(username); + this->emplace(username, username, originalTextColor, + textColor) + ->setTrailingSpace(remainder.isEmpty()); - this->emplace(string.remove(username), - MessageElementFlag::Text, - originalTextColor); + if (!remainder.isEmpty()) + { + this->emplace(remainder, MessageElementFlag::Text, + originalTextColor); + } return; } @@ -1189,13 +1171,8 @@ void TwitchMessageBuilder::processIgnorePhrases( shiftIndicesAfter(static_cast(from + length), static_cast(replacement.length() - length)); -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) auto midExtendedRef = QStringView{originalMessage}.mid(wordStart, wordEnd - wordStart); -#else - auto midExtendedRef = - originalMessage.midRef(wordStart, wordEnd - wordStart); -#endif for (auto &emote : removedEmotes) { @@ -1605,6 +1582,15 @@ void TwitchMessageBuilder::appendChannelPointRewardMessage( } builder->emplace(redeemed, MessageElementFlag::ChannelPointReward); + if (reward.id == "CELEBRATION") + { + const auto emotePtr = + getIApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( + EmoteId{reward.emoteId}, EmoteName{reward.emoteName}); + builder->emplace(emotePtr, + MessageElementFlag::ChannelPointReward, + MessageColor::Text); + } builder->emplace( reward.title, MessageElementFlag::ChannelPointReward, MessageColor::Text, FontStyle::ChatMediumBold); @@ -1613,6 +1599,12 @@ void TwitchMessageBuilder::appendChannelPointRewardMessage( builder->emplace( QString::number(reward.cost), MessageElementFlag::ChannelPointReward, MessageColor::Text, FontStyle::ChatMediumBold); + if (reward.isBits) + { + builder->emplace( + "bits", MessageElementFlag::ChannelPointReward, MessageColor::Text, + FontStyle::ChatMediumBold); + } if (reward.isUserInputRequired) { builder->emplace( @@ -1625,6 +1617,8 @@ void TwitchMessageBuilder::appendChannelPointRewardMessage( builder->message().messageText = textList.join(" "); builder->message().searchText = textList.join(" "); builder->message().loginName = reward.user.login; + + builder->message().reward = std::make_shared(reward); } void TwitchMessageBuilder::liveMessage(const QString &channelName, @@ -1819,7 +1813,7 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix, MessageColor color = MessageColor::System; - if (tc && getSettings()->colorUsernames) + if (tc) { if (auto userColor = tc->getUserColor(username); userColor.isValid()) @@ -1828,15 +1822,10 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix, } } + // TODO: Ensure we make use of display name / username(login name) correctly here builder - ->emplace(username, MessageElementFlag::BoldUsername, - color, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, username}) - ->setTrailingSpace(false); - builder - ->emplace(username, - MessageElementFlag::NonBoldUsername, color) - ->setLink({Link::UserInfo, username}) + ->emplace(username, username, MessageColor::System, + color) ->setTrailingSpace(false); } } @@ -1871,7 +1860,7 @@ void TwitchMessageBuilder::listOfUsersSystemMessage( MessageColor color = MessageColor::System; - if (tc && getSettings()->colorUsernames) + if (tc) { if (auto userColor = tc->getUserColor(user.userLogin); userColor.isValid()) @@ -1881,15 +1870,8 @@ void TwitchMessageBuilder::listOfUsersSystemMessage( } builder - ->emplace(user.userName, - MessageElementFlag::BoldUsername, color, - FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, user.userLogin}) - ->setTrailingSpace(false); - builder - ->emplace(user.userName, - MessageElementFlag::NonBoldUsername, color) - ->setLink({Link::UserInfo, user.userLogin}) + ->emplace(user.userName, user.userLogin, + MessageColor::System, color) ->setTrailingSpace(false); } @@ -1958,12 +1940,8 @@ MessagePtr TwitchMessageBuilder::makeAutomodInfoMessage( builder.emplace(makeAutoModBadge(), MessageElementFlag::BadgeChannelAuthority); // AutoMod "username" - builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, - MessageColor(QColor("blue")), - FontStyle::ChatMediumBold); - builder.emplace( - "AutoMod:", MessageElementFlag::NonBoldUsername, - MessageColor(QColor("blue"))); + builder.emplace("AutoMod:", MessageElementFlag::Text, + AUTOMOD_USER_COLOR, FontStyle::ChatMediumBold); switch (action.type) { case AutomodInfoAction::OnHold: { @@ -2017,12 +1995,9 @@ std::pair TwitchMessageBuilder::makeAutomodMessage( builder.emplace(makeAutoModBadge(), MessageElementFlag::BadgeChannelAuthority); // AutoMod "username" - builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, - MessageColor(QColor("blue")), - FontStyle::ChatMediumBold); - builder.emplace( - "AutoMod:", MessageElementFlag::NonBoldUsername, - MessageColor(QColor("blue"))); + builder2.emplace("AutoMod:", MessageElementFlag::Text, + AUTOMOD_USER_COLOR, + FontStyle::ChatMediumBold); // AutoMod header message builder.emplace( ("Held a message for reason: " + action.reason + @@ -2069,16 +2044,9 @@ std::pair TwitchMessageBuilder::makeAutomodMessage( builder2.message().flags.set(MessageFlag::AutoModOffendingMessage); // sender username - builder2 - .emplace( - action.target.displayName + ":", MessageElementFlag::BoldUsername, - MessageColor(action.target.color), FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.target.login}); - builder2 - .emplace(action.target.displayName + ":", - MessageElementFlag::NonBoldUsername, - MessageColor(action.target.color)) - ->setLink({Link::UserInfo, action.target.login}); + builder2.emplace(action.target.displayName + ":", + action.target.login, MessageColor::Text, + action.target.color); // sender's message caught by AutoMod builder2.emplace(action.message, MessageElementFlag::Text, MessageColor::Text); @@ -2273,17 +2241,9 @@ std::pair TwitchMessageBuilder::makeLowTrustUserMessage( appendBadges(&builder2, action.senderBadges, {}, twitchChannel); // sender username - builder2 - .emplace(action.suspiciousUserDisplayName + ":", - MessageElementFlag::BoldUsername, - MessageColor(action.suspiciousUserColor), - FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.suspiciousUserLogin}); - builder2 - .emplace(action.suspiciousUserDisplayName + ":", - MessageElementFlag::NonBoldUsername, - MessageColor(action.suspiciousUserColor)) - ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder2.emplace( + action.suspiciousUserDisplayName + ":", action.suspiciousUserLogin, + MessageColor::Text, action.suspiciousUserColor); // sender's message caught by AutoMod for (const auto &fragment : action.fragments) diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index daf17021f0c..5b8c9fbfd09 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -613,11 +613,13 @@ void Helix::unblockUser(QString targetUserId, const QObject *caller, .execute(); } -void Helix::updateChannel(QString broadcasterId, QString gameId, - QString language, QString title, - std::function successCallback, - HelixFailureCallback failureCallback) +void Helix::updateChannel( + QString broadcasterId, QString gameId, QString language, QString title, + std::function successCallback, + FailureCallback failureCallback) { + using Error = HelixUpdateChannelError; + QUrlQuery urlQuery; auto obj = QJsonObject(); if (!gameId.isEmpty()) @@ -646,7 +648,61 @@ void Helix::updateChannel(QString broadcasterId, QString gameId, successCallback(result); }) .onError([failureCallback](NetworkResult result) { - failureCallback(); + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (*result.status()) + { + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare( + "The ID in broadcaster_id must match the user " + "ID found in the request's OAuth token.", + Qt::CaseInsensitive) == 0) + { + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 400: + case 403: { + failureCallback(Error::Forwarded, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + case 500: { + failureCallback(Error::Unknown, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Helix update channel, unhandled error data:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } }) .execute(); } @@ -1407,16 +1463,6 @@ void Helix::removeChannelVIP( .execute(); } -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch void Helix::unbanUser( QString broadcasterID, QString moderatorID, QString userID, ResultCallback<> successCallback, @@ -1516,18 +1562,7 @@ void Helix::unbanUser( } }) .execute(); -} // These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch -// These changes are from the helix-command-migration/unban-untimeout branch +} void Helix::startRaid( QString fromBroadcasterID, QString toBroadcasterID, @@ -2210,6 +2245,107 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, .execute(); } +// Warn a user +// https://dev.twitch.tv/docs/api/reference#warn-chat-user +void Helix::warnUser( + QString broadcasterID, QString moderatorID, QString userID, QString reason, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixWarnUserError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject payload; + { + QJsonObject data; + data["reason"] = reason; + data["user_id"] = userID; + + payload["data"] = data; + } + + this->makePost("moderation/warnings", urlQuery) + .json(payload) + .onSuccess([successCallback](auto result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for warning a user was" + << result.formatError() << "but we expected it to be 200"; + } + // we don't care about the response + successCallback(); + }) + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (*result.status()) + { + case 400: { + if (message.startsWith("The user specified in the user_id " + "field may not be warned", + Qt::CaseInsensitive)) + { + failureCallback(Error::CannotWarnUser, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 409: { + failureCallback(Error::ConflictingOperation, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error warning user:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + // https://dev.twitch.tv/docs/api/reference#send-whisper void Helix::sendWhisper( QString fromUserID, QString toUserID, QString message, diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 0d5412ba3f0..346c9f3c375 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -595,6 +595,19 @@ enum class HelixUpdateChatSettingsError { // update chat settings Forwarded, }; // update chat settings +/// Error type for Helix::updateChannel +/// +/// Used in the /settitle and /setgame commands +enum class HelixUpdateChannelError { + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixBanUserError { // /timeout, /ban Unknown, UserMissingScope, @@ -608,6 +621,18 @@ enum class HelixBanUserError { // /timeout, /ban Forwarded, }; // /timeout, /ban +enum class HelixWarnUserError { // /warn + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + ConflictingOperation, + CannotWarnUser, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /warn + enum class HelixWhisperError { // /w Unknown, UserMissingScope, @@ -862,7 +887,7 @@ class IHelix virtual void updateChannel( QString broadcasterId, QString gameId, QString language, QString title, std::function successCallback, - HelixFailureCallback failureCallback) = 0; + FailureCallback failureCallback) = 0; // https://dev.twitch.tv/docs/api/reference#manage-held-automod-messages virtual void manageAutoModMessages( @@ -1011,6 +1036,13 @@ class IHelix ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // Warn a user + // https://dev.twitch.tv/docs/api/reference#warn-chat-user + virtual void warnUser( + QString broadcasterID, QString moderatorID, QString userID, + QString reason, ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + // Send a whisper // https://dev.twitch.tv/docs/api/reference#send-whisper virtual void sendWhisper( @@ -1183,7 +1215,8 @@ class Helix final : public IHelix void updateChannel(QString broadcasterId, QString gameId, QString language, QString title, std::function successCallback, - HelixFailureCallback failureCallback) final; + FailureCallback + failureCallback) final; // https://dev.twitch.tv/docs/api/reference#manage-held-automod-messages void manageAutoModMessages( @@ -1332,6 +1365,13 @@ class Helix final : public IHelix ResultCallback<> successCallback, FailureCallback failureCallback) final; + // Warn a user + // https://dev.twitch.tv/docs/api/reference#warn-chat-user + void warnUser( + QString broadcasterID, QString moderatorID, QString userID, + QString reason, ResultCallback<> successCallback, + FailureCallback failureCallback) final; + // Send a whisper // https://dev.twitch.tv/docs/api/reference#send-whisper void sendWhisper( diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index 23509c94b8d..18e9d6e4a9d 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -178,13 +178,21 @@ Used in: - `controllers/commands/CommandController.cpp` to send Twitch native shoutout using "/shoutout " +### Warn Chat User + +URL: https://dev.twitch.tv/docs/api/reference/#warn-chat-user + +Used in: + +- `controllers/commands/CommandController.cpp` to warn users via "/warn" command + ## PubSub ### Whispers We listen to the `whispers.` PubSub topic to receive information about incoming whispers to the user -No EventSub alternative available. +The EventSub alternative (`user.whisper.message`) is not yet implemented. ### Chat Moderator Actions @@ -192,25 +200,17 @@ We listen to the `chat_moderator_actions..` PubSub topic to We listen to this topic in every channel the user is a moderator. -No complete EventSub alternative available yet. Some functionality can be pieced together but it would not be zero cost, causing the `max_total_cost` of 10 to cause issues. - -- For showing bans & timeouts: `channel.ban`, but does not work with moderator token??? -- For showing unbans & untimeouts: `channel.unban`, but does not work with moderator token??? -- Clear/delete message: not in eventsub, and IRC doesn't tell us which mod performed the action -- Roomstate (slow(off), followers(off), r9k(off), emoteonly(off), subscribers(off)) => not in eventsub, and IRC doesn't tell us which mod performed the action -- VIP added => not in eventsub, but not critical -- VIP removed => not in eventsub, but not critical -- Moderator added => channel.moderator.add eventsub, but doesn't work with moderator token -- Moderator removed => channel.moderator.remove eventsub, but doesn't work with moderator token -- Raid started => channel.raid eventsub, but cost=1 for moderator token -- Unraid => not in eventsub -- Add permitted term => not in eventsub -- Delete permitted term => not in eventsub -- Add blocked term => not in eventsub -- Delete blocked term => not in eventsub -- Modified automod properties => not in eventsub -- Approve unban request => cannot read moderator message in eventsub -- Deny unban request => not in eventsub +We have not yet migrated to the EventSub equivalent topics: + +- For showing bans & timeouts => `channel.moderate` +- For showing unbans & untimeouts => `channel.moderate` +- Clear/delete message => `channel.moderate` +- Roomstate (slow(off), followers(off), r9k(off), emoteonly(off), subscribers(off)) => `channel.moderate` +- VIP/Moderator added/removed => `channel.moderate` +- Raid started/cancelled => `channel.moderate` +- Add/delete permitted/blocked term => `channel.moderate` (or `automod.terms.update`) +- Modified automod properties => `automod.settings.update` +- Approve/deny unban request => `channel.moderate` (or `channel.unban_request.resolve`) ### AutoMod Queue @@ -218,7 +218,7 @@ We listen to the `automod-queue..` PubSub topic to rec We listen to this topic in every channel the user is a moderator. -No EventSub alternative available yet. +The EventSub alternative (`automod.message.hold` and `automod.message.update`) is not yet implemented. ### Channel Point Rewards @@ -230,4 +230,4 @@ The EventSub alternative requires broadcaster auth, which is not a feasible alte We want to listen to the `low-trust-users` PubSub topic to receive information about messages from users who are marked as low-trust. -There is no EventSub alternative available yet. +The EventSub alternative (`channel.suspicious_user.message` and `channel.suspicious_user.update`) is not yet implemented. diff --git a/src/providers/twitch/pubsubmessages/ChannelPoints.hpp b/src/providers/twitch/pubsubmessages/ChannelPoints.hpp index be8d1bd68af..fe2254a7a67 100644 --- a/src/providers/twitch/pubsubmessages/ChannelPoints.hpp +++ b/src/providers/twitch/pubsubmessages/ChannelPoints.hpp @@ -8,6 +8,7 @@ namespace chatterino { struct PubSubCommunityPointsChannelV1Message { enum class Type { + AutomaticRewardRedeemed, RewardRedeemed, INVALID, @@ -30,6 +31,9 @@ constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< { switch (value) { + case chatterino::PubSubCommunityPointsChannelV1Message::Type:: + AutomaticRewardRedeemed: + return "automatic-reward-redeemed"; case chatterino::PubSubCommunityPointsChannelV1Message::Type:: RewardRedeemed: return "reward-redeemed"; diff --git a/src/singletons/ImageUploader.cpp b/src/singletons/ImageUploader.cpp index f8ad53d2e1e..3926df8f5e1 100644 --- a/src/singletons/ImageUploader.cpp +++ b/src/singletons/ImageUploader.cpp @@ -5,6 +5,7 @@ #include "common/network/NetworkRequest.hpp" #include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" +#include "debug/Benchmark.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Paths.hpp" @@ -21,6 +22,8 @@ #include #include +#include + #define UPLOAD_DELAY 2000 // Delay between uploads in milliseconds @@ -195,6 +198,11 @@ void ImageUploader::handleFailedUpload(const NetworkResult &result, } channel->addMessage(makeSystemMessage(errorMessage)); + // NOTE: We abort any future uploads on failure. Should this be handled differently? + while (!this->uploadQueue_.empty()) + { + this->uploadQueue_.pop(); + } this->uploadMutex_.unlock(); } @@ -248,22 +256,20 @@ void ImageUploader::handleSuccessfulUpload(const NetworkResult &result, this->logToFile(originalFilePath, link, deletionLink, channel); } -void ImageUploader::upload(const QMimeData *source, ChannelPtr channel, - QPointer outputTextEdit) +std::pair, QString> ImageUploader::getImages( + const QMimeData *source) const { - if (!this->uploadMutex_.tryLock()) - { - channel->addMessage(makeSystemMessage( - QString("Please wait until the upload finishes."))); - return; - } + BenchmarkGuard benchmarkGuard("ImageUploader::getImages"); - channel->addMessage(makeSystemMessage(QString("Started upload..."))); - auto tryUploadFromUrls = [&]() -> bool { + auto tryUploadFromUrls = + [&]() -> std::pair, QString> { if (!source->hasUrls()) { - return false; + return {{}, {}}; } + + std::queue images; + auto mimeDb = QMimeDatabase(); // This path gets chosen when files are copied from a file manager, like explorer.exe, caja. // Each entry in source->urls() is a QUrl pointing to a file that was copied. @@ -273,101 +279,118 @@ void ImageUploader::upload(const QMimeData *source, ChannelPtr channel, QMimeType mime = mimeDb.mimeTypeForUrl(path); if (mime.name().startsWith("image") && !mime.inherits("image/gif")) { - channel->addMessage(makeSystemMessage( - QString("Uploading image: %1").arg(localPath))); QImage img = QImage(localPath); if (img.isNull()) { - channel->addMessage( - makeSystemMessage(QString("Couldn't load image :("))); - return false; + return {{}, "Couldn't load image :("}; } auto imageData = convertToPng(img); - if (imageData) - { - RawImageData data = {*imageData, "png", localPath}; - this->uploadQueue_.push(data); - } - else + if (!imageData) { - channel->addMessage(makeSystemMessage( + return { + {}, QString("Cannot upload file: %1. Couldn't convert " "image to png.") - .arg(localPath))); - return false; + .arg(localPath), + }; } + images.push({*imageData, "png", localPath}); } else if (mime.inherits("image/gif")) { - channel->addMessage(makeSystemMessage( - QString("Uploading GIF: %1").arg(localPath))); QFile file(localPath); bool isOkay = file.open(QIODevice::ReadOnly); if (!isOkay) { - channel->addMessage( - makeSystemMessage(QString("Failed to open file. :("))); - return false; + return {{}, "Failed to open file :("}; } // file.readAll() => might be a bit big but it /should/ work - RawImageData data = {file.readAll(), "gif", localPath}; - this->uploadQueue_.push(data); + images.push({file.readAll(), "gif", localPath}); file.close(); } } - if (!this->uploadQueue_.empty()) - { - this->sendImageUploadRequest(this->uploadQueue_.front(), channel, - outputTextEdit); - this->uploadQueue_.pop(); - return true; - } - return false; + + return {images, {}}; }; - auto tryUploadDirectly = [&]() -> bool { + auto tryUploadDirectly = + [&]() -> std::pair, QString> { + std::queue images; + if (source->hasFormat("image/png")) { // the path to file is not present every time, thus the filePath is empty - this->sendImageUploadRequest({source->data("image/png"), "png", ""}, - channel, outputTextEdit); - return true; + images.push({source->data("image/png"), "png", ""}); + return {images, {}}; } + if (source->hasFormat("image/jpeg")) { - this->sendImageUploadRequest( - {source->data("image/jpeg"), "jpeg", ""}, channel, - outputTextEdit); - return true; + images.push({source->data("image/jpeg"), "jpeg", ""}); + return {images, {}}; } + if (source->hasFormat("image/gif")) { - this->sendImageUploadRequest({source->data("image/gif"), "gif", ""}, - channel, outputTextEdit); - return true; + images.push({source->data("image/gif"), "gif", ""}); + return {images, {}}; } + // not PNG, try loading it into QImage and save it to a PNG. auto image = qvariant_cast(source->imageData()); auto imageData = convertToPng(image); if (imageData) { - sendImageUploadRequest({*imageData, "png", ""}, channel, - outputTextEdit); - return true; + images.push({*imageData, "png", ""}); + return {images, {}}; } + // No direct upload happenned - channel->addMessage(makeSystemMessage( - QString("Cannot upload file, failed to convert to png."))); - return false; + return {{}, "Cannot upload file, failed to convert to png."}; }; - if (!tryUploadFromUrls() && !tryUploadDirectly()) + const auto [urlImageData, urlError] = tryUploadFromUrls(); + + if (!urlImageData.empty()) { - channel->addMessage( - makeSystemMessage(QString("Cannot upload file from clipboard."))); - this->uploadMutex_.unlock(); + return {urlImageData, {}}; } + + const auto [directImageData, directError] = tryUploadDirectly(); + if (!directImageData.empty()) + { + return {directImageData, {}}; + } + + return { + {}, + // TODO: verify that this looks ok xd + urlError + directError, + }; +} + +void ImageUploader::upload(std::queue images, ChannelPtr channel, + QPointer outputTextEdit) +{ + BenchmarkGuard benchmarkGuard("upload"); + if (!this->uploadMutex_.tryLock()) + { + channel->addMessage(makeSystemMessage( + QString("Please wait until the upload finishes."))); + return; + } + + assert(!images.empty()); + assert(this->uploadQueue_.empty()); + + std::swap(this->uploadQueue_, images); + + channel->addMessage(makeSystemMessage("Started upload...")); + + this->sendImageUploadRequest(this->uploadQueue_.front(), std::move(channel), + std::move(outputTextEdit)); + this->uploadQueue_.pop(); } } // namespace chatterino diff --git a/src/singletons/ImageUploader.hpp b/src/singletons/ImageUploader.hpp index 260180583d7..41f4c8b6050 100644 --- a/src/singletons/ImageUploader.hpp +++ b/src/singletons/ImageUploader.hpp @@ -25,8 +25,16 @@ struct RawImageData { class ImageUploader final : public Singleton { public: + /** + * Tries to get the image(s) from the given QMimeData + * + * If no images were found, the second value in the pair will contain an error message + */ + std::pair, QString> getImages( + const QMimeData *source) const; + void save() override; - void upload(const QMimeData *source, ChannelPtr channel, + void upload(std::queue images, ChannelPtr channel, QPointer outputTextEdit); private: diff --git a/src/singletons/Logging.hpp b/src/singletons/Logging.hpp index edd1ac07fc9..af86a702dcf 100644 --- a/src/singletons/Logging.hpp +++ b/src/singletons/Logging.hpp @@ -16,13 +16,22 @@ struct Message; using MessagePtr = std::shared_ptr; class LoggingChannel; -class Logging +class ILogging +{ +public: + virtual ~ILogging() = default; + + virtual void addMessage(const QString &channelName, MessagePtr message, + const QString &platformName) = 0; +}; + +class Logging : public ILogging { public: Logging(Settings &settings); void addMessage(const QString &channelName, MessagePtr message, - const QString &platformName); + const QString &platformName) override; private: using PlatformName = QString; diff --git a/src/singletons/NativeMessaging.cpp b/src/singletons/NativeMessaging.cpp index 6150cd8faeb..524913ef8f4 100644 --- a/src/singletons/NativeMessaging.cpp +++ b/src/singletons/NativeMessaging.cpp @@ -236,14 +236,13 @@ void NativeMessagingServer::ReceiverThread::handleSelect( } postToThread([=] { - auto *app = getApp(); - if (!name.isEmpty()) { - auto channel = app->twitch->getOrAddChannel(name); - if (app->twitch->watchingChannel.get() != channel) + auto channel = + getIApp()->getTwitchAbstract()->getOrAddChannel(name); + if (getIApp()->getTwitch()->getWatchingChannel().get() != channel) { - app->twitch->watchingChannel.reset(channel); + getIApp()->getTwitch()->setWatchingChannel(channel); } } @@ -253,7 +252,8 @@ void NativeMessagingServer::ReceiverThread::handleSelect( auto *window = AttachedWindow::getForeground(args); if (!name.isEmpty()) { - window->setChannel(app->twitch->getOrAddChannel(name)); + window->setChannel( + getIApp()->getTwitchAbstract()->getOrAddChannel(name)); } #endif } @@ -294,8 +294,6 @@ void NativeMessagingServer::syncChannels(const QJsonArray &twitchChannels) { assertInGuiThread(); - auto *app = getApp(); - std::vector updated; updated.reserve(twitchChannels.size()); for (const auto &value : twitchChannels) @@ -306,7 +304,8 @@ void NativeMessagingServer::syncChannels(const QJsonArray &twitchChannels) continue; } // the deduping is done on the extension side - updated.emplace_back(app->twitch->getOrAddChannel(name)); + updated.emplace_back( + getIApp()->getTwitchAbstract()->getOrAddChannel(name)); } // This will destroy channels that aren't used anymore. diff --git a/src/singletons/Resources.cpp b/src/singletons/Resources.cpp index 18995dc31da..7bafdd4fbca 100644 --- a/src/singletons/Resources.cpp +++ b/src/singletons/Resources.cpp @@ -2,10 +2,15 @@ #include "debug/AssertInGuiThread.hpp" -namespace chatterino { namespace { - static Resources2 *resources = nullptr; -} + +using namespace chatterino; + +static Resources2 *resources = nullptr; + +} // namespace + +namespace chatterino { Resources2 &getResources() { diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index e923c3132ef..59f5017a1e1 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -48,19 +48,6 @@ enum UsernameDisplayMode : int { UsernameAndLocalizedName = 3, // Username (Localized name) }; -enum HelixTimegateOverride : int { - // Use the default timegated behaviour - // This means we use the old IRC command up until the migration date and - // switch over to the Helix API only after the migration date - Timegate = 1, - - // Ignore timegating and always force use the IRC command - AlwaysUseIRC = 2, - - // Ignore timegating and always force use the Helix API - AlwaysUseHelix = 3, -}; - enum ThumbnailPreviewMode : int { DontShow = 0, @@ -554,29 +541,6 @@ class Settings 1000, }; - // Temporary time-gate-overrides - EnumSetting helixTimegateRaid = { - "/misc/twitch/helix-timegate/raid", - HelixTimegateOverride::Timegate, - }; - EnumSetting helixTimegateWhisper = { - "/misc/twitch/helix-timegate/whisper", - HelixTimegateOverride::Timegate, - }; - EnumSetting helixTimegateVIPs = { - "/misc/twitch/helix-timegate/vips", - HelixTimegateOverride::Timegate, - }; - EnumSetting helixTimegateModerators = { - "/misc/twitch/helix-timegate/moderators", - HelixTimegateOverride::Timegate, - }; - - EnumSetting helixTimegateCommercial = { - "/misc/twitch/helix-timegate/commercial", - HelixTimegateOverride::Timegate, - }; - EnumStringSetting chatSendProtocol = { "/misc/chatSendProtocol", ChatSendProtocol::Default}; diff --git a/src/singletons/StreamerMode.cpp b/src/singletons/StreamerMode.cpp index 7ee4fa5884b..5b8b4402b0d 100644 --- a/src/singletons/StreamerMode.cpp +++ b/src/singletons/StreamerMode.cpp @@ -51,6 +51,9 @@ const QStringList &broadcastingBinaries() bool isBroadcasterSoftwareActive() { #if defined(Q_OS_LINUX) || defined(Q_OS_MACOS) + static bool shouldShowTimeoutWarning = true; + static bool shouldShowWarning = true; + QProcess p; p.start("pgrep", {"-xi", broadcastingBinaries().join("|")}, QIODevice::NotOpen); @@ -62,20 +65,46 @@ bool isBroadcasterSoftwareActive() // Fallback to false and showing a warning - static bool shouldShowWarning = true; - if (shouldShowWarning) + switch (p.error()) { - shouldShowWarning = false; - - postToThread([] { - getApp()->twitch->addGlobalSystemMessage( - "Streamer Mode is set to Automatic, but pgrep is missing. " - "Install it to fix the issue or set Streamer Mode to " - "Enabled or Disabled in the Settings."); - }); + case QProcess::Timedout: { + qCWarning(chatterinoStreamerMode) << "pgrep execution timed out!"; + if (shouldShowTimeoutWarning) + { + shouldShowTimeoutWarning = false; + + postToThread([] { + getIApp()->getTwitchAbstract()->addGlobalSystemMessage( + "Streamer Mode is set to Automatic, but pgrep timed " + "out. This can happen if your system lagged at the " + "wrong moment. If Streamer Mode continues to not work, " + "you can manually set it to Enabled or Disabled in the " + "Settings."); + }); + } + } + break; + + default: { + qCWarning(chatterinoStreamerMode) + << "pgrep execution failed:" << p.error(); + + if (shouldShowWarning) + { + shouldShowWarning = false; + + postToThread([] { + getIApp()->getTwitchAbstract()->addGlobalSystemMessage( + "Streamer Mode is set to Automatic, but pgrep is " + "missing. " + "Install it to fix the issue or set Streamer Mode to " + "Enabled or Disabled in the Settings."); + }); + } + } + break; } - qCWarning(chatterinoStreamerMode) << "pgrep execution timed out!"; return false; #elif defined(Q_OS_WIN) if (!IsWindowsVistaOrGreater()) diff --git a/src/singletons/Toasts.cpp b/src/singletons/Toasts.cpp index 51dbf468010..3ca5b6e0c8a 100644 --- a/src/singletons/Toasts.cpp +++ b/src/singletons/Toasts.cpp @@ -1,6 +1,7 @@ #include "Toasts.hpp" #include "Application.hpp" +#include "common/Common.hpp" #include "common/Literals.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" @@ -177,9 +178,8 @@ class CustomHandler : public WinToastLib::IWinToastHandler case ToastReaction::OpenInPlayer: if (platform_ == Platform::Twitch) { - QDesktopServices::openUrl(QUrl( - u"https://player.twitch.tv/?parent=twitch.tv&channel=" % - channelName_)); + QDesktopServices::openUrl( + QUrl(TWITCH_PLAYER_URL.arg(channelName_))); } break; case ToastReaction::OpenInStreamlink: { diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index 56bea50ae69..1e0f7845198 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -108,7 +108,6 @@ WindowManager::WindowManager(const Paths &paths) this->wordFlagsListener_.addSetting(settings->showBadgesFfz); this->wordFlagsListener_.addSetting(settings->showBadgesSevenTV); this->wordFlagsListener_.addSetting(settings->enableEmoteImages); - this->wordFlagsListener_.addSetting(settings->boldUsernames); this->wordFlagsListener_.addSetting(settings->lowercaseDomains); this->wordFlagsListener_.addSetting(settings->showReplyButton); this->wordFlagsListener_.setCB([this] { @@ -182,8 +181,6 @@ void WindowManager::updateWordTypeMask() // misc flags.set(MEF::AlwaysShow); flags.set(MEF::Collapsed); - flags.set(settings->boldUsernames ? MEF::BoldUsername - : MEF::NonBoldUsername); flags.set(MEF::LowercaseLinks, settings->lowercaseDomains); flags.set(MEF::ChannelPointReward); @@ -331,14 +328,18 @@ void WindowManager::scrollToMessage(const MessagePtr &message) this->scrollToMessageSignal.invoke(message); } -QPoint WindowManager::emotePopupPos() +QRect WindowManager::emotePopupBounds() const { - return this->emotePopupPos_; + return this->emotePopupBounds_; } -void WindowManager::setEmotePopupPos(QPoint pos) +void WindowManager::setEmotePopupBounds(QRect bounds) { - this->emotePopupPos_ = pos; + if (this->emotePopupBounds_ != bounds) + { + this->emotePopupBounds_ = bounds; + this->queueSave(); + } } void WindowManager::initialize(Settings &settings, const Paths &paths) @@ -374,7 +375,7 @@ void WindowManager::initialize(Settings &settings, const Paths &paths) windowLayout.activateOrAddChannel(desired->provider, desired->name); } - this->emotePopupPos_ = windowLayout.emotePopupPos_; + this->emotePopupBounds_ = windowLayout.emotePopupBounds_; this->applyWindowLayout(windowLayout); } @@ -422,6 +423,13 @@ void WindowManager::initialize(Settings &settings, const Paths &paths) this->forceLayoutChannelViews(); }); + settings.colorUsernames.connect([this](auto, auto) { + this->forceLayoutChannelViews(); + }); + settings.boldUsernames.connect([this](auto, auto) { + this->forceLayoutChannelViews(); + }); + this->initialized_ = true; } @@ -479,10 +487,12 @@ void WindowManager::save() windowObj.insert("width", rect.width()); windowObj.insert("height", rect.height()); - QJsonObject emotePopupObj; - emotePopupObj.insert("x", this->emotePopupPos_.x()); - emotePopupObj.insert("y", this->emotePopupPos_.y()); - windowObj.insert("emotePopup", emotePopupObj); + windowObj["emotePopup"] = QJsonObject{ + {"x", this->emotePopupBounds_.x()}, + {"y", this->emotePopupBounds_.y()}, + {"width", this->emotePopupBounds_.width()}, + {"height", this->emotePopupBounds_.height()}, + }; // window tabs QJsonArray tabsArr; @@ -678,27 +688,28 @@ IndirectChannel WindowManager::decodeChannel(const SplitDescriptor &descriptor) if (descriptor.type_ == "twitch") { - return app->twitch->getOrAddChannel(descriptor.channelName_); + return getIApp()->getTwitchAbstract()->getOrAddChannel( + descriptor.channelName_); } else if (descriptor.type_ == "mentions") { - return app->twitch->mentionsChannel; + return getIApp()->getTwitch()->getMentionsChannel(); } else if (descriptor.type_ == "watching") { - return app->twitch->watchingChannel; + return getIApp()->getTwitch()->getWatchingChannel(); } else if (descriptor.type_ == "whispers") { - return app->twitch->whispersChannel; + return getIApp()->getTwitch()->getWhispersChannel(); } else if (descriptor.type_ == "live") { - return app->twitch->liveChannel; + return getIApp()->getTwitch()->getLiveChannel(); } else if (descriptor.type_ == "automod") { - return app->twitch->automodChannel; + return getIApp()->getTwitch()->getAutomodChannel(); } else if (descriptor.type_ == "irc") { @@ -707,7 +718,8 @@ IndirectChannel WindowManager::decodeChannel(const SplitDescriptor &descriptor) } else if (descriptor.type_ == "misc") { - return app->twitch->getChannelOrEmpty(descriptor.channelName_); + return getIApp()->getTwitchAbstract()->getChannelOrEmpty( + descriptor.channelName_); } return Channel::getEmpty(); @@ -749,7 +761,7 @@ void WindowManager::applyWindowLayout(const WindowLayout &layout) } // Set emote popup position - this->emotePopupPos_ = layout.emotePopupPos_; + this->emotePopupBounds_ = layout.emotePopupBounds_; for (const auto &windowData : layout.windows_) { @@ -798,10 +810,14 @@ void WindowManager::applyWindowLayout(const WindowLayout &layout) // Have to offset x by one because qt moves the window 1px too // far to the left:w - window.setInitialBounds({windowData.geometry_.x(), - windowData.geometry_.y(), - windowData.geometry_.width(), - windowData.geometry_.height()}); + window.setInitialBounds( + { + windowData.geometry_.x(), + windowData.geometry_.y(), + windowData.geometry_.width(), + windowData.geometry_.height(), + }, + widgets::BoundsChecking::Off); } } diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 927a5712977..21040fab094 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -99,8 +99,8 @@ class WindowManager final : public Singleton */ void scrollToMessage(const MessagePtr &message); - QPoint emotePopupPos(); - void setEmotePopupPos(QPoint pos); + QRect emotePopupBounds() const; + void setEmotePopupBounds(QRect bounds); void initialize(Settings &settings, const Paths &paths) override; void save() override; @@ -154,7 +154,7 @@ class WindowManager final : public Singleton bool initialized_ = false; bool shuttingDown_ = false; - QPoint emotePopupPos_; + QRect emotePopupBounds_; std::atomic generation_{0}; diff --git a/src/util/AttachToConsole.cpp b/src/util/AttachToConsole.cpp index 41689c699af..5f887260e21 100644 --- a/src/util/AttachToConsole.cpp +++ b/src/util/AttachToConsole.cpp @@ -3,6 +3,7 @@ #ifdef USEWINSDK # include +# include # include #endif diff --git a/src/util/DebugCount.cpp b/src/util/DebugCount.cpp index 1fe915cf2f5..1c390678209 100644 --- a/src/util/DebugCount.cpp +++ b/src/util/DebugCount.cpp @@ -85,11 +85,7 @@ void DebugCount::decrease(const QString &name, const int64_t &amount) QString DebugCount::getDebugText() { -#if QT_VERSION > QT_VERSION_CHECK(5, 13, 0) static const QLocale locale(QLocale::English); -#else - static QLocale locale(QLocale::English); -#endif auto counts = COUNTS.access(); diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index f81ac9b792d..7d66184d192 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -11,7 +11,7 @@ namespace chatterino { namespace _helpers_internal { - SizeType skipSpace(StringView view, SizeType startPos) + SizeType skipSpace(QStringView view, SizeType startPos) { while (startPos < view.length() && view.at(startPos).isSpace()) { @@ -20,7 +20,7 @@ namespace _helpers_internal { return startPos - 1; } - bool matchesIgnorePlural(StringView word, const QString &expected) + bool matchesIgnorePlural(QStringView word, const QString &expected) { if (!word.startsWith(expected)) { @@ -34,7 +34,7 @@ namespace _helpers_internal { word.at(word.length() - 1).toLatin1() == 's'; } - std::pair findUnitMultiplierToSec(StringView view, + std::pair findUnitMultiplierToSec(QStringView view, SizeType &pos) { // Step 1. find end of unit @@ -207,11 +207,7 @@ int64_t parseDurationToSeconds(const QString &inputString, return -1; } -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 2) - StringView input(inputString); -#else - StringView input(&inputString); -#endif + QStringView input(inputString); input = input.trimmed(); uint64_t currentValue = 0; diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index 56e25082a9c..6f0552487be 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -4,10 +4,6 @@ #include #include -#if QT_VERSION < QT_VERSION_CHECK(5, 15, 2) -# include -#endif - #include #include #include @@ -17,12 +13,7 @@ namespace chatterino { // only qualified for tests namespace _helpers_internal { -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 2) - using StringView = QStringView; -#else - using StringView = QStringRef; -#endif - using SizeType = StringView::size_type; + using SizeType = QStringView::size_type; /** * Skips all spaces. @@ -32,7 +23,7 @@ namespace _helpers_internal { * @param startPos The starting position (there must be a space in the view). * @return The position of the last space. */ - SizeType skipSpace(StringView view, SizeType startPos); + SizeType skipSpace(QStringView view, SizeType startPos); /** * Checks if `word` equals `expected` (singular) or `expected` + 's' (plural). @@ -41,7 +32,7 @@ namespace _helpers_internal { * @param expected Singular of the expected word. * @return true if `word` is singular or plural of `expected`. */ - bool matchesIgnorePlural(StringView word, const QString &expected); + bool matchesIgnorePlural(QStringView word, const QString &expected); /** * Tries to find the unit starting at `pos` and returns its multiplier so @@ -58,7 +49,7 @@ namespace _helpers_internal { * if it's a valid unit, undefined otherwise. * @return (multiplier, ok) */ - std::pair findUnitMultiplierToSec(StringView view, + std::pair findUnitMultiplierToSec(QStringView view, SizeType &pos); } // namespace _helpers_internal diff --git a/src/util/IncognitoBrowser.cpp b/src/util/IncognitoBrowser.cpp index 93ae2983bb4..77f14160a8b 100644 --- a/src/util/IncognitoBrowser.cpp +++ b/src/util/IncognitoBrowser.cpp @@ -16,12 +16,20 @@ QString getPrivateSwitch(const QString &browserExecutable) { // list of command line switches to turn on private browsing in browsers static auto switches = std::vector>{ - {"firefox", "-private-window"}, {"librewolf", "-private-window"}, - {"waterfox", "-private-window"}, {"icecat", "-private-window"}, - {"chrome", "-incognito"}, {"vivaldi", "-incognito"}, - {"opera", "-newprivatetab"}, {"opera\\launcher", "--private"}, - {"iexplore", "-private"}, {"msedge", "-inprivate"}, - {"firefox-esr", "-private-window"}, {"chromium", "-incognito"}, + {"firefox", "-private-window"}, + {"librewolf", "-private-window"}, + {"waterfox", "-private-window"}, + {"icecat", "-private-window"}, + {"chrome", "-incognito"}, + {"google-chrome-stable", "-incognito"}, + {"vivaldi", "-incognito"}, + {"opera", "-newprivatetab"}, + {"opera\\launcher", "--private"}, + {"iexplore", "-private"}, + {"msedge", "-inprivate"}, + {"firefox-esr", "-private-window"}, + {"chromium", "-incognito"}, + {"brave", "-incognito"}, }; // compare case-insensitively diff --git a/src/util/LoadPixmap.cpp b/src/util/LoadPixmap.cpp new file mode 100644 index 00000000000..99fdf95f369 --- /dev/null +++ b/src/util/LoadPixmap.cpp @@ -0,0 +1,48 @@ +#include "util/LoadPixmap.hpp" + +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/QLogging.hpp" + +#include +#include +#include +#include + +namespace chatterino { + +void loadPixmapFromUrl(const Url &url, std::function &&callback) +{ + NetworkRequest(url.string) + .concurrent() + .cache() + .onSuccess( + [callback = std::move(callback), url](const NetworkResult &result) { + auto data = result.getData(); + QBuffer buffer(&data); + buffer.open(QIODevice::ReadOnly); + QImageReader reader(&buffer); + + if (!reader.canRead() || reader.size().isEmpty()) + { + qCWarning(chatterinoImage) + << "Can't read image file at" << url.string << ":" + << reader.errorString(); + return; + } + + QImage image = reader.read(); + if (image.isNull()) + { + qCWarning(chatterinoImage) + << "Failed reading image at" << url.string << ":" + << reader.errorString(); + return; + } + + callback(QPixmap::fromImage(image)); + }) + .execute(); +} + +} // namespace chatterino diff --git a/src/util/LoadPixmap.hpp b/src/util/LoadPixmap.hpp new file mode 100644 index 00000000000..81fb1192144 --- /dev/null +++ b/src/util/LoadPixmap.hpp @@ -0,0 +1,15 @@ +#pragma once +#include "common/Aliases.hpp" + +#include + +namespace chatterino { + +/** + * Loads an image from url into a QPixmap. Allows for file:// protocol links. Uses cacheing. + * + * @param callback The callback you will get the pixmap by. It will be invoked concurrently with no guarantees on which thread. + */ +void loadPixmapFromUrl(const Url &url, std::function &&callback); + +} // namespace chatterino diff --git a/src/util/PostToThread.hpp b/src/util/PostToThread.hpp index afeb34d06b0..e0db1fdd075 100644 --- a/src/util/PostToThread.hpp +++ b/src/util/PostToThread.hpp @@ -70,4 +70,13 @@ static void runInGuiThread(F &&fun) } } +template +inline void postToGuiThread(F &&fun) +{ + assert(!isGuiThread() && + "postToGuiThread must be called from a non-GUI thread"); + + postToThread(std::forward(fun)); +} + } // namespace chatterino diff --git a/src/util/QStringHash.hpp b/src/util/QStringHash.hpp index eb4efe2f04c..ac4bef57aaf 100644 --- a/src/util/QStringHash.hpp +++ b/src/util/QStringHash.hpp @@ -4,8 +4,6 @@ #include #include -#include - namespace boost { template <> @@ -17,17 +15,3 @@ struct hash { }; } // namespace boost - -namespace std { - -#if QT_VERSION < QT_VERSION_CHECK(5, 14, 0) -template <> -struct hash { - std::size_t operator()(const QString &s) const - { - return qHash(s); - } -}; -#endif - -} // namespace std diff --git a/src/util/Qt.hpp b/src/util/Qt.hpp deleted file mode 100644 index 1187953d820..00000000000 --- a/src/util/Qt.hpp +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once - -#include - -#if QT_VERSION < QT_VERSION_CHECK(5, 14, 0) -namespace Qt { -const QString::SplitBehavior SkipEmptyParts = QString::SkipEmptyParts; -} -#endif diff --git a/src/util/SampleData.cpp b/src/util/SampleData.cpp index 953646139bd..0b976f1900c 100644 --- a/src/util/SampleData.cpp +++ b/src/util/SampleData.cpp @@ -64,12 +64,21 @@ const QStringList &getSampleCheerMessages() const QStringList &getSampleSubMessages() { static QStringList list{ - R"(@badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=ronni;emotes=;id=db25007f-7a18-43eb-9379-80131e44d633;login=ronni;mod=0;msg-id=resub;msg-param-months=6;msg-param-sub-plan=Prime;msg-param-sub-plan-name=Prime;room-id=1337;subscriber=1;system-msg=ronni\shas\ssubscribed\sfor\s6\smonths!;tmi-sent-ts=1507246572675;turbo=1;user-id=1337;user-type=staff :tmi.twitch.tv USERNOTICE #pajlada :Great stream -- keep it up!)", + R"(@badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=ronni;emotes=;id=db25007f-7a18-43eb-9379-80131e44d633;login=ronni;mod=0;msg-id=resub;msg-param-months=6;msg-param-sub-plan=Prime;msg-param-sub-plan-name=Prime;room-id=11148817;subscriber=1;system-msg=ronni\shas\ssubscribed\sfor\s6\smonths!;tmi-sent-ts=1507246572675;turbo=1;user-id=1337;user-type=staff :tmi.twitch.tv USERNOTICE #pajlada :Great stream -- keep it up!)", R"(@badges=staff/1,premium/1;color=#0000FF;display-name=TWW2;emotes=;id=e9176cd8-5e22-4684-ad40-ce53c2561c5e;login=tww2;mod=0;msg-id=subgift;msg-param-months=1;msg-param-recipient-display-name=Mr_Woodchuck;msg-param-recipient-id=89614178;msg-param-recipient-name=mr_woodchuck;msg-param-sub-plan-name=House\sof\sNyoro~n;msg-param-sub-plan=1000;room-id=19571752;subscriber=0;system-msg=TWW2\sgifted\sa\sTier\s1\ssub\sto\sMr_Woodchuck!;tmi-sent-ts=1521159445153;turbo=0;user-id=13405587;user-type=staff :tmi.twitch.tv USERNOTICE #pajlada)", // hyperbolicxd gifted a sub to quote_if_nam R"(@badges=subscriber/0,premium/1;color=#00FF7F;display-name=hyperbolicxd;emotes=;id=b20ef4fe-cba8-41d0-a371-6327651dc9cc;login=hyperbolicxd;mod=0;msg-id=subgift;msg-param-months=1;msg-param-recipient-display-name=quote_if_nam;msg-param-recipient-id=217259245;msg-param-recipient-user-name=quote_if_nam;msg-param-sender-count=1;msg-param-sub-plan-name=Channel\sSubscription\s(nymn_hs);msg-param-sub-plan=1000;room-id=62300805;subscriber=1;system-msg=hyperbolicxd\sgifted\sa\sTier\s1\ssub\sto\squote_if_nam!\sThis\sis\stheir\sfirst\sGift\sSub\sin\sthe\schannel!;tmi-sent-ts=1528190938558;turbo=0;user-id=111534250;user-type= :tmi.twitch.tv USERNOTICE #pajlada)", + // multi-month sub gift + R"(@badge-info=subscriber/32;badges=subscriber/3030,sub-gift-leader/2;color=#FF8EA3;display-name=iNatsuFN;emotes=;flags=;id=0d0decbd-b8f4-4e83-9e18-eca9cab69153;login=inatsufn;mod=0;msg-id=subgift;msg-param-gift-months=6;msg-param-goal-contribution-type=SUBS;msg-param-goal-current-contributions=881;msg-param-goal-target-contributions=900;msg-param-goal-user-contributions=1;msg-param-months=16;msg-param-origin-id=2524053421157386961;msg-param-recipient-display-name=kimmi_tm;msg-param-recipient-id=225806893;msg-param-recipient-user-name=kimmi_tm;msg-param-sender-count=334;msg-param-sub-plan-name=Channel\sSubscription\s(mxddy);msg-param-sub-plan=1000;room-id=210915729;subscriber=1;system-msg=iNatsuFN\sgifted\s6\smonths\sof\sTier\s1\sto\skimmi_tm.\sThey've\sgifted\s334\smonths\sin\sthe\schannel!;tmi-sent-ts=1712034497332;user-id=218205938;user-type=;vip=0 :tmi.twitch.tv USERNOTICE #pajlada)", + + // multi-month anon sub gift + R"(@msg-param-goal-user-contributions=1;system-msg=An\sanonymous\suser\sgifted\sa\sTier\s1\ssub\sto\sMohammadrezaDH!\s;msg-param-goal-current-contributions=2;vip=0;color=;user-id=274598607;mod=0;flags=;msg-param-months=2;historical=1;id=afa2155b-f563-4973-a5c2-e4075882bbfb;msg-param-gift-months=6;msg-id=subgift;badge-info=;msg-param-recipient-user-name=mohammadrezadh;login=ananonymousgifter;room-id=441388138;msg-param-goal-target-contributions=25;rm-received-ts=1712002037736;msg-param-recipient-id=204174899;emotes=;display-name=AnAnonymousGifter;badges=;msg-param-fun-string=FunStringFive;msg-param-goal-contribution-type=NEW_SUB_POINTS;msg-param-origin-id=8862142563198473546;msg-param-recipient-display-name=MohammadrezaDH;msg-param-sub-plan-name=jmarxists;user-type=;subscriber=0;tmi-sent-ts=1712002037615;msg-param-sub-plan=1000;msg-param-goal-description=day\slee\sgoal\s:-) :tmi.twitch.tv USERNOTICE #pajlada)", + + // multi-month sub gift by broadcaster + R"(@user-id=35759863;msg-param-origin-id=2862055070165643340;display-name=Lucidfoxx;id=eeb3cdb8-337c-413a-9521-3a884ff78754;msg-param-gift-months=12;msg-param-sub-plan=1000;vip=0;emotes=;badges=broadcaster/1,subscriber/3042,partner/1;msg-param-recipient-user-name=ogprodigy;msg-param-recipient-id=53888434;badge-info=subscriber/71;room-id=35759863;msg-param-recipient-display-name=OGprodigy;msg-param-sub-plan-name=Silver\sPackage;subscriber=1;system-msg=Lucidfoxx\sgifted\sa\sTier\s1\ssub\sto\sOGprodigy!;login=lucidfoxx;msg-param-sender-count=0;user-type=;mod=0;flags=;rm-received-ts=1712803947891;color=#EB078D;msg-param-months=15;tmi-sent-ts=1712803947773;msg-id=subgift :tmi.twitch.tv USERNOTICE #pajlada)", + // first time sub R"(@badges=subscriber/0,premium/1;color=#0000FF;display-name=byebyeheart;emotes=;id=fe390424-ab89-4c33-bb5a-53c6e5214b9f;login=byebyeheart;mod=0;msg-id=sub;msg-param-months=0;msg-param-sub-plan-name=Dakotaz;msg-param-sub-plan=Prime;room-id=39298218;subscriber=0;system-msg=byebyeheart\sjust\ssubscribed\swith\sTwitch\sPrime!;tmi-sent-ts=1528190963670;turbo=0;user-id=131956000;user-type= :tmi.twitch.tv USERNOTICE #pajlada)", diff --git a/src/util/WidgetHelpers.cpp b/src/util/WidgetHelpers.cpp index b5e6fa9a303..cd76dea8091 100644 --- a/src/util/WidgetHelpers.cpp +++ b/src/util/WidgetHelpers.cpp @@ -8,8 +8,7 @@ namespace { -/// Move the `window` into the `screen` geometry if it's not already in there. -void moveWithinScreen(QWidget *window, QScreen *screen, QPoint point) +QPoint applyBounds(QScreen *screen, QPoint point, QSize frameSize, int height) { if (screen == nullptr) { @@ -21,9 +20,6 @@ void moveWithinScreen(QWidget *window, QScreen *screen, QPoint point) bool stickRight = false; bool stickBottom = false; - const auto w = window->frameGeometry().width(); - const auto h = window->frameGeometry().height(); - if (point.x() < bounds.left()) { point.setX(bounds.left()); @@ -32,30 +28,72 @@ void moveWithinScreen(QWidget *window, QScreen *screen, QPoint point) { point.setY(bounds.top()); } - if (point.x() + w > bounds.right()) + if (point.x() + frameSize.width() > bounds.right()) { stickRight = true; - point.setX(bounds.right() - w); + point.setX(bounds.right() - frameSize.width()); } - if (point.y() + h > bounds.bottom()) + if (point.y() + frameSize.height() > bounds.bottom()) { stickBottom = true; - point.setY(bounds.bottom() - h); + point.setY(bounds.bottom() - frameSize.height()); } if (stickRight && stickBottom) { const QPoint globalCursorPos = QCursor::pos(); - point.setY(globalCursorPos.y() - window->height() - 16); + point.setY(globalCursorPos.y() - height - 16); } - window->move(point); + return point; +} + +/// Move the `window` into the `screen` geometry if it's not already in there. +void moveWithinScreen(QWidget *window, QScreen *screen, QPoint point) +{ + auto checked = + applyBounds(screen, point, window->frameSize(), window->height()); + window->move(checked); } } // namespace namespace chatterino::widgets { +QRect checkInitialBounds(QRect initialBounds, BoundsChecking mode) +{ + switch (mode) + { + case BoundsChecking::Off: { + return initialBounds; + } + break; + + case BoundsChecking::CursorPosition: { + return QRect{ + applyBounds(QGuiApplication::screenAt(QCursor::pos()), + initialBounds.topLeft(), initialBounds.size(), + initialBounds.height()), + initialBounds.size(), + }; + } + break; + + case BoundsChecking::DesiredPosition: { + return QRect{ + applyBounds(QGuiApplication::screenAt(initialBounds.topLeft()), + initialBounds.topLeft(), initialBounds.size(), + initialBounds.height()), + initialBounds.size(), + }; + } + break; + default: + assert(false && "Invalid bounds checking mode"); + return initialBounds; + } +} + void moveWindowTo(QWidget *window, QPoint position, BoundsChecking mode) { switch (mode) diff --git a/src/util/WidgetHelpers.hpp b/src/util/WidgetHelpers.hpp index b09e93d0b95..5de90340ffc 100644 --- a/src/util/WidgetHelpers.hpp +++ b/src/util/WidgetHelpers.hpp @@ -3,6 +3,7 @@ class QWidget; class QPoint; class QScreen; +class QRect; namespace chatterino::widgets { @@ -17,6 +18,14 @@ enum class BoundsChecking { DesiredPosition, }; +/// Applies bounds checking to @a initialBounds. +/// +/// @param initialBounds The bounds to check. +/// @param mode The desired bounds checking. +/// @returns The potentially modified bounds. +QRect checkInitialBounds(QRect initialBounds, + BoundsChecking mode = BoundsChecking::DesiredPosition); + /// Moves the `window` to the (global) `position` /// while doing bounds-checking according to `mode` to ensure the window stays on one screen. /// diff --git a/src/util/XDGDirectory.cpp b/src/util/XDGDirectory.cpp index 979e58170c8..ab401e50fb2 100644 --- a/src/util/XDGDirectory.cpp +++ b/src/util/XDGDirectory.cpp @@ -1,7 +1,6 @@ #include "util/XDGDirectory.hpp" #include "util/CombinePath.hpp" -#include "util/Qt.hpp" #include diff --git a/src/util/XDGHelper.cpp b/src/util/XDGHelper.cpp index 588c4616648..e6e2df63fc9 100644 --- a/src/util/XDGHelper.cpp +++ b/src/util/XDGHelper.cpp @@ -3,7 +3,6 @@ #include "common/Literals.hpp" #include "common/QLogging.hpp" #include "util/CombinePath.hpp" -#include "util/Qt.hpp" #include "util/XDGDesktopFile.hpp" #include "util/XDGDirectory.hpp" diff --git a/src/widgets/AttachedWindow.cpp b/src/widgets/AttachedWindow.cpp index 5ce232a1f2f..b83afb65db5 100644 --- a/src/widgets/AttachedWindow.cpp +++ b/src/widgets/AttachedWindow.cpp @@ -270,20 +270,22 @@ void AttachedWindow::updateWindowRect(void *_attachedPtr) } float scale = 1.f; + float ourScale = 1.F; if (auto dpi = getWindowDpi(attached)) { scale = *dpi / 96.f; + ourScale = scale / this->devicePixelRatio(); for (auto w : this->ui_.split->findChildren()) { - w->setOverrideScale(scale); + w->setOverrideScale(ourScale); } - this->ui_.split->setOverrideScale(scale); + this->ui_.split->setOverrideScale(ourScale); } if (this->height_ != -1) { - this->ui_.split->setFixedWidth(int(this->width_ * scale)); + this->ui_.split->setFixedWidth(int(this->width_ * ourScale)); // offset int o = this->fullscreen_ ? 0 : 8; diff --git a/src/widgets/BaseWidget.cpp b/src/widgets/BaseWidget.cpp index 5302d039745..564ba5963c6 100644 --- a/src/widgets/BaseWidget.cpp +++ b/src/widgets/BaseWidget.cpp @@ -54,7 +54,11 @@ float BaseWidget::scale() const void BaseWidget::setScale(float value) { - // update scale value + if (this->scale_ == value) + { + return; + } + this->scale_ = value; this->scaleChangedEvent(this->scale()); @@ -120,19 +124,6 @@ void BaseWidget::setScaleIndependantHeight(int value) QSize(this->scaleIndependantSize_.width(), value)); } -float BaseWidget::qtFontScale() const -{ - if (auto *window = dynamic_cast(this->window())) - { - // ensure no div by 0 - return this->scale() / std::max(0.01f, window->nativeScale_); - } - else - { - return this->scale(); - } -} - void BaseWidget::childEvent(QChildEvent *event) { if (event->added()) diff --git a/src/widgets/BaseWidget.hpp b/src/widgets/BaseWidget.hpp index 2e9c0472813..4fdc421cdde 100644 --- a/src/widgets/BaseWidget.hpp +++ b/src/widgets/BaseWidget.hpp @@ -34,8 +34,6 @@ class BaseWidget : public QWidget void setScaleIndependantWidth(int value); void setScaleIndependantHeight(int value); - float qtFontScale() const; - protected: void childEvent(QChildEvent *) override; void showEvent(QShowEvent *) override; diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index c819c8f4a88..6d636b9d6b5 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -29,12 +29,172 @@ # pragma comment(lib, "Dwmapi.lib") # include - -# define WM_DPICHANGED 0x02E0 +# include +# include +# include #endif #include "widgets/helper/TitlebarButton.hpp" +namespace { + +#ifdef USEWINSDK + +// From kHiddenTaskbarSize in Firefox +constexpr UINT HIDDEN_TASKBAR_SIZE = 2; + +bool isWindows11OrGreater() +{ + static const bool result = [] { + // This calls RtlGetVersion under the hood so we don't have to. + // The micro version corresponds to dwBuildNumber. + auto version = QOperatingSystemVersion::current(); + return (version.majorVersion() > 10) || + (version.microVersion() >= 22000); + }(); + + return result; +} + +/// Finds the taskbar HWND on a specific monitor (or any) +HWND findTaskbarWindow(LPRECT rcMon = nullptr) +{ + HWND taskbar = nullptr; + RECT taskbarRect; + // return value of IntersectRect, unused + RECT intersectionRect; + + while ((taskbar = FindWindowEx(nullptr, taskbar, L"Shell_TrayWnd", + nullptr)) != nullptr) + { + if (!rcMon) + { + // no monitor was specified, return the first encountered window + break; + } + if (GetWindowRect(taskbar, &taskbarRect) != 0 && + IntersectRect(&intersectionRect, &taskbarRect, rcMon) != 0) + { + // taskbar intersects with the monitor - this is the one + break; + } + } + + return taskbar; +} + +/// Gets the edge of the taskbar if it's automatically hidden +std::optional hiddenTaskbarEdge(LPRECT rcMon = nullptr) +{ + HWND taskbar = findTaskbarWindow(rcMon); + if (!taskbar) + { + return std::nullopt; + } + + APPBARDATA state = {sizeof(state), taskbar}; + APPBARDATA pos = {sizeof(pos), taskbar}; + + auto appBarState = + static_cast(SHAppBarMessage(ABM_GETSTATE, &state)); + if ((appBarState & ABS_AUTOHIDE) == 0) + { + return std::nullopt; + } + + if (SHAppBarMessage(ABM_GETTASKBARPOS, &pos) == 0) + { + qCDebug(chatterinoApp) << "Failed to get taskbar pos"; + return ABE_BOTTOM; + } + + return pos.uEdge; +} + +/// @brief Gets the window borders for @a hwnd +/// +/// Each side of the returned RECT has the correct sign, so they can be added +/// to a window rect. +/// Shrinking by 1px would return {left: 1, top: 1, right: -1, left: -1}. +RECT windowBordersFor(HWND hwnd, bool isMaximized) +{ + RECT margins{0, 0, 0, 0}; + + auto addBorders = isMaximized || isWindows11OrGreater(); + if (addBorders) + { + // GetDpiForWindow and GetSystemMetricsForDpi are only supported on + // Windows 10 and later. Qt 6 requires Windows 10. +# if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + auto dpi = GetDpiForWindow(hwnd); +# endif + + auto systemMetric = [&](auto index) { +# if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + if (dpi != 0) + { + return GetSystemMetricsForDpi(index, dpi); + } +# endif + return GetSystemMetrics(index); + }; + + auto paddedBorder = systemMetric(SM_CXPADDEDBORDER); + auto borderWidth = systemMetric(SM_CXSIZEFRAME) + paddedBorder; + auto borderHeight = systemMetric(SM_CYSIZEFRAME) + paddedBorder; + + margins.left += borderWidth; + margins.right -= borderWidth; + if (isMaximized) + { + margins.top += borderHeight; + } + margins.bottom -= borderHeight; + } + + if (isMaximized) + { + auto *hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi; + mi.cbSize = sizeof(mi); + auto *monitor = [&]() -> LPRECT { + if (GetMonitorInfo(hMonitor, &mi)) + { + return &mi.rcMonitor; + } + return nullptr; + }(); + + auto edge = hiddenTaskbarEdge(monitor); + if (edge) + { + switch (*edge) + { + case ABE_LEFT: + margins.left += HIDDEN_TASKBAR_SIZE; + break; + case ABE_RIGHT: + margins.right -= HIDDEN_TASKBAR_SIZE; + break; + case ABE_TOP: + margins.top += HIDDEN_TASKBAR_SIZE; + break; + case ABE_BOTTOM: + margins.bottom -= HIDDEN_TASKBAR_SIZE; + break; + default: + break; + } + } + } + + return margins; +} + +#endif + +} // namespace + namespace chatterino { BaseWindow::BaseWindow(FlagsEnum _flags, QWidget *parent) @@ -69,7 +229,6 @@ BaseWindow::BaseWindow(FlagsEnum _flags, QWidget *parent) [this]() { postToThread([this] { this->updateScale(); - this->updateScale(); }); }, this->connections_, false); @@ -81,7 +240,7 @@ BaseWindow::BaseWindow(FlagsEnum _flags, QWidget *parent) #ifdef USEWINSDK this->useNextBounds_.setSingleShot(true); QObject::connect(&this->useNextBounds_, &QTimer::timeout, this, [this]() { - this->currentBounds_ = this->nextBounds_; + this->currentBounds_ = this->geometry(); }); #endif @@ -94,8 +253,9 @@ BaseWindow::~BaseWindow() DebugCount::decrease("BaseWindow"); } -void BaseWindow::setInitialBounds(const QRect &bounds) +void BaseWindow::setInitialBounds(QRect bounds, widgets::BoundsChecking mode) { + bounds = widgets::checkInitialBounds(bounds, mode); #ifdef USEWINSDK this->initalBounds_ = bounds; #else @@ -103,7 +263,7 @@ void BaseWindow::setInitialBounds(const QRect &bounds) #endif } -QRect BaseWindow::getBounds() +QRect BaseWindow::getBounds() const { #ifdef USEWINSDK return this->currentBounds_; @@ -117,95 +277,80 @@ float BaseWindow::scale() const return std::max(0.01f, this->overrideScale().value_or(this->scale_)); } -float BaseWindow::qtFontScale() const -{ - return this->scale() / std::max(0.01F, this->nativeScale_); -} - void BaseWindow::init() { #ifdef USEWINSDK if (this->hasCustomWindowFrame()) { // CUSTOM WINDOW FRAME - QVBoxLayout *layout = new QVBoxLayout(); + auto *layout = new QVBoxLayout(this); this->ui_.windowLayout = layout; - layout->setContentsMargins(1, 1, 1, 1); + layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - this->setLayout(layout); + + if (!this->frameless_) { - if (!this->frameless_) - { - QHBoxLayout *buttonLayout = this->ui_.titlebarBox = - new QHBoxLayout(); - buttonLayout->setContentsMargins(0, 0, 0, 0); - layout->addLayout(buttonLayout); - - // title - Label *title = new Label; - QObject::connect(this, &QWidget::windowTitleChanged, - [title](const QString &text) { - title->setText(text); - }); - - QSizePolicy policy(QSizePolicy::Ignored, - QSizePolicy::Preferred); - policy.setHorizontalStretch(1); - title->setSizePolicy(policy); - buttonLayout->addWidget(title); - this->ui_.titleLabel = title; - - // buttons - TitleBarButton *_minButton = new TitleBarButton; - _minButton->setButtonStyle(TitleBarButtonStyle::Minimize); - TitleBarButton *_maxButton = new TitleBarButton; - _maxButton->setButtonStyle(TitleBarButtonStyle::Maximize); - TitleBarButton *_exitButton = new TitleBarButton; - _exitButton->setButtonStyle(TitleBarButtonStyle::Close); - - QObject::connect(_minButton, &TitleBarButton::leftClicked, this, - [this] { - this->setWindowState(Qt::WindowMinimized | - this->windowState()); - }); - QObject::connect(_maxButton, &TitleBarButton::leftClicked, this, - [this, _maxButton] { - this->setWindowState( - _maxButton->getButtonStyle() != + QHBoxLayout *buttonLayout = this->ui_.titlebarBox = + new QHBoxLayout(); + buttonLayout->setContentsMargins(0, 0, 0, 0); + layout->addLayout(buttonLayout); + + // title + Label *title = new Label; + QObject::connect(this, &QWidget::windowTitleChanged, + [title](const QString &text) { + title->setText(text); + }); + + QSizePolicy policy(QSizePolicy::Ignored, QSizePolicy::Preferred); + policy.setHorizontalStretch(1); + title->setSizePolicy(policy); + buttonLayout->addWidget(title); + this->ui_.titleLabel = title; + + // buttons + auto *minButton = new TitleBarButton; + minButton->setButtonStyle(TitleBarButtonStyle::Minimize); + auto *maxButton = new TitleBarButton; + maxButton->setButtonStyle(TitleBarButtonStyle::Maximize); + auto *exitButton = new TitleBarButton; + exitButton->setButtonStyle(TitleBarButtonStyle::Close); + + QObject::connect(minButton, &TitleBarButton::leftClicked, this, + [this] { + this->setWindowState(Qt::WindowMinimized | + this->windowState()); + }); + QObject::connect( + maxButton, &TitleBarButton::leftClicked, this, + [this, maxButton] { + this->setWindowState(maxButton->getButtonStyle() != TitleBarButtonStyle::Maximize ? Qt::WindowActive : Qt::WindowMaximized); - }); - QObject::connect(_exitButton, &TitleBarButton::leftClicked, - this, [this] { - this->close(); - }); - - this->ui_.titlebarButtons = new TitleBarButtons( - this, _minButton, _maxButton, _exitButton); - - this->ui_.buttons.push_back(_minButton); - this->ui_.buttons.push_back(_maxButton); - this->ui_.buttons.push_back(_exitButton); - - // buttonLayout->addStretch(1); - buttonLayout->addWidget(_minButton); - buttonLayout->addWidget(_maxButton); - buttonLayout->addWidget(_exitButton); - buttonLayout->setSpacing(0); - } + }); + QObject::connect(exitButton, &TitleBarButton::leftClicked, this, + [this] { + this->close(); + }); + + this->ui_.titlebarButtons = + new TitleBarButtons(this, minButton, maxButton, exitButton); + + this->ui_.buttons.push_back(minButton); + this->ui_.buttons.push_back(maxButton); + this->ui_.buttons.push_back(exitButton); + + buttonLayout->addWidget(minButton); + buttonLayout->addWidget(maxButton); + buttonLayout->addWidget(exitButton); + buttonLayout->setSpacing(0); } + this->ui_.layoutBase = new BaseWidget(this); this->ui_.layoutBase->setContentsMargins(1, 0, 1, 1); layout->addWidget(this->ui_.layoutBase); } - -// DPI -// auto dpi = getWindowDpi(this->safeHWND()); - -// if (dpi) { -// this->scale = dpi.value() / 96.f; -// } #endif // TopMost flag overrides setting @@ -263,6 +408,13 @@ void BaseWindow::tryApplyTopMost() } this->waitingForTopMost_ = false; + if (this->parent()) + { + // Don't change the topmost value of child windows. This would apply + // to the top-level window too. + return; + } + ::SetWindowPos(*hwnd, this->isTopMost_ ? HWND_TOPMOST : HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); } @@ -295,7 +447,7 @@ QWidget *BaseWindow::getLayoutContainer() } } -bool BaseWindow::hasCustomWindowFrame() +bool BaseWindow::hasCustomWindowFrame() const { return BaseWindow::supportsCustomWindowFrame() && this->enableCustomFrame_; } @@ -527,6 +679,7 @@ void BaseWindow::changeEvent(QEvent *) void BaseWindow::leaveEvent(QEvent *) { + this->leaving.invoke(); } void BaseWindow::moveTo(QPoint point, widgets::BoundsChecking mode) @@ -563,29 +716,8 @@ void BaseWindow::resizeEvent(QResizeEvent *) } #ifdef USEWINSDK - if (this->hasCustomWindowFrame() && !this->isResizeFixing_) - { - this->isResizeFixing_ = true; - QTimer::singleShot(50, this, [this] { - auto hwnd = this->safeHWND(); - if (!hwnd) - { - this->isResizeFixing_ = false; - return; - } - RECT rect; - ::GetWindowRect(*hwnd, &rect); - ::SetWindowPos(*hwnd, nullptr, 0, 0, rect.right - rect.left + 1, - rect.bottom - rect.top, SWP_NOMOVE | SWP_NOZORDER); - ::SetWindowPos(*hwnd, nullptr, 0, 0, rect.right - rect.left, - rect.bottom - rect.top, SWP_NOMOVE | SWP_NOZORDER); - QTimer::singleShot(10, this, [this] { - this->isResizeFixing_ = false; - }); - }); - } - this->calcButtonsSizes(); + this->updateRealSize(); #endif } @@ -647,10 +779,6 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message, switch (msg->message) { - case WM_DPICHANGED: - returnValue = this->handleDPICHANGED(msg); - break; - case WM_SHOWWINDOW: returnValue = this->handleSHOWWINDOW(msg); break; @@ -689,12 +817,15 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message, { *result = 0; returnValue = true; - long x = GET_X_LPARAM(msg->lParam); - long y = GET_Y_LPARAM(msg->lParam); - RECT winrect; - GetWindowRect(msg->hwnd, &winrect); - QPoint globalPos(x, y); + POINT p{GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam)}; + ScreenToClient(msg->hwnd, &p); + + QPoint globalPos(p.x, p.y); + globalPos /= this->devicePixelRatio(); + globalPos = this->mapToGlobal(globalPos); + + // TODO(nerix): use TrackMouseEvent here this->ui_.titlebarButtons->hover(msg->wParam, globalPos); this->lastEventWasNcMouseMove_ = true; } @@ -740,12 +871,14 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message, *result = 0; auto ht = msg->wParam; - long x = GET_X_LPARAM(msg->lParam); - long y = GET_Y_LPARAM(msg->lParam); - RECT winrect; - GetWindowRect(msg->hwnd, &winrect); - QPoint globalPos(x, y); + POINT p{GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam)}; + ScreenToClient(msg->hwnd, &p); + + QPoint globalPos(p.x, p.y); + globalPos /= this->devicePixelRatio(); + globalPos = this->mapToGlobal(globalPos); + if (msg->message == WM_NCLBUTTONDOWN) { this->ui_.titlebarButtons->mousePress(ht, globalPos); @@ -776,7 +909,7 @@ void BaseWindow::scaleChangedEvent(float scale) #endif this->setFont( - getIApp()->getFonts()->getFont(FontStyle::UiTabs, this->qtFontScale())); + getIApp()->getFonts()->getFont(FontStyle::UiTabs, this->scale())); } void BaseWindow::paintEvent(QPaintEvent *) @@ -794,19 +927,52 @@ void BaseWindow::paintEvent(QPaintEvent *) void BaseWindow::updateScale() { - auto scale = - this->nativeScale_ * (this->flags_.has(DisableCustomScaling) - ? 1 - : getSettings()->getClampedUiScale()); + auto scale = this->flags_.has(DisableCustomScaling) + ? 1 + : getSettings()->getClampedUiScale(); this->setScale(scale); - for (auto *child : this->findChildren()) + BaseWindow::applyScaleRecursive(this, scale); +} + +// NOLINTNEXTLINE(misc-no-recursion) +void BaseWindow::applyScaleRecursive(QObject *root, float scale) +{ + for (QObject *obj : root->children()) { - child->setScale(scale); + auto *base = dynamic_cast(obj); + if (base) + { + auto *window = dynamic_cast(obj); + if (window) + { + // stop here, the window will get the event as well (via uiScale) + continue; + } + base->setScale(scale); + } + + applyScaleRecursive(obj, scale); } } +#ifdef USEWINSDK +void BaseWindow::updateRealSize() +{ + auto hwnd = this->safeHWND(); + if (!hwnd) + { + return; + } + + RECT real; + ::GetWindowRect(*hwnd, &real); + this->realBounds_ = QRect(real.left, real.top, real.right - real.left, + real.bottom - real.top); +} +#endif + void BaseWindow::calcButtonsSizes() { if (!this->shown_) @@ -838,34 +1004,28 @@ void BaseWindow::drawCustomWindowFrame(QPainter &painter) { QColor bg = this->overrideBackgroundColor_.value_or( this->theme->window.background); - painter.fillRect(QRect(1, 2, this->width() - 2, this->height() - 3), - bg); + if (this->isMaximized_) + { + painter.fillRect(this->rect(), bg); + } + else + { + // Draw a border that's exactly 1px wide + // + // There is a bug where the border can get px wide while dragging. + // this "fixes" itself when deselecting the window. + auto dpr = this->devicePixelRatio(); + if (dpr != 1) + { + painter.setTransform(QTransform::fromScale(1 / dpr, 1 / dpr)); + } + painter.fillRect(1, 1, this->realBounds_.width() - 2, + this->realBounds_.height() - 2, bg); + } } #endif } -bool BaseWindow::handleDPICHANGED(MSG *msg) -{ -#ifdef USEWINSDK - int dpi = HIWORD(msg->wParam); - - float _scale = dpi / 96.f; - - auto *prcNewWindow = reinterpret_cast(msg->lParam); - SetWindowPos(msg->hwnd, nullptr, prcNewWindow->left, prcNewWindow->top, - prcNewWindow->right - prcNewWindow->left, - prcNewWindow->bottom - prcNewWindow->top, - SWP_NOZORDER | SWP_NOACTIVATE); - - this->nativeScale_ = _scale; - this->updateScale(); - - return true; -#else - return false; -#endif -} - bool BaseWindow::handleSHOWWINDOW(MSG *msg) { #ifdef USEWINSDK @@ -875,16 +1035,6 @@ bool BaseWindow::handleSHOWWINDOW(MSG *msg) return true; } - if (auto dpi = getWindowDpi(msg->hwnd)) - { - float currentScale = (float)dpi.value() / 96.F; - if (currentScale != this->nativeScale_) - { - this->nativeScale_ = currentScale; - this->updateScale(); - } - } - if (!this->shown_) { this->shown_ = true; @@ -898,14 +1048,12 @@ bool BaseWindow::handleSHOWWINDOW(MSG *msg) if (!this->initalBounds_.isNull()) { - ::SetWindowPos(msg->hwnd, nullptr, this->initalBounds_.x(), - this->initalBounds_.y(), this->initalBounds_.width(), - this->initalBounds_.height(), - SWP_NOZORDER | SWP_NOACTIVATE); + this->setGeometry(this->initalBounds_); this->currentBounds_ = this->initalBounds_; } this->calcButtonsSizes(); + this->updateRealSize(); } return true; @@ -921,23 +1069,54 @@ bool BaseWindow::handleNCCALCSIZE(MSG *msg, long *result) #endif { #ifdef USEWINSDK - if (this->hasCustomWindowFrame()) + if (!this->hasCustomWindowFrame()) { - if (msg->wParam == TRUE) - { - // remove 1 extra pixel on top of custom frame - auto *ncp = reinterpret_cast(msg->lParam); - if (ncp) - { - ncp->lppos->flags |= SWP_NOREDRAW; - ncp->rgrc[0].top -= 1; - } - } + return false; + } + if (msg->wParam != TRUE) + { *result = 0; return true; } - return false; + + auto *params = reinterpret_cast(msg->lParam); + auto *r = ¶ms->rgrc[0]; + + WINDOWPLACEMENT wp; + wp.length = sizeof(WINDOWPLACEMENT); + this->isMaximized_ = GetWindowPlacement(msg->hwnd, &wp) != 0 && + (wp.showCmd == SW_SHOWMAXIMIZED); + + auto borders = windowBordersFor(msg->hwnd, this->isMaximized_); + r->left += borders.left; + r->top += borders.top; + r->right += borders.right; + r->bottom += borders.bottom; + + if (borders.left != 0 || borders.top != 0 || borders.right != 0 || + borders.bottom != 0) + { + // We added borders -> we changed the rect, so we can't return + // WVR_VALIDRECTS + *result = 0; + return true; + } + + // This is an attempt at telling Windows to not redraw (or at least to do a + // better job at redrawing) the window. There is a long list of tricks + // people tried to prevent this at + // https://stackoverflow.com/q/53000291/16300717 + // + // We set the source and destination rectangles to a 1x1 rectangle at the + // top left. Windows is instructed by WVR_VALIDRECTS to copy and preserve + // some parts of the window image. + QPoint fixed = {r->left, r->top}; + params->rgrc[1] = {fixed.x(), fixed.y(), fixed.x() + 1, fixed.y() + 1}; + params->rgrc[2] = {fixed.x(), fixed.y(), fixed.x() + 1, fixed.y() + 1}; + *result = WVR_VALIDRECTS; + + return true; #else return false; #endif @@ -954,28 +1133,15 @@ bool BaseWindow::handleSIZE(MSG *msg) } else if (this->hasCustomWindowFrame()) { - if (msg->wParam == SIZE_MAXIMIZED) - { - auto offset = - int(getWindowDpi(msg->hwnd).value_or(96) * 8 / 96); - - this->ui_.windowLayout->setContentsMargins(offset, offset, - offset, offset); - } - else - { - this->ui_.windowLayout->setContentsMargins(0, 1, 0, 0); - } - this->isNotMinimizedOrMaximized_ = msg->wParam == SIZE_RESTORED; if (this->isNotMinimizedOrMaximized_) { - RECT rect; - ::GetWindowRect(msg->hwnd, &rect); - this->currentBounds_ = - QRect(QPoint(rect.left, rect.top), - QPoint(rect.right - 1, rect.bottom - 1)); + // Wait for WM_SIZE to be processed by Qt and update the current + // bounds afterwards. + postToThread([this] { + this->currentBounds_ = this->geometry(); + }); } this->useNextBounds_.stop(); @@ -985,6 +1151,12 @@ bool BaseWindow::handleSIZE(MSG *msg) // the minimize button, so we have to emulate it. this->ui_.titlebarButtons->leave(); } + + RECT real; + ::GetWindowRect(msg->hwnd, &real); + this->realBounds_ = + QRect(real.left, real.top, real.right - real.left, + real.bottom - real.top); } } return false; @@ -998,11 +1170,8 @@ bool BaseWindow::handleMOVE(MSG *msg) #ifdef USEWINSDK if (this->isNotMinimizedOrMaximized_) { - RECT rect; - ::GetWindowRect(msg->hwnd, &rect); - this->nextBounds_ = QRect(QPoint(rect.left, rect.top), - QPoint(rect.right - 1, rect.bottom - 1)); - + // Wait for WM_SIZE (in case the window was maximized, we don't want to + // save the bounds but keep the old ones) this->useNextBounds_.start(10); } #endif @@ -1016,31 +1185,37 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) #endif { #ifdef USEWINSDK - const LONG border_width = 8; // in pixels - RECT winrect; - GetWindowRect(msg->hwnd, &winrect); + const LONG borderWidth = 8; // in device independent pixels + + auto rect = this->rect(); + + POINT p{GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam)}; + ScreenToClient(msg->hwnd, &p); - long x = GET_X_LPARAM(msg->lParam); - long y = GET_Y_LPARAM(msg->lParam); + QPoint point(p.x, p.y); + point /= this->devicePixelRatio(); - QPoint point(x - winrect.left, y - winrect.top); + auto x = point.x(); + auto y = point.y(); if (this->hasCustomWindowFrame()) { *result = 0; - bool resizeWidth = minimumWidth() != maximumWidth(); - bool resizeHeight = minimumHeight() != maximumHeight(); + bool resizeWidth = + minimumWidth() != maximumWidth() && !this->isMaximized(); + bool resizeHeight = + minimumHeight() != maximumHeight() && !this->isMaximized(); if (resizeWidth) { // left border - if (x < winrect.left + border_width) + if (x < rect.left() + borderWidth) { *result = HTLEFT; } // right border - if (x >= winrect.right - border_width) + if (x >= rect.right() - borderWidth) { *result = HTRIGHT; } @@ -1048,12 +1223,12 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) if (resizeHeight) { // bottom border - if (y >= winrect.bottom - border_width) + if (y >= rect.bottom() - borderWidth) { *result = HTBOTTOM; } // top border - if (y < winrect.top + border_width) + if (y < rect.top() + borderWidth) { *result = HTTOP; } @@ -1061,26 +1236,26 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) if (resizeWidth && resizeHeight) { // bottom left corner - if (x >= winrect.left && x < winrect.left + border_width && - y < winrect.bottom && y >= winrect.bottom - border_width) + if (x >= rect.left() && x < rect.left() + borderWidth && + y < rect.bottom() && y >= rect.bottom() - borderWidth) { *result = HTBOTTOMLEFT; } // bottom right corner - if (x < winrect.right && x >= winrect.right - border_width && - y < winrect.bottom && y >= winrect.bottom - border_width) + if (x < rect.right() && x >= rect.right() - borderWidth && + y < rect.bottom() && y >= rect.bottom() - borderWidth) { *result = HTBOTTOMRIGHT; } // top left corner - if (x >= winrect.left && x < winrect.left + border_width && - y >= winrect.top && y < winrect.top + border_width) + if (x >= rect.left() && x < rect.left() + borderWidth && + y >= rect.top() && y < rect.top() + borderWidth) { *result = HTTOPLEFT; } // top right corner - if (x < winrect.right && x >= winrect.right - border_width && - y >= winrect.top && y < winrect.top + border_width) + if (x < rect.right() && x >= rect.right() - borderWidth && + y >= rect.top() && y < rect.top() + borderWidth) { *result = HTTOPRIGHT; } diff --git a/src/widgets/BaseWindow.hpp b/src/widgets/BaseWindow.hpp index e59c8ae8173..2f21686457a 100644 --- a/src/widgets/BaseWindow.hpp +++ b/src/widgets/BaseWindow.hpp @@ -45,11 +45,11 @@ class BaseWindow : public BaseWidget QWidget *parent = nullptr); ~BaseWindow() override; - void setInitialBounds(const QRect &bounds); - QRect getBounds(); + void setInitialBounds(QRect bounds, widgets::BoundsChecking mode); + QRect getBounds() const; QWidget *getLayoutContainer(); - bool hasCustomWindowFrame(); + bool hasCustomWindowFrame() const; TitleBarButton *addTitleBarButton(const TitleBarButtonStyle &style, std::function onClicked); EffectLabel *addTitleBarLabel(std::function onClicked); @@ -75,7 +75,6 @@ class BaseWindow : public BaseWidget bool applyLastBoundsCheck(); float scale() const override; - float qtFontScale() const; /// @returns true if the window is the top-most window. /// Either #setTopMost was called or the `TopMost` flag is set which overrides this @@ -85,6 +84,7 @@ class BaseWindow : public BaseWidget void setTopMost(bool topMost); pajlada::Signals::NoArgSignal closing; + pajlada::Signals::NoArgSignal leaving; static bool supportsCustomWindowFrame(); @@ -131,7 +131,8 @@ class BaseWindow : public BaseWidget void drawCustomWindowFrame(QPainter &painter); void onFocusLost(); - bool handleDPICHANGED(MSG *msg); + static void applyScaleRecursive(QObject *root, float scale); + bool handleSHOWWINDOW(MSG *msg); bool handleSIZE(MSG *msg); bool handleMOVE(MSG *msg); @@ -148,8 +149,6 @@ class BaseWindow : public BaseWidget bool frameless_; bool shown_ = false; FlagsEnum flags_; - float nativeScale_ = 1; - bool isResizeFixing_ = false; bool isTopMost_ = false; struct { @@ -167,6 +166,7 @@ class BaseWindow : public BaseWidget widgets::BoundsChecking lastBoundsCheckMode_ = widgets::BoundsChecking::Off; #ifdef USEWINSDK + void updateRealSize(); /// @brief Returns the HWND of this window if it has one /// /// A QWidget only has an HWND if it has been created. Before that, @@ -188,10 +188,13 @@ class BaseWindow : public BaseWidget QRect initalBounds_; QRect currentBounds_; - QRect nextBounds_; QTimer useNextBounds_; bool isNotMinimizedOrMaximized_{}; bool lastEventWasNcMouseMove_ = false; + /// The real bounds of the window as returned by + /// GetWindowRect. Used for drawing. + QRect realBounds_; + bool isMaximized_ = false; #endif pajlada::Signals::SignalHolder connections_; diff --git a/src/widgets/FramelessEmbedWindow.cpp b/src/widgets/FramelessEmbedWindow.cpp index cf61c9bd4be..9735edcee74 100644 --- a/src/widgets/FramelessEmbedWindow.cpp +++ b/src/widgets/FramelessEmbedWindow.cpp @@ -54,7 +54,8 @@ bool FramelessEmbedWindow::nativeEvent(const QByteArray &eventType, auto channelName = root.value("channel-name").toString(); this->split_->setChannel( - getApp()->twitch->getOrAddChannel(channelName)); + getIApp()->getTwitchAbstract()->getOrAddChannel( + channelName)); } } } diff --git a/src/widgets/Label.cpp b/src/widgets/Label.cpp index 37bd9df3601..529fb75751d 100644 --- a/src/widgets/Label.cpp +++ b/src/widgets/Label.cpp @@ -20,6 +20,7 @@ Label::Label(BaseWidget *parent, QString text, FontStyle style) [this] { this->updateSize(); }); + this->updateSize(); } const QString &Label::getText() const @@ -88,23 +89,10 @@ void Label::paintEvent(QPaintEvent *) { QPainter painter(this); - qreal deviceDpi = -#ifdef Q_OS_WIN - this->devicePixelRatioF(); -#else - 1.0; -#endif - QFontMetrics metrics = getIApp()->getFonts()->getFontMetrics( - this->getFontStyle(), - this->scale() * 96.f / - std::max( - 0.01F, static_cast(this->logicalDpiX() * deviceDpi))); - painter.setFont(getIApp()->getFonts()->getFont( - this->getFontStyle(), - this->scale() * 96.f / - std::max( - 0.02F, static_cast(this->logicalDpiX() * deviceDpi)))); + this->getFontStyle(), this->scale()); + painter.setFont( + getIApp()->getFonts()->getFont(this->getFontStyle(), this->scale())); int offset = this->getOffset(); diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index c16181f8072..a3871eee90f 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -29,11 +29,12 @@ #include #include +#include + namespace chatterino { Notebook::Notebook(QWidget *parent) : BaseWidget(parent) - , menu_(this) , addButton_(new NotebookButton(this)) { this->addButton_->setIcon(NotebookButton::Icon::Plus); @@ -81,8 +82,6 @@ Notebook::Notebook(QWidget *parent) << "Notebook must be created within a BaseWindow"; } - this->addNotebookActionsToMenu(&this->menu_); - // Manually resize the add button so the initial paint uses the correct // width when computing the maximum width occupied per column in vertical // tab rendering. @@ -90,6 +89,12 @@ Notebook::Notebook(QWidget *parent) } NotebookTab *Notebook::addPage(QWidget *page, QString title, bool select) +{ + return this->addPageAt(page, -1, std::move(title), select); +} + +NotebookTab *Notebook::addPageAt(QWidget *page, int position, QString title, + bool select) { // Queue up save because: Tab added getIApp()->getWindows()->queueSave(); @@ -104,7 +109,14 @@ NotebookTab *Notebook::addPage(QWidget *page, QString title, bool select) item.page = page; item.tab = tab; - this->items_.append(item); + if (position == -1) + { + this->items_.push_back(item); + } + else + { + this->items_.insert(position, item); + } page->hide(); page->setParent(this); @@ -168,6 +180,48 @@ void Notebook::removePage(QWidget *page) this->performLayout(true); } +void Notebook::duplicatePage(QWidget *page) +{ + auto *item = this->findItem(page); + assert(item != nullptr); + if (item == nullptr) + { + return; + } + + auto *container = dynamic_cast(item->page); + if (!container) + { + return; + } + + auto *newContainer = new SplitContainer(this); + if (!container->getSplits().empty()) + { + auto descriptor = container->buildDescriptor(); + newContainer->applyFromDescriptor(descriptor); + } + + const auto tabPosition = this->indexOf(page); + auto newTabPosition = -1; + if (tabPosition != -1) + { + newTabPosition = tabPosition + 1; + } + auto newTabHighlightState = item->tab->highlightState(); + QString newTabTitle = ""; + if (item->tab->hasCustomTitle()) + { + newTabTitle = item->tab->getCustomTitle(); + } + + auto *tab = + this->addPageAt(newContainer, newTabPosition, newTabTitle, false); + tab->setHighlightState(newTabHighlightState); + + newContainer->setTab(tab); +} + void Notebook::removeCurrentPage() { if (this->selectedPage_ != nullptr) @@ -596,6 +650,12 @@ void Notebook::showTabVisibilityInfoPopup() void Notebook::refresh() { + if (this->refreshPaused_) + { + this->refreshRequested_ = true; + return; + } + this->performLayout(); this->updateTabVisibility(); } @@ -655,13 +715,20 @@ void Notebook::resizeAddButton() this->addButton_->setFixedSize(h, h); } -void Notebook::scaleChangedEvent(float) +void Notebook::scaleChangedEvent(float /*scale*/) { this->resizeAddButton(); + this->refreshPaused_ = true; + this->refreshRequested_ = false; for (auto &i : this->items_) { i.tab->updateSize(); } + this->refreshPaused_ = false; + if (this->refreshRequested_) + { + this->refresh(); + } } void Notebook::resizeEvent(QResizeEvent *) @@ -1125,7 +1192,14 @@ void Notebook::mousePressEvent(QMouseEvent *event) switch (event->button()) { case Qt::RightButton: { - this->menu_.popup(event->globalPos() + QPoint(0, 8)); + event->accept(); + + if (!this->menu_) + { + this->menu_ = new QMenu(this); + this->addNotebookActionsToMenu(this->menu_); + } + this->menu_->popup(event->globalPos() + QPoint(0, 8)); } break; default:; @@ -1294,6 +1368,10 @@ SplitNotebook::SplitNotebook(Window *parent) this->addCustomButtons(); } + this->toggleOfflineTabsAction_ = new QAction({}, this); + QObject::connect(this->toggleOfflineTabsAction_, &QAction::triggered, this, + &SplitNotebook::toggleOfflineTabs); + getSettings()->tabVisibility.connect( [this](int val, auto) { auto visibility = NotebookTabVisibility(val); @@ -1307,12 +1385,17 @@ SplitNotebook::SplitNotebook(Window *parent) this->setTabVisibilityFilter([](const NotebookTab *tab) { return tab->isLive(); }); + this->toggleOfflineTabsAction_->setText("Show all tabs"); break; case NotebookTabVisibility::AllTabs: default: this->setTabVisibilityFilter(nullptr); + this->toggleOfflineTabsAction_->setText( + "Show live tabs only"); break; } + + this->updateToggleOfflineTabsHotkey(visibility); }, this->signalHolder_, true); @@ -1365,6 +1448,31 @@ SplitNotebook::SplitNotebook(Window *parent) }); } +void SplitNotebook::toggleOfflineTabs() +{ + if (!this->getShowTabs()) + { + // Tabs are currently hidden, so the intention is to show + // tabs again before enabling the live only setting + this->setShowTabs(true); + getSettings()->tabVisibility.setValue(NotebookTabVisibility::LiveOnly); + } + else + { + getSettings()->tabVisibility.setValue( + getSettings()->tabVisibility.getEnum() == + NotebookTabVisibility::LiveOnly + ? NotebookTabVisibility::AllTabs + : NotebookTabVisibility::LiveOnly); + } +} + +void SplitNotebook::addNotebookActionsToMenu(QMenu *menu) +{ + Notebook::addNotebookActionsToMenu(menu); + menu->addAction(this->toggleOfflineTabsAction_); +} + void SplitNotebook::showEvent(QShowEvent * /*event*/) { if (auto *page = this->getSelectedPage()) @@ -1442,6 +1550,42 @@ void SplitNotebook::addCustomButtons() this->updateStreamerModeIcon(); } +void SplitNotebook::updateToggleOfflineTabsHotkey( + NotebookTabVisibility newTabVisibility) +{ + auto *hotkeys = getIApp()->getHotkeys(); + auto getKeySequence = [&](auto argument) { + return hotkeys->getDisplaySequence(HotkeyCategory::Window, + "setTabVisibility", {{argument}}); + }; + + auto toggleSeq = getKeySequence("toggleLiveOnly"); + + switch (newTabVisibility) + { + case NotebookTabVisibility::AllTabs: + if (toggleSeq.isEmpty()) + { + toggleSeq = getKeySequence("liveOnly"); + } + break; + + case NotebookTabVisibility::LiveOnly: + if (toggleSeq.isEmpty()) + { + toggleSeq = getKeySequence("toggle"); + + if (toggleSeq.isEmpty()) + { + toggleSeq = getKeySequence("on"); + } + } + break; + } + + this->toggleOfflineTabsAction_->setShortcut(toggleSeq); +} + void SplitNotebook::updateStreamerModeIcon() { if (this->streamerModeIcon_ == nullptr) diff --git a/src/widgets/Notebook.hpp b/src/widgets/Notebook.hpp index 9aa694c66d6..c024998d881 100644 --- a/src/widgets/Notebook.hpp +++ b/src/widgets/Notebook.hpp @@ -42,7 +42,16 @@ class Notebook : public BaseWidget NotebookTab *addPage(QWidget *page, QString title = QString(), bool select = false); + + /** + * @brief Adds a page to the Notebook at a given position. + * + * @param position if set to -1, adds the page to the end + **/ + NotebookTab *addPageAt(QWidget *page, int position, + QString title = QString(), bool select = false); void removePage(QWidget *page); + void duplicatePage(QWidget *page); void removeCurrentPage(); /** @@ -118,7 +127,7 @@ class Notebook : public BaseWidget bool isNotebookLayoutLocked() const; void setLockNotebookLayout(bool value); - void addNotebookActionsToMenu(QMenu *menu); + virtual void addNotebookActionsToMenu(QMenu *menu); // Update layout and tab visibility void refresh(); @@ -182,7 +191,7 @@ class Notebook : public BaseWidget size_t visibleButtonCount() const; QList items_; - QMenu menu_; + QMenu *menu_ = nullptr; QWidget *selectedPage_ = nullptr; NotebookButton *addButton_; @@ -193,7 +202,12 @@ class Notebook : public BaseWidget bool showAddButton_ = false; int lineOffset_ = 20; bool lockNotebookLayout_ = false; + + bool refreshPaused_ = false; + bool refreshRequested_ = false; + NotebookTabLocation tabLocation_ = NotebookTabLocation::Top; + QAction *lockNotebookLayoutAction_; QAction *showTabsAction_; QAction *toggleTopMostAction_; @@ -215,6 +229,9 @@ class SplitNotebook : public Notebook void select(QWidget *page, bool focusPage = true) override; void themeChangedEvent() override; + void addNotebookActionsToMenu(QMenu *menu) override; + void toggleOfflineTabs(); + protected: void showEvent(QShowEvent *event) override; @@ -223,6 +240,9 @@ class SplitNotebook : public Notebook pajlada::Signals::SignalHolder signalHolder_; + QAction *toggleOfflineTabsAction_; + void updateToggleOfflineTabsHotkey(NotebookTabVisibility newTabVisibility); + // Main window on Windows has basically a duplicate of this in Window NotebookButton *streamerModeIcon_{}; void updateStreamerModeIcon(); diff --git a/src/widgets/Scrollbar.cpp b/src/widgets/Scrollbar.cpp index dd707643522..4aef450363a 100644 --- a/src/widgets/Scrollbar.cpp +++ b/src/widgets/Scrollbar.cpp @@ -4,7 +4,6 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" -#include "util/Clamp.hpp" #include "widgets/helper/ChannelView.hpp" #include @@ -13,7 +12,19 @@ #include -#define MIN_THUMB_HEIGHT 10 +namespace { + +constexpr int MIN_THUMB_HEIGHT = 10; + +/// Amount of messages to move by when clicking on the track +constexpr qreal SCROLL_DELTA = 5.0; + +bool areClose(auto a, auto b) +{ + return std::abs(a - b) <= 0.0001; +} + +} // namespace namespace chatterino { @@ -22,40 +33,51 @@ Scrollbar::Scrollbar(size_t messagesLimit, ChannelView *parent) , currentValueAnimation_(this, "currentValue_") , highlights_(messagesLimit) { - this->resize(int(16 * this->scale()), 100); + this->resize(static_cast(16 * this->scale()), 100); this->currentValueAnimation_.setDuration(150); this->currentValueAnimation_.setEasingCurve( QEasingCurve(QEasingCurve::OutCubic)); connect(&this->currentValueAnimation_, &QAbstractAnimation::finished, this, - &Scrollbar::resetMaximum); + &Scrollbar::resetBounds); this->setMouseTracking(true); } -void Scrollbar::addHighlight(ScrollbarHighlight highlight) +boost::circular_buffer Scrollbar::getHighlights() const { - this->highlights_.pushBack(highlight); + return this->highlights_; } -void Scrollbar::addHighlightsAtStart( - const std::vector &_highlights) +void Scrollbar::addHighlight(ScrollbarHighlight highlight) { - this->highlights_.pushFront(_highlights); + this->highlights_.push_back(std::move(highlight)); } -void Scrollbar::replaceHighlight(size_t index, ScrollbarHighlight replacement) +void Scrollbar::addHighlightsAtStart( + const std::vector &highlights) { - this->highlights_.replaceItem(index, replacement); -} + size_t nItems = std::min(highlights.size(), this->highlights_.capacity() - + this->highlights_.size()); -void Scrollbar::pauseHighlights() -{ - this->highlightsPaused_ = true; + if (nItems == 0) + { + return; + } + + for (size_t i = 0; i < nItems; i++) + { + this->highlights_.push_front(highlights[highlights.size() - 1 - i]); + } } -void Scrollbar::unpauseHighlights() +void Scrollbar::replaceHighlight(size_t index, ScrollbarHighlight replacement) { - this->highlightsPaused_ = false; + if (this->highlights_.size() <= index) + { + return; + } + + this->highlights_[index] = std::move(replacement); } void Scrollbar::clearHighlights() @@ -63,16 +85,6 @@ void Scrollbar::clearHighlights() this->highlights_.clear(); } -LimitedQueueSnapshot &Scrollbar::getHighlightSnapshot() -{ - if (!this->highlightsPaused_) - { - this->highlightSnapshot_ = this->highlights_.getSnapshot(); - } - - return this->highlightSnapshot_; -} - void Scrollbar::scrollToBottom(bool animate) { this->setDesiredValue(this->getBottom(), animate); @@ -102,7 +114,7 @@ void Scrollbar::offsetMaximum(qreal value) this->updateScroll(); } -void Scrollbar::resetMaximum() +void Scrollbar::resetBounds() { if (this->minimum_ > 0) { @@ -132,26 +144,21 @@ void Scrollbar::offsetMinimum(qreal value) this->updateScroll(); } -void Scrollbar::setLargeChange(qreal value) +void Scrollbar::setPageSize(qreal value) { - this->largeChange_ = value; - - this->updateScroll(); -} - -void Scrollbar::setSmallChange(qreal value) -{ - this->smallChange_ = value; + this->pageSize_ = value; this->updateScroll(); } void Scrollbar::setDesiredValue(qreal value, bool animated) { + // this can't use std::clamp, because minimum_ < getBottom() isn't always + // true, which is a precondition for std::clamp value = std::max(this->minimum_, std::min(this->getBottom(), value)); - - if (std::abs(this->currentValue_ - value) <= 0.0001) + if (areClose(this->currentValue_, value)) { + // value has not changed return; } @@ -159,7 +166,7 @@ void Scrollbar::setDesiredValue(qreal value, bool animated) this->desiredValueChanged_.invoke(); - this->atBottom_ = (this->getBottom() - value) <= 0.0001; + this->atBottom_ = areClose(this->getBottom(), value); if (animated && getSettings()->enableSmoothScrolling) { @@ -178,7 +185,7 @@ void Scrollbar::setDesiredValue(qreal value, bool animated) else { this->setCurrentValue(value); - this->resetMaximum(); + this->resetBounds(); } } } @@ -193,19 +200,14 @@ qreal Scrollbar::getMinimum() const return this->minimum_; } -qreal Scrollbar::getLargeChange() const +qreal Scrollbar::getPageSize() const { - return this->largeChange_; + return this->pageSize_; } qreal Scrollbar::getBottom() const { - return this->maximum_ - this->largeChange_; -} - -qreal Scrollbar::getSmallChange() const -{ - return this->smallChange_; + return this->maximum_ - this->pageSize_; } qreal Scrollbar::getDesiredValue() const @@ -222,8 +224,8 @@ qreal Scrollbar::getRelativeCurrentValue() const { // currentValue - minimum can be negative if minimum is incremented while // scrolling up to or down from the top when smooth scrolling is enabled. - return clamp(this->currentValue_ - this->minimum_, qreal(0.0), - this->currentValue_); + return std::clamp(this->currentValue_ - this->minimum_, 0.0, + this->currentValue_); } void Scrollbar::offset(qreal value) @@ -244,9 +246,9 @@ pajlada::Signals::NoArgSignal &Scrollbar::getDesiredValueChanged() void Scrollbar::setCurrentValue(qreal value) { value = std::max(this->minimum_, std::min(this->getBottom(), value)); - - if (std::abs(this->currentValue_ - value) <= 0.0001) + if (areClose(this->currentValue_, value)) { + // value has not changed return; } @@ -258,21 +260,24 @@ void Scrollbar::setCurrentValue(qreal value) void Scrollbar::printCurrentState(const QString &prefix) const { - qCDebug(chatterinoWidget) - << prefix // - << "Current value: " << this->getCurrentValue() // - << ". Maximum: " << this->getMaximum() // - << ". Minimum: " << this->getMinimum() // - << ". Large change: " << this->getLargeChange(); // + qCDebug(chatterinoWidget).nospace().noquote() + << prefix // + << " { currentValue: " << this->getCurrentValue() // + << ", desiredValue: " << this->getDesiredValue() // + << ", maximum: " << this->getMaximum() // + << ", minimum: " << this->getMinimum() // + << ", pageSize: " << this->getPageSize() // + << " }"; } -void Scrollbar::paintEvent(QPaintEvent *) +void Scrollbar::paintEvent(QPaintEvent * /*event*/) { - bool mouseOver = this->mouseOverIndex_ != -1; - int xOffset = mouseOver ? 0 : width() - int(4 * this->scale()); + bool mouseOver = this->mouseOverLocation_ != MouseLocation::Outside; + int xOffset = + mouseOver ? 0 : this->width() - static_cast(4.0F * this->scale()); QPainter painter(this); - painter.fillRect(rect(), this->theme->scrollbars.background); + painter.fillRect(this->rect(), this->theme->scrollbars.background); bool enableRedeemedHighlights = getSettings()->enableRedeemedHighlight; bool enableFirstMessageHighlights = @@ -280,18 +285,12 @@ void Scrollbar::paintEvent(QPaintEvent *) bool enableElevatedMessageHighlights = getSettings()->enableElevatedMessageHighlight; - // painter.fillRect(QRect(xOffset, 0, width(), this->buttonHeight), - // this->themeManager->ScrollbarArrow); - // painter.fillRect(QRect(xOffset, height() - this->buttonHeight, - // width(), this->buttonHeight), - // this->themeManager->ScrollbarArrow); - if (this->showThumb_) { this->thumbRect_.setX(xOffset); // mouse over thumb - if (this->mouseDownIndex_ == 2) + if (this->mouseDownLocation_ == MouseLocation::InsideThumb) { painter.fillRect(this->thumbRect_, this->theme->scrollbars.thumbSelected); @@ -304,23 +303,21 @@ void Scrollbar::paintEvent(QPaintEvent *) } // draw highlights - auto &snapshot = this->getHighlightSnapshot(); - size_t snapshotLength = snapshot.size(); - - if (snapshotLength == 0) + if (this->highlights_.empty()) { return; } + size_t nHighlights = this->highlights_.size(); int w = this->width(); - float y = 0; - float dY = float(this->height()) / float(snapshotLength); + float dY = + static_cast(this->height()) / static_cast(nHighlights); int highlightHeight = - int(std::ceil(std::max(this->scale() * 2, dY))); + static_cast(std::ceil(std::max(this->scale() * 2.0F, dY))); - for (size_t i = 0; i < snapshotLength; i++, y += dY) + for (size_t i = 0; i < nHighlights; i++) { - ScrollbarHighlight const &highlight = snapshot[i]; + const auto &highlight = this->highlights_[i]; if (highlight.isNull()) { @@ -347,16 +344,16 @@ void Scrollbar::paintEvent(QPaintEvent *) QColor color = highlight.getColor(); color.setAlpha(255); + int y = static_cast(dY * static_cast(i)); switch (highlight.getStyle()) { case ScrollbarHighlight::Default: { - painter.fillRect(w / 8 * 3, int(y), w / 4, highlightHeight, - color); + painter.fillRect(w / 8 * 3, y, w / 4, highlightHeight, color); } break; case ScrollbarHighlight::Line: { - painter.fillRect(0, int(y), w, 1, color); + painter.fillRect(0, y, w, 1, color); } break; @@ -365,52 +362,30 @@ void Scrollbar::paintEvent(QPaintEvent *) } } -void Scrollbar::resizeEvent(QResizeEvent *) +void Scrollbar::resizeEvent(QResizeEvent * /*event*/) { - this->resize(int(16 * this->scale()), this->height()); + this->resize(static_cast(16 * this->scale()), this->height()); } void Scrollbar::mouseMoveEvent(QMouseEvent *event) { - if (this->mouseDownIndex_ == -1) + if (this->mouseDownLocation_ == MouseLocation::Outside) { - int y = event->pos().y(); - - auto oldIndex = this->mouseOverIndex_; - - if (y < this->buttonHeight_) - { - this->mouseOverIndex_ = 0; - } - else if (y < this->thumbRect_.y()) - { - this->mouseOverIndex_ = 1; - } - else if (this->thumbRect_.contains(2, y)) - { - this->mouseOverIndex_ = 2; - } - else if (y < height() - this->buttonHeight_) - { - this->mouseOverIndex_ = 3; - } - else - { - this->mouseOverIndex_ = 4; - } - - if (oldIndex != this->mouseOverIndex_) + auto moveLocation = this->locationOfMouseEvent(event); + if (this->mouseOverLocation_ != moveLocation) { + this->mouseOverLocation_ = moveLocation; this->update(); } } - else if (this->mouseDownIndex_ == 2) + else if (this->mouseDownLocation_ == MouseLocation::InsideThumb) { - int delta = event->pos().y() - this->lastMousePosition_.y(); + qreal delta = + static_cast(event->pos().y() - this->lastMousePosition_.y()); this->setDesiredValue( this->desiredValue_ + - (qreal(delta) / std::max(0.00000002, this->trackHeight_)) * + (delta / std::max(0.00000002, this->trackHeight_)) * this->maximum_); } @@ -419,96 +394,60 @@ void Scrollbar::mouseMoveEvent(QMouseEvent *event) void Scrollbar::mousePressEvent(QMouseEvent *event) { - int y = event->pos().y(); - - if (y < this->buttonHeight_) - { - this->mouseDownIndex_ = 0; - } - else if (y < this->thumbRect_.y()) - { - this->mouseDownIndex_ = 1; - } - else if (this->thumbRect_.contains(2, y)) - { - this->mouseDownIndex_ = 2; - } - else if (y < height() - this->buttonHeight_) - { - this->mouseDownIndex_ = 3; - } - else - { - this->mouseDownIndex_ = 4; - } + this->mouseDownLocation_ = this->locationOfMouseEvent(event); + this->update(); } void Scrollbar::mouseReleaseEvent(QMouseEvent *event) { - int y = event->pos().y(); - - if (y < this->buttonHeight_) - { - if (this->mouseDownIndex_ == 0) - { - this->setDesiredValue(this->desiredValue_ - this->smallChange_, - true); - } - } - else if (y < this->thumbRect_.y()) - { - if (this->mouseDownIndex_ == 1) - { - this->setDesiredValue(this->desiredValue_ - this->smallChange_, - true); - } - } - else if (this->thumbRect_.contains(2, y)) - { - // do nothing - } - else if (y < height() - this->buttonHeight_) + auto releaseLocation = this->locationOfMouseEvent(event); + if (this->mouseDownLocation_ != releaseLocation) { - if (this->mouseDownIndex_ == 3) - { - this->setDesiredValue(this->desiredValue_ + this->smallChange_, - true); - } + // Ignore event. User released the mouse from a different spot than + // they first clicked. For example, they clicked above the thumb, + // changed their mind, dragged the mouse below the thumb, and released. + this->mouseDownLocation_ = MouseLocation::Outside; + return; } - else + + switch (releaseLocation) { - if (this->mouseDownIndex_ == 4) - { - this->setDesiredValue(this->desiredValue_ + this->smallChange_, - true); - } + case MouseLocation::AboveThumb: + // Move scrollbar up a small bit. + this->setDesiredValue(this->desiredValue_ - SCROLL_DELTA, true); + break; + case MouseLocation::BelowThumb: + // Move scrollbar down a small bit. + this->setDesiredValue(this->desiredValue_ + SCROLL_DELTA, true); + break; + default: + break; } - this->mouseDownIndex_ = -1; - + this->mouseDownLocation_ = MouseLocation::Outside; this->update(); } -void Scrollbar::leaveEvent(QEvent *) +void Scrollbar::leaveEvent(QEvent * /*event*/) { - this->mouseOverIndex_ = -1; - + this->mouseOverLocation_ = MouseLocation::Outside; this->update(); } void Scrollbar::updateScroll() { - this->trackHeight_ = this->height() - this->buttonHeight_ - - this->buttonHeight_ - MIN_THUMB_HEIGHT - 1; + this->trackHeight_ = this->height() - MIN_THUMB_HEIGHT - 1; auto div = std::max(0.0000001, this->maximum_ - this->minimum_); - this->thumbRect_ = QRect( - 0, - int((this->getRelativeCurrentValue()) / div * this->trackHeight_) + 1 + - this->buttonHeight_, - this->width(), - int(this->largeChange_ / div * this->trackHeight_) + MIN_THUMB_HEIGHT); + this->thumbRect_ = + QRect(0, + static_cast((this->getRelativeCurrentValue()) / div * + this->trackHeight_) + + 1, + this->width(), + static_cast(this->pageSize_ / div * this->trackHeight_) + + MIN_THUMB_HEIGHT); this->update(); } @@ -524,4 +463,22 @@ void Scrollbar::setShowThumb(bool showThumb) this->update(); } +Scrollbar::MouseLocation Scrollbar::locationOfMouseEvent( + QMouseEvent *event) const +{ + int y = event->pos().y(); + + if (y < this->thumbRect_.y()) + { + return MouseLocation::AboveThumb; + } + + if (this->thumbRect_.contains(2, y)) + { + return MouseLocation::InsideThumb; + } + + return MouseLocation::BelowThumb; +} + } // namespace chatterino diff --git a/src/widgets/Scrollbar.hpp b/src/widgets/Scrollbar.hpp index f88d234c611..65fad101101 100644 --- a/src/widgets/Scrollbar.hpp +++ b/src/widgets/Scrollbar.hpp @@ -1,11 +1,10 @@ #pragma once -#include "messages/LimitedQueue.hpp" #include "widgets/BaseWidget.hpp" #include "widgets/helper/ScrollbarHighlight.hpp" +#include #include -#include #include #include @@ -13,41 +12,119 @@ namespace chatterino { class ChannelView; +/// @brief A scrollbar for views with partially laid out items +/// +/// This scrollbar is made for views that only lay out visible items. This is +/// the case for a @a ChannelView for example. There, only the visible messages +/// are laid out. For a traditional scrollbar, all messages would need to be +/// laid out to be able to compute the total height of all items. However, for +/// these messages this isn't possible. +/// +/// To avoid having to lay out all items, this scrollbar tracks the position of +/// the content in messages (as opposed to pixels). The position is given by +/// `currentValue` which refers to the index of the message at the top plus a +/// fraction inside the message. The position can be animated to have a smooth +/// scrolling effect. In this case, `currentValue` refers to the displayed +/// position and `desiredValue` refers to the position the scrollbar is set to +/// be at after the animation. The latter is used for example to check if the +/// scrollbar is at the bottom. +/// +/// `minimum` and `maximum` are used to map scrollbar positions to +/// (message-)buffer indices. The buffer is of size `maximum - minimum` and an +/// index is computed by `scrollbarPos - minimum` - thus a scrollbar position +/// of a message is at `index + minimum. +/// +/// @cond src-only +/// +/// The following illustrates a scrollbar in a channel view with seven +/// messages. The scrollbar is at the bottom. No animation is active, thus +/// `currentValue = desiredValue`. +/// +/// ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐←╌╌╌ minimum +/// Alice: This message is quite = 0 +/// ┬ ╭─────────────────────────────────╮←╮ +/// │ │ long, so it gets wrapped │ ┆ +/// │ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ ╰╌╌╌ currentValue +/// │ │ Bob: are you sure? │ = 0.5 +/// │ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ = desiredValue +/// pageSize ╌╌╌┤ │ Alice: Works for me... try for │ = maximum +/// = 6.5 │ │ yourself │ - pageSize +/// │ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ = bottom +/// │ │ Bob: I'm trying to get my really│ ⇒ atBottom = true +/// │ │ long message to wrap so I can │ +/// │ │ debug this issue I'm facing... │ +/// │ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +/// │ │ Bob: Omg it worked │ +/// │ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +/// │ │ Alice: That's amazing! ╭┤ ┬ +/// │ │╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌││ ├╌╌ thumbRect.height() +/// │ │ Bob: you're right ╰┤ ┴ +/// ┴╭→╰─────────────────────────────────╯ +/// ┆ +/// maximum +/// = 7 +/// @endcond +/// +/// When messages are added at the bottom, both maximum and minimum are offset +/// by 1 and after a layout, the desired value is updated, causing the content +/// to move. Afterwards, the bounds are reset (potentially waiting for the +/// animation to finish). +/// +/// While scrolling is paused, the desired (and current) value won't be +/// updated. However, messages can still come in and "shift" the values in the +/// backing ring-buffer. If the current value would be used, the messages would +/// still shift upwards (just at a different offset). To avoid this, there's a +/// _relative current value_, which is `currentValue - minimum`. It's the +/// actual index of the top message in the buffer. Since the minimum is shifted +/// by 1 when messages come in, the view will remain idle (visually). class Scrollbar : public BaseWidget { Q_OBJECT public: - Scrollbar(size_t messagesLimit, ChannelView *parent = nullptr); + Scrollbar(size_t messagesLimit, ChannelView *parent); + /// Return a copy of the highlights + /// + /// Should only be used for tests + boost::circular_buffer getHighlights() const; void addHighlight(ScrollbarHighlight highlight); void addHighlightsAtStart( const std::vector &highlights_); void replaceHighlight(size_t index, ScrollbarHighlight replacement); - void pauseHighlights(); - void unpauseHighlights(); void clearHighlights(); void scrollToBottom(bool animate = false); void scrollToTop(bool animate = false); bool isAtBottom() const; + qreal getMaximum() const; void setMaximum(qreal value); void offsetMaximum(qreal value); - void resetMaximum(); + + qreal getMinimum() const; void setMinimum(qreal value); void offsetMinimum(qreal value); - void setLargeChange(qreal value); - void setSmallChange(qreal value); + + void resetBounds(); + + qreal getPageSize() const; + void setPageSize(qreal value); + + qreal getDesiredValue() const; void setDesiredValue(qreal value, bool animated = false); - qreal getMaximum() const; - qreal getMinimum() const; - qreal getLargeChange() const; + + /// The bottom-most scroll position qreal getBottom() const; - qreal getSmallChange() const; - qreal getDesiredValue() const; qreal getCurrentValue() const; + + /// @brief The current value relative to the minimum + /// + /// > currentValue - minimum + /// + /// This should be used as an index into a buffer of messages, as it is + /// unaffected by simultaneous shifts of minimum and maximum. qreal getRelativeCurrentValue() const; void setShowThumb(bool showthumb); @@ -58,46 +135,55 @@ class Scrollbar : public BaseWidget pajlada::Signals::NoArgSignal &getDesiredValueChanged(); void setCurrentValue(qreal value); - void printCurrentState(const QString &prefix = QString()) const; + void printCurrentState( + const QString &prefix = QStringLiteral("Scrollbar")) const; Q_PROPERTY(qreal desiredValue_ READ getDesiredValue WRITE setDesiredValue) protected: - void paintEvent(QPaintEvent *) override; - void resizeEvent(QResizeEvent *) override; + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; - void leaveEvent(QEvent *) override; + void leaveEvent(QEvent *event) override; private: Q_PROPERTY(qreal currentValue_ READ getCurrentValue WRITE setCurrentValue) - LimitedQueueSnapshot &getHighlightSnapshot(); void updateScroll(); + enum class MouseLocation { + /// The mouse is positioned outside the scrollbar + Outside, + /// The mouse is positioned inside the scrollbar, but above the thumb (the thing you can drag inside the scrollbar) + AboveThumb, + /// The mouse is positioned inside the scrollbar, and on top of the thumb + InsideThumb, + /// The mouse is positioned inside the scrollbar, but below the thumb + BelowThumb, + }; + + MouseLocation locationOfMouseEvent(QMouseEvent *event) const; + QPropertyAnimation currentValueAnimation_; - LimitedQueue highlights_; - bool highlightsPaused_{false}; - LimitedQueueSnapshot highlightSnapshot_; + boost::circular_buffer highlights_; bool atBottom_{false}; bool showThumb_ = true; - int mouseOverIndex_ = -1; - int mouseDownIndex_ = -1; + MouseLocation mouseOverLocation_ = MouseLocation::Outside; + MouseLocation mouseDownLocation_ = MouseLocation::Outside; QPoint lastMousePosition_; - int buttonHeight_ = 0; int trackHeight_ = 100; QRect thumbRect_; qreal maximum_ = 0; qreal minimum_ = 0; - qreal largeChange_ = 0; - qreal smallChange_ = 5; + qreal pageSize_ = 0; qreal desiredValue_ = 0; qreal currentValue_ = 0; diff --git a/src/widgets/TooltipEntryWidget.cpp b/src/widgets/TooltipEntryWidget.cpp index 7ef7274e385..0f0ac5336b5 100644 --- a/src/widgets/TooltipEntryWidget.cpp +++ b/src/widgets/TooltipEntryWidget.cpp @@ -86,6 +86,7 @@ bool TooltipEntryWidget::refreshPixmap() this->attemptRefresh_ = true; return false; } + pixmap->setDevicePixelRatio(this->devicePixelRatio()); if (this->customImgWidth_ > 0 || this->customImgHeight_ > 0) { diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index 5cca6cdb0b9..a7c396fad33 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "common/Args.hpp" +#include "common/Common.hpp" #include "common/Credentials.hpp" #include "common/Modes.hpp" #include "common/QLogging.hpp" @@ -254,7 +255,7 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) const auto &messages = getSampleMiscMessages(); static int index = 0; const auto &msg = messages[index++ % messages.size()]; - getApp()->twitch->addFakeMessage(msg); + getIApp()->getTwitchAbstract()->addFakeMessage(msg); return ""; }); @@ -262,7 +263,7 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) const auto &messages = getSampleCheerMessages(); static int index = 0; const auto &msg = messages[index++ % messages.size()]; - getApp()->twitch->addFakeMessage(msg); + getIApp()->getTwitchAbstract()->addFakeMessage(msg); return ""; }); @@ -270,7 +271,7 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) const auto &messages = getSampleLinkMessages(); static int index = 0; const auto &msg = messages[index++ % messages.size()]; - getApp()->twitch->addFakeMessage(msg); + getIApp()->getTwitchAbstract()->addFakeMessage(msg); return ""; }); @@ -286,7 +287,8 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) oMessage->toInner() ->toInner(); - app->twitch->addFakeMessage(getSampleChannelRewardIRCMessage()); + getIApp()->getTwitchAbstract()->addFakeMessage( + getSampleChannelRewardIRCMessage()); getIApp()->getTwitchPubSub()->pointReward.redeemed.invoke( oInnerMessage->data.value("redemption").toObject()); alt = !alt; @@ -309,7 +311,7 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) const auto &messages = getSampleEmoteTestMessages(); static int index = 0; const auto &msg = messages[index++ % messages.size()]; - getApp()->twitch->addFakeMessage(msg); + getIApp()->getTwitchAbstract()->addFakeMessage(msg); return ""; }); @@ -317,7 +319,7 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) const auto &messages = getSampleSubMessages(); static int index = 0; const auto &msg = messages[index++ % messages.size()]; - getApp()->twitch->addFakeMessage(msg); + getIApp()->getTwitchAbstract()->addFakeMessage(msg); return ""; }); #endif @@ -493,8 +495,8 @@ void Window::addShortcuts() splitContainer = this->notebook_->getOrAddSelectedPage(); } Split *split = new Split(splitContainer); - split->setChannel( - getApp()->twitch->getOrAddChannel(si.channelName)); + split->setChannel(getIApp()->getTwitchAbstract()->getOrAddChannel( + si.channelName)); split->setFilters(si.filters); splitContainer->insertSplit(split); splitContainer->setSelected(split); @@ -672,22 +674,7 @@ void Window::addShortcuts() } else if (arg == "toggleLiveOnly") { - if (!this->notebook_->getShowTabs()) - { - // Tabs are currently hidden, so the intention is to show - // tabs again before enabling the live only setting - this->notebook_->setShowTabs(true); - getSettings()->tabVisibility.setValue( - NotebookTabVisibility::LiveOnly); - } - else - { - getSettings()->tabVisibility.setValue( - getSettings()->tabVisibility.getEnum() == - NotebookTabVisibility::LiveOnly - ? NotebookTabVisibility::AllTabs - : NotebookTabVisibility::LiveOnly); - } + this->notebook_->toggleOfflineTabs(); } else { @@ -716,6 +703,14 @@ void Window::addMenuBar() // First menu. QMenu *menu = mainMenu->addMenu(QString()); + + // About button that shows the About tab in the Settings Dialog. + QAction *about = menu->addAction(QString()); + about->setMenuRole(QAction::AboutRole); + connect(about, &QAction::triggered, this, [this] { + SettingsDialog::showDialog(this, SettingsDialogPreference::About); + }); + QAction *prefs = menu->addAction(QString()); prefs->setMenuRole(QAction::PreferencesRole); connect(prefs, &QAction::triggered, this, [this] { @@ -725,6 +720,13 @@ void Window::addMenuBar() // Window menu. QMenu *windowMenu = mainMenu->addMenu(QString("Window")); + // Window->Minimize item + QAction *minimizeWindow = windowMenu->addAction(QString("Minimize")); + minimizeWindow->setShortcuts({QKeySequence("Meta+M")}); + connect(minimizeWindow, &QAction::triggered, this, [this] { + this->setWindowState(Qt::WindowMinimized); + }); + QAction *nextTab = windowMenu->addAction(QString("Select next tab")); nextTab->setShortcuts({QKeySequence("Meta+Tab")}); connect(nextTab, &QAction::triggered, this, [this] { @@ -736,6 +738,27 @@ void Window::addMenuBar() connect(prevTab, &QAction::triggered, this, [this] { this->notebook_->selectPreviousTab(); }); + + // Help menu. + QMenu *helpMenu = mainMenu->addMenu(QString("Help")); + + // Help->Chatterino Wiki item + QAction *helpWiki = helpMenu->addAction(QString("Chatterino Wiki")); + connect(helpWiki, &QAction::triggered, this, []() { + QDesktopServices::openUrl(QUrl(LINK_CHATTERINO_WIKI)); + }); + + // Help->Chatterino Github + QAction *helpGithub = helpMenu->addAction(QString("Chatterino GitHub")); + connect(helpGithub, &QAction::triggered, this, []() { + QDesktopServices::openUrl(QUrl(LINK_CHATTERINO_SOURCE)); + }); + + // Help->Chatterino Discord + QAction *helpDiscord = helpMenu->addAction(QString("Chatterino Discord")); + connect(helpDiscord, &QAction::triggered, this, []() { + QDesktopServices::openUrl(QUrl(LINK_CHATTERINO_DISCORD)); + }); } void Window::onAccountSelected() diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index 71a18a0a3c3..989233ff7e2 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -203,13 +203,18 @@ EmoteMap filterEmoteMap(const QString &text, namespace chatterino { EmotePopup::EmotePopup(QWidget *parent) - : BasePopup(BaseWindow::EnableCustomFrame, parent) + : BasePopup({BaseWindow::EnableCustomFrame, BaseWindow::DisableLayoutSave}, + parent) , search_(new QLineEdit()) , notebook_(new Notebook(this)) { // this->setStayInScreenRect(true); - this->moveTo(getIApp()->getWindows()->emotePopupPos(), - widgets::BoundsChecking::DesiredPosition); + auto bounds = getIApp()->getWindows()->emotePopupBounds(); + if (bounds.size().isEmpty()) + { + bounds.setSize(QSize{300, 500} * this->scale()); + } + this->setInitialBounds(bounds, widgets::BoundsChecking::DesiredPosition); auto *layout = new QVBoxLayout(); this->getLayoutContainer()->setLayout(layout); @@ -350,11 +355,11 @@ void EmotePopup::addShortcuts() auto &scrollbar = channelView->getScrollBar(); if (direction == "up") { - scrollbar.offset(-scrollbar.getLargeChange()); + scrollbar.offset(-scrollbar.getPageSize()); } else if (direction == "down") { - scrollbar.offset(scrollbar.getLargeChange()); + scrollbar.offset(scrollbar.getPageSize()); } else { @@ -594,10 +599,27 @@ void EmotePopup::filterEmotes(const QString &searchText) this->searchView_->show(); } +void EmotePopup::saveBounds() const +{ + getIApp()->getWindows()->setEmotePopupBounds(this->getBounds()); +} + +void EmotePopup::resizeEvent(QResizeEvent *event) +{ + this->saveBounds(); + BasePopup::resizeEvent(event); +} + +void EmotePopup::moveEvent(QMoveEvent *event) +{ + this->saveBounds(); + BasePopup::moveEvent(event); +} + void EmotePopup::closeEvent(QCloseEvent *event) { - getIApp()->getWindows()->setEmotePopupPos(this->pos()); - BaseWindow::closeEvent(event); + this->saveBounds(); + BasePopup::closeEvent(event); } } // namespace chatterino diff --git a/src/widgets/dialogs/EmotePopup.hpp b/src/widgets/dialogs/EmotePopup.hpp index 43dbb45c626..85b5aa5c4f2 100644 --- a/src/widgets/dialogs/EmotePopup.hpp +++ b/src/widgets/dialogs/EmotePopup.hpp @@ -25,6 +25,10 @@ class EmotePopup : public BasePopup pajlada::Signals::Signal linkClicked; +protected: + void resizeEvent(QResizeEvent *event) override; + void moveEvent(QMoveEvent *event) override; + private: ChannelView *globalEmotesView_{}; ChannelView *channelEmotesView_{}; @@ -47,6 +51,8 @@ class EmotePopup : public BasePopup void filterEmotes(const QString &text); void addShortcuts() override; bool eventFilter(QObject *object, QEvent *event) override; + + void saveBounds() const; }; } // namespace chatterino diff --git a/src/widgets/dialogs/ReplyThreadPopup.cpp b/src/widgets/dialogs/ReplyThreadPopup.cpp index 32ee5a52920..4d3dd3a83b3 100644 --- a/src/widgets/dialogs/ReplyThreadPopup.cpp +++ b/src/widgets/dialogs/ReplyThreadPopup.cpp @@ -51,11 +51,11 @@ ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, Split *split) auto &scrollbar = this->ui_.threadView->getScrollBar(); if (direction == "up") { - scrollbar.offset(-scrollbar.getLargeChange()); + scrollbar.offset(-scrollbar.getPageSize()); } else if (direction == "down") { - scrollbar.offset(scrollbar.getLargeChange()); + scrollbar.offset(scrollbar.getPageSize()); } else { diff --git a/src/widgets/dialogs/SelectChannelDialog.cpp b/src/widgets/dialogs/SelectChannelDialog.cpp index febd382764f..d482bb12123 100644 --- a/src/widgets/dialogs/SelectChannelDialog.cpp +++ b/src/widgets/dialogs/SelectChannelDialog.cpp @@ -375,35 +375,33 @@ IndirectChannel SelectChannelDialog::getSelectedChannel() const return this->selectedChannel_; } - auto *app = getApp(); - switch (this->ui_.notebook->getSelectedIndex()) { case TAB_TWITCH: { if (this->ui_.twitch.channel->isChecked()) { - return app->twitch->getOrAddChannel( + return getIApp()->getTwitchAbstract()->getOrAddChannel( this->ui_.twitch.channelName->text().trimmed()); } else if (this->ui_.twitch.watching->isChecked()) { - return app->twitch->watchingChannel; + return getIApp()->getTwitch()->getWatchingChannel(); } else if (this->ui_.twitch.mentions->isChecked()) { - return app->twitch->mentionsChannel; + return getIApp()->getTwitch()->getMentionsChannel(); } else if (this->ui_.twitch.whispers->isChecked()) { - return app->twitch->whispersChannel; + return getIApp()->getTwitch()->getWhispersChannel(); } else if (this->ui_.twitch.live->isChecked()) { - return app->twitch->liveChannel; + return getIApp()->getTwitch()->getLiveChannel(); } else if (this->ui_.twitch.automod->isChecked()) { - return app->twitch->automodChannel; + return getIApp()->getTwitch()->getAutomodChannel(); } } break; diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index 49cdf8e3539..d9ad48fff78 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -47,7 +47,10 @@ SettingsDialog::SettingsDialog(QWidget *parent) this->resize(915, 600); this->themeChangedEvent(); - this->scaleChangedEvent(this->scale()); + QFile styleFile(":/qss/settings.qss"); + styleFile.open(QFile::ReadOnly); + QString stylesheet = QString::fromUtf8(styleFile.readAll()); + this->setStyleSheet(stylesheet); this->initUi(); this->addTabs(); @@ -193,7 +196,7 @@ void SettingsDialog::filterElements(const QString &text) auto *item = this->ui_.tabContainer->itemAt(i); if (auto *x = dynamic_cast(item); x) { - x->changeSize(10, shouldShowSpace ? int(16 * this->scale()) : 0); + x->changeSize(10, shouldShowSpace ? 16 : 0); shouldShowSpace = false; } else if (item->widget()) @@ -249,7 +252,7 @@ void SettingsDialog::addTabs() this->addTab([]{return new PluginsPage;}, "Plugins", ":/settings/plugins.svg"); #endif this->ui_.tabContainer->addStretch(1); - this->addTab([]{return new AboutPage;}, "About", ":/settings/about.svg", SettingsTabId(), Qt::AlignBottom); + this->addTab([]{return new AboutPage;}, "About", ":/settings/about.svg", SettingsTabId::About, Qt::AlignBottom); // clang-format on } @@ -366,6 +369,11 @@ void SettingsDialog::showDialog(QWidget *parent, } break; + case SettingsDialogPreference::About: { + instance->selectTab(SettingsTabId::About); + } + break; + default:; } @@ -389,27 +397,21 @@ void SettingsDialog::refresh() } } -void SettingsDialog::scaleChangedEvent(float newDpi) +void SettingsDialog::scaleChangedEvent(float newScale) { - QFile file(":/qss/settings.qss"); - file.open(QFile::ReadOnly); - QString styleSheet = QLatin1String(file.readAll()); - styleSheet.replace("", QString::number(int(14 * newDpi))); - styleSheet.replace("", QString::number(int(14 * newDpi))); + assert(newScale == 1.F && + "Scaling is disabled for the settings dialog - its scale should " + "always be 1"); for (SettingsDialogTab *tab : this->tabs_) { - tab->setFixedHeight(int(30 * newDpi)); + tab->setFixedHeight(30); } - this->setStyleSheet(styleSheet); - if (this->ui_.tabContainerContainer) { - this->ui_.tabContainerContainer->setFixedWidth(int(150 * newDpi)); + this->ui_.tabContainerContainer->setFixedWidth(150); } - - this->dpi_ = newDpi; } void SettingsDialog::themeChangedEvent() diff --git a/src/widgets/dialogs/SettingsDialog.hpp b/src/widgets/dialogs/SettingsDialog.hpp index e227223de96..6c32e0ccbc3 100644 --- a/src/widgets/dialogs/SettingsDialog.hpp +++ b/src/widgets/dialogs/SettingsDialog.hpp @@ -30,6 +30,7 @@ enum class SettingsDialogPreference { StreamerMode, Accounts, ModerationActions, + About, }; class SettingsDialog : public BaseWindow diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 953be229cde..d519cdcfc8d 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -164,11 +164,11 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split) auto &scrollbar = this->ui_.latestMessages->getScrollBar(); if (direction == "up") { - scrollbar.offset(-scrollbar.getLargeChange()); + scrollbar.offset(-scrollbar.getPageSize()); } else if (direction == "down") { - scrollbar.offset(scrollbar.getLargeChange()); + scrollbar.offset(scrollbar.getPageSize()); } else { @@ -298,21 +298,23 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split) menu->addAction( "Open channel in a new popup window", this, [loginName] { - auto *app = getApp(); + auto *app = getIApp(); auto &window = app->getWindows()->createWindow( WindowType::Popup, true); auto *split = window.getNotebook() .getOrAddSelectedPage() ->appendNewSplit(false); - split->setChannel(app->twitch->getOrAddChannel( - loginName.toLower())); + split->setChannel( + app->getTwitchAbstract()->getOrAddChannel( + loginName.toLower())); }); menu->addAction( "Open channel in a new tab", this, [loginName] { ChannelPtr channel = - getApp()->twitch->getOrAddChannel( - loginName); + getIApp() + ->getTwitchAbstract() + ->getOrAddChannel(loginName); auto &nb = getApp() ->getWindows() ->getMainWindow() diff --git a/src/widgets/dialogs/switcher/NewPopupItem.cpp b/src/widgets/dialogs/switcher/NewPopupItem.cpp index ac4c45a9ccb..d4bc01bbd84 100644 --- a/src/widgets/dialogs/switcher/NewPopupItem.cpp +++ b/src/widgets/dialogs/switcher/NewPopupItem.cpp @@ -21,7 +21,8 @@ NewPopupItem::NewPopupItem(const QString &channelName) void NewPopupItem::action() { - auto channel = getApp()->twitch->getOrAddChannel(this->channelName_); + auto channel = + getIApp()->getTwitchAbstract()->getOrAddChannel(this->channelName_); getIApp()->getWindows()->openInPopup(channel); } diff --git a/src/widgets/dialogs/switcher/NewTabItem.cpp b/src/widgets/dialogs/switcher/NewTabItem.cpp index c264785b254..967c14aca1f 100644 --- a/src/widgets/dialogs/switcher/NewTabItem.cpp +++ b/src/widgets/dialogs/switcher/NewTabItem.cpp @@ -26,7 +26,8 @@ void NewTabItem::action() SplitContainer *container = nb.addPage(true); Split *split = new Split(container); - split->setChannel(getApp()->twitch->getOrAddChannel(this->channelName_)); + split->setChannel( + getIApp()->getTwitchAbstract()->getOrAddChannel(this->channelName_)); container->insertSplit(split); } diff --git a/src/widgets/helper/Button.cpp b/src/widgets/helper/Button.cpp index 08dde78e146..0574326666a 100644 --- a/src/widgets/helper/Button.cpp +++ b/src/widgets/helper/Button.cpp @@ -8,26 +8,44 @@ #include #include -namespace chatterino { namespace { - // returns a new resized image or the old one if the size didn't change - auto resizePixmap(const QPixmap ¤t, const QPixmap resized, - const QSize &size) -> QPixmap +QSizeF deviceIndependentSize(const QPixmap &pixmap) +{ +#if QT_VERSION < QT_VERSION_CHECK(6, 2, 0) + return QSizeF(pixmap.width(), pixmap.height()) / pixmap.devicePixelRatio(); +#else + return pixmap.deviceIndependentSize(); +#endif +} + +/** + * Resizes a pixmap to a desired size. + * Does nothing if the target pixmap is already sized correctly. + * + * @param target The target pixmap. + * @param source The unscaled pixmap. + * @param size The desired device independent size. + * @param dpr The device pixel ratio of the target area. The size of the target in pixels will be `size * dpr`. + */ +void resizePixmap(QPixmap &target, const QPixmap &source, const QSize &size, + qreal dpr) +{ + if (deviceIndependentSize(target) == size) { - if (resized.size() == size) - { - return resized; - } - else - { - return current.scaled(size, Qt::IgnoreAspectRatio, - Qt::SmoothTransformation); - } + return; } + QPixmap resized = source; + resized.setDevicePixelRatio(dpr); + target = resized.scaled(size * dpr, Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); +} + } // namespace +namespace chatterino { + Button::Button(BaseWidget *parent) : BaseWidget(parent) { @@ -47,6 +65,12 @@ void Button::setMouseEffectColor(std::optional color) void Button::setPixmap(const QPixmap &_pixmap) { + // Avoid updates if the pixmap didn't change + if (_pixmap.cacheKey() == this->pixmap_.cacheKey()) + { + return; + } + this->pixmap_ = _pixmap; this->resizedPixmap_ = {}; this->update(); @@ -158,8 +182,8 @@ void Button::paintButton(QPainter &painter) QRect rect = this->rect(); - this->resizedPixmap_ = - resizePixmap(this->pixmap_, this->resizedPixmap_, rect.size()); + resizePixmap(this->resizedPixmap_, this->pixmap_, rect.size(), + this->devicePixelRatio()); int margin = this->height() < 22 * this->scale() ? 3 : 6; diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 5fa316ee347..dbe2601e5ed 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -268,6 +268,20 @@ void addHiddenContextMenuItems(QMenu *menu, jsonObject["searchText"] = message->searchText; jsonObject["messageText"] = message->messageText; jsonObject["flags"] = qmagicenum::enumFlagsName(message->flags.value()); + if (message->reward) + { + QJsonObject reward; + reward["id"] = message->reward->id; + reward["title"] = message->reward->title; + reward["cost"] = message->reward->cost; + reward["isUserInputRequired"] = + message->reward->isUserInputRequired; + jsonObject["reward"] = reward; + } + else + { + jsonObject["reward"] = QJsonValue(); + } jsonDocument.setObject(jsonObject); @@ -541,6 +555,8 @@ void ChannelView::updatePauses() this->pauseScrollMaximumOffset_ = 0; this->queueLayout(); + // make sure we re-render + this->update(); } else if (std::any_of(this->pauses_.begin(), this->pauses_.end(), [](auto &&value) { @@ -565,8 +581,9 @@ void ChannelView::updatePauses() { /// Start the timer this->pauseEnd_ = pauseEnd; - this->pauseTimer_.start( - duration_cast(pauseEnd - SteadyClock::now())); + auto duration = + duration_cast(pauseEnd - SteadyClock::now()); + this->pauseTimer_.start(std::max(duration, 0ms)); } } } @@ -621,7 +638,7 @@ void ChannelView::scaleChangedEvent(float scale) if (this->goToBottom_) { - auto factor = this->qtFontScale(); + auto factor = this->scale(); #ifdef Q_OS_MACOS factor = scale * 80.F / std::max( @@ -709,8 +726,10 @@ void ChannelView::layoutVisibleMessages( { const auto &message = messages[i]; - redrawRequired |= message->layout(layoutWidth, this->scale(), flags, - this->bufferInvalidationQueued_); + redrawRequired |= message->layout( + layoutWidth, this->scale(), + this->scale() * static_cast(this->devicePixelRatio()), + flags, this->bufferInvalidationQueued_); y += message->getHeight(); } @@ -744,13 +763,16 @@ void ChannelView::updateScrollbar( { auto *message = messages[i].get(); - message->layout(layoutWidth, this->scale(), flags, false); + message->layout( + layoutWidth, this->scale(), + this->scale() * static_cast(this->devicePixelRatio()), flags, + false); h -= message->getHeight(); if (h < 0) // break condition { - this->scrollBar_->setLargeChange( + this->scrollBar_->setPageSize( (messages.size() - i) + qreal(h) / std::max(1, message->getHeight())); @@ -784,10 +806,11 @@ void ChannelView::clearMessages() // Clear all stored messages in this chat widget this->messages_.clear(); this->scrollBar_->clearHighlights(); - this->scrollBar_->resetMaximum(); + this->scrollBar_->resetBounds(); this->scrollBar_->setMaximum(0); this->scrollBar_->setMinimum(0); this->queueLayout(); + this->update(); this->lastMessageHasAlternateBackground_ = false; this->lastMessageHasAlternateBackgroundReverse_ = true; @@ -987,8 +1010,7 @@ void ChannelView::setChannel(const ChannelPtr &underlyingChannel) // and the ui. auto snapshot = underlyingChannel->getMessageSnapshot(); - this->scrollBar_->setMaximum(qreal(snapshot.size())); - + size_t nMessagesAdded = 0; for (const auto &msg : snapshot) { if (!this->shouldIncludeMessage(msg)) @@ -1012,12 +1034,16 @@ void ChannelView::setChannel(const ChannelPtr &underlyingChannel) this->messages_.pushBack(messageLayout); this->channel_->addMessage(msg); + nMessagesAdded++; if (this->showScrollbarHighlights()) { this->scrollBar_->addHighlight(msg->getScrollBarHighlight()); } } + this->scrollBar_->setMaximum( + static_cast(std::min(nMessagesAdded, this->messages_.limit()))); + // // Standard channel connections // @@ -1279,7 +1305,7 @@ void ChannelView::messagesUpdated() this->messages_.clear(); this->scrollBar_->clearHighlights(); - this->scrollBar_->resetMaximum(); + this->scrollBar_->resetBounds(); this->scrollBar_->setMaximum(qreal(snapshot.size())); this->scrollBar_->setMinimum(0); this->lastMessageHasAlternateBackground_ = false; @@ -1380,17 +1406,20 @@ MessageElementFlags ChannelView::getFlags() const { flags.set(MessageElementFlag::ModeratorTools); } - if (this->underlyingChannel_ == app->twitch->mentionsChannel || - this->underlyingChannel_ == app->twitch->liveChannel || - this->underlyingChannel_ == app->twitch->automodChannel) + if (this->underlyingChannel_ == + getIApp()->getTwitch()->getMentionsChannel() || + this->underlyingChannel_ == + getIApp()->getTwitch()->getLiveChannel() || + this->underlyingChannel_ == + getIApp()->getTwitch()->getAutomodChannel()) { flags.set(MessageElementFlag::ChannelName); flags.unset(MessageElementFlag::ChannelPointReward); } } - if (this->sourceChannel_ == app->twitch->mentionsChannel || - this->sourceChannel_ == app->twitch->automodChannel) + if (this->sourceChannel_ == getIApp()->getTwitch()->getMentionsChannel() || + this->sourceChannel_ == getIApp()->getTwitch()->getAutomodChannel()) { flags.set(MessageElementFlag::ChannelName); } @@ -1543,8 +1572,8 @@ void ChannelView::drawMessages(QPainter &painter, const QRect &area) .canvasWidth = this->width(), .isWindowFocused = this->window() == QApplication::activeWindow(), - .isMentions = - this->underlyingChannel_ == getApp()->twitch->mentionsChannel, + .isMentions = this->underlyingChannel_ == + getIApp()->getTwitch()->getMentionsChannel(), .y = int(-(messagesSnapshot[start]->getHeight() * (fmod(this->scrollBar_->getRelativeCurrentValue(), 1)))), @@ -1722,9 +1751,11 @@ void ChannelView::wheelEvent(QWheelEvent *event) } else { - snapshot[i - 1]->layout(this->getLayoutWidth(), - this->scale(), this->getFlags(), - false); + snapshot[i - 1]->layout( + this->getLayoutWidth(), this->scale(), + this->scale() * + static_cast(this->devicePixelRatio()), + this->getFlags(), false); scrollFactor = 1; currentScrollLeft = snapshot[i - 1]->getHeight(); } @@ -1757,9 +1788,11 @@ void ChannelView::wheelEvent(QWheelEvent *event) } else { - snapshot[i + 1]->layout(this->getLayoutWidth(), - this->scale(), this->getFlags(), - false); + snapshot[i + 1]->layout( + this->getLayoutWidth(), this->scale(), + this->scale() * + static_cast(this->devicePixelRatio()), + this->getFlags(), false); scrollFactor = 1; currentScrollLeft = snapshot[i + 1]->getHeight(); @@ -2700,8 +2733,8 @@ void ChannelView::showUserInfoPopup(const QString &userName, auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup, this->split_); - auto contextChannel = - getApp()->twitch->getChannelOrEmpty(alternativePopoutChannel); + auto contextChannel = getIApp()->getTwitchAbstract()->getChannelOrEmpty( + alternativePopoutChannel); auto openingChannel = this->hasSourceChannel() ? this->sourceChannel_ : this->underlyingChannel_; userPopup->setData(userName, contextChannel, openingChannel); diff --git a/src/widgets/helper/EditableModelView.cpp b/src/widgets/helper/EditableModelView.cpp index b549c9e9ba5..cbc04c03b32 100644 --- a/src/widgets/helper/EditableModelView.cpp +++ b/src/widgets/helper/EditableModelView.cpp @@ -1,6 +1,7 @@ #include "EditableModelView.hpp" #include "widgets/helper/RegExpItemDelegate.hpp" +#include "widgets/helper/TableStyles.hpp" #include #include @@ -28,6 +29,8 @@ EditableModelView::EditableModelView(QAbstractTableModel *model, bool movable) this->tableView_->verticalHeader()->setVisible(false); this->tableView_->horizontalHeader()->setSectionsClickable(false); + TableRowDragStyle::applyTo(this->tableView_); + // create layout QVBoxLayout *vbox = new QVBoxLayout(this); vbox->setContentsMargins(0, 0, 0, 0); diff --git a/src/widgets/helper/IconDelegate.cpp b/src/widgets/helper/IconDelegate.cpp new file mode 100644 index 00000000000..c89037eea68 --- /dev/null +++ b/src/widgets/helper/IconDelegate.cpp @@ -0,0 +1,29 @@ +#include "widgets/helper/IconDelegate.hpp" + +#include +#include + +namespace chatterino { + +IconDelegate::IconDelegate(QObject *parent) + : QStyledItemDelegate(parent) +{ +} + +void IconDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + auto data = index.data(Qt::DecorationRole); + + if (data.type() != QVariant::Pixmap) + { + return QStyledItemDelegate::paint(painter, option, index); + } + + auto scaledRect = option.rect; + scaledRect.setWidth(scaledRect.height()); + + painter->drawPixmap(scaledRect, data.value()); +} + +} // namespace chatterino diff --git a/src/widgets/helper/IconDelegate.hpp b/src/widgets/helper/IconDelegate.hpp new file mode 100644 index 00000000000..6afd5183ae6 --- /dev/null +++ b/src/widgets/helper/IconDelegate.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace chatterino { + +/** + * IconDelegate draws the decoration role pixmap scaled down to a square icon + */ +class IconDelegate : public QStyledItemDelegate +{ +public: + explicit IconDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; +}; + +} // namespace chatterino diff --git a/src/widgets/helper/NotebookTab.cpp b/src/widgets/helper/NotebookTab.cpp index da04562fae2..a5d8cfb690e 100644 --- a/src/widgets/helper/NotebookTab.cpp +++ b/src/widgets/helper/NotebookTab.cpp @@ -8,7 +8,6 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" -#include "util/Clamp.hpp" #include "util/Helpers.hpp" #include "widgets/dialogs/SettingsDialog.hpp" #include "widgets/Notebook.hpp" @@ -16,6 +15,7 @@ #include "widgets/splits/SplitContainer.hpp" #include +#include #include #include #include @@ -25,17 +25,10 @@ #include #include +#include + namespace chatterino { namespace { - qreal deviceDpi(QWidget *widget) - { -#ifdef Q_OS_WIN - return widget->devicePixelRatioF(); -#else - return 1.0; -#endif - } - // Translates the given rectangle by an amount in the direction to appear like the tab is selected. // For example, if location is Top, the rectangle will be translated in the negative Y direction, // or "up" on the screen, by amount. @@ -107,6 +100,10 @@ NotebookTab::NotebookTab(Notebook *notebook) getIApp()->getHotkeys()->getDisplaySequence(HotkeyCategory::Window, "popup", {{"window"}})); + this->menu_.addAction("Duplicate Tab", [this]() { + this->notebook_->duplicatePage(this->page); + }); + highlightNewMessagesAction_ = new QAction("Mark Tab as Unread on New Messages", &this->menu_); highlightNewMessagesAction_->setCheckable(true); @@ -191,13 +188,18 @@ void NotebookTab::growWidth(int width) } } -int NotebookTab::normalTabWidth() +int NotebookTab::normalTabWidth() const +{ + return this->normalTabWidthForHeight(this->height()); +} + +int NotebookTab::normalTabWidthForHeight(int height) const { float scale = this->scale(); - int width; + int width = 0; - auto metrics = getIApp()->getFonts()->getFontMetrics( - FontStyle::UiTabs, float(qreal(this->scale()) * deviceDpi(this))); + QFontMetrics metrics = + getIApp()->getFonts()->getFontMetrics(FontStyle::UiTabs, scale); if (this->hasXButton()) { @@ -208,13 +210,13 @@ int NotebookTab::normalTabWidth() width = (metrics.horizontalAdvance(this->getTitle()) + int(16 * scale)); } - if (this->height() > 150 * scale) + if (static_cast(height) > 150 * scale) { - width = this->height(); + width = height; } else { - width = clamp(width, this->height(), int(150 * scale)); + width = std::clamp(width, height, static_cast(150 * scale)); } return width; @@ -223,8 +225,8 @@ int NotebookTab::normalTabWidth() void NotebookTab::updateSize() { float scale = this->scale(); - int width = this->normalTabWidth(); - auto height = int(NOTEBOOK_TAB_HEIGHT * scale); + auto height = static_cast(NOTEBOOK_TAB_HEIGHT * scale); + int width = this->normalTabWidthForHeight(height); if (width < this->growWidth_) { @@ -406,22 +408,24 @@ void NotebookTab::hideTabXChanged() this->update(); } -void NotebookTab::moveAnimated(QPoint pos, bool animated) +void NotebookTab::moveAnimated(QPoint targetPos, bool animated) { - this->positionAnimationDesiredPoint_ = pos; + this->positionAnimationDesiredPoint_ = targetPos; - QWidget *w = this->window(); - - if ((w != nullptr && !w->isVisible()) || !animated || - !this->positionChangedAnimationRunning_) + if (this->pos() == targetPos) { - this->move(pos); + return; + } - this->positionChangedAnimationRunning_ = true; + if (!animated || !this->notebook_->isVisible()) + { + this->move(targetPos); return; } - if (this->positionChangedAnimation_.endValue() == pos) + if (this->positionChangedAnimation_.state() == + QAbstractAnimation::Running && + this->positionChangedAnimation_.endValue() == targetPos) { return; } @@ -429,7 +433,7 @@ void NotebookTab::moveAnimated(QPoint pos, bool animated) this->positionChangedAnimation_.stop(); this->positionChangedAnimation_.setDuration(75); this->positionChangedAnimation_.setStartValue(this->pos()); - this->positionChangedAnimation_.setEndValue(pos); + this->positionChangedAnimation_.setEndValue(targetPos); this->positionChangedAnimation_.start(); } @@ -439,11 +443,9 @@ void NotebookTab::paintEvent(QPaintEvent *) QPainter painter(this); float scale = this->scale(); - auto div = std::max(0.01f, this->logicalDpiX() * deviceDpi(this)); - painter.setFont( - getIApp()->getFonts()->getFont(FontStyle::UiTabs, scale * 96.f / div)); + painter.setFont(app->getFonts()->getFont(FontStyle::UiTabs, scale)); QFontMetrics metrics = - app->getFonts()->getFontMetrics(FontStyle::UiTabs, scale * 96.f / div); + app->getFonts()->getFontMetrics(FontStyle::UiTabs, scale); int height = int(scale * NOTEBOOK_TAB_HEIGHT); @@ -639,13 +641,13 @@ void NotebookTab::paintEvent(QPaintEvent *) } } -bool NotebookTab::hasXButton() +bool NotebookTab::hasXButton() const { return getSettings()->showTabCloseButton && this->notebook_->getAllowUserTabManagement(); } -bool NotebookTab::shouldDrawXButton() +bool NotebookTab::shouldDrawXButton() const { return this->hasXButton() && (this->mouseOver_ || this->selected_); } @@ -831,18 +833,15 @@ void NotebookTab::update() Button::update(); } -QRect NotebookTab::getXRect() +QRect NotebookTab::getXRect() const { QRect rect = this->rect(); float s = this->scale(); int size = static_cast(16 * s); - int centerAdjustment = - this->tabLocation_ == - (NotebookTabLocation::Top || - this->tabLocation_ == NotebookTabLocation::Bottom) - ? (size / 3) // slightly off true center - : (size / 2); // true center + int centerAdjustment = this->tabLocation_ == NotebookTabLocation::Top + ? (size / 3) // slightly off true center + : (size / 2); // true center QRect xRect(rect.right() - static_cast(20 * s), rect.center().y() - centerAdjustment, size, size); diff --git a/src/widgets/helper/NotebookTab.hpp b/src/widgets/helper/NotebookTab.hpp index 65b1f46ed31..7f746fd7e14 100644 --- a/src/widgets/helper/NotebookTab.hpp +++ b/src/widgets/helper/NotebookTab.hpp @@ -65,13 +65,13 @@ class NotebookTab : public Button void setHighlightsEnabled(const bool &newVal); bool hasHighlightsEnabled() const; - void moveAnimated(QPoint pos, bool animated = true); + void moveAnimated(QPoint targetPos, bool animated = true); QRect getDesiredRect() const; void hideTabXChanged(); void growWidth(int width); - int normalTabWidth(); + int normalTabWidth() const; protected: void themeChangedEvent() override; @@ -100,13 +100,14 @@ class NotebookTab : public Button private: void showRenameDialog(); - bool hasXButton(); - bool shouldDrawXButton(); - QRect getXRect(); + bool hasXButton() const; + bool shouldDrawXButton() const; + QRect getXRect() const; void titleUpdated(); + int normalTabWidthForHeight(int height) const; + QPropertyAnimation positionChangedAnimation_; - bool positionChangedAnimationRunning_ = false; QPoint positionAnimationDesiredPoint_; Notebook *notebook_; diff --git a/src/widgets/helper/ResizingTextEdit.cpp b/src/widgets/helper/ResizingTextEdit.cpp index 3a44aa3e4e2..0ac7e0e8957 100644 --- a/src/widgets/helper/ResizingTextEdit.cpp +++ b/src/widgets/helper/ResizingTextEdit.cpp @@ -77,11 +77,7 @@ QString ResizingTextEdit::textUnderCursor(bool *hadSpace) const auto textUpToCursor = currentText.left(tc.selectionStart()); -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) auto words = QStringView{textUpToCursor}.split(' '); -#else - auto words = textUpToCursor.splitRef(' '); -#endif if (words.size() == 0) { return QString(); diff --git a/src/widgets/helper/SettingsDialogTab.hpp b/src/widgets/helper/SettingsDialogTab.hpp index 97a1ad51d6d..0c60688b2aa 100644 --- a/src/widgets/helper/SettingsDialogTab.hpp +++ b/src/widgets/helper/SettingsDialogTab.hpp @@ -18,6 +18,7 @@ enum class SettingsTabId { General, Accounts, Moderation, + About, }; class SettingsDialogTab : public BaseWidget diff --git a/src/widgets/helper/TableStyles.cpp b/src/widgets/helper/TableStyles.cpp new file mode 100644 index 00000000000..bf586f198a6 --- /dev/null +++ b/src/widgets/helper/TableStyles.cpp @@ -0,0 +1,71 @@ +#include "widgets/helper/TableStyles.hpp" + +#include +#include +#include +#include +#include + +namespace chatterino { + +TableRowDragStyle::TableRowDragStyle(const QString &name) + : QProxyStyle(name) +{ +} + +void TableRowDragStyle::applyTo(QTableView *view) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 1, 0) + auto styleName = view->style()->name(); +#else + QString styleName = "fusion"; +#endif + auto *proxyStyle = new TableRowDragStyle(styleName); + proxyStyle->setParent(view); + view->setStyle(proxyStyle); +} + +void TableRowDragStyle::drawPrimitive(QStyle::PrimitiveElement element, + const QStyleOption *option, + QPainter *painter, + const QWidget *widget) const +{ + if (element != QStyle::PE_IndicatorItemViewItemDrop) + { + QProxyStyle::drawPrimitive(element, option, painter, widget); + return; + } + + const auto *view = dynamic_cast(widget); + if (!view) + { + assert(false && "TableStyle must be used on a QAbstractItemView"); + return; + } + + if (option->rect.isNull()) + { + return; + } + + // Get the direction a row is dragged in + auto selected = view->currentIndex(); + auto hovered = view->indexAt(option->rect.center()); + if (!selected.isValid() || !hovered.isValid()) + { + // This shouldn't happen as we're in a drag operation + assert(false && "Got bad indices"); + return; + } + + int y = option->rect.top(); // move up + if (hovered.row() >= selected.row()) + { + y = option->rect.bottom(); // move down + } + + painter->setPen({Qt::white, 2}); + painter->drawLine(0, y, widget->width(), y); +} + +} // namespace chatterino diff --git a/src/widgets/helper/TableStyles.hpp b/src/widgets/helper/TableStyles.hpp new file mode 100644 index 00000000000..a8d264831d0 --- /dev/null +++ b/src/widgets/helper/TableStyles.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +class QTableView; + +namespace chatterino { + +/// @brief A custom style for drag operations of rows on tables +/// +/// This style overwrites how `PE_IndicatorItemViewItemDrop`, the drop +/// indicator of item-views, is drawn. It's intended to be used on QTableViews +/// where entire rows are moved (not individual cells). The indicator is shown +/// as a line at the position where the dragged item should be inserted. If no +/// such position exists, a red border is drawn around the viewport. +class TableRowDragStyle : public QProxyStyle +{ +public: + /// Applies the style to @a view + static void applyTo(QTableView *view); + + void drawPrimitive(QStyle::PrimitiveElement element, + const QStyleOption *option, QPainter *painter, + const QWidget *widget = nullptr) const override; + +private: + /// @param name The style name to emulate. + /// This should be set to `style()->name()`. + TableRowDragStyle(const QString &name); +}; + +} // namespace chatterino diff --git a/src/widgets/helper/TitlebarButtons.hpp b/src/widgets/helper/TitlebarButtons.hpp index 42a430d6942..e7ee3eb5bf5 100644 --- a/src/widgets/helper/TitlebarButtons.hpp +++ b/src/widgets/helper/TitlebarButtons.hpp @@ -3,6 +3,7 @@ class QPoint; class QWidget; +#include #include namespace chatterino { diff --git a/src/widgets/layout/FlowLayout.cpp b/src/widgets/layout/FlowLayout.cpp new file mode 100644 index 00000000000..d815f62378b --- /dev/null +++ b/src/widgets/layout/FlowLayout.cpp @@ -0,0 +1,252 @@ +#include "widgets/layout/FlowLayout.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace chatterino; + +class Linebreak : public QWidget +{ +}; + +} // namespace + +namespace chatterino { + +FlowLayout::FlowLayout(QWidget *parent, Options options) + : QLayout(parent) + , hSpace_(options.hSpacing) + , vSpace_(options.vSpacing) +{ + if (options.margin >= 0) + { + this->setContentsMargins(options.margin, options.margin, options.margin, + options.margin); + } +} + +FlowLayout::FlowLayout(Options options) + : FlowLayout(nullptr, options) +{ +} + +FlowLayout::~FlowLayout() +{ + for (auto *item : this->itemList_) + { + delete item; + } + this->itemList_ = {}; +} + +void FlowLayout::addItem(QLayoutItem *item) +{ + this->itemList_.push_back(item); +} + +void FlowLayout::addLinebreak(int height) +{ + auto *linebreak = new Linebreak; + linebreak->setFixedHeight(height); + this->addWidget(linebreak); +} + +int FlowLayout::horizontalSpacing() const +{ + if (this->hSpace_ >= 0) + { + return this->hSpace_; + } + + return this->defaultSpacing(QStyle::PM_LayoutHorizontalSpacing); +} + +void FlowLayout::setHorizontalSpacing(int value) +{ + if (this->hSpace_ == value) + { + return; + } + this->hSpace_ = value; + this->invalidate(); +} + +int FlowLayout::verticalSpacing() const +{ + if (this->vSpace_ >= 0) + { + return this->vSpace_; + } + + return this->defaultSpacing(QStyle::PM_LayoutVerticalSpacing); +} + +void FlowLayout::setVerticalSpacing(int value) +{ + if (this->vSpace_ == value) + { + return; + } + this->vSpace_ = value; + this->invalidate(); +} + +int FlowLayout::count() const +{ + return static_cast(this->itemList_.size()); +} + +QLayoutItem *FlowLayout::itemAt(int index) const +{ + if (index >= 0 && index < static_cast(this->itemList_.size())) + { + return this->itemList_[static_cast(index)]; + } + return nullptr; +} + +QLayoutItem *FlowLayout::takeAt(int index) +{ + if (index >= 0 && index < static_cast(this->itemList_.size())) + { + auto *it = this->itemList_[static_cast(index)]; + this->itemList_.erase(this->itemList_.cbegin() + + static_cast(index)); + return it; + } + return nullptr; +} + +Qt::Orientations FlowLayout::expandingDirections() const +{ + return {}; +} + +bool FlowLayout::hasHeightForWidth() const +{ + return true; +} + +int FlowLayout::heightForWidth(int width) const +{ + return this->doLayout({0, 0, width, 0}, true); +} + +void FlowLayout::setGeometry(const QRect &rect) +{ + QLayout::setGeometry(rect); + this->doLayout(rect, false); +} + +QSize FlowLayout::sizeHint() const +{ + return this->minimumSize(); +} + +QSize FlowLayout::minimumSize() const +{ + QSize size; + for (const auto *item : this->itemList_) + { + size = size.expandedTo(item->minimumSize()); + } + + const QMargins margins = contentsMargins(); + size += QSize(margins.left() + margins.right(), + margins.top() + margins.bottom()); + return size; +} + +int FlowLayout::doLayout(const QRect &rect, bool testOnly) const +{ + auto margins = this->contentsMargins(); + QRect effectiveRect = rect.adjusted(margins.left(), margins.top(), + -margins.right(), -margins.bottom()); + int x = effectiveRect.x(); + int y = effectiveRect.y(); + int lineHeight = 0; + for (QLayoutItem *item : this->itemList_) + { + auto *linebreak = dynamic_cast(item->widget()); + if (linebreak) + { + item->setGeometry({x, y, 0, linebreak->height()}); + x = effectiveRect.x(); + y = y + lineHeight + linebreak->height(); + lineHeight = 0; + continue; + } + + auto space = this->getSpacing(item); + int nextX = x + item->sizeHint().width() + space.width(); + if (nextX - space.width() > effectiveRect.right() && lineHeight > 0) + { + x = effectiveRect.x(); + y = y + lineHeight + space.height(); + nextX = x + item->sizeHint().width() + space.width(); + lineHeight = 0; + } + + if (!testOnly) + { + item->setGeometry({QPoint{x, y}, item->sizeHint()}); + } + + x = nextX; + lineHeight = qMax(lineHeight, item->sizeHint().height()); + } + + return y + lineHeight - rect.y() + margins.bottom(); +} + +int FlowLayout::defaultSpacing(QStyle::PixelMetric pm) const +{ + QObject *parent = this->parent(); + if (!parent) + { + return -1; + } + if (auto *widget = dynamic_cast(parent)) + { + return widget->style()->pixelMetric(pm, nullptr, widget); + } + if (auto *layout = dynamic_cast(parent)) + { + return layout->spacing(); + } + return -1; +} + +QSize FlowLayout::getSpacing(QLayoutItem *item) const +{ + // called if there isn't any parent or the parent can't provide any spacing + auto fallbackSpacing = [&](auto dir) { + if (auto *widget = item->widget()) + { + return widget->style()->layoutSpacing(QSizePolicy::PushButton, + QSizePolicy::PushButton, dir); + } + if (auto *layout = item->layout()) + { + return layout->spacing(); + } + return 0; + }; + + QSize spacing(this->horizontalSpacing(), this->verticalSpacing()); + if (spacing.width() == -1) + { + spacing.rwidth() = fallbackSpacing(Qt::Horizontal); + } + if (spacing.height() == -1) + { + spacing.rheight() = fallbackSpacing(Qt::Vertical); + } + return spacing; +} + +} // namespace chatterino diff --git a/src/widgets/layout/FlowLayout.hpp b/src/widgets/layout/FlowLayout.hpp new file mode 100644 index 00000000000..39a359ff143 --- /dev/null +++ b/src/widgets/layout/FlowLayout.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include +#include + +#include + +namespace chatterino { + +/// @brief A QLayout wrapping items +/// +/// Similar to a box layout that wraps its items. It's not super optimized. +/// Some computations in #doLayout() could be cached. +/// +/// This is based on the Qt flow layout example: +/// https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html +class FlowLayout : public QLayout +{ +public: + struct Options { + int margin = -1; + int hSpacing = -1; + int vSpacing = -1; + }; + + explicit FlowLayout(QWidget *parent, Options options = {-1, -1, -1}); + explicit FlowLayout(Options options = {-1, -1, -1}); + + ~FlowLayout() override; + FlowLayout(const FlowLayout &) = delete; + FlowLayout(FlowLayout &&) = delete; + FlowLayout &operator=(const FlowLayout &) = delete; + FlowLayout &operator=(FlowLayout &&) = delete; + + /// @brief Adds @a item to this layout + /// + /// Ownership of @a item is transferred. This method isn't usually called + /// in application code (use addWidget/addLayout). + /// See QLayout::addItem for more information. + void addItem(QLayoutItem *item) override; + + /// @brief Adds a linebreak to this layout + /// + /// @param height Specifies the height of the linebreak + void addLinebreak(int height = 0); + + /// @brief Spacing on the horizontal axis + /// + /// -1 if the default spacing for an item will be used. + [[nodiscard]] int horizontalSpacing() const; + + /// Setter for #horizontalSpacing(). -1 to use defaults. + void setHorizontalSpacing(int value); + + /// @brief Spacing on the vertical axis + /// + /// -1 if the default spacing for an item will be used. + [[nodiscard]] int verticalSpacing() const; + + /// Setter for #verticalSpacing(). -1 to use defaults. + void setVerticalSpacing(int value); + + /// From QLayout. This layout doesn't expand in any direction. + Qt::Orientations expandingDirections() const override; + bool hasHeightForWidth() const override; + int heightForWidth(int width) const override; + + QSize minimumSize() const override; + QSize sizeHint() const override; + + void setGeometry(const QRect &rect) override; + + int count() const override; + QLayoutItem *itemAt(int index) const override; + + /// From QLayout. Ownership is transferred to the caller + QLayoutItem *takeAt(int index) override; + +private: + /// @brief Computes the layout + /// + /// @param rect The area in which items can be layed out + /// @param testOnly If set, items won't be moved, only the total height + /// will be computed. + /// @returns The total height including margins. + int doLayout(const QRect &rect, bool testOnly) const; + + /// @brief Computes the default spacing based for items on the parent + /// + /// @param pm Either PM_LayoutHorizontalSpacing or PM_LayoutVerticalSpacing + /// for the respective direction. + /// @returns The spacing in dp, -1 if there isn't any parent + int defaultSpacing(QStyle::PixelMetric pm) const; + + /// Computes the spacing for @a item + QSize getSpacing(QLayoutItem *item) const; + + std::vector itemList_; + int hSpace_ = -1; + int vSpace_ = -1; + int lineSpacing_ = -1; +}; + +} // namespace chatterino diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 471b6d03b25..bc503ca6107 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -1,5 +1,6 @@ #include "AboutPage.hpp" +#include "common/Common.hpp" #include "common/Modes.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" @@ -7,6 +8,7 @@ #include "util/RemoveScrollAreaBackground.hpp" #include "widgets/BasePopup.hpp" #include "widgets/helper/SignalLabel.hpp" +#include "widgets/layout/FlowLayout.hpp" #include #include @@ -18,10 +20,8 @@ #define PIXMAP_WIDTH 500 -#define LINK_CHATTERINO_WIKI "https://wiki.chatterino.com" #define LINK_DONATE "https://streamelements.com/fourtf/tip" #define LINK_CHATTERINO_FEATURES "https://chatterino.com/#features" -#define LINK_CHATTERINO_DISCORD "https://discord.gg/7Y5AYhAK4z" namespace chatterino { @@ -55,6 +55,7 @@ AboutPage::AboutPage() auto label = vbox.emplace(version.buildString() + "
" + version.runningString()); + label->setWordWrap(true); label->setOpenExternalLinks(true); label->setTextInteractionFlags(Qt::TextBrowserInteraction); } @@ -126,6 +127,9 @@ AboutPage::AboutPage() addLicense(form.getElement(), "Fluent icons", "https://github.com/microsoft/fluentui-system-icons", ":/licenses/fluenticons.txt"); + addLicense(form.getElement(), "expected-lite", + "https://github.com/martinmoene/expected-lite", + ":/licenses/expected-lite.txt"); } // Attributions @@ -138,15 +142,15 @@ AboutPage::AboutPage() l.emplace("Facebook emojis provided by Facebook")->setOpenExternalLinks(true); l.emplace("Apple emojis provided by Apple")->setOpenExternalLinks(true); l.emplace("Google emojis provided by Google")->setOpenExternalLinks(true); - l.emplace("Emoji datasource provided by Cal Henderson" + l.emplace("Emoji datasource provided by Cal Henderson " "(show license)")->setOpenExternalLinks(true); // clang-format on } // Contributors - auto contributors = layout.emplace("Contributors"); + auto contributors = layout.emplace("People"); { - auto l = contributors.emplace(); + auto l = contributors.emplace(); QFile contributorsFile(":/contributors.txt"); contributorsFile.open(QFile::ReadOnly); @@ -167,11 +171,24 @@ AboutPage::AboutPage() continue; } + if (line.startsWith(u"@header")) + { + if (l->count() != 0) + { + l->addLinebreak(20); + } + auto *label = new QLabel(QStringLiteral("

%1

") + .arg(line.mid(8).trimmed())); + l->addWidget(label); + l->addLinebreak(8); + continue; + } + QStringList contributorParts = line.split("|"); - if (contributorParts.size() != 4) + if (contributorParts.size() != 3) { - qCDebug(chatterinoWidget) + qCWarning(chatterinoWidget) << "Missing parts in line" << line; continue; } @@ -179,39 +196,42 @@ AboutPage::AboutPage() QString username = contributorParts[0].trimmed(); QString url = contributorParts[1].trimmed(); QString avatarUrl = contributorParts[2].trimmed(); - QString role = contributorParts[3].trimmed(); auto *usernameLabel = new QLabel("" + username + ""); usernameLabel->setOpenExternalLinks(true); - auto *roleLabel = new QLabel(role); + usernameLabel->setToolTip(url); - auto contributorBox2 = l.emplace(); + auto contributorBox2 = l.emplace(); - const auto addAvatar = [&avatarUrl, &contributorBox2] { - if (!avatarUrl.isEmpty()) + const auto addAvatar = [&] { + auto *avatar = new QLabel(); + QPixmap avatarPixmap; + if (avatarUrl.isEmpty()) + { + // TODO: or anon.png + avatarPixmap.load(":/avatars/anon.png"); + } + else { - QPixmap avatarPixmap; avatarPixmap.load(avatarUrl); - - auto avatar = contributorBox2.emplace(); - avatar->setPixmap(avatarPixmap); - avatar->setFixedSize(64, 64); - avatar->setScaledContents(true); } + + avatar->setPixmap(avatarPixmap); + avatar->setFixedSize(64, 64); + avatar->setScaledContents(true); + contributorBox2->addWidget(avatar, 0, Qt::AlignCenter); }; - const auto addLabels = [&contributorBox2, &usernameLabel, - &roleLabel] { + const auto addLabels = [&] { auto *labelBox = new QVBoxLayout(); contributorBox2->addLayout(labelBox); - labelBox->addWidget(usernameLabel); - labelBox->addWidget(roleLabel); + labelBox->addWidget(usernameLabel, 0, Qt::AlignCenter); }; - addLabels(); addAvatar(); + addLabels(); } } } diff --git a/src/widgets/settingspages/CommandPage.cpp b/src/widgets/settingspages/CommandPage.cpp index 9dc53b9bf19..de94d11d281 100644 --- a/src/widgets/settingspages/CommandPage.cpp +++ b/src/widgets/settingspages/CommandPage.cpp @@ -7,7 +7,6 @@ #include "singletons/Settings.hpp" #include "util/CombinePath.hpp" #include "util/LayoutCreator.hpp" -#include "util/Qt.hpp" #include "util/StandardItemHelper.hpp" #include "widgets/helper/EditableModelView.hpp" diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 54e0edcc92d..4042805931c 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -462,7 +462,7 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addTitle("Messages"); layout.addCheckbox( "Separate with lines", s.separateMessages, false, - "Adds a line inbetween each message to help better tell them apart."); + "Adds a line between each message to help better tell them apart."); layout.addCheckbox("Alternate background color", s.alternateMessages, false, "Slightly change the background behind every other " "message to help better tell them apart."); @@ -570,7 +570,7 @@ void GeneralPage::initLayout(GeneralPageView &layout) // as an official description from 7TV devs is best s.showUnlistedSevenTVEmotes.connect( []() { - getApp()->twitch->forEachChannelAndSpecialChannels( + getIApp()->getTwitch()->forEachChannelAndSpecialChannels( [](const auto &c) { if (c->isTwitchChannel()) { @@ -904,7 +904,10 @@ void GeneralPage::initLayout(GeneralPageView &layout) toggleLocalr9kShortcut + "."); layout.addCheckbox("Hide similar messages", s.similarityEnabled); //layout.addCheckbox("Gray out matches", s.colorSimilarDisabled); - layout.addCheckbox("By the same user", s.hideSimilarBySameUser); + layout.addCheckbox( + "By the same user", s.hideSimilarBySameUser, false, + "When checked, messages that are very similar to each other can still " + "be shown as long as they're sent by different users."); layout.addCheckbox("Hide my own messages", s.hideSimilarMyself); layout.addCheckbox("Receive notification sounds from hidden messages", s.shownSimilarTriggerHighlights); @@ -920,7 +923,10 @@ void GeneralPage::initLayout(GeneralPageView &layout) }, [](auto args) { return fuzzyToFloat(args.value, 0.9f); - }); + }, + true, + "A value of 0.9 means the messages need to be 90% similar to be marked " + "as similar."); layout.addDropdown( "Maximum delay between messages", {"5s", "10s", "15s", "30s", "60s", "120s"}, s.hideSimilarMaxDelay, @@ -929,7 +935,10 @@ void GeneralPage::initLayout(GeneralPageView &layout) }, [](auto args) { return fuzzyToInt(args.value, 5); - }); + }, + true, + "A value of 5s means if there's a 5s break between messages, we will " + "stop looking further through the messages for similarities."); layout.addDropdown( "Amount of previous messages to check", {"1", "2", "3", "4", "5"}, s.hideSimilarMaxMessagesToCheck, @@ -1187,96 +1196,6 @@ void GeneralPage::initLayout(GeneralPageView &layout) "@mention for the related thread. If the reply context is hidden, " "these mentions will never be stripped."); - // Helix timegate settings - auto helixTimegateGetValue = [](auto val) { - switch (val) - { - case HelixTimegateOverride::Timegate: - return "Timegate"; - case HelixTimegateOverride::AlwaysUseIRC: - return "Always use IRC"; - case HelixTimegateOverride::AlwaysUseHelix: - return "Always use Helix"; - default: - return "Timegate"; - } - }; - - auto helixTimegateSetValue = [](auto args) { - const auto &v = args.value; - if (v == "Timegate") - { - return HelixTimegateOverride::Timegate; - } - if (v == "Always use IRC") - { - return HelixTimegateOverride::AlwaysUseIRC; - } - if (v == "Always use Helix") - { - return HelixTimegateOverride::AlwaysUseHelix; - } - - qCDebug(chatterinoSettings) << "Unknown Helix timegate override value" - << v << ", using default value Timegate"; - return HelixTimegateOverride::Timegate; - }; - - auto *helixTimegateRaid = - layout.addDropdown::type>( - "Helix timegate /raid behaviour", - {"Timegate", "Always use IRC", "Always use Helix"}, - s.helixTimegateRaid, - helixTimegateGetValue, // - helixTimegateSetValue, // - false); - helixTimegateRaid->setMinimumWidth( - helixTimegateRaid->minimumSizeHint().width()); - - auto *helixTimegateWhisper = - layout.addDropdown::type>( - "Helix timegate /w behaviour", - {"Timegate", "Always use IRC", "Always use Helix"}, - s.helixTimegateWhisper, - helixTimegateGetValue, // - helixTimegateSetValue, // - false); - helixTimegateWhisper->setMinimumWidth( - helixTimegateWhisper->minimumSizeHint().width()); - - auto *helixTimegateVIPs = - layout.addDropdown::type>( - "Helix timegate /vips behaviour", - {"Timegate", "Always use IRC", "Always use Helix"}, - s.helixTimegateVIPs, - helixTimegateGetValue, // - helixTimegateSetValue, // - false); - helixTimegateVIPs->setMinimumWidth( - helixTimegateVIPs->minimumSizeHint().width()); - - auto *helixTimegateCommercial = - layout.addDropdown::type>( - "Helix timegate /commercial behaviour", - {"Timegate", "Always use IRC", "Always use Helix"}, - s.helixTimegateCommercial, - helixTimegateGetValue, // - helixTimegateSetValue, // - false); - helixTimegateCommercial->setMinimumWidth( - helixTimegateCommercial->minimumSizeHint().width()); - - auto *helixTimegateModerators = - layout.addDropdown::type>( - "Helix timegate /mods behaviour", - {"Timegate", "Always use IRC", "Always use Helix"}, - s.helixTimegateModerators, - helixTimegateGetValue, // - helixTimegateSetValue, // - false); - helixTimegateModerators->setMinimumWidth( - helixTimegateModerators->minimumSizeHint().width()); - layout.addDropdownEnumClass( "Chat send protocol", qmagicenum::enumNames(), s.chatSendProtocol, diff --git a/src/widgets/settingspages/ModerationPage.cpp b/src/widgets/settingspages/ModerationPage.cpp index fce69eff00e..65ba577b1ae 100644 --- a/src/widgets/settingspages/ModerationPage.cpp +++ b/src/widgets/settingspages/ModerationPage.cpp @@ -9,12 +9,16 @@ #include "singletons/Settings.hpp" #include "util/Helpers.hpp" #include "util/LayoutCreator.hpp" +#include "util/LoadPixmap.hpp" +#include "util/PostToThread.hpp" #include "widgets/helper/EditableModelView.hpp" +#include "widgets/helper/IconDelegate.hpp" #include #include #include #include +#include #include #include #include @@ -207,11 +211,51 @@ ModerationPage::ModerationPage() ->initialized(&getSettings()->moderationActions)) .getElement(); - view->setTitles({"Actions"}); + view->setTitles({"Action", "Icon"}); view->getTableView()->horizontalHeader()->setSectionResizeMode( QHeaderView::Fixed); view->getTableView()->horizontalHeader()->setSectionResizeMode( 0, QHeaderView::Stretch); + view->getTableView()->setItemDelegateForColumn( + ModerationActionModel::Column::Icon, new IconDelegate(view)); + QObject::connect( + view->getTableView(), &QTableView::clicked, + [this, view](const QModelIndex &clicked) { + if (clicked.column() == ModerationActionModel::Column::Icon) + { + auto fileUrl = QFileDialog::getOpenFileUrl( + this, "Open Image", QUrl(), + "Image Files (*.png *.jpg *.jpeg)"); + view->getModel()->setData(clicked, fileUrl, Qt::UserRole); + view->getModel()->setData(clicked, fileUrl.fileName(), + Qt::DisplayRole); + // Clear the icon if the user canceled the dialog + if (fileUrl.isEmpty()) + { + view->getModel()->setData(clicked, QVariant(), + Qt::DecorationRole); + } + else + { + // QPointer will be cleared when view is destroyed + QPointer viewtemp = view; + + loadPixmapFromUrl( + {fileUrl.toString()}, + [clicked, view = viewtemp](const QPixmap &pixmap) { + postToThread([clicked, view, pixmap]() { + if (view.isNull()) + { + return; + } + + view->getModel()->setData( + clicked, pixmap, Qt::DecorationRole); + }); + }); + } + } + }); // We can safely ignore this signal connection since we own the view std::ignore = view->addButtonPressed.connect([] { diff --git a/src/widgets/settingspages/PluginsPage.cpp b/src/widgets/settingspages/PluginsPage.cpp index aad35f7513c..05c80a37c7c 100644 --- a/src/widgets/settingspages/PluginsPage.cpp +++ b/src/widgets/settingspages/PluginsPage.cpp @@ -37,6 +37,21 @@ PluginsPage::PluginsPage() auto group = layout.emplace("General plugin settings"); this->generalGroup = group.getElement(); auto groupLayout = group.setLayoutType(); + auto *scaryLabel = new QLabel( + "Plugins can expand functionality of " + "Chatterino. They can be made in Lua. This functionality is " + "still in public alpha stage. Use ONLY the plugins you trust. " + "The permission system is best effort, always " + "assume plugins can bypass permissions and can execute " + "arbitrary code. To see how to create plugins " + + formatRichNamedLink("https://github.com/Chatterino/chatterino2/" + "blob/master/docs/wip-plugins.md", + "look at the manual") + + "."); + scaryLabel->setWordWrap(true); + scaryLabel->setOpenExternalLinks(true); + groupLayout->addRow(scaryLabel); + auto *description = new QLabel("You can load plugins by putting them into " + formatRichNamedLink( diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 9e31263197f..2be5bff5705 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -272,7 +272,7 @@ Split::Split(QWidget *parent) std::ignore = this->view_->openChannelIn.connect( [this](QString twitchChannel, FromTwitchLinkOpenChannelIn openIn) { ChannelPtr channel = - getApp()->twitch->getOrAddChannel(twitchChannel); + getIApp()->getTwitchAbstract()->getOrAddChannel(twitchChannel); switch (openIn) { case FromTwitchLinkOpenChannelIn::Split: @@ -380,12 +380,26 @@ Split::Split(QWidget *parent) // this connection can be ignored since the SplitInput is owned by this Split std::ignore = this->input_->ui_.textEdit->imagePasted.connect( - [this](const QMimeData *source) { + [this](const QMimeData *original) { if (!getSettings()->imageUploaderEnabled) { return; } + auto channel = this->getChannel(); + auto *imageUploader = getIApp()->getImageUploader(); + + auto [images, imageProcessError] = + imageUploader->getImages(original); + if (images.empty()) + { + channel->addMessage(makeSystemMessage( + QString( + "An error occurred trying to process your image: %1") + .arg(imageProcessError))); + return; + } + if (getSettings()->askOnImageUpload.getValue()) { QMessageBox msgBox(this->window()); @@ -427,9 +441,9 @@ Split::Split(QWidget *parent) return; } } + QPointer edit = this->input_->ui_.textEdit; - getIApp()->getImageUploader()->upload(source, this->getChannel(), - edit); + imageUploader->upload(std::move(images), channel, edit); }); getSettings()->imageUploaderEnabled.connect( @@ -539,11 +553,11 @@ void Split::addShortcuts() auto &scrollbar = this->getChannelView().getScrollBar(); if (direction == "up") { - scrollbar.offset(-scrollbar.getLargeChange()); + scrollbar.offset(-scrollbar.getPageSize()); } else if (direction == "down") { - scrollbar.offset(scrollbar.getLargeChange()); + scrollbar.offset(scrollbar.getPageSize()); } else { @@ -822,8 +836,7 @@ void Split::openChannelInBrowserPlayer(ChannelPtr channel) if (auto *twitchChannel = dynamic_cast(channel.get())) { QDesktopServices::openUrl( - "https://player.twitch.tv/?parent=twitch.tv&channel=" + - twitchChannel->getName()); + QUrl(TWITCH_PLAYER_URL.arg(twitchChannel->getName()))); } } diff --git a/src/widgets/splits/SplitContainer.cpp b/src/widgets/splits/SplitContainer.cpp index 2a274c6cc82..bee31cd116d 100644 --- a/src/widgets/splits/SplitContainer.cpp +++ b/src/widgets/splits/SplitContainer.cpp @@ -5,9 +5,12 @@ #include "common/QLogging.hpp" #include "common/WindowDescriptors.hpp" #include "debug/AssertInGuiThread.hpp" +#include "providers/irc/IrcChannel2.hpp" +#include "providers/irc/IrcServer.hpp" #include "singletons/Fonts.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" +#include "util/QMagicEnum.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/NotebookTab.hpp" #include "widgets/Notebook.hpp" @@ -762,6 +765,11 @@ SplitContainer::Node *SplitContainer::getBaseNode() return &this->baseNode_; } +NodeDescriptor SplitContainer::buildDescriptor() const +{ + return this->buildDescriptorRecursively(&this->baseNode_); +} + void SplitContainer::applyFromDescriptor(const NodeDescriptor &rootNode) { assert(this->baseNode_.type_ == Node::Type::EmptyRoot); @@ -799,6 +807,49 @@ void SplitContainer::popup() window.show(); } +NodeDescriptor SplitContainer::buildDescriptorRecursively( + const Node *currentNode) const +{ + if (currentNode->children_.empty()) + { + const auto channelType = + currentNode->split_->getIndirectChannel().getType(); + + SplitNodeDescriptor result; + result.type_ = qmagicenum::enumNameString(channelType); + + switch (channelType) + { + case Channel::Type::Irc: { + if (auto *ircChannel = dynamic_cast( + currentNode->split_->getChannel().get())) + { + if (ircChannel->server()) + { + result.server_ = ircChannel->server()->id(); + } + } + } + break; + } + + result.channelName_ = currentNode->split_->getChannel()->getName(); + result.filters_ = currentNode->split_->getFilters(); + return result; + } + + ContainerNodeDescriptor descriptor; + for (const auto &child : currentNode->children_) + { + descriptor.vertical_ = + currentNode->type_ == Node::Type::VerticalContainer; + descriptor.items_.push_back( + this->buildDescriptorRecursively(child.get())); + } + + return descriptor; +} + void SplitContainer::applyFromDescriptorRecursively( const NodeDescriptor &rootNode, Node *baseNode) { @@ -849,9 +900,9 @@ void SplitContainer::applyFromDescriptorRecursively( } const auto &splitNode = *inner; auto *split = new Split(this); + split->setFilters(splitNode.filters_); split->setChannel(WindowManager::decodeChannel(splitNode)); split->setModerationMode(splitNode.moderationMode_); - split->setFilters(splitNode.filters_); auto *node = new Node(); node->parent_ = baseNode; diff --git a/src/widgets/splits/SplitContainer.hpp b/src/widgets/splits/SplitContainer.hpp index 9022085dad8..9e4a6cc7515 100644 --- a/src/widgets/splits/SplitContainer.hpp +++ b/src/widgets/splits/SplitContainer.hpp @@ -220,6 +220,7 @@ class SplitContainer final : public BaseWidget void hideResizeHandles(); void resetMouseStatus(); + NodeDescriptor buildDescriptor() const; void applyFromDescriptor(const NodeDescriptor &rootNode); void popup(); @@ -237,6 +238,7 @@ class SplitContainer final : public BaseWidget void resizeEvent(QResizeEvent *event) override; private: + NodeDescriptor buildDescriptorRecursively(const Node *currentNode) const; void applyFromDescriptorRecursively(const NodeDescriptor &rootNode, Node *baseNode); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index b8b811ca4c4..35fb1dbde40 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -257,6 +257,22 @@ SplitHeader::SplitHeader(Split *split) getSettings()->headerStreamTitle.connect(_, this->managedConnections_); getSettings()->headerGame.connect(_, this->managedConnections_); getSettings()->headerUptime.connect(_, this->managedConnections_); + + auto *window = dynamic_cast(this->window()); + if (window) + { + // Hack: In some cases Qt doesn't send the leaveEvent the "actual" last mouse receiver. + // This can happen when quickly moving the mouse out of the window and right clicking. + // To prevent the tooltip from getting stuck, we use the window's leaveEvent. + this->managedConnections_.managedConnect(window->leaving, [this] { + if (this->tooltipWidget_->isVisible()) + { + this->tooltipWidget_->hide(); + } + }); + } + + this->scaleChangedEvent(this->scale()); } void SplitHeader::initializeLayout() @@ -511,9 +527,12 @@ std::unique_ptr SplitHeader::createMainMenu() if (twitchChannel) { - moreMenu->addAction( - "Show chatter list", this->split_, &Split::showChatterList, - h->getDisplaySequence(HotkeyCategory::Split, "openViewerList")); + if (twitchChannel->hasModRights()) + { + moreMenu->addAction( + "Show chatter list", this->split_, &Split::showChatterList, + h->getDisplaySequence(HotkeyCategory::Split, "openViewerList")); + } moreMenu->addAction("Subscribe", this->split_, &Split::openSubPage); diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index d53b4bad991..865392163e3 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -226,20 +226,16 @@ void SplitInput::themeChangedEvent() QPalette placeholderPalette; palette.setColor(QPalette::WindowText, this->theme->splits.input.text); -#if (QT_VERSION >= QT_VERSION_CHECK(5, 12, 0)) placeholderPalette.setColor( QPalette::PlaceholderText, this->theme->messages.textColors.chatPlaceholder); -#endif this->updateEmoteButton(); this->updateCancelReplyButton(); this->ui_.textEditLength->setPalette(palette); this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet); -#if (QT_VERSION >= QT_VERSION_CHECK(5, 12, 0)) this->ui_.textEdit->setPalette(placeholderPalette); -#endif auto marginPx = static_cast(2.F * this->scale()); this->ui_.vbox->setContentsMargins(marginPx, marginPx, marginPx, marginPx); @@ -309,8 +305,6 @@ void SplitInput::openEmotePopup() }); } - this->emotePopup_->resize(int(300 * this->emotePopup_->scale()), - int(500 * this->emotePopup_->scale())); this->emotePopup_->loadChannel(this->split_->getChannel()); this->emotePopup_->show(); this->emotePopup_->raise(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fb5730048b8..19ac9195b31 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,7 +5,8 @@ option(CHATTERINO_TEST_USE_PUBLIC_HTTPBIN "Use public httpbin for testing networ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/main.cpp ${CMAKE_CURRENT_LIST_DIR}/resources/test-resources.qrc - ${CMAKE_CURRENT_LIST_DIR}/src/TestHelpers.hpp + ${CMAKE_CURRENT_LIST_DIR}/src/Test.hpp + ${CMAKE_CURRENT_LIST_DIR}/src/Test.cpp ${CMAKE_CURRENT_LIST_DIR}/src/ChannelChatters.cpp ${CMAKE_CURRENT_LIST_DIR}/src/AccessGuard.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NetworkCommon.cpp @@ -41,6 +42,9 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/LinkInfo.cpp ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayout.cpp ${CMAKE_CURRENT_LIST_DIR}/src/QMagicEnum.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/ModerationAction.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Scrollbar.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Commands.cpp # Add your new file above this line! ) diff --git a/tests/README.md b/tests/README.md index f9bc5798a9a..47ac1b202ae 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,4 +7,4 @@ docker run --network=host --detach ghcr.io/chatterino/twitch-pubsub-server-test: docker run -p 9051:80 --detach kennethreitz/httpbin ``` -If you're unable to use docker, you can use [httpbox](github.com/kevinastone/httpbox) (`httpbox --port 9051`) and [Chatterino/twitch-pubsub-server-test](https://github.com/Chatterino/twitch-pubsub-server-test/releases/latest) manually. +If you're unable to use docker, you can use [httpbox](https://github.com/kevinastone/httpbox) (`httpbox --port 9051`) and [Chatterino/twitch-pubsub-server-test](https://github.com/Chatterino/twitch-pubsub-server-test/releases/latest) manually. diff --git a/tests/src/AccessGuard.cpp b/tests/src/AccessGuard.cpp index a0d1c6d3199..56cbc727fea 100644 --- a/tests/src/AccessGuard.cpp +++ b/tests/src/AccessGuard.cpp @@ -1,6 +1,6 @@ #include "common/UniqueAccess.hpp" +#include "Test.hpp" -#include #include #include diff --git a/tests/src/BasicPubSub.cpp b/tests/src/BasicPubSub.cpp index dc277522028..6315970ff06 100644 --- a/tests/src/BasicPubSub.cpp +++ b/tests/src/BasicPubSub.cpp @@ -1,7 +1,7 @@ #include "providers/liveupdates/BasicPubSubClient.hpp" #include "providers/liveupdates/BasicPubSubManager.hpp" +#include "Test.hpp" -#include #include #include #include diff --git a/tests/src/BttvLiveUpdates.cpp b/tests/src/BttvLiveUpdates.cpp index 580f2e61f96..2d238f9b0b8 100644 --- a/tests/src/BttvLiveUpdates.cpp +++ b/tests/src/BttvLiveUpdates.cpp @@ -1,6 +1,7 @@ #include "providers/bttv/BttvLiveUpdates.hpp" -#include +#include "Test.hpp" + #include #include diff --git a/tests/src/ChannelChatters.cpp b/tests/src/ChannelChatters.cpp index c665836bb38..79711ce15af 100644 --- a/tests/src/ChannelChatters.cpp +++ b/tests/src/ChannelChatters.cpp @@ -1,8 +1,8 @@ #include "common/ChannelChatters.hpp" #include "mocks/Channel.hpp" +#include "Test.hpp" -#include #include #include diff --git a/tests/src/ChatterSet.cpp b/tests/src/ChatterSet.cpp index ac5b81ee926..57a67a77113 100644 --- a/tests/src/ChatterSet.cpp +++ b/tests/src/ChatterSet.cpp @@ -1,6 +1,7 @@ #include "common/ChatterSet.hpp" -#include +#include "Test.hpp" + #include TEST(ChatterSet, insert) diff --git a/tests/src/Commands.cpp b/tests/src/Commands.cpp new file mode 100644 index 00000000000..f180a718080 --- /dev/null +++ b/tests/src/Commands.cpp @@ -0,0 +1,1057 @@ +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/Command.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "controllers/commands/CommandController.hpp" +#include "controllers/commands/common/ChannelAction.hpp" +#include "mocks/EmptyApplication.hpp" +#include "mocks/Helix.hpp" +#include "mocks/Logging.hpp" +#include "mocks/TwitchIrcServer.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Settings.hpp" +#include "Test.hpp" + +#include + +using namespace chatterino; + +using ::testing::_; +using ::testing::StrictMock; + +namespace { + +class MockApplication : mock::EmptyApplication +{ +public: + MockApplication() + : settings(this->settingsDir.filePath("settings.json")) + { + } + + ITwitchIrcServer *getTwitch() override + { + return &this->twitch; + } + + AccountController *getAccounts() override + { + return &this->accounts; + } + + CommandController *getCommands() override + { + return &this->commands; + } + + IEmotes *getEmotes() override + { + return &this->emotes; + } + + ILogging *getChatLogger() override + { + return &this->chatLogger; + } + + Settings settings; + AccountController accounts; + CommandController commands; + mock::MockTwitchIrcServer twitch; + Emotes emotes; + mock::EmptyLogging chatLogger; +}; + +} // namespace + +namespace chatterino { + +TEST(Commands, parseBanActions) +{ + MockApplication app; + + std::shared_ptr channel = + std::make_shared("forsen"); + + CommandContext ctx{}; + + QString command("/ban"); + QString usage("usage string"); + bool withDuration = false; + bool withReason = true; + + struct Test { + CommandContext inputContext; + + std::vector expectedActions; + QString expectedError; + }; + + std::vector tests{ + { + // Normal ban with an added reason, with the user maybe trying to use the --channel parameter at the end, but it gets eaten by the reason + .inputContext = + { + .words = {"/ban", "forsen", "the", "ban", "reason", + "--channel", "xD"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "the ban reason --channel xD", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // Normal ban with an added reason + .inputContext = + { + .words = {"/ban", "forsen", "the", "ban", "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "the ban reason", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // Normal ban without an added reason + .inputContext = + { + .words = {"/ban", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // User forgot to specify who to ban + .inputContext = + { + .words = {"/ban"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User tried to use /ban outside of a channel context (shouldn't really be able to happen) + .inputContext = + { + .words = {"/ban"}, + }, + .expectedActions = {}, + .expectedError = + "A " % command % + " action must be performed with a channel as a context", + }, + { + // User tried to use /ban without a target, but with a --channel specified + .inputContext = + { + .words = {"/ban", "--channel", "pajlada"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User overriding the ban to be done in the pajlada channel + .inputContext = + { + .words = {"/ban", "--channel", "pajlada", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // User overriding the ban to be done in the pajlada channel and in the channel with the id 11148817 + .inputContext = + { + .words = {"/ban", "--channel", "pajlada", "--channel", + "id:11148817", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 0, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 0, + }, + }, + .expectedError = "", + }, + { + // User overriding the ban to be done in the pajlada channel and in the channel with the id 11148817, with a reason specified + .inputContext = + { + .words = {"/ban", "--channel", "pajlada", "--channel", + "id:11148817", "forsen", "the", "ban", "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "the ban reason", + .duration = 0, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "the ban reason", + .duration = 0, + }, + }, + .expectedError = "", + }, + }; + + for (const auto &test : tests) + { + auto oActions = commands::parseChannelAction( + test.inputContext, command, usage, withDuration, withReason); + + if (!test.expectedActions.empty()) + { + ASSERT_TRUE(oActions.has_value()) << oActions.error(); + auto actions = *oActions; + ASSERT_EQ(actions.size(), test.expectedActions.size()); + ASSERT_EQ(actions, test.expectedActions); + } + else + { + ASSERT_FALSE(oActions.has_value()); + } + + if (!test.expectedError.isEmpty()) + { + ASSERT_FALSE(oActions.has_value()); + ASSERT_EQ(oActions.error(), test.expectedError); + } + } +} + +TEST(Commands, parseTimeoutActions) +{ + MockApplication app; + + std::shared_ptr channel = + std::make_shared("forsen"); + + CommandContext ctx{}; + + QString command("/timeout"); + QString usage("usage string"); + bool withDuration = true; + bool withReason = true; + + struct Test { + CommandContext inputContext; + + std::vector expectedActions; + QString expectedError; + }; + + std::vector tests{ + { + // Normal timeout without an added reason, with the default duration + .inputContext = + { + .words = {"/timeout", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 10 * 60, + }, + }, + .expectedError = "", + }, + { + // Normal timeout without an added reason, with a custom duration + .inputContext = + { + .words = {"/timeout", "forsen", "5m"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 5 * 60, + }, + }, + .expectedError = "", + }, + { + // Normal timeout without an added reason, with a custom duration, with an added reason + .inputContext = + { + .words = {"/timeout", "forsen", "5m", "the", "timeout", + "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + .reason = "the timeout reason", + .duration = 5 * 60, + }, + }, + .expectedError = "", + }, + { + // Normal timeout without an added reason, with an added reason, but user forgot to specify a timeout duration so it fails + .inputContext = + { + .words = {"/timeout", "forsen", "the", "timeout", "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Invalid duration - " % usage, + }, + { + // User forgot to specify who to timeout + .inputContext = + { + .words = {"/timeout"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User tried to use /timeout outside of a channel context (shouldn't really be able to happen) + .inputContext = + { + .words = {"/timeout"}, + }, + .expectedActions = {}, + .expectedError = + "A " % command % + " action must be performed with a channel as a context", + }, + { + // User tried to use /timeout without a target, but with a --channel specified + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User overriding the timeout to be done in the pajlada channel + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 10 * 60, + }, + }, + .expectedError = "", + }, + { + // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817 + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada", "--channel", + "id:11148817", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 10 * 60, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 10 * 60, + }, + }, + .expectedError = "", + }, + { + // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817, with a custom duration + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada", "--channel", + "id:11148817", "forsen", "5m"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 5 * 60, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "", + .duration = 5 * 60, + }, + }, + .expectedError = "", + }, + { + // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817, with a reason specified + .inputContext = + { + .words = {"/timeout", "--channel", "pajlada", "--channel", + "id:11148817", "forsen", "10m", "the", "timeout", + "reason"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + .reason = "the timeout reason", + .duration = 10 * 60, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + .reason = "the timeout reason", + .duration = 10 * 60, + }, + }, + .expectedError = "", + }, + }; + + for (const auto &test : tests) + { + auto oActions = commands::parseChannelAction( + test.inputContext, command, usage, withDuration, withReason); + + if (!test.expectedActions.empty()) + { + ASSERT_TRUE(oActions.has_value()) << oActions.error(); + auto actions = *oActions; + ASSERT_EQ(actions.size(), test.expectedActions.size()); + ASSERT_EQ(actions, test.expectedActions); + } + else + { + ASSERT_FALSE(oActions.has_value()); + } + + if (!test.expectedError.isEmpty()) + { + ASSERT_FALSE(oActions.has_value()); + ASSERT_EQ(oActions.error(), test.expectedError); + } + } +} + +TEST(Commands, parseUnbanActions) +{ + MockApplication app; + + std::shared_ptr channel = + std::make_shared("forsen"); + + CommandContext ctx{}; + + QString command("/unban"); + QString usage("usage string"); + bool withDuration = false; + bool withReason = false; + + struct Test { + CommandContext inputContext; + + std::vector expectedActions; + QString expectedError; + }; + + std::vector tests{ + { + // Normal unban + .inputContext = + { + .words = {"/unban", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + }, + }, + .expectedError = "", + }, + { + // Normal unban but user input some random junk after the target + .inputContext = + { + .words = {"/unban", "forsen", "foo", "bar", "baz"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "forsen", + .displayName = "forsen", + }, + .target = + { + .login = "forsen", + }, + }, + }, + .expectedError = "", + }, + { + // User forgot to specify who to unban + .inputContext = + { + .words = {"/unban"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User tried to use /unban outside of a channel context (shouldn't really be able to happen) + .inputContext = + { + .words = {"/unban"}, + }, + .expectedActions = {}, + .expectedError = + "A " % command % + " action must be performed with a channel as a context", + }, + { + // User tried to use /unban without a target, but with a --channel specified + .inputContext = + { + .words = {"/unban", "--channel", "pajlada"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = {}, + .expectedError = "Missing target - " % usage, + }, + { + // User overriding the unban to be done in the pajlada channel + .inputContext = + { + .words = {"/unban", "--channel", "pajlada", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + }, + }, + .expectedError = "", + }, + { + // User overriding the unban to be done in the pajlada channel and in the channel with the id 11148817 + .inputContext = + { + .words = {"/unban", "--channel", "pajlada", "--channel", + "id:11148817", "forsen"}, + .channel = channel, + .twitchChannel = channel.get(), + }, + .expectedActions = + { + { + .channel = + { + .login = "pajlada", + }, + .target = + { + .login = "forsen", + }, + }, + { + .channel = + { + .id = "11148817", + }, + .target = + { + .login = "forsen", + }, + }, + }, + .expectedError = "", + }, + }; + + for (const auto &test : tests) + { + auto oActions = commands::parseChannelAction( + test.inputContext, command, usage, withDuration, withReason); + + if (!test.expectedActions.empty()) + { + ASSERT_TRUE(oActions.has_value()) << oActions.error(); + auto actions = *oActions; + ASSERT_EQ(actions.size(), test.expectedActions.size()); + ASSERT_EQ(actions, test.expectedActions); + } + else + { + ASSERT_FALSE(oActions.has_value()); + } + + if (!test.expectedError.isEmpty()) + { + ASSERT_FALSE(oActions.has_value()); + ASSERT_EQ(oActions.error(), test.expectedError); + } + } +} + +TEST(Commands, E2E) +{ + ::testing::InSequence seq; + MockApplication app; + + app.commands.initialize(*getSettings(), getIApp()->getPaths()); + + QJsonObject pajlada; + pajlada["id"] = "11148817"; + pajlada["login"] = "pajlada"; + pajlada["display_name"] = "pajlada"; + pajlada["created_at"] = "2010-03-17T11:50:53Z"; + pajlada["description"] = " ͡° ͜ʖ ͡°)"; + pajlada["profile_image_url"] = + "https://static-cdn.jtvnw.net/jtv_user_pictures/" + "cbe986e3-06ad-4506-a3aa-eb05466c839c-profile_image-300x300.png"; + + QJsonObject testaccount420; + testaccount420["id"] = "117166826"; + testaccount420["login"] = "testaccount_420"; + testaccount420["display_name"] = "테스트계정420"; + testaccount420["created_at"] = "2016-02-27T18:55:59Z"; + testaccount420["description"] = ""; + testaccount420["profile_image_url"] = + "https://static-cdn.jtvnw.net/user-default-pictures-uv/" + "ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png"; + + QJsonObject forsen; + forsen["id"] = "22484632"; + forsen["login"] = "forsen"; + forsen["display_name"] = "Forsen"; + forsen["created_at"] = "2011-05-19T00:28:28Z"; + forsen["description"] = + "Approach with caution! No roleplaying or tryharding allowed."; + forsen["profile_image_url"] = + "https://static-cdn.jtvnw.net/jtv_user_pictures/" + "forsen-profile_image-48b43e1e4f54b5c8-300x300.png"; + + std::shared_ptr channel = + std::make_shared("pajlada"); + channel->setRoomId("11148817"); + + StrictMock mockHelix; + initializeHelix(&mockHelix); + + EXPECT_CALL(mockHelix, update).Times(1); + EXPECT_CALL(mockHelix, loadBlocks).Times(1); + + auto account = std::make_shared( + testaccount420["login"].toString(), "token", "oauthclient", + testaccount420["id"].toString()); + getIApp()->getAccounts()->twitch.accounts.append(account); + getIApp()->getAccounts()->twitch.currentUsername = + testaccount420["login"].toString(); + getIApp()->getAccounts()->twitch.load(); + + // Simple single-channel ban + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, + QStringList{"forsen"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(pajlada), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, + banUser(pajlada["id"].toString(), QString("117166826"), + forsen["id"].toString(), std::optional{}, + QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand("/ban forsen", channel, false); + + // Multi-channel ban + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, + QStringList{"forsen"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(pajlada), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, banUser(pajlada["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), + std::optional{}, QString(""), _, _)) + .Times(1); + + EXPECT_CALL(mockHelix, + fetchUsers(QStringList{}, + QStringList{"forsen", "testaccount_420"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(testaccount420), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, banUser(testaccount420["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), + std::optional{}, QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/ban --channel id:11148817 --channel testaccount_420 forsen", channel, + false); + + // ID-based ban + EXPECT_CALL(mockHelix, + banUser(pajlada["id"].toString(), QString("117166826"), + forsen["id"].toString(), std::optional{}, + QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand("/ban id:22484632", channel, false); + + // ID-based redirected ban + EXPECT_CALL(mockHelix, + banUser(testaccount420["id"].toString(), QString("117166826"), + forsen["id"].toString(), std::optional{}, + QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/ban --channel id:117166826 id:22484632", channel, false); + + // name-based redirected ban + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"22484632"}, + QStringList{"testaccount_420"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(testaccount420), + HelixUser(forsen), + }; + success(users); + }); + EXPECT_CALL(mockHelix, + banUser(testaccount420["id"].toString(), QString("117166826"), + forsen["id"].toString(), std::optional{}, + QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/ban --channel testaccount_420 id:22484632", channel, false); + + // Multi-channel timeout + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, + QStringList{"forsen"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(pajlada), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, banUser(pajlada["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), + std::optional{600}, QString(""), _, _)) + .Times(1); + + EXPECT_CALL(mockHelix, + fetchUsers(QStringList{}, + QStringList{"forsen", "testaccount_420"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(testaccount420), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, banUser(testaccount420["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), + std::optional{600}, QString(""), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/timeout --channel id:11148817 --channel testaccount_420 forsen", + channel, false); + + // Multi-channel unban + EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, + QStringList{"forsen"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(pajlada), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, unbanUser(pajlada["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), _, _)) + .Times(1); + + EXPECT_CALL(mockHelix, + fetchUsers(QStringList{}, + QStringList{"forsen", "testaccount_420"}, _, _)) + .WillOnce([=](auto, auto, auto success, auto) { + std::vector users{ + HelixUser(testaccount420), + HelixUser(forsen), + }; + success(users); + }); + + EXPECT_CALL(mockHelix, unbanUser(testaccount420["id"].toString(), + testaccount420["id"].toString(), + forsen["id"].toString(), _, _)) + .Times(1); + + getIApp()->getCommands()->execCommand( + "/unban --channel id:11148817 --channel testaccount_420 forsen", + channel, false); +} + +} // namespace chatterino diff --git a/tests/src/Emojis.cpp b/tests/src/Emojis.cpp index 0f6cf67628e..42df110a426 100644 --- a/tests/src/Emojis.cpp +++ b/tests/src/Emojis.cpp @@ -1,8 +1,8 @@ #include "providers/emoji/Emojis.hpp" #include "common/Literals.hpp" +#include "Test.hpp" -#include #include #include @@ -53,7 +53,7 @@ TEST(Emojis, ShortcodeParsing) } EXPECT_EQ(output, test.expectedOutput) - << "Input " << test.input.toStdString() << " failed"; + << "Input " << test.input << " failed"; } } @@ -165,8 +165,7 @@ TEST(Emojis, Parse) // can't use EXPECT_EQ because EmotePtr can't be printed if (output != test.expectedOutput) { - EXPECT_TRUE(false) - << "Input " << test.input.toStdString() << " failed"; + EXPECT_TRUE(false) << "Input " << test.input << " failed"; } } } diff --git a/tests/src/ExponentialBackoff.cpp b/tests/src/ExponentialBackoff.cpp index 2a4259744a1..7099ea08a17 100644 --- a/tests/src/ExponentialBackoff.cpp +++ b/tests/src/ExponentialBackoff.cpp @@ -1,6 +1,6 @@ #include "util/ExponentialBackoff.hpp" -#include +#include "Test.hpp" using namespace chatterino; diff --git a/tests/src/Filters.cpp b/tests/src/Filters.cpp index ff1b0590212..89c0a510f88 100644 --- a/tests/src/Filters.cpp +++ b/tests/src/Filters.cpp @@ -13,8 +13,8 @@ #include "providers/twitch/TwitchBadge.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Emotes.hpp" +#include "Test.hpp" -#include #include #include @@ -101,7 +101,7 @@ namespace chatterino::filters { std::ostream &operator<<(std::ostream &os, Type t) { - os << qUtf8Printable(typeToString(t)); + os << typeToString(t); return os; } @@ -138,8 +138,8 @@ TEST(Filters, Validity) auto filterResult = Filter::fromString(input); bool isValid = std::holds_alternative(filterResult); EXPECT_EQ(isValid, expected) - << "Filter::fromString( " << qUtf8Printable(input) - << " ) should be " << (expected ? "valid" : "invalid"); + << "Filter::fromString( " << input << " ) should be " + << (expected ? "valid" : "invalid"); } } @@ -168,15 +168,14 @@ TEST(Filters, TypeSynthesis) { auto filterResult = Filter::fromString(input); bool isValid = std::holds_alternative(filterResult); - ASSERT_TRUE(isValid) << "Filter::fromString( " << qUtf8Printable(input) - << " ) is invalid"; + ASSERT_TRUE(isValid) + << "Filter::fromString( " << input << " ) is invalid"; auto filter = std::move(std::get(filterResult)); T type = filter.returnType(); EXPECT_EQ(type, expected) - << "Filter{ " << qUtf8Printable(input) << " } has type " << type - << " instead of " << expected << ".\nDebug: " - << qUtf8Printable(filter.debugString(typingContext)); + << "Filter{ " << input << " } has type " << type << " instead of " + << expected << ".\nDebug: " << filter.debugString(typingContext); } } @@ -244,17 +243,16 @@ TEST(Filters, Evaluation) { auto filterResult = Filter::fromString(input); bool isValid = std::holds_alternative(filterResult); - ASSERT_TRUE(isValid) << "Filter::fromString( " << qUtf8Printable(input) - << " ) is invalid"; + ASSERT_TRUE(isValid) + << "Filter::fromString( " << input << " ) is invalid"; auto filter = std::move(std::get(filterResult)); auto result = filter.execute(contextMap); EXPECT_EQ(result, expected) - << "Filter{ " << qUtf8Printable(input) << " } evaluated to " - << qUtf8Printable(result.toString()) << " instead of " - << qUtf8Printable(expected.toString()) << ".\nDebug: " - << qUtf8Printable(filter.debugString(typingContext)); + << "Filter{ " << input << " } evaluated to " << result.toString() + << " instead of " << expected.toString() + << ".\nDebug: " << filter.debugString(typingContext); } } @@ -354,20 +352,17 @@ TEST_F(FiltersF, ExpressionDebug) { const auto filterResult = Filter::fromString(input); const auto *filter = std::get_if(&filterResult); - EXPECT_NE(filter, nullptr) - << "Filter::fromString(" << qUtf8Printable(input) - << ") did not build a proper filter"; + EXPECT_NE(filter, nullptr) << "Filter::fromString(" << input + << ") did not build a proper filter"; const auto actualDebugString = filter->debugString(typingContext); EXPECT_EQ(actualDebugString, debugString) - << "filter->debugString() on '" << qUtf8Printable(input) - << "' should be '" << qUtf8Printable(debugString) << "', but got '" - << qUtf8Printable(actualDebugString) << "'"; + << "filter->debugString() on '" << input << "' should be '" + << debugString << "', but got '" << actualDebugString << "'"; const auto actualFilterString = filter->filterString(); EXPECT_EQ(actualFilterString, filterString) - << "filter->filterString() on '" << qUtf8Printable(input) - << "' should be '" << qUtf8Printable(filterString) << "', but got '" - << qUtf8Printable(actualFilterString) << "'"; + << "filter->filterString() on '" << input << "' should be '" + << filterString << "', but got '" << actualFilterString << "'"; } } diff --git a/tests/src/FormatTime.cpp b/tests/src/FormatTime.cpp index bc15f44efe6..6fe82ab9a8c 100644 --- a/tests/src/FormatTime.cpp +++ b/tests/src/FormatTime.cpp @@ -1,6 +1,6 @@ #include "util/FormatTime.hpp" -#include +#include "Test.hpp" #include @@ -62,8 +62,8 @@ TEST(FormatTime, Int) const auto actual = formatTime(input); EXPECT_EQ(actual, expected) - << qUtf8Printable(actual) << " (" << input - << ") did not match expected value " << qUtf8Printable(expected); + << actual << " (" << input << ") did not match expected value " + << expected; } } @@ -130,8 +130,8 @@ TEST(FormatTime, QString) const auto actual = formatTime(input); EXPECT_EQ(actual, expected) - << qUtf8Printable(actual) << " (" << qUtf8Printable(input) - << ") did not match expected value " << qUtf8Printable(expected); + << actual << " (" << input << ") did not match expected value " + << expected; } } @@ -202,7 +202,6 @@ TEST(FormatTime, chrono) const auto actual = formatTime(input); EXPECT_EQ(actual, expected) - << qUtf8Printable(actual) << " did not match expected value " - << qUtf8Printable(expected); + << actual << " did not match expected value " << expected; } } diff --git a/tests/src/Helpers.cpp b/tests/src/Helpers.cpp index d6a74fec037..a98868bfa7e 100644 --- a/tests/src/Helpers.cpp +++ b/tests/src/Helpers.cpp @@ -1,6 +1,6 @@ #include "util/Helpers.hpp" -#include +#include "Test.hpp" using namespace chatterino; using namespace _helpers_internal; @@ -252,12 +252,6 @@ TEST(Helpers, BatchDifferentInputType) EXPECT_EQ(result, expectation); } -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 2) -# define makeView(x) x -#else -# define makeView(str) (&(str)) -#endif - TEST(Helpers, skipSpace) { struct TestCase { @@ -272,11 +266,11 @@ TEST(Helpers, skipSpace) for (const auto &c : tests) { - const auto actual = skipSpace(makeView(c.input), c.startIdx); + const auto actual = skipSpace(c.input, c.startIdx); EXPECT_EQ(actual, c.expected) - << actual << " (" << qUtf8Printable(c.input) - << ") did not match expected value " << c.expected; + << actual << " (" << c.input << ") did not match expected value " + << c.expected; } } @@ -414,18 +408,17 @@ TEST(Helpers, findUnitMultiplierToSec) for (const auto &c : tests) { SizeType pos = c.startPos; - const auto actual = findUnitMultiplierToSec(makeView(c.input), pos); + const auto actual = findUnitMultiplierToSec(c.input, pos); if (c.expectedMultiplier == bad) { - EXPECT_FALSE(actual.second) << qUtf8Printable(c.input); + EXPECT_FALSE(actual.second) << c.input; } else { EXPECT_TRUE(pos == c.expectedEndPos && actual.second && actual.first == c.expectedMultiplier) - << qUtf8Printable(c.input) - << ": Expected(end: " << c.expectedEndPos + << c.input << ": Expected(end: " << c.expectedEndPos << ", mult: " << c.expectedMultiplier << ") Actual(end: " << pos << ", mult: " << actual.first << ")"; } @@ -503,7 +496,7 @@ TEST(Helpers, parseDurationToSeconds) const auto actual = parseDurationToSeconds(c.input, c.noUnitMultiplier); EXPECT_EQ(actual, c.output) - << actual << " (" << qUtf8Printable(c.input) - << ") did not match expected value " << c.output; + << actual << " (" << c.input << ") did not match expected value " + << c.output; } } diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index a45bbf98c02..090acf37b15 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -10,9 +10,8 @@ #include "providers/twitch/TwitchBadge.hpp" // for Badge #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" +#include "Test.hpp" -#include -#include #include #include #include @@ -216,11 +215,9 @@ class HighlightControllerTest : public ::testing::Test input.originalMessage, input.flags); EXPECT_EQ(isMatch, expected.state) - << qUtf8Printable(input.senderName) << ": " - << qUtf8Printable(input.originalMessage); + << input.senderName << ": " << input.originalMessage; EXPECT_EQ(matchResult, expected.result) - << qUtf8Printable(input.senderName) << ": " - << qUtf8Printable(input.originalMessage); + << input.senderName << ": " << input.originalMessage; } } diff --git a/tests/src/HighlightPhrase.cpp b/tests/src/HighlightPhrase.cpp index 374670b03d3..2ec2530f0f5 100644 --- a/tests/src/HighlightPhrase.cpp +++ b/tests/src/HighlightPhrase.cpp @@ -1,6 +1,6 @@ #include "controllers/highlights/HighlightPhrase.hpp" -#include +#include "Test.hpp" using namespace chatterino; diff --git a/tests/src/Hotkeys.cpp b/tests/src/Hotkeys.cpp index ebbfe50297f..7c3d8d10fe6 100644 --- a/tests/src/Hotkeys.cpp +++ b/tests/src/Hotkeys.cpp @@ -1,6 +1,5 @@ #include "controllers/hotkeys/HotkeyHelpers.hpp" - -#include +#include "Test.hpp" #include diff --git a/tests/src/InputCompletion.cpp b/tests/src/InputCompletion.cpp index 86003543871..22c42b31cb1 100644 --- a/tests/src/InputCompletion.cpp +++ b/tests/src/InputCompletion.cpp @@ -12,9 +12,9 @@ #include "singletons/Emotes.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" +#include "Test.hpp" #include "widgets/splits/InputCompletionPopup.hpp" -#include #include #include #include @@ -224,7 +224,7 @@ void containsRoughly(std::span span, std::set values) } } - ASSERT_TRUE(found) << qPrintable(v) << " was not found in the span"; + ASSERT_TRUE(found) << v << " was not found in the span"; } } diff --git a/tests/src/IrcHelpers.cpp b/tests/src/IrcHelpers.cpp index acae81c3537..d210b14e003 100644 --- a/tests/src/IrcHelpers.cpp +++ b/tests/src/IrcHelpers.cpp @@ -1,6 +1,7 @@ #include "util/IrcHelpers.hpp" -#include +#include "Test.hpp" + #include #include #include @@ -55,7 +56,7 @@ TEST(IrcHelpers, ParseTagString) const auto actual = parseTagString(input); EXPECT_EQ(actual, expected) - << qUtf8Printable(actual) << " (" << qUtf8Printable(input) - << ") did not match expected value " << qUtf8Printable(expected); + << actual << " (" << input << ") did not match expected value " + << expected; } } diff --git a/tests/src/LimitedQueue.cpp b/tests/src/LimitedQueue.cpp index 39a8bba86ac..0a94ea92874 100644 --- a/tests/src/LimitedQueue.cpp +++ b/tests/src/LimitedQueue.cpp @@ -1,6 +1,6 @@ #include "messages/LimitedQueue.hpp" -#include +#include "Test.hpp" #include diff --git a/tests/src/LinkInfo.cpp b/tests/src/LinkInfo.cpp index 91f065035ce..a06a78c0f9c 100644 --- a/tests/src/LinkInfo.cpp +++ b/tests/src/LinkInfo.cpp @@ -2,8 +2,7 @@ #include "common/Literals.hpp" #include "SignalSpy.hpp" - -#include +#include "Test.hpp" using namespace chatterino; using namespace literals; diff --git a/tests/src/LinkParser.cpp b/tests/src/LinkParser.cpp index 0931ef85917..9d964ce1542 100644 --- a/tests/src/LinkParser.cpp +++ b/tests/src/LinkParser.cpp @@ -1,6 +1,7 @@ #include "common/LinkParser.hpp" -#include +#include "Test.hpp" + #include #include @@ -15,13 +16,13 @@ struct Case { { auto input = this->protocol + this->host + this->rest; LinkParser p(input); - ASSERT_TRUE(p.result().has_value()) << input.toStdString(); + ASSERT_TRUE(p.result().has_value()) << input; const auto &r = *p.result(); ASSERT_EQ(r.source, input); - ASSERT_EQ(r.protocol, this->protocol) << this->protocol.toStdString(); - ASSERT_EQ(r.host, this->host) << this->host.toStdString(); - ASSERT_EQ(r.rest, this->rest) << this->rest.toStdString(); + ASSERT_EQ(r.protocol, this->protocol) << this->protocol; + ASSERT_EQ(r.host, this->host) << this->host; + ASSERT_EQ(r.rest, this->rest) << this->rest; } }; @@ -126,7 +127,7 @@ TEST(LinkParser, doesntParseInvalidIpv4Links) for (const auto &input : inputs) { LinkParser p(input); - ASSERT_FALSE(p.result().has_value()) << input.toStdString(); + ASSERT_FALSE(p.result().has_value()) << input; } } @@ -170,6 +171,6 @@ TEST(LinkParser, doesntParseInvalidLinks) for (const auto &input : inputs) { LinkParser p(input); - ASSERT_FALSE(p.result().has_value()) << input.toStdString(); + ASSERT_FALSE(p.result().has_value()) << input; } } diff --git a/tests/src/Literals.cpp b/tests/src/Literals.cpp index 77607b73977..17d459b1431 100644 --- a/tests/src/Literals.cpp +++ b/tests/src/Literals.cpp @@ -1,6 +1,6 @@ #include "common/Literals.hpp" -#include +#include "Test.hpp" using namespace chatterino::literals; diff --git a/tests/src/MessageLayout.cpp b/tests/src/MessageLayout.cpp index 9ce0c7f21e8..8533b87b871 100644 --- a/tests/src/MessageLayout.cpp +++ b/tests/src/MessageLayout.cpp @@ -10,8 +10,8 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" +#include "Test.hpp" -#include #include #include @@ -63,7 +63,7 @@ class MessageLayoutTest builder.append( std::make_unique(text, MessageElementFlag::Text)); this->layout = std::make_unique(builder.release()); - this->layout->layout(WIDTH, 1, MessageElementFlag::Text, false); + this->layout->layout(WIDTH, 1, 1, MessageElementFlag::Text, false); } MockApplication mockApplication; diff --git a/tests/src/ModerationAction.cpp b/tests/src/ModerationAction.cpp new file mode 100644 index 00000000000..75daf8e3e22 --- /dev/null +++ b/tests/src/ModerationAction.cpp @@ -0,0 +1,112 @@ +#include "controllers/moderationactions/ModerationAction.hpp" + +#include "messages/Image.hpp" +#include "mocks/EmptyApplication.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Resources.hpp" +#include "singletons/Settings.hpp" +#include "Test.hpp" + +#include + +using namespace chatterino; + +using namespace std::chrono_literals; + +namespace { + +class MockApplication : mock::EmptyApplication +{ +public: + MockApplication() + : settings(this->settingsDir.filePath("settings.json")) + { + } + + IEmotes *getEmotes() override + { + return &this->emotes; + } + + Settings settings; + Emotes emotes; +}; + +class ModerationActionTest : public ::testing::Test +{ +public: + MockApplication mockApplication; +}; + +} // namespace + +TEST_F(ModerationActionTest, Parse) +{ + struct TestCase { + QString action; + QString iconPath; + + QString expectedLine1; + QString expectedLine2; + + std::optional expectedImage; + + ModerationAction::Type expectedType; + }; + + std::vector tests{ + { + .action = "/ban forsen", + .expectedImage = + Image::fromResourcePixmap(getResources().buttons.ban), + .expectedType = ModerationAction::Type::Ban, + }, + { + .action = "/delete {message.id}", + .expectedImage = + Image::fromResourcePixmap(getResources().buttons.trashCan), + .expectedType = ModerationAction::Type::Delete, + }, + { + .action = "/timeout {user.name} 1d", + .expectedLine1 = "1", + .expectedLine2 = "d", + .expectedType = ModerationAction::Type::Timeout, + }, + { + .action = ".timeout {user.name} 300", + .expectedLine1 = "5", + .expectedLine2 = "m", + .expectedType = ModerationAction::Type::Timeout, + }, + { + .action = "forsen", + .expectedLine1 = "fo", + .expectedLine2 = "rs", + .expectedType = ModerationAction::Type::Custom, + }, + { + .action = "forsen", + .iconPath = "file:///this-is-the-path-to-the-icon.png", + .expectedLine1 = "fo", + .expectedLine2 = "rs", + .expectedImage = + Image::fromUrl(Url{"file:///this-is-the-path-to-the-icon.png"}), + .expectedType = ModerationAction::Type::Custom, + }, + }; + + for (const auto &test : tests) + { + ModerationAction moderationAction(test.action, test.iconPath); + + EXPECT_EQ(moderationAction.getAction(), test.action); + + EXPECT_EQ(moderationAction.getLine1(), test.expectedLine1); + EXPECT_EQ(moderationAction.getLine2(), test.expectedLine2); + + EXPECT_EQ(moderationAction.getImage(), test.expectedImage); + + EXPECT_EQ(moderationAction.getType(), test.expectedType); + } +} diff --git a/tests/src/NetworkCommon.cpp b/tests/src/NetworkCommon.cpp index 481f951aee5..9beab8da68c 100644 --- a/tests/src/NetworkCommon.cpp +++ b/tests/src/NetworkCommon.cpp @@ -1,6 +1,6 @@ #include "common/network/NetworkCommon.hpp" -#include +#include "Test.hpp" using namespace chatterino; diff --git a/tests/src/NetworkRequest.cpp b/tests/src/NetworkRequest.cpp index 2f6b8102f96..ca723481efe 100644 --- a/tests/src/NetworkRequest.cpp +++ b/tests/src/NetworkRequest.cpp @@ -2,8 +2,8 @@ #include "common/network/NetworkManager.hpp" #include "common/network/NetworkResult.hpp" +#include "Test.hpp" -#include #include using namespace chatterino; diff --git a/tests/src/NetworkResult.cpp b/tests/src/NetworkResult.cpp index 72a2ca771f0..4d4c574210d 100644 --- a/tests/src/NetworkResult.cpp +++ b/tests/src/NetworkResult.cpp @@ -1,6 +1,6 @@ #include "common/network/NetworkResult.hpp" -#include +#include "Test.hpp" using namespace chatterino; @@ -37,12 +37,21 @@ TEST(NetworkResult, Errors) "RemoteHostClosedError"); // status code takes precedence - checkResult({Error::TimeoutError, 400, {}}, Error::TimeoutError, 400, - "400"); + checkResult({Error::InternalServerError, 400, {}}, + Error::InternalServerError, 400, "400"); + + // error takes precedence (1..=99) + checkResult({Error::BackgroundRequestNotAllowedError, 400, {}}, + Error::BackgroundRequestNotAllowedError, 400, + "BackgroundRequestNotAllowedError"); + checkResult({Error::UnknownNetworkError, 400, {}}, + Error::UnknownNetworkError, 400, "UnknownNetworkError"); } TEST(NetworkResult, InvalidError) { checkResult({static_cast(-1), {}, {}}, static_cast(-1), std::nullopt, "unknown error (-1)"); + checkResult({static_cast(-1), 42, {}}, static_cast(-1), 42, + "unknown error (status: 42, error: -1)"); } diff --git a/tests/src/NotebookTab.cpp b/tests/src/NotebookTab.cpp index 2ac4903f47e..36133b648c8 100644 --- a/tests/src/NotebookTab.cpp +++ b/tests/src/NotebookTab.cpp @@ -7,10 +7,9 @@ #include "singletons/Fonts.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" +#include "Test.hpp" #include "widgets/Notebook.hpp" -#include -#include #include #include diff --git a/tests/src/QMagicEnum.cpp b/tests/src/QMagicEnum.cpp index 80c265efe24..6778427fe43 100644 --- a/tests/src/QMagicEnum.cpp +++ b/tests/src/QMagicEnum.cpp @@ -2,8 +2,7 @@ #include "common/FlagsEnum.hpp" #include "common/Literals.hpp" - -#include +#include "Test.hpp" using namespace chatterino; using namespace literals; diff --git a/tests/src/RatelimitBucket.cpp b/tests/src/RatelimitBucket.cpp index c92a42234b8..850f14c68cb 100644 --- a/tests/src/RatelimitBucket.cpp +++ b/tests/src/RatelimitBucket.cpp @@ -1,6 +1,7 @@ #include "util/RatelimitBucket.hpp" -#include +#include "Test.hpp" + #include #include #include diff --git a/tests/src/Scrollbar.cpp b/tests/src/Scrollbar.cpp new file mode 100644 index 00000000000..98ca9a64062 --- /dev/null +++ b/tests/src/Scrollbar.cpp @@ -0,0 +1,187 @@ +#include "widgets/Scrollbar.hpp" + +#include "Application.hpp" +#include "mocks/EmptyApplication.hpp" +#include "singletons/Fonts.hpp" +#include "singletons/Settings.hpp" +#include "singletons/Theme.hpp" +#include "singletons/WindowManager.hpp" +#include "Test.hpp" +#include "widgets/helper/ScrollbarHighlight.hpp" + +#include + +#include + +using namespace chatterino; + +namespace { + +class MockApplication : mock::EmptyApplication +{ +public: + MockApplication() + : settings(this->settingsDir.filePath("settings.json")) + , fonts(this->settings) + , windowManager(this->paths_) + { + } + Theme *getThemes() override + { + return &this->theme; + } + + Fonts *getFonts() override + { + return &this->fonts; + } + + WindowManager *getWindows() override + { + return &this->windowManager; + } + + Settings settings; + Theme theme; + Fonts fonts; + WindowManager windowManager; +}; + +} // namespace + +TEST(Scrollbar, AddHighlight) +{ + MockApplication mockApplication; + + Scrollbar scrollbar(10, nullptr); + EXPECT_EQ(scrollbar.getHighlights().size(), 0); + + for (int i = 0; i < 15; ++i) + { + auto color = std::make_shared(i, 0, 0); + ScrollbarHighlight scrollbarHighlight{color}; + scrollbar.addHighlight(scrollbarHighlight); + } + + EXPECT_EQ(scrollbar.getHighlights().size(), 10); + auto highlights = scrollbar.getHighlights(); + for (int i = 0; i < 10; ++i) + { + auto highlight = highlights[i]; + EXPECT_EQ(highlight.getColor().red(), i + 5); + } +} + +TEST(Scrollbar, AddHighlightsAtStart) +{ + MockApplication mockApplication; + + Scrollbar scrollbar(10, nullptr); + EXPECT_EQ(scrollbar.getHighlights().size(), 0); + + { + scrollbar.addHighlightsAtStart({ + { + std::make_shared(1, 0, 0), + }, + }); + auto highlights = scrollbar.getHighlights(); + EXPECT_EQ(highlights.size(), 1); + EXPECT_EQ(highlights[0].getColor().red(), 1); + } + + { + scrollbar.addHighlightsAtStart({ + { + std::make_shared(2, 0, 0), + }, + }); + auto highlights = scrollbar.getHighlights(); + EXPECT_EQ(highlights.size(), 2); + EXPECT_EQ(highlights[0].getColor().red(), 2); + EXPECT_EQ(highlights[1].getColor().red(), 1); + } + + { + scrollbar.addHighlightsAtStart({ + { + std::make_shared(4, 0, 0), + }, + { + std::make_shared(3, 0, 0), + }, + }); + auto highlights = scrollbar.getHighlights(); + EXPECT_EQ(highlights.size(), 4); + EXPECT_EQ(highlights[0].getColor().red(), 4); + EXPECT_EQ(highlights[1].getColor().red(), 3); + EXPECT_EQ(highlights[2].getColor().red(), 2); + EXPECT_EQ(highlights[3].getColor().red(), 1); + } + + { + // Adds as many as it can, in reverse order + scrollbar.addHighlightsAtStart({ + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(10, 0, 0)}, + {std::make_shared(9, 0, 0)}, + {std::make_shared(8, 0, 0)}, + {std::make_shared(7, 0, 0)}, + {std::make_shared(6, 0, 0)}, + {std::make_shared(5, 0, 0)}, + }); + auto highlights = scrollbar.getHighlights(); + EXPECT_EQ(highlights.size(), 10); + for (const auto &highlight : highlights) + { + std::cout << highlight.getColor().red() << '\n'; + } + EXPECT_EQ(highlights[0].getColor().red(), 10); + EXPECT_EQ(highlights[1].getColor().red(), 9); + EXPECT_EQ(highlights[2].getColor().red(), 8); + EXPECT_EQ(highlights[3].getColor().red(), 7); + EXPECT_EQ(highlights[4].getColor().red(), 6); + EXPECT_EQ(highlights[5].getColor().red(), 5); + EXPECT_EQ(highlights[6].getColor().red(), 4); + EXPECT_EQ(highlights[7].getColor().red(), 3); + EXPECT_EQ(highlights[8].getColor().red(), 2); + EXPECT_EQ(highlights[9].getColor().red(), 1); + } + + { + // Adds as many as it can, in reverse order + // Since the highlights are already full, nothing will be added + scrollbar.addHighlightsAtStart({ + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + {std::make_shared(255, 0, 0)}, + }); + auto highlights = scrollbar.getHighlights(); + EXPECT_EQ(highlights.size(), 10); + for (const auto &highlight : highlights) + { + std::cout << highlight.getColor().red() << '\n'; + } + EXPECT_EQ(highlights[0].getColor().red(), 10); + EXPECT_EQ(highlights[1].getColor().red(), 9); + EXPECT_EQ(highlights[2].getColor().red(), 8); + EXPECT_EQ(highlights[3].getColor().red(), 7); + EXPECT_EQ(highlights[4].getColor().red(), 6); + EXPECT_EQ(highlights[5].getColor().red(), 5); + EXPECT_EQ(highlights[6].getColor().red(), 4); + EXPECT_EQ(highlights[7].getColor().red(), 3); + EXPECT_EQ(highlights[8].getColor().red(), 2); + EXPECT_EQ(highlights[9].getColor().red(), 1); + } +} diff --git a/tests/src/Selection.cpp b/tests/src/Selection.cpp index 1f1f4a62137..a904b076656 100644 --- a/tests/src/Selection.cpp +++ b/tests/src/Selection.cpp @@ -1,6 +1,6 @@ #include "messages/Selection.hpp" -#include +#include "Test.hpp" using namespace chatterino; diff --git a/tests/src/SeventvEventAPI.cpp b/tests/src/SeventvEventAPI.cpp index 780f90bac10..4e2d3228193 100644 --- a/tests/src/SeventvEventAPI.cpp +++ b/tests/src/SeventvEventAPI.cpp @@ -3,8 +3,8 @@ #include "providers/seventv/eventapi/Client.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" #include "providers/seventv/eventapi/Message.hpp" +#include "Test.hpp" -#include #include #include diff --git a/tests/src/SplitInput.cpp b/tests/src/SplitInput.cpp index ed092f94baf..d84da81188a 100644 --- a/tests/src/SplitInput.cpp +++ b/tests/src/SplitInput.cpp @@ -12,11 +12,10 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" +#include "Test.hpp" #include "widgets/Notebook.hpp" #include "widgets/splits/Split.hpp" -#include -#include #include #include @@ -110,9 +109,8 @@ TEST_P(SplitInputTest, Reply) auto reply = MessagePtr(message); this->input.setReply(reply); QString actual = this->input.getInputText(); - ASSERT_EQ(expected, actual) - << "Input text after setReply should be '" << qUtf8Printable(expected) - << "', but got '" << qUtf8Printable(actual) << "'"; + ASSERT_EQ(expected, actual) << "Input text after setReply should be '" + << expected << "', but got '" << actual << "'"; } INSTANTIATE_TEST_SUITE_P( diff --git a/tests/src/Test.cpp b/tests/src/Test.cpp new file mode 100644 index 00000000000..5f245f5d7f2 --- /dev/null +++ b/tests/src/Test.cpp @@ -0,0 +1,42 @@ +#include "Test.hpp" + +#include +#include + +std::ostream &operator<<(std::ostream &os, QStringView str) +{ + os << str.toString().toStdString(); + return os; +} + +std::ostream &operator<<(std::ostream &os, const QByteArray &bytes) +{ + os << std::string_view{bytes.data(), static_cast(bytes.size())}; + return os; +} + +std::ostream &operator<<(std::ostream &os, const QString &str) +{ + os << str.toStdString(); + return os; +} + +// The PrintTo overloads use UniversalPrint to print strings in quotes. +// Even though this uses testing::internal, this is publically documented in +// gtest/gtest-printers.h. + +void PrintTo(const QByteArray &bytes, std::ostream *os) +{ + ::testing::internal::UniversalPrint(bytes.toStdString(), os); +} + +void PrintTo(QStringView str, std::ostream *os) +{ + ::testing::internal::UniversalPrint( + std::u16string{str.utf16(), static_cast(str.size())}, os); +} + +void PrintTo(const QString &str, std::ostream *os) +{ + ::testing::internal::UniversalPrint(str.toStdU16String(), os); +} diff --git a/tests/src/Test.hpp b/tests/src/Test.hpp new file mode 100644 index 00000000000..064f90c6d79 --- /dev/null +++ b/tests/src/Test.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include + +class QString; +class QStringView; +class QByteArray; + +// This file is included in all TUs in chatterino-test to avoid ODR violations. +std::ostream &operator<<(std::ostream &os, QStringView str); +std::ostream &operator<<(std::ostream &os, const QByteArray &bytes); +std::ostream &operator<<(std::ostream &os, const QString &str); + +// NOLINTBEGIN(readability-identifier-naming) +// PrintTo is used for naming parameterized tests, and is part of gtest +void PrintTo(const QByteArray &bytes, std::ostream *os); +void PrintTo(QStringView str, std::ostream *os); +void PrintTo(const QString &str, std::ostream *os); +// NOLINTEND(readability-identifier-naming) diff --git a/tests/src/TestHelpers.hpp b/tests/src/TestHelpers.hpp deleted file mode 100644 index 05190e0dadc..00000000000 --- a/tests/src/TestHelpers.hpp +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include - -template -class ReceivedMessage -{ - mutable std::mutex mutex; - - bool isSet{false}; - T t; - -public: - ReceivedMessage() = default; - - explicit operator bool() const - { - std::unique_lock lock(this->mutex); - - return this->isSet; - } - - ReceivedMessage &operator=(const T &newT) - { - std::unique_lock lock(this->mutex); - - this->isSet = true; - this->t = newT; - - return *this; - } - - bool operator==(const T &otherT) const - { - std::unique_lock lock(this->mutex); - - return this->t == otherT; - } - - const T *operator->() const - { - return &this->t; - } -}; diff --git a/tests/src/TwitchMessageBuilder.cpp b/tests/src/TwitchMessageBuilder.cpp index 7b6b42c33f0..d9d1d5a62f6 100644 --- a/tests/src/TwitchMessageBuilder.cpp +++ b/tests/src/TwitchMessageBuilder.cpp @@ -15,8 +15,8 @@ #include "providers/seventv/SeventvBadges.hpp" #include "providers/twitch/TwitchBadge.hpp" #include "singletons/Emotes.hpp" +#include "Test.hpp" -#include #include #include #include @@ -147,7 +147,7 @@ TEST(TwitchMessageBuilder, CommaSeparatedListTagParsing) auto output = TwitchMessageBuilder::slashKeyValue(test.input); EXPECT_EQ(output, test.expectedOutput) - << "Input " << test.input.toStdString() << " failed"; + << "Input " << test.input << " failed"; } } @@ -230,12 +230,12 @@ TEST(TwitchMessageBuilder, BadgeInfoParsing) auto outputBadgeInfo = TwitchMessageBuilder::parseBadgeInfoTag(privmsg->tags()); EXPECT_EQ(outputBadgeInfo, test.expectedBadgeInfo) - << "Input for badgeInfo " << test.input.toStdString() << " failed"; + << "Input for badgeInfo " << test.input << " failed"; auto outputBadges = SharedMessageBuilder::parseBadgeTag(privmsg->tags()); EXPECT_EQ(outputBadges, test.expectedBadges) - << "Input for badges " << test.input.toStdString() << " failed"; + << "Input for badges " << test.input << " failed"; delete privmsg; } @@ -413,8 +413,7 @@ TEST_F(TestTwitchMessageBuilder, ParseTwitchEmotes) privmsg->tags(), originalMessage, 0); EXPECT_EQ(actualTwitchEmotes, test.expectedTwitchEmotes) - << "Input for twitch emotes " << test.input.toStdString() - << " failed"; + << "Input for twitch emotes " << test.input << " failed"; delete privmsg; } @@ -617,11 +616,11 @@ TEST_F(TestTwitchMessageBuilder, IgnoresReplace) emotes); EXPECT_EQ(message, test.expectedMessage) - << "Message not equal for input '" << test.input.toStdString() - << "' - expected: '" << test.expectedMessage.toStdString() - << "' got: '" << message.toStdString() << "'"; + << "Message not equal for input '" << test.input + << "' - expected: '" << test.expectedMessage << "' got: '" + << message << "'"; EXPECT_EQ(emotes, test.expectedTwitchEmotes) - << "Twitch emotes not equal for input '" << test.input.toStdString() - << "' and output '" << message.toStdString() << "'"; + << "Twitch emotes not equal for input '" << test.input + << "' and output '" << message << "'"; } } diff --git a/tests/src/TwitchPubSubClient.cpp b/tests/src/TwitchPubSubClient.cpp index 30e02e56759..dff88450f04 100644 --- a/tests/src/TwitchPubSubClient.cpp +++ b/tests/src/TwitchPubSubClient.cpp @@ -4,12 +4,12 @@ #include "providers/twitch/pubsubmessages/AutoMod.hpp" #include "providers/twitch/pubsubmessages/Whisper.hpp" #include "providers/twitch/TwitchAccount.hpp" -#include "TestHelpers.hpp" +#include "Test.hpp" -#include #include #include +#include #include using namespace chatterino; @@ -33,6 +33,47 @@ using namespace std::chrono_literals; #ifdef RUN_PUBSUB_TESTS +template +class ReceivedMessage +{ + mutable std::mutex mutex; + + bool isSet{false}; + T t; + +public: + ReceivedMessage() = default; + + explicit operator bool() const + { + std::unique_lock lock(this->mutex); + + return this->isSet; + } + + ReceivedMessage &operator=(const T &newT) + { + std::unique_lock lock(this->mutex); + + this->isSet = true; + this->t = newT; + + return *this; + } + + bool operator==(const T &otherT) const + { + std::unique_lock lock(this->mutex); + + return this->t == otherT; + } + + const T *operator->() const + { + return &this->t; + } +}; + class FTest : public PubSub { public: @@ -138,14 +179,7 @@ TEST(TwitchPubSubClient, DisconnectedAfter1s) ASSERT_EQ(pubSub.diag.messagesReceived, 2); // Listen RESPONSE & Pong ASSERT_EQ(pubSub.diag.listenResponses, 1); - std::this_thread::sleep_for(350ms); - - ASSERT_EQ(pubSub.diag.connectionsOpened, 1); - ASSERT_EQ(pubSub.diag.connectionsClosed, 0); - ASSERT_EQ(pubSub.diag.connectionsFailed, 0); - ASSERT_EQ(pubSub.diag.messagesReceived, 2); - - std::this_thread::sleep_for(600ms); + std::this_thread::sleep_for(950ms); ASSERT_EQ(pubSub.diag.connectionsOpened, 2); ASSERT_EQ(pubSub.diag.connectionsClosed, 1); diff --git a/tests/src/Updates.cpp b/tests/src/Updates.cpp index da4762517a6..ce16f329f6a 100644 --- a/tests/src/Updates.cpp +++ b/tests/src/Updates.cpp @@ -1,8 +1,8 @@ #include "singletons/Updates.hpp" #include "common/Version.hpp" +#include "Test.hpp" -#include #include using namespace chatterino; diff --git a/tests/src/UtilTwitch.cpp b/tests/src/UtilTwitch.cpp index 6a0b58d9fa6..3a2a7b41ba5 100644 --- a/tests/src/UtilTwitch.cpp +++ b/tests/src/UtilTwitch.cpp @@ -1,6 +1,6 @@ +#include "Test.hpp" #include "util/Twitch.hpp" -#include #include #include #include @@ -72,9 +72,8 @@ TEST(UtilTwitch, StripUserName) stripUserName(userName); EXPECT_EQ(userName, expectedUserName) - << qUtf8Printable(userName) << " (" << qUtf8Printable(inputUserName) - << ") did not match expected value " - << qUtf8Printable(expectedUserName); + << userName << " (" << inputUserName + << ") did not match expected value " << expectedUserName; } } @@ -153,10 +152,8 @@ TEST(UtilTwitch, StripChannelName) stripChannelName(userName); EXPECT_EQ(userName, expectedChannelName) - << qUtf8Printable(userName) << " (" - << qUtf8Printable(inputChannelName) - << ") did not match expected value " - << qUtf8Printable(expectedChannelName); + << userName << " (" << inputChannelName + << ") did not match expected value " << expectedChannelName; } } @@ -259,14 +256,12 @@ TEST(UtilTwitch, ParseUserNameOrID) auto [actualUserName, actualUserID] = parseUserNameOrID(input); EXPECT_EQ(actualUserName, expectedUserName) - << "name " << qUtf8Printable(actualUserName) << " (" - << qUtf8Printable(input) << ") did not match expected value " - << qUtf8Printable(expectedUserName); + << "name " << actualUserName << " (" << input + << ") did not match expected value " << expectedUserName; EXPECT_EQ(actualUserID, expectedUserID) - << "id " << qUtf8Printable(actualUserID) << " (" - << qUtf8Printable(input) << ") did not match expected value " - << qUtf8Printable(expectedUserID); + << "id " << actualUserID << " (" << input + << ") did not match expected value " << expectedUserID; } } @@ -319,7 +314,7 @@ TEST(UtilTwitch, UserLoginRegexp) auto actual = regexp.match(inputUserLogin); EXPECT_EQ(match.hasMatch(), expectedMatch) - << qUtf8Printable(inputUserLogin) << " did not match as expected"; + << inputUserLogin << " did not match as expected"; } } @@ -371,7 +366,7 @@ TEST(UtilTwitch, UserNameRegexp) auto actual = regexp.match(inputUserLogin); EXPECT_EQ(match.hasMatch(), expectedMatch) - << qUtf8Printable(inputUserLogin) << " did not match as expected"; + << inputUserLogin << " did not match as expected"; } } @@ -405,8 +400,7 @@ TEST(UtilTwitch, CleanHelixColor) cleanHelixColorName(actualColor); EXPECT_EQ(actualColor, expectedColor) - << qUtf8Printable(inputColor) << " cleaned up to " - << qUtf8Printable(actualColor) << " instead of " - << qUtf8Printable(expectedColor); + << inputColor << " cleaned up to " << actualColor << " instead of " + << expectedColor; } } diff --git a/tests/src/XDGDesktopFile.cpp b/tests/src/XDGDesktopFile.cpp index bffe529aad2..69f4d370642 100644 --- a/tests/src/XDGDesktopFile.cpp +++ b/tests/src/XDGDesktopFile.cpp @@ -1,6 +1,7 @@ #include "util/XDGDesktopFile.hpp" -#include +#include "Test.hpp" + #include #if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) diff --git a/tests/src/XDGHelper.cpp b/tests/src/XDGHelper.cpp index a8bcac80182..3ab48daa3d5 100644 --- a/tests/src/XDGHelper.cpp +++ b/tests/src/XDGHelper.cpp @@ -1,6 +1,7 @@ #include "util/XDGHelper.hpp" -#include +#include "Test.hpp" + #include #if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) @@ -57,9 +58,8 @@ TEST(XDGHelper, ParseDesktopExecProgram) auto output = parseDesktopExecProgram(test.input); EXPECT_EQ(output, test.expectedOutput) - << "Input '" << test.input.toStdString() << "' failed. Expected '" - << test.expectedOutput.toStdString() << "' but got '" - << output.toStdString() << "'"; + << "Input '" << test.input << "' failed. Expected '" + << test.expectedOutput << "' but got '" << output << "'"; } } diff --git a/tests/src/main.cpp b/tests/src/main.cpp index 3b24a997885..6c82f632cb8 100644 --- a/tests/src/main.cpp +++ b/tests/src/main.cpp @@ -1,8 +1,8 @@ #include "common/network/NetworkManager.hpp" #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" +#include "Test.hpp" -#include #include #include #include diff --git a/vcpkg.json b/vcpkg.json index ed80080e548..5ab85f87a8e 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", "name": "chatterino", "version": "2.0.0", - "builtin-baseline": "c6d6efed3e9b4242765bfe1b5c5befffd85f7b92", + "builtin-baseline": "01f602195983451bc83e72f4214af2cbc495aa94", "dependencies": [ "boost-asio", "boost-circular-buffer",