diff --git a/.CI/build-installer.ps1 b/.CI/build-installer.ps1 new file mode 100644 index 000000000..1d897e208 --- /dev/null +++ b/.CI/build-installer.ps1 @@ -0,0 +1,47 @@ +if (-not (Test-Path -PathType Container Chatterino2)) { + Write-Error "Couldn't find a folder called 'Chatterino2' in the current directory."; + exit 1 +} + +# Check if we're on a tag +$OldErrorActionPref = $ErrorActionPreference; +$ErrorActionPreference = 'Continue'; +git describe --exact-match --match 'v*' *> $null; +$isTagged = $?; +$ErrorActionPreference = $OldErrorActionPref; + +$defines = $null; +if ($isTagged) { + # This is a release. + # Make sure, any existing `modes` file is overwritten for the user, + # for example when updating from nightly to stable. + Write-Output "" > Chatterino2/modes; + $installerBaseName = "Chatterino.Installer"; +} +else { + Write-Output nightly > Chatterino2/modes; + $defines = "/DIS_NIGHTLY=1"; + $installerBaseName = "Chatterino.Nightly.Installer"; +} + +if ($Env:GITHUB_OUTPUT) { + # This is used in CI when creating the artifact + "C2_INSTALLER_BASE_NAME=$installerBaseName" >> "$Env:GITHUB_OUTPUT" +} + +# Copy vc_redist.x64.exe +if ($null -eq $Env:VCToolsRedistDir) { + Write-Error "VCToolsRedistDir is not set. Forgot to set Visual Studio environment variables?"; + exit 1 +} +Copy-Item "$Env:VCToolsRedistDir\vc_redist.x64.exe" .; + +# Build the installer +ISCC ` + /DWORKING_DIR="$($pwd.Path)\" ` + /DINSTALLER_BASE_NAME="$installerBaseName" ` + $defines ` + /O. ` + "$PSScriptRoot\chatterino-installer.iss"; + +Move-Item "$installerBaseName.exe" "$installerBaseName$($Env:VARIANT_SUFFIX).exe" diff --git a/.CI/chatterino-installer.iss b/.CI/chatterino-installer.iss new file mode 100644 index 000000000..108775536 --- /dev/null +++ b/.CI/chatterino-installer.iss @@ -0,0 +1,87 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "Chatterino" +#define MyAppVersion "2.4.4" +#define MyAppPublisher "Chatterino Team" +#define MyAppURL "https://www.chatterino.com" +#define MyAppExeName "chatterino.exe" + +; used in build-installer.ps1 +; if set, must end in a backslash +#ifndef WORKING_DIR +#define WORKING_DIR "" +#endif + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{F5FE6614-04D4-4D32-8600-0ABA0AC113A4} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +VersionInfoVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +ArchitecturesInstallIn64BitMode=x64 +;Uncomment the following line to run in non administrative install mode (install for current user only.) +;PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir=out +; This is defined by the build-installer.ps1 script, +; but kept optional for regular use. +#ifdef INSTALLER_BASE_NAME +OutputBaseFilename={#INSTALLER_BASE_NAME} +#else +OutputBaseFilename=Chatterino.Installer +#endif +Compression=lzma +SolidCompression=yes +WizardStyle=modern +UsePreviousTasks=no +UninstallDisplayIcon={app}\{#MyAppExeName} +RestartIfNeededByRun=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +#ifdef IS_NIGHTLY +[Messages] +SetupAppTitle=Setup (Nightly) +SetupWindowTitle=Setup - %1 (Nightly) +#endif + +[Tasks] +Name: "vcredist"; Description: "Install the required Visual C++ 2015/2017/2019/2022 Redistributable"; +; GroupDescription: "{cm:AdditionalIcons}"; +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; Flags: unchecked +Name: "freshinstall"; Description: "Fresh install (delete old settings/logs)"; Flags: unchecked + +[Files] +Source: "{#WORKING_DIR}Chatterino2\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#WORKING_DIR}vc_redist.x64.exe"; DestDir: "{tmp}"; Tasks: vcredist; +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +; VC++ redistributable +Filename: {tmp}\vc_redist.x64.exe; Parameters: "/install /passive /norestart"; StatusMsg: "Installing 64-bit Windows Universal Runtime..."; Flags: waituntilterminated; Tasks: vcredist +; Run chatterino +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[InstallDelete] +; Delete cache on install +Type: filesandordirs; Name: "{userappdata}\Chatterino2\Cache" +; Delete %appdata%\Chatterino2 on freshinstall +Type: filesandordirs; Name: "{userappdata}\Chatterino2"; Tasks: freshinstall + +[UninstallDelete] +; Delete cache on uninstall +Type: filesandordirs; Name: "{userappdata}\Chatterino2\Cache" diff --git a/.clang-tidy b/.clang-tidy index d75c5dce2..db94ae838 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -18,6 +18,7 @@ Checks: "-*, -cppcoreguidelines-pro-bounds-array-to-pointer-decay, -cppcoreguidelines-owning-memory, -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-avoid-const-or-ref-data-members, -readability-magic-numbers, -performance-noexcept-move-constructor, -misc-non-private-member-variables-in-classes, @@ -49,6 +50,8 @@ CheckOptions: value: CamelCase - key: readability-identifier-naming.GlobalConstantCase value: UPPER_CASE + - key: readability-identifier-naming.GlobalVariableCase + value: UPPER_CASE - key: readability-identifier-naming.VariableCase value: camelBack - key: readability-implicit-bool-conversion.AllowPointerConditions diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..e5ae6a097 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,14 @@ +# If a commit modifies a ton of files and doesn't really contribute to the +# output of git-blame, please add it here +# +# Don't add commits from the same PR you are creating. We squash PRs into a +# single commit, so references to those commits will be lost +# +# 2018 - changed to 80 max column +f71ff08e686ae76c3dd4084d0f05f27ba9b3fdcb +# +# 2018 - added brace wrapping after if and for +e259b9e39f46f3cb0e4838c988d4f320a03dfaa4 +# +# 2019 - Normalize line endings in already existing files +b06eb9df835c25154899fbcf43e9b37addcea1b1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 27bb7e51e..d8dfcb73d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,7 @@ on: - master pull_request: workflow_dispatch: + merge_group: concurrency: group: build-${{ github.ref }} @@ -107,7 +108,7 @@ jobs: - name: Install Qt5 if: startsWith(matrix.qt-version, '5.') - uses: jurplel/install-qt-action@v3.2.0 + uses: jurplel/install-qt-action@v3.2.1 with: cache: true cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 @@ -115,7 +116,7 @@ jobs: - name: Install Qt6 if: startsWith(matrix.qt-version, '6.') - uses: jurplel/install-qt-action@v3.2.0 + uses: jurplel/install-qt-action@v3.2.1 with: cache: true cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 @@ -134,6 +135,18 @@ jobs: "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.5.3 + uses: nerixyz/ccache-action@9a7e8d00116ede600ee7717350c6594b8af6aaa5 + if: startsWith(matrix.os, 'windows') + with: + variant: sccache + # only save on on the default (master) branch + save: ${{ github.event_name == 'push' }} + key: sccache-build-${{ matrix.os }}-${{ matrix.qt-version }}-${{ matrix.skip-crashpad }} + restore-keys: | + sccache-build-${{ matrix.os }}-${{ matrix.qt-version }} + - name: Cache conan packages (Windows) if: startsWith(matrix.os, 'windows') uses: actions/cache@v3 @@ -171,13 +184,16 @@ jobs: - name: Build (Windows) if: startsWith(matrix.os, 'windows') shell: pwsh + env: + # Enable PCH on Windows when crashpad is enabled + C2_WINDOWS_USE_PCH: ${{ matrix.skip-crashpad && 'OFF' || 'ON' }} run: | cd build cmake ` -G"NMake Makefiles" ` -DCMAKE_BUILD_TYPE=RelWithDebInfo ` -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ` - -DUSE_PRECOMPILED_HEADERS=OFF ` + -DUSE_PRECOMPILED_HEADERS=${{ env.C2_WINDOWS_USE_PCH }} ` -DBUILD_WITH_CRASHPAD="$Env:C2_ENABLE_CRASHPAD" ` -DCHATTERINO_LTO="$Env:C2_ENABLE_LTO" ` -DCHATTERINO_PLUGINS="$Env:C2_PLUGINS" ` @@ -276,16 +292,34 @@ jobs: - name: clang-tidy review if: matrix.clang-tidy-review && github.event_name == 'pull_request' - uses: ZedThree/clang-tidy-review@v0.13.0 + uses: ZedThree/clang-tidy-review@v0.13.2 with: - build_dir: build + build_dir: build-clang-tidy config_file: ".clang-tidy" split_workflow: true - exclude: "tests/*,lib/*" + exclude: "lib/*" + 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 + 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 - name: clang-tidy-review upload if: matrix.clang-tidy-review && github.event_name == 'pull_request' - uses: ZedThree/clang-tidy-review/upload@v0.13.0 + uses: ZedThree/clang-tidy-review/upload@v0.13.2 - name: Package - AppImage (Ubuntu) if: startsWith(matrix.os, 'ubuntu-20.04') && !matrix.skip-artifact diff --git a/.github/workflows/check-formatting.yml b/.github/workflows/check-formatting.yml index f4e024507..d52111bcd 100644 --- a/.github/workflows/check-formatting.yml +++ b/.github/workflows/check-formatting.yml @@ -6,6 +6,7 @@ on: branches: - master pull_request: + merge_group: concurrency: group: check-formatting-${{ github.ref }} diff --git a/.github/workflows/create-installer.yml b/.github/workflows/create-installer.yml new file mode 100644 index 000000000..cf7bf19e7 --- /dev/null +++ b/.github/workflows/create-installer.yml @@ -0,0 +1,56 @@ +name: Create installer + +on: + workflow_run: + workflows: ["Build"] + types: [completed] + # make sure this only runs on the default branch + branches: [master] + workflow_dispatch: + +jobs: + create-installer: + runs-on: windows-latest + # Only run manually or when a build succeeds + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + strategy: + matrix: + qt-version: [5.15.2, 6.5.0] + env: + VARIANT_SUFFIX: ${{ startsWith(matrix.qt-version, '6.') && '.EXPERIMENTAL-Qt6' || '' }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # allows for tags access + + - name: Download artifact + uses: dawidd6/action-download-artifact@v2 + with: + workflow: build.yml + name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip + path: build/ + + - name: Unzip + run: 7z e -spf chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip + working-directory: build + + - name: Install InnoSetup + run: choco install innosetup + + - name: Add InnoSetup to path + run: echo "C:\Program Files (x86)\Inno Setup 6\" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Enable Developer Command Prompt + uses: ilammy/msvc-dev-cmd@v1.12.1 + + - name: Build installer + id: build-installer + working-directory: build + run: ..\.CI\build-installer.ps1 + shell: powershell + + - name: Upload installer + uses: actions/upload-artifact@v3 + with: + path: build/${{ steps.build-installer.outputs.C2_INSTALLER_BASE_NAME }}${{ env.VARIANT_SUFFIX }}.exe + name: ${{ steps.build-installer.outputs.C2_INSTALLER_BASE_NAME }}${{ env.VARIANT_SUFFIX }}.exe diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8e7de38cd..83f3cf846 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,7 @@ on: branches: - master pull_request: + merge_group: concurrency: group: lint-${{ github.ref }} @@ -19,7 +20,7 @@ jobs: - uses: actions/checkout@v3 - name: Check formatting with Prettier - uses: actionsx/prettier@e90ec5455552f0f640781bdd5f5d2415acb52f1a + uses: actionsx/prettier@3d9f7c3fa44c9cb819e68292a328d7f4384be206 with: # prettier CLI arguments. args: --write . diff --git a/.github/workflows/post-clang-tidy-review.yml b/.github/workflows/post-clang-tidy-review.yml index 0254d51d2..aac20736f 100644 --- a/.github/workflows/post-clang-tidy-review.yml +++ b/.github/workflows/post-clang-tidy-review.yml @@ -12,6 +12,6 @@ jobs: runs-on: ubuntu-latest steps: - - uses: ZedThree/clang-tidy-review/post@v0.13.0 + - uses: ZedThree/clang-tidy-review/post@v0.13.2 with: lgtm_comment_body: "" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 967c34e36..242e8ad9d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,9 +4,11 @@ name: Test on: pull_request: workflow_dispatch: + merge_group: env: TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.6 + QT_QPA_PLATFORM: minimal concurrency: group: test-${{ github.ref }} @@ -17,29 +19,30 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] - qt-version: [5.15.2] + include: + - os: "ubuntu-20.04" + qt-version: "5.15.2" + - os: "ubuntu-20.04" + qt-version: "5.12.12" + - os: "ubuntu-22.04" + qt-version: "6.2.4" fail-fast: false + env: + C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} + QT_MODULES: ${{ startsWith(matrix.qt-version, '6.') && 'qt5compat qtimageformats' || '' }} steps: - uses: actions/checkout@v3 with: submodules: recursive - - name: Cache Qt - id: cache-qt - uses: actions/cache@v3 - with: - path: "${{ github.workspace }}/qt/" - key: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} - - name: Install Qt - uses: jurplel/install-qt-action@v3.2.0 + uses: jurplel/install-qt-action@v3.2.1 with: cache: true - cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + modules: ${{ env.QT_MODULES }} version: ${{ matrix.qt-version }} - dir: "${{ github.workspace }}/qt/" # LINUX - name: Install dependencies (Ubuntu) @@ -73,18 +76,23 @@ jobs: - name: Build (Ubuntu) if: startsWith(matrix.os, 'ubuntu') run: | - cmake -DBUILD_TESTS=On -DBUILD_APP=OFF .. - cmake --build . --config Release + cmake \ + -DBUILD_TESTS=On \ + -DBUILD_APP=OFF \ + -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ + .. + cmake --build . working-directory: build-test shell: bash - name: Test (Ubuntu) if: startsWith(matrix.os, 'ubuntu') + timeout-minutes: 30 run: | docker pull kennethreitz/httpbin docker pull ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} docker run --network=host --detach ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} docker run -p 9051:80 --detach kennethreitz/httpbin - ./bin/chatterino-test --platform minimal || ./bin/chatterino-test --platform minimal || ./bin/chatterino-test --platform minimal + ./bin/chatterino-test || ./bin/chatterino-test || ./bin/chatterino-test working-directory: build-test shell: bash diff --git a/.gitignore b/.gitignore index 9e5859235..620f233a9 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,6 @@ resources/resources_autogenerated.qrc # Leftovers from running `aqt install` aqtinstall.log + +# sccache (CI) +.sccache diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ca0c3128..1308fd8cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,65 @@ ## Unversioned +- Minor: Message input is now focused when clicking on emotes. (#4719) +- Minor: Changed viewer list to chatter list to more match Twitch's terminology. (#4732) +- Minor: Nicknames are now taken into consideration when searching for messages. (#4663, #4742) +- Minor: Add an icon showing when streamer mode is enabled (#4410, #4690) +- Minor: Added `/shoutout ` commands to shoutout specified user. (#4638) +- Minor: Improved editing hotkeys. (#4628) +- Minor: The input completion and quick switcher are now styled to match your theme. (#4671) +- Minor: Added setting to only show tabs with live channels (default toggle hotkey: Ctrl+Shift+L). (#4358) +- Minor: Added better support for Twitch's Hype Chat feature. (#4715) +- Minor: Added option to subscribe to and unsubscribe from reply threads. (#4680, #4739) +- Minor: Added a message for when Chatterino joins a channel (#4616) +- Minor: Add accelerators to the right click menu for messages (#4705) +- Minor: Add pin action to usercards and reply threads. (#4692) +- Minor: 7TV badges now automatically update upon changing. (#4512) +- Minor: Stream status requests are now batched. (#4713) +- Minor: Added `/c2-theme-autoreload` command to automatically reload a custom theme. This is useful for when you're developing your own theme. (#4718) +- Minor: Remove restriction on Go To Message on system messages from search. (#4614) +- Minor: Highlights loaded from message history will now correctly appear in the /mentions tab. (#4475) +- Minor: All channels opened in browser tabs are synced when using the extension for quicker switching between tabs. (#4741) +- Minor: Show channel point redemptions without messages in usercard. (#4557) +- Minor: Allow for customizing the behavior of `Right Click`ing of usernames. (#4622, #4751) +- Minor: Added support for opening incognito links in firefox-esr and chromium. (#4745) +- Minor: Added support for opening incognito links under Linux/BSD using XDG. (#4745) +- Bugfix: Increased amount of blocked users loaded from 100 to 1,000. (#4721) +- Bugfix: Fixed generation of crashdumps by the browser-extension process when the browser was closed. (#4667) +- Bugfix: Fix spacing issue with mentions inside RTL text. (#4677) +- Bugfix: Fixed a crash when opening and closing a reply thread and switching the user. (#4675) +- Bugfix: Fixed a crash that could happen when closing splits before their display name was updated. This was especially noticeable after the live controller changes. (#4731) +- Bugfix: Fix visual glitches with smooth scrolling. (#4501) +- Bugfix: Fixed pings firing for the "Your username" highlight when not signed in. (#4698) +- Bugfix: Fixed partially broken filters on Qt 6 builds. (#4702) +- Bugfix: Fixed tooltips & popups sometimes showing up on the wrong monitor. (#4740) +- Bugfix: Fixed some network errors having `0` as their HTTP status. (#4704) +- Bugfix: Fixed crash that could occurr when closing the usercard too quickly after blocking or unblocking a user. (#4711) +- Bugfix: Fixed highlights sometimes not working after changing sound device, or switching users in your operating system. (#4729) +- Bugfix: Fixed key bindings not showing in context menus on Mac. (#4722) +- Bugfix: Fixed tab completion rarely completing the wrong word. (#4735) +- Bugfix: Fixed an issue where Subscriptions & Announcements that contained ignored phrases would still appear if the Block option was enabled. (#4748) +- Dev: Added command to set Qt's logging filter/rules at runtime (`/c2-set-logging-rules`). (#4637) +- Dev: Added the ability to see & load custom themes from the Themes directory. No stable promises are made of this feature, changes might be made that breaks custom themes without notice. (#4570) +- Dev: Added test cases for emote and tab completion. (#4644) +- Dev: Fixed `clang-tidy-review` action not picking up dependencies. (#4648) +- Dev: Expanded upon `$$$` test channels. (#4655) +- Dev: Added tools to help debug image GC. (#4578) +- Dev: Removed duplicate license when having plugins enabled. (#4665) +- Dev: Replace our QObjectRef class with Qt's QPointer class. (#4666) +- Dev: Fixed warnings about QWidgets already having a QLayout. (#4672) +- Dev: Fixed undefined behavior when loading non-existant credentials. (#4673) +- Dev: Added support for compiling with `sccache`. (#4678) +- Dev: Added `sccache` in Windows CI. (#4678) +- Dev: Moved preprocessor Git and date definitions to executables only. (#4681) +- Dev: Refactored tests to be able to use `ctest` and run in debug builds. (#4700) +- Dev: Added the ability to use an alternate linker using the `-DUSE_ALTERNATE_LINKER=...` CMake parameter. (#4711) +- Dev: The Windows installer is now built in CI. (#4408) +- Dev: Removed `getApp` and `getSettings` calls from message rendering. (#4535) +- Dev: Get the default browser executable instead of the entire command line when opening incognito links. (#4745) + +## 2.4.4 + - Minor: Added a Send button in the input box so you can click to send a message. This is disabled by default and can be enabled with the "Show send message button" setting. (#4607) - Minor: Improved error messages when the updater fails a download. (#4594) - Minor: Added `/shield` and `/shieldoff` commands to toggle shield mode. (#4580) @@ -9,8 +68,10 @@ - Bugfix: Fixed the menu warping on macOS on Qt6. (#4595) - Bugfix: Fixed link tooltips not showing unless the thumbnail setting was enabled. (#4597) - Bugfix: Domains starting with `http` are now parsed as links again. (#4598) +- Bugfix: Reduced the size of the update prompt to prevent it from going off the users screen. (#4626) - Bugfix: Fixed click effects on buttons not being antialiased. (#4473) - Bugfix: Fixed Ctrl+Backspace not working after Select All in chat search popup. (#4461) +- Bugfix: Fixed crash when scrolling up really fast. (#4621) - Dev: Added the ability to control the `followRedirect` mode for requests. (#4594) ## 2.4.3 diff --git a/CMakeLists.txt b/CMakeLists.txt index ff354d8f7..db82c9ed0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/sanitizers-cmake/cmake" ) -project(chatterino VERSION 2.4.3) +project(chatterino VERSION 2.4.4) option(BUILD_APP "Build Chatterino" ON) option(BUILD_TESTS "Build the tests for Chatterino" OFF) @@ -42,11 +42,51 @@ else() endif() find_program(CCACHE_PROGRAM ccache) -if (CCACHE_PROGRAM) - set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") - message("Using ${CCACHE_PROGRAM} for speeding up build") +find_program(SCCACHE_PROGRAM sccache) +if (SCCACHE_PROGRAM) + set(_compiler_launcher ${SCCACHE_PROGRAM}) +elseif (CCACHE_PROGRAM) + set(_compiler_launcher ${CCACHE_PROGRAM}) endif () + +# Alternate linker code taken from heavyai/heavydb +# https://github.com/heavyai/heavydb/blob/0517d99b467806f6af7b4c969e351368a667497d/CMakeLists.txt#L87-L103 +macro(set_alternate_linker linker) + find_program(LINKER_EXECUTABLE ld.${USE_ALTERNATE_LINKER} ${USE_ALTERNATE_LINKER}) + if(LINKER_EXECUTABLE) + if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang" AND "${CMAKE_CXX_COMPILER_VERSION}" VERSION_LESS 12.0.0) + add_link_options("-ld-path=${USE_ALTERNATE_LINKER}") + else() + add_link_options("-fuse-ld=${USE_ALTERNATE_LINKER}") + endif() + else() + set(USE_ALTERNATE_LINKER "" CACHE STRING "Use alternate linker" FORCE) + endif() +endmacro() + +set(USE_ALTERNATE_LINKER "" CACHE STRING "Use alternate linker. Leave empty for system default; alternatives are 'gold', 'lld', 'bfd', 'mold'") +if(NOT "${USE_ALTERNATE_LINKER}" STREQUAL "") + set_alternate_linker(${USE_ALTERNATE_LINKER}) +endif() + +if (_compiler_launcher) + set(CMAKE_CXX_COMPILER_LAUNCHER "${_compiler_launcher}" CACHE STRING "CXX compiler launcher") + message(STATUS "Using ${_compiler_launcher} for speeding up build") + + if (MSVC) + # /Zi can't be used with (s)ccache + # Use /Z7 instead (debug info in object files) + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + string(REPLACE "/Zi" "/Z7" CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG}") + elseif(CMAKE_BUILD_TYPE STREQUAL "Release") + string(REPLACE "/Zi" "/Z7" CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE}") + elseif(CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo") + string(REPLACE "/Zi" "/Z7" CMAKE_CXX_FLAGS_RELWITHDEBINFO "${CMAKE_CXX_FLAGS_RELWITHDEBINFO}") + endif() + endif() +endif() + include(${CMAKE_CURRENT_LIST_DIR}/cmake/GIT.cmake) find_package(Qt${MAJOR_QT_VERSION} REQUIRED @@ -72,7 +112,7 @@ if (WIN32) find_package(WinToast REQUIRED) endif () -find_package(Sanitizers) +find_package(Sanitizers QUIET) # Find boost on the system # `OPTIONAL_COMPONENTS random` is required for vcpkg builds to link. @@ -119,6 +159,7 @@ find_package(RapidJSON REQUIRED) find_package(Websocketpp REQUIRED) if (BUILD_TESTS) + include(GoogleTest) # For MSVC: Prevent overriding the parent project's compiler/linker settings # See https://github.com/google/googletest/blob/main/googletest/README.md#visual-studio-dynamic-vs-static-runtimes set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) @@ -166,6 +207,16 @@ if (BUILD_WITH_CRASHPAD) add_subdirectory("${CMAKE_SOURCE_DIR}/lib/crashpad" EXCLUDE_FROM_ALL) endif() +# Used to provide a date of build in the About page (for nightly builds). Getting the actual time of +# compilation in CMake is a more involved, as documented in https://stackoverflow.com/q/24292898. +# For CI runs, however, the date of build file generation should be consistent with the date of +# compilation so this approximation is "good enough" for our purpose. +if (DEFINED ENV{CHATTERINO_SKIP_DATE_GEN}) + set(cmake_gen_date "1970-01-01") +else () + string(TIMESTAMP cmake_gen_date "%Y-%m-%d") +endif () + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -174,6 +225,10 @@ include(cmake/resources/generate_resources.cmake) add_subdirectory(src) +if (BUILD_TESTS OR BUILD_BENCHMARKS) + add_subdirectory(mocks) +endif () + if (BUILD_TESTS) enable_testing() add_subdirectory(tests) diff --git a/README.md b/README.md index 47986f534..8a6ed2227 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,18 @@ git submodule update --init --recursive [Building on FreeBSD](../master/BUILDING_ON_FREEBSD.md) +## Git blame + +This project has big commits in the history which for example update all line +endings. To improve the output of git-blame, consider setting: + +``` +git config blame.ignoreRevsFile .git-blame-ignore-revs +``` + +This will ignore all revisions mentioned in the [`.git-blame-ignore-revs` +file](./.git-blame-ignore-revs). GitHub does this by default. + ## Code style The code is formatted using clang format in Qt Creator. [.clang-format](src/.clang-format) contains the style file for clang format. diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 344258516..84ac8aa19 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -15,6 +15,7 @@ add_executable(${PROJECT_NAME} ${benchmark_SOURCES}) add_sanitizers(${PROJECT_NAME}) target_link_libraries(${PROJECT_NAME} PRIVATE chatterino-lib) +target_link_libraries(${PROJECT_NAME} PRIVATE chatterino-mocks) target_link_libraries(${PROJECT_NAME} PRIVATE benchmark::benchmark) diff --git a/benchmarks/src/Highlights.cpp b/benchmarks/src/Highlights.cpp index c35a0847f..c363e508c 100644 --- a/benchmarks/src/Highlights.cpp +++ b/benchmarks/src/Highlights.cpp @@ -1,16 +1,18 @@ #include "Application.hpp" -#include "singletons/Settings.hpp" #include "common/Channel.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/highlights/HighlightController.hpp" #include "controllers/highlights/HighlightPhrase.hpp" #include "messages/Message.hpp" #include "messages/SharedMessageBuilder.hpp" +#include "mocks/EmptyApplication.hpp" +#include "singletons/Settings.hpp" #include "util/Helpers.hpp" #include #include #include +#include using namespace chatterino; @@ -45,65 +47,17 @@ public: } }; -class MockApplication : IApplication +class MockApplication : mock::EmptyApplication { public: - Theme *getThemes() override - { - return nullptr; - } - Fonts *getFonts() override - { - return nullptr; - } - IEmotes *getEmotes() override - { - return nullptr; - } AccountController *getAccounts() override { return &this->accounts; } - HotkeyController *getHotkeys() override - { - return nullptr; - } - WindowManager *getWindows() override - { - return nullptr; - } - Toasts *getToasts() override - { - return nullptr; - } - CommandController *getCommands() override - { - return nullptr; - } - NotificationController *getNotifications() override - { - return nullptr; - } HighlightController *getHighlights() override { return &this->highlights; } - TwitchIrcServer *getTwitch() override - { - return nullptr; - } - ChatterinoBadges *getChatterinoBadges() override - { - return nullptr; - } - FfzBadges *getFfzBadges() override - { - return nullptr; - } - IUserDataController *getUserData() override - { - return nullptr; - } AccountController accounts; HighlightController highlights; @@ -113,7 +67,8 @@ public: static void BM_HighlightTest(benchmark::State &state) { MockApplication mockApplication; - Settings settings("/tmp/c2-mock"); + QTemporaryDir settingsDir; + Settings settings(settingsDir.path()); std::string message = R"(@badge-info=subscriber/34;badges=moderator/1,subscriber/24;color=#FF0000;display-name=테스트계정420;emotes=41:6-13,15-22;flags=;id=a3196c7e-be4c-4b49-9c5a-8b8302b50c2a;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1590922213730;turbo=0;user-id=117166826;user-type=mod :testaccount_420!testaccount_420@testaccount_420.tmi.twitch.tv PRIVMSG #pajlada :-tags Kreygasm,Kreygasm (no space))"; diff --git a/benchmarks/src/main.cpp b/benchmarks/src/main.cpp index 501b3aa51..32e0636fa 100644 --- a/benchmarks/src/main.cpp +++ b/benchmarks/src/main.cpp @@ -1,6 +1,11 @@ +#include "singletons/Settings.hpp" + #include #include #include +#include + +using namespace chatterino; int main(int argc, char **argv) { @@ -8,11 +13,26 @@ int main(int argc, char **argv) ::benchmark::Initialize(&argc, argv); - QtConcurrent::run([&app] { + // Ensure settings are initialized before any benchmarks are run + QTemporaryDir settingsDir; + settingsDir.setAutoRemove(false); // we'll remove it manually + chatterino::Settings settings(settingsDir.path()); + + QTimer::singleShot(0, [&]() { ::benchmark::RunSpecifiedBenchmarks(); - app.exit(0); + settingsDir.remove(); + + // Pick up the last events from the eventloop + // Using a loop to catch events queueing other events (e.g. deletions) + for (size_t i = 0; i < 32; i++) + { + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + QApplication::exit(0); }); - return app.exec(); + return QApplication::exec(); } diff --git a/docs/ChatterinoTheme.schema.json b/docs/ChatterinoTheme.schema.json index a91de0129..a0c7972dd 100644 --- a/docs/ChatterinoTheme.schema.json +++ b/docs/ChatterinoTheme.schema.json @@ -30,7 +30,7 @@ }, { "title": "SVG Color", - "description": "This is stricter than Qt. You could theoretically put tabs an spaces between characters in a named color and capitalize the color.", + "description": "This enum is stricter than Qt. You could theoretically put tabs and spaces between characters in a named color and capitalize the color.", "$comment": "https://www.w3.org/TR/SVG11/types.html#ColorKeywords", "enum": [ "aliceblue", diff --git a/docs/make-release.md b/docs/make-release.md index 84cd96c3e..2cea79618 100644 --- a/docs/make-release.md +++ b/docs/make-release.md @@ -5,6 +5,7 @@ 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` - [ ] Updated version code 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` - [ ] Update the changelog `## Unreleased` section to the new version `CHANGELOG.md` Make sure to leave the `## Unreleased` line unchanged for easier merges - [ ] Push directly to master :tf: diff --git a/lib/crashpad b/lib/crashpad index ec9925786..432ff49ec 160000 --- a/lib/crashpad +++ b/lib/crashpad @@ -1 +1 @@ -Subproject commit ec992578688b4c51c1856d08731cf7dcf10e446a +Subproject commit 432ff49ecccc1cdebf1a7646007bb0594ac3481f diff --git a/lib/lua/src b/lib/lua/src index 5d708c3f9..ea39042e1 160000 --- a/lib/lua/src +++ b/lib/lua/src @@ -1 +1 @@ -Subproject commit 5d708c3f9cae12820e415d4f89c9eacbe2ab964b +Subproject commit ea39042e13645f63713425c05cc9ee4cfdcf0a40 diff --git a/mocks/.clang-format b/mocks/.clang-format new file mode 100644 index 000000000..7bae09f2c --- /dev/null +++ b/mocks/.clang-format @@ -0,0 +1,55 @@ +Language: Cpp + +AccessModifierOffset: -4 +AlignEscapedNewlinesLeft: true +AllowShortFunctionsOnASingleLine: false +AllowShortIfStatementsOnASingleLine: false +AllowShortLambdasOnASingleLine: Empty +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: false +AlwaysBreakBeforeMultilineStrings: false +BasedOnStyle: Google +BraceWrapping: + AfterClass: "true" + AfterControlStatement: "true" + AfterFunction: "true" + AfterNamespace: "false" + BeforeCatch: "true" + BeforeElse: "true" +BreakBeforeBraces: Custom +BreakConstructorInitializersBeforeComma: true +ColumnLimit: 80 +ConstructorInitializerAllOnOneLineOrOnePerLine: false +DerivePointerBinding: false +FixNamespaceComments: true +IndentCaseLabels: true +IndentWidth: 4 +IndentWrappedFunctionNames: true +IndentPPDirectives: AfterHash +SortIncludes: CaseInsensitive +IncludeBlocks: Regroup +IncludeCategories: + # Project includes + - Regex: '^"[a-zA-Z\._-]+(/[a-zA-Z0-9\._-]+)*"$' + Priority: 1 + # Third party library includes + - Regex: '<[[:alnum:].]+/[a-zA-Z0-9\._\/-]+>' + Priority: 3 + # Qt includes + - Regex: '^$' + Priority: 3 + CaseSensitive: true + # LibCommuni includes + - Regex: "^$" + Priority: 3 + # Misc libraries + - Regex: '^<[a-zA-Z_0-9]+\.h(pp)?>$' + Priority: 3 + # Standard library includes + - Regex: "^<[a-zA-Z_]+>$" + Priority: 4 +NamespaceIndentation: Inner +PointerBindsToType: false +SpacesBeforeTrailingComments: 2 +Standard: Auto +ReflowComments: false diff --git a/mocks/CMakeLists.txt b/mocks/CMakeLists.txt new file mode 100644 index 000000000..47abd0ef4 --- /dev/null +++ b/mocks/CMakeLists.txt @@ -0,0 +1,7 @@ +project(chatterino-mocks) + +add_library(chatterino-mocks INTERFACE) + +target_include_directories(chatterino-mocks INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) + +target_link_libraries(${PROJECT_NAME} INTERFACE gmock) diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp new file mode 100644 index 000000000..afb53d5a9 --- /dev/null +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "Application.hpp" + +namespace chatterino::mock { + +class EmptyApplication : public IApplication +{ +public: + Theme *getThemes() override + { + return nullptr; + } + + Fonts *getFonts() override + { + return nullptr; + } + + IEmotes *getEmotes() override + { + return nullptr; + } + + AccountController *getAccounts() override + { + return nullptr; + } + + HotkeyController *getHotkeys() override + { + return nullptr; + } + + WindowManager *getWindows() override + { + return nullptr; + } + + Toasts *getToasts() override + { + return nullptr; + } + + CommandController *getCommands() override + { + return nullptr; + } + + NotificationController *getNotifications() override + { + return nullptr; + } + + HighlightController *getHighlights() override + { + return nullptr; + } + + ITwitchIrcServer *getTwitch() override + { + return nullptr; + } + + ChatterinoBadges *getChatterinoBadges() override + { + return nullptr; + } + + FfzBadges *getFfzBadges() override + { + return nullptr; + } + + IUserDataController *getUserData() override + { + return nullptr; + } + + ITwitchLiveController *getTwitchLiveController() override + { + return nullptr; + } +}; + +} // namespace chatterino::mock diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp new file mode 100644 index 000000000..3b01c5b0d --- /dev/null +++ b/mocks/include/mocks/Helix.hpp @@ -0,0 +1,414 @@ +#pragma once + +#include "providers/twitch/api/Helix.hpp" +#include "util/CancellationToken.hpp" + +#include +#include +#include + +#include + +namespace chatterino::mock { + +class Helix : public IHelix +{ +public: + virtual ~Helix() = default; + + MOCK_METHOD(void, fetchUsers, + (QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getUserByName, + (QString userName, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + MOCK_METHOD(void, getUserById, + (QString userId, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, fetchUsersFollows, + (QString fromId, QString toId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getUserFollowers, + (QString userId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, fetchStreams, + (QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, getStreamById, + (QString userId, + (ResultCallback successCallback), + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, getStreamByName, + (QString userName, + (ResultCallback successCallback), + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, fetchGames, + (QStringList gameIds, QStringList gameNames, + (ResultCallback> successCallback), + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, searchGames, + (QString gameName, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getGameById, + (QString gameId, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, createClip, + (QString channelId, ResultCallback successCallback, + std::function failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, fetchChannels, + (QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getChannel, + (QString broadcasterId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, createStreamMarker, + (QString broadcasterId, QString description, + ResultCallback successCallback, + std::function failureCallback), + (override)); + + MOCK_METHOD(void, loadBlocks, + (QString userId, + ResultCallback> successCallback, + FailureCallback failureCallback, + CancellationToken &&token), + (override)); + + MOCK_METHOD(void, blockUser, + (QString targetUserId, const QObject *caller, + std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, unblockUser, + (QString targetUserId, const QObject *caller, + std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, updateChannel, + (QString broadcasterId, QString gameId, QString language, + QString title, + std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, manageAutoModMessages, + (QString userID, QString msgID, QString action, + std::function successCallback, + std::function failureCallback), + (override)); + + MOCK_METHOD(void, getCheermotes, + (QString broadcasterId, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getEmoteSetData, + (QString emoteSetId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getChannelEmotes, + (QString broadcasterId, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, getGlobalBadges, + (ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, getChannelBadges, + (QString broadcasterID, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateUserChatColor, + (QString userID, QString color, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, deleteChatMessages, + (QString broadcasterID, QString moderatorID, QString messageID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, addChannelModerator, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, removeChannelModerator, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, sendChatAnnouncement, + (QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, addChannelVIP, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, removeChannelVIP, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, unbanUser, + (QString broadcasterID, QString moderatorID, QString userID, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( // /raid + void, startRaid, + (QString fromBroadcasterID, QString toBroadcasterId, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /raid + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( // /unraid + void, cancelRaid, + (QString broadcasterID, ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /unraid + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateEmoteMode, + (QString broadcasterID, QString moderatorID, bool emoteMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateFollowerMode, + (QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateNonModeratorChatDelay, + (QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateSlowMode, + (QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateSubscriberMode, + (QString broadcasterID, QString moderatorID, + bool subscriberMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateUniqueChatMode, + (QString broadcasterID, QString moderatorID, + bool uniqueChatMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + // update chat settings + + // /timeout, /ban + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, banUser, + (QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /timeout, /ban + + // /w + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, sendWhisper, + (QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /w + + // getChatters + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, getChatters, + (QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); // getChatters + + // /vips + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, getChannelVIPs, + (QString broadcasterID, + ResultCallback> successCallback, + (FailureCallback failureCallback)), + (override)); // /vips + + // /commercial + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, startCommercial, + (QString broadcasterID, int length, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); // /commercial + + // /mods + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, getModerators, + (QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + (FailureCallback failureCallback)), + (override)); // /mods + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateShieldMode, + (QString broadcasterID, QString moderatorID, bool isActive, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // /shoutout + MOCK_METHOD( + void, sendShoutout, + (QString fromBroadcasterID, QString toBroadcasterID, + QString moderatorID, ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), + (override)); + +protected: + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateChatSettings, + (QString broadcasterID, QString moderatorID, QJsonObject json, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); +}; + +} // namespace chatterino::mock diff --git a/tests/src/mocks/UserData.hpp b/mocks/include/mocks/UserData.hpp similarity index 100% rename from tests/src/mocks/UserData.hpp rename to mocks/include/mocks/UserData.hpp diff --git a/resources/buttons/streamerModeEnabledDark.png b/resources/buttons/streamerModeEnabledDark.png new file mode 100644 index 000000000..7ab2b1d8b Binary files /dev/null and b/resources/buttons/streamerModeEnabledDark.png differ diff --git a/resources/buttons/streamerModeEnabledDark.svg b/resources/buttons/streamerModeEnabledDark.svg new file mode 100644 index 000000000..7f95aae5f --- /dev/null +++ b/resources/buttons/streamerModeEnabledDark.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/resources/buttons/streamerModeEnabledLight.png b/resources/buttons/streamerModeEnabledLight.png new file mode 100644 index 000000000..8da207f79 Binary files /dev/null and b/resources/buttons/streamerModeEnabledLight.png differ diff --git a/resources/buttons/streamerModeEnabledLight.svg b/resources/buttons/streamerModeEnabledLight.svg new file mode 100644 index 000000000..5f27d4393 --- /dev/null +++ b/resources/buttons/streamerModeEnabledLight.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/resources/com.chatterino.chatterino.appdata.xml b/resources/com.chatterino.chatterino.appdata.xml index 450bbff94..33b7a362b 100644 --- a/resources/com.chatterino.chatterino.appdata.xml +++ b/resources/com.chatterino.chatterino.appdata.xml @@ -32,6 +32,9 @@ chatterino + + https://github.com/Chatterino/chatterino2/releases/tag/v2.4.4 + https://github.com/Chatterino/chatterino2/releases/tag/v2.4.3 diff --git a/resources/contributors.txt b/resources/contributors.txt index 70b2d517e..b2167359d 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -62,6 +62,9 @@ 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 # If you are a contributor add yourself above this line diff --git a/src/Application.cpp b/src/Application.cpp index 412555a99..d3007f43e 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -14,6 +14,7 @@ # include "controllers/plugins/PluginController.hpp" #endif #include "controllers/sound/SoundController.hpp" +#include "controllers/twitch/LiveController.hpp" #include "controllers/userdata/UserDataController.hpp" #include "debug/AssertInGuiThread.hpp" #include "messages/Message.hpp" @@ -88,6 +89,7 @@ Application::Application(Settings &_settings, Paths &_paths) , seventvBadges(&this->emplace()) , userData(&this->emplace()) , sound(&this->emplace()) + , twitchLiveController(&this->emplace()) #ifdef CHATTERINO_HAVE_PLUGINS , plugins(&this->emplace()) #endif @@ -245,6 +247,16 @@ IUserDataController *Application::getUserData() return this->userData; } +ITwitchLiveController *Application::getTwitchLiveController() +{ + return this->twitchLiveController; +} + +ITwitchIrcServer *Application::getTwitch() +{ + return this->twitch; +} + void Application::save() { for (auto &singleton : this->singletons_) @@ -258,7 +270,7 @@ void Application::initNm(Paths &paths) (void)paths; #ifdef Q_OS_WIN -# if defined QT_NO_DEBUG || defined C_DEBUG_NM +# if defined QT_NO_DEBUG || defined CHATTERINO_DEBUG_NM registerNmHost(paths); this->nmServer.start(); # endif diff --git a/src/Application.hpp b/src/Application.hpp index 7c5525505..1d637cda1 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -3,6 +3,8 @@ #include "common/Singleton.hpp" #include "singletons/NativeMessaging.hpp" +#include +#include #include #include @@ -10,6 +12,7 @@ namespace chatterino { class TwitchIrcServer; +class ITwitchIrcServer; class PubSub; class CommandController; @@ -20,6 +23,8 @@ class HotkeyController; class IUserDataController; class UserDataController; class SoundController; +class ITwitchLiveController; +class TwitchLiveController; #ifdef CHATTERINO_HAVE_PLUGINS class PluginController; #endif @@ -55,10 +60,11 @@ public: virtual CommandController *getCommands() = 0; virtual HighlightController *getHighlights() = 0; virtual NotificationController *getNotifications() = 0; - virtual TwitchIrcServer *getTwitch() = 0; + virtual ITwitchIrcServer *getTwitch() = 0; virtual ChatterinoBadges *getChatterinoBadges() = 0; virtual FfzBadges *getFfzBadges() = 0; virtual IUserDataController *getUserData() = 0; + virtual ITwitchLiveController *getTwitchLiveController() = 0; }; class Application : public IApplication @@ -98,6 +104,10 @@ public: UserDataController *const userData{}; SoundController *const sound{}; +private: + TwitchLiveController *const twitchLiveController{}; + +public: #ifdef CHATTERINO_HAVE_PLUGINS PluginController *const plugins{}; #endif @@ -141,10 +151,7 @@ public: { return this->highlights; } - TwitchIrcServer *getTwitch() override - { - return this->twitch; - } + ITwitchIrcServer *getTwitch() override; ChatterinoBadges *getChatterinoBadges() override { return this->chatterinoBadges; @@ -154,6 +161,9 @@ public: return this->ffzBadges; } IUserDataController *getUserData() override; + ITwitchLiveController *getTwitchLiveController() override; + + pajlada::Signals::NoArgSignal streamerModeChanged; private: void addSingleton(Singleton *singleton); diff --git a/src/BrowserExtension.cpp b/src/BrowserExtension.cpp index dad0ac2af..3ac8dc2dd 100644 --- a/src/BrowserExtension.cpp +++ b/src/BrowserExtension.cpp @@ -30,7 +30,7 @@ namespace { #endif } - void runLoop(NativeMessagingClient &client) + void runLoop() { auto received_message = std::make_shared(true); @@ -73,8 +73,9 @@ namespace { received_message->store(true); - client.sendMessage(data); + nm::client::sendMessage(data); } + _Exit(0); } } // namespace @@ -82,9 +83,7 @@ void runBrowserExtensionHost() { initFileMode(); - NativeMessagingClient client; - - runLoop(client); + runLoop(); } } // namespace chatterino diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0fa8d9073..e1d92fe9d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,7 +1,11 @@ set(LIBRARY_PROJECT "${PROJECT_NAME}-lib") +set(VERSION_PROJECT "${LIBRARY_PROJECT}-version") set(EXECUTABLE_PROJECT "${PROJECT_NAME}") add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x050F00) +# registers the native messageing host +option(CHATTERINO_DEBUG_NATIVE_MESSAGES "Debug native messages" OFF) + set(SOURCE_FILES Application.cpp Application.hpp @@ -32,6 +36,7 @@ set(SOURCE_FILES common/Env.hpp common/LinkParser.cpp common/LinkParser.hpp + common/Literals.hpp common/Modes.cpp common/Modes.hpp common/NetworkCommon.cpp @@ -46,8 +51,6 @@ set(SOURCE_FILES common/NetworkResult.hpp common/QLogging.cpp common/QLogging.hpp - common/Version.cpp - common/Version.hpp common/WindowDescriptors.cpp common/WindowDescriptors.hpp @@ -60,10 +63,14 @@ set(SOURCE_FILES controllers/accounts/AccountModel.cpp controllers/accounts/AccountModel.hpp + controllers/commands/builtin/chatterino/Debugging.cpp + controllers/commands/builtin/chatterino/Debugging.hpp controllers/commands/builtin/twitch/ChatSettings.cpp controllers/commands/builtin/twitch/ChatSettings.hpp controllers/commands/builtin/twitch/ShieldMode.cpp controllers/commands/builtin/twitch/ShieldMode.hpp + controllers/commands/builtin/twitch/Shoutout.cpp + controllers/commands/builtin/twitch/Shoutout.hpp controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp @@ -163,13 +170,16 @@ set(SOURCE_FILES controllers/plugins/LuaUtilities.cpp controllers/plugins/LuaUtilities.hpp + controllers/sound/SoundController.cpp + controllers/sound/SoundController.hpp + + controllers/twitch/LiveController.cpp + controllers/twitch/LiveController.hpp + controllers/userdata/UserDataController.cpp controllers/userdata/UserDataController.hpp controllers/userdata/UserData.hpp - controllers/sound/SoundController.cpp - controllers/sound/SoundController.hpp - debug/Benchmark.cpp debug/Benchmark.hpp @@ -199,6 +209,8 @@ set(SOURCE_FILES messages/layouts/MessageLayout.hpp messages/layouts/MessageLayoutContainer.cpp messages/layouts/MessageLayoutContainer.hpp + messages/layouts/MessageLayoutContext.cpp + messages/layouts/MessageLayoutContext.hpp messages/layouts/MessageLayoutElement.cpp messages/layouts/MessageLayoutElement.hpp messages/search/AuthorPredicate.cpp @@ -276,8 +288,11 @@ set(SOURCE_FILES providers/liveupdates/BasicPubSubManager.hpp providers/liveupdates/BasicPubSubWebsocket.hpp + providers/seventv/SeventvAPI.cpp + providers/seventv/SeventvAPI.hpp providers/seventv/SeventvBadges.cpp providers/seventv/SeventvBadges.hpp + providers/seventv/SeventvCosmetics.hpp providers/seventv/SeventvEmotes.cpp providers/seventv/SeventvEmotes.hpp providers/seventv/SeventvEventAPI.cpp @@ -378,6 +393,7 @@ set(SOURCE_FILES util/AttachToConsole.cpp util/AttachToConsole.hpp + util/CancellationToken.hpp util/Clipboard.cpp util/Clipboard.hpp util/ConcurrentMap.hpp @@ -397,6 +413,8 @@ set(SOURCE_FILES util/IncognitoBrowser.hpp util/InitUpdateButton.cpp util/InitUpdateButton.hpp + util/IpcQueue.cpp + util/IpcQueue.hpp util/LayoutHelper.cpp util/LayoutHelper.hpp util/NuulsUploader.cpp @@ -420,6 +438,12 @@ set(SOURCE_FILES util/TypeName.hpp util/WindowsHelper.cpp util/WindowsHelper.hpp + util/XDGDesktopFile.cpp + util/XDGDesktopFile.hpp + util/XDGDirectory.cpp + util/XDGDirectory.hpp + util/XDGHelper.cpp + util/XDGHelper.hpp util/serialize/Container.hpp @@ -706,7 +730,12 @@ if (BUILD_APP) else() add_executable(${EXECUTABLE_PROJECT} main.cpp) endif() - add_sanitizers(${EXECUTABLE_PROJECT}) + + if(COMMAND add_sanitizers) + add_sanitizers(${EXECUTABLE_PROJECT}) + else() + message(WARNING "Sanitizers support is disabled") + endif() target_include_directories(${EXECUTABLE_PROJECT} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_BINARY_DIR}/autogen/) @@ -790,15 +819,26 @@ set_target_properties(${LIBRARY_PROJECT} AUTOUIC ON ) -# Used to provide a date of build in the About page (for nightly builds). Getting the actual time of -# compilation in CMake is a more involved, as documented in https://stackoverflow.com/q/24292898. -# For CI runs, however, the date of build file generation should be consistent with the date of -# compilation so this approximation is "good enough" for our purpose. -if (DEFINED ENV{CHATTERINO_SKIP_DATE_GEN}) - set(cmake_gen_date "1970-01-01") -else () - string(TIMESTAMP cmake_gen_date "%Y-%m-%d") -endif () +# The version project has definitions about the build. +# To avoid recompilations because of changing preprocessor definitions, +# this is its own project. +set(VERSION_SOURCE_FILES common/Version.cpp common/Version.hpp) +add_library(${VERSION_PROJECT} STATIC ${VERSION_SOURCE_FILES}) + +# source group for IDEs +source_group(TREE ${CMAKE_SOURCE_DIR} FILES ${VERSION_SOURCE_FILES}) +target_include_directories(${VERSION_PROJECT} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(${VERSION_PROJECT} PRIVATE Qt${MAJOR_QT_VERSION}::Core) +target_compile_definitions(${VERSION_PROJECT} PRIVATE + CHATTERINO_GIT_HASH=\"${GIT_HASH}\" + CHATTERINO_GIT_RELEASE=\"${GIT_RELEASE}\" + CHATTERINO_GIT_COMMIT=\"${GIT_COMMIT}\" + CHATTERINO_GIT_MODIFIED=${GIT_MODIFIED} + + CHATTERINO_CMAKE_GEN_DATE=\"${cmake_gen_date}\" +) + +target_link_libraries(${LIBRARY_PROJECT} PRIVATE ${VERSION_PROJECT}) target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO @@ -806,18 +846,7 @@ target_compile_definitions(${LIBRARY_PROJECT} PUBLIC AB_CUSTOM_SETTINGS IRC_STATIC IRC_NAMESPACE=Communi - - CHATTERINO_GIT_HASH=\"${GIT_HASH}\" - CHATTERINO_GIT_RELEASE=\"${GIT_RELEASE}\" - CHATTERINO_GIT_COMMIT=\"${GIT_COMMIT}\" - - CHATTERINO_CMAKE_GEN_DATE=\"${cmake_gen_date}\" ) -if (GIT_MODIFIED) - target_compile_definitions(${LIBRARY_PROJECT} PUBLIC - CHATTERINO_GIT_MODIFIED - ) -endif () if (USE_SYSTEM_QTKEYCHAIN) target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CMAKE_BUILD @@ -831,6 +860,9 @@ if (WIN32) set_target_properties(${EXECUTABLE_PROJECT} PROPERTIES WIN32_EXECUTABLE TRUE) endif () endif () +if (CHATTERINO_DEBUG_NATIVE_MESSAGES) + target_compile_definitions(${LIBRARY_PROJECT} PRIVATE CHATTERINO_DEBUG_NM) +endif () if (MSVC) target_compile_options(${LIBRARY_PROJECT} PUBLIC /EHsc /bigobj) diff --git a/src/PrecompiledHeader.hpp b/src/PrecompiledHeader.hpp index a4a453954..a774d57d8 100644 --- a/src/PrecompiledHeader.hpp +++ b/src/PrecompiledHeader.hpp @@ -1,8 +1,10 @@ #ifdef __cplusplus +# include # include # include # include # include +# include # include # include # include @@ -12,40 +14,29 @@ # include # include # include -# include # include # include # include # include -# include # include # include # include # include # include -# include -# include # include # include # include # include # include # include -# include -# include -# include # include -# include # include # include # include # include # include -# include -# include # include # include -# include # include # include # include @@ -58,7 +49,6 @@ # include # include # include -# include # include # include # include @@ -92,31 +82,17 @@ # include # include # include -# include # include # include # include # include # include -# include # include # include # include # include # include # include -# include -# include -# include -# include -# include -# include -# include -# include -# include -# include -# include -# include # include # include # include diff --git a/src/RunGui.cpp b/src/RunGui.cpp index 6d903bb45..ce36d7a91 100644 --- a/src/RunGui.cpp +++ b/src/RunGui.cpp @@ -86,6 +86,13 @@ namespace { QApplication::setWindowIcon(QIcon(":/icon.ico")); #endif +#ifdef Q_OS_MAC + // On the Mac/Cocoa platform this attribute is enabled by default + // We override it to ensure shortcuts show in context menus on that platform + QApplication::setAttribute(Qt::AA_DontShowShortcutsInContextMenus, + false); +#endif + installCustomPalette(); } diff --git a/src/common/CompletionModel.cpp b/src/common/CompletionModel.cpp index 9b123aa4c..61f1cc249 100644 --- a/src/common/CompletionModel.cpp +++ b/src/common/CompletionModel.cpp @@ -92,6 +92,7 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) return; } + auto *app = getIApp(); // Twitch channel auto *tc = dynamic_cast(&this->channel_); @@ -130,7 +131,7 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) } }; - if (auto account = getApp()->accounts->twitch.getCurrent()) + if (auto account = app->getAccounts()->twitch.getCurrent()) { // Twitch Emotes available globally for (const auto &emote : account->accessEmotes()->emotes) @@ -153,18 +154,18 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) // 7TV Global for (const auto &emote : - *getApp()->twitch->getSeventvEmotes().globalEmotes()) + *app->getTwitch()->getSeventvEmotes().globalEmotes()) { addString(emote.first.string, TaggedString::Type::SeventvGlobalEmote); } // Bttv Global - for (const auto &emote : *getApp()->twitch->getBttvEmotes().emotes()) + for (const auto &emote : *app->getTwitch()->getBttvEmotes().emotes()) { addString(emote.first.string, TaggedString::Type::BTTVChannelEmote); } // Ffz Global - for (const auto &emote : *getApp()->twitch->getFfzEmotes().emotes()) + for (const auto &emote : *app->getTwitch()->getFfzEmotes().emotes()) { addString(emote.first.string, TaggedString::Type::FFZChannelEmote); } @@ -172,7 +173,8 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) // Emojis if (prefix.startsWith(":")) { - const auto &emojiShortCodes = getApp()->emotes->emojis.shortCodes; + const auto &emojiShortCodes = + app->getEmotes()->getEmojis()->getShortCodes(); for (const auto &m : emojiShortCodes) { addString(QString(":%1:").arg(m), TaggedString::Type::Emoji); @@ -231,20 +233,20 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote); } #ifdef CHATTERINO_HAVE_PLUGINS - for (const auto &command : getApp()->commands->pluginCommands()) + for (const auto &command : app->getCommands()->pluginCommands()) { addString(command, TaggedString::PluginCommand); } #endif // Custom Chatterino commands - for (const auto &command : getApp()->commands->items) + for (const auto &command : app->getCommands()->items) { addString(command.name, TaggedString::CustomCommand); } // Default Chatterino commands for (const auto &command : - getApp()->commands->getDefaultChatterinoCommandList()) + app->getCommands()->getDefaultChatterinoCommandList()) { addString(command, TaggedString::ChatterinoCommand); } @@ -256,6 +258,19 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) } } +std::vector CompletionModel::allItems() const +{ + std::shared_lock lock(this->itemsMutex_); + + std::vector results; + results.reserve(this->items_.size()); + for (const auto &item : this->items_) + { + results.push_back(item.string); + } + return results; +} + bool CompletionModel::compareStrings(const QString &a, const QString &b) { // try comparing insensitively, if they are the same then senstively diff --git a/src/common/CompletionModel.hpp b/src/common/CompletionModel.hpp index 5b46fb2de..affbd3f10 100644 --- a/src/common/CompletionModel.hpp +++ b/src/common/CompletionModel.hpp @@ -6,6 +6,8 @@ #include #include +class InputCompletionTest; + namespace chatterino { class Channel; @@ -60,10 +62,14 @@ public: static bool compareStrings(const QString &a, const QString &b); private: + std::vector allItems() const; + mutable std::shared_mutex itemsMutex_; std::set items_; Channel &channel_; + + friend class ::InputCompletionTest; }; } // namespace chatterino diff --git a/src/common/Credentials.cpp b/src/common/Credentials.cpp index f3985701f..d3bb5ebbd 100644 --- a/src/common/Credentials.cpp +++ b/src/common/Credentials.cpp @@ -197,9 +197,9 @@ void Credentials::get(const QString &provider, const QString &name_, } else { - auto &instance = insecureInstance(); + const auto &instance = insecureInstance(); - onLoaded(instance.object().find(name).value().toString()); + onLoaded(instance[name].toString()); } } diff --git a/src/common/Literals.hpp b/src/common/Literals.hpp new file mode 100644 index 000000000..b7276d499 --- /dev/null +++ b/src/common/Literals.hpp @@ -0,0 +1,170 @@ +#pragma once + +#include + +/// This namespace defines the string suffixes _s, _ba, and _L1 used to create Qt types at compile-time. +/// They're easier to use comapred to their corresponding macros. +/// +/// * u"foobar"_s creates a QString (like QStringLiteral). The u prefix is required. +/// +/// * "foobar"_ba creates a QByteArray (like QByteArrayLiteral). +/// +/// * "foobar"_L1 creates a QLatin1String(-View). +namespace chatterino::literals { + +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + +// This makes sure that the backing data never causes allocation after compilation. +// It's essentially the QStringLiteral macro inlined. +// +// From desktop-app/lib_base +// https://github.com/desktop-app/lib_base/blob/f904c60987115a4b514a575b23009ff25de0fafa/base/basic_types.h#L63-L152 +// And qt/qtbase (5.15) +// https://github.com/qt/qtbase/blob/29400a683f96867133b28299c0d0bd6bcf40df35/src/corelib/text/qstringliteral.h#L64-L104 +namespace detail { + // NOLINTBEGIN(modernize-avoid-c-arrays) + // NOLINTBEGIN(cppcoreguidelines-avoid-c-arrays) + + template + struct LiteralResolver { + template + constexpr LiteralResolver(const char16_t (&text)[N], + std::index_sequence /*seq*/) + : utf16Text{text[I]...} + { + } + template + constexpr LiteralResolver(const char (&text)[N], + std::index_sequence /*seq*/) + : latin1Text{text[I]...} + , latin1(true) + { + } + constexpr LiteralResolver(const char16_t (&text)[N]) + : LiteralResolver(text, std::make_index_sequence{}) + { + } + constexpr LiteralResolver(const char (&text)[N]) + : LiteralResolver(text, std::make_index_sequence{}) + { + } + + const char16_t utf16Text[N]{}; + const char latin1Text[N]{}; + size_t length = N; + bool latin1 = false; + }; + + template + struct StaticStringData { + template + constexpr StaticStringData(const char16_t (&text)[N], + std::index_sequence /*seq*/) + : data Q_STATIC_STRING_DATA_HEADER_INITIALIZER(N - 1) + , text{text[I]...} + { + } + QArrayData data; + char16_t text[N]; + + QStringData *pointer() + { + Q_ASSERT(data.ref.isStatic()); + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-static-cast-downcast) + return static_cast(&data); + } + }; + + template + struct StaticByteArrayData { + template + constexpr StaticByteArrayData(const char (&text)[N], + std::index_sequence /*seq*/) + : data Q_STATIC_BYTE_ARRAY_DATA_HEADER_INITIALIZER(N - 1) + , text{text[I]...} + { + } + QByteArrayData data; + char text[N]; + + QByteArrayData *pointer() + { + Q_ASSERT(data.ref.isStatic()); + return &data; + } + }; + + // NOLINTEND(cppcoreguidelines-avoid-c-arrays) + // NOLINTEND(modernize-avoid-c-arrays) + +} // namespace detail + +template +inline QString operator""_s() noexcept +{ + static_assert(R.length > 0); // always has a terminating null + static_assert(!R.latin1, "QString literals must be made up of 16bit " + "characters. Forgot a u\"\"?"); + + static auto literal = detail::StaticStringData( + R.utf16Text, std::make_index_sequence{}); + return QString{QStringDataPtr{literal.pointer()}}; +}; + +template +inline QByteArray operator""_ba() noexcept +{ + static_assert(R.length > 0); // always has a terminating null + static_assert(R.latin1, "QByteArray literals must be made up of 8bit " + "characters. Misplaced u\"\"?"); + + static auto literal = detail::StaticByteArrayData( + R.latin1Text, std::make_index_sequence{}); + return QByteArray{QByteArrayDataPtr{literal.pointer()}}; +}; + +#elif QT_VERSION < QT_VERSION_CHECK(6, 4, 0) + +// The operators were added in 6.4, but their implementation works in any 6.x version. +// +// NOLINTBEGIN(cppcoreguidelines-pro-type-const-cast) +inline QString operator""_s(const char16_t *str, size_t size) noexcept +{ + return QString( + QStringPrivate(nullptr, const_cast(str), qsizetype(size))); +} + +inline QByteArray operator""_ba(const char *str, size_t size) noexcept +{ + return QByteArray( + QByteArrayData(nullptr, const_cast(str), qsizetype(size))); +} +// NOLINTEND(cppcoreguidelines-pro-type-const-cast) + +#else + +inline QString operator""_s(const char16_t *str, size_t size) noexcept +{ + return Qt::Literals::StringLiterals::operator""_s(str, size); +} + +inline QByteArray operator""_ba(const char *str, size_t size) noexcept +{ + return Qt::Literals::StringLiterals::operator""_ba(str, size); +} + +#endif + +constexpr inline QLatin1String operator""_L1(const char *str, + size_t size) noexcept +{ +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + using SizeType = int; +#else + using SizeType = qsizetype; +#endif + + return QLatin1String{str, static_cast(size)}; +} + +} // namespace chatterino::literals diff --git a/src/common/NetworkPrivate.cpp b/src/common/NetworkPrivate.cpp index 44cb87102..661b2eccf 100644 --- a/src/common/NetworkPrivate.cpp +++ b/src/common/NetworkPrivate.cpp @@ -72,12 +72,12 @@ void writeToCache(const std::shared_ptr &data, } } -void loadUncached(const std::shared_ptr &data) +void loadUncached(std::shared_ptr &&data) { DebugCount::increase("http request started"); NetworkRequester requester; - NetworkWorker *worker = new NetworkWorker; + auto *worker = new NetworkWorker; worker->moveToThread(&NetworkManager::workerThread); @@ -89,7 +89,7 @@ void loadUncached(const std::shared_ptr &data) data->timer_->start(data->timeoutMS_); } - auto reply = [&]() -> QNetworkReply * { + auto *reply = [&]() -> QNetworkReply * { switch (data->requestType_) { case NetworkRequestType::Get: @@ -155,7 +155,8 @@ void loadUncached(const std::shared_ptr &data) { postToThread([data] { data->onError_(NetworkResult( - {}, NetworkResult::timedoutStatus)); + NetworkResult::NetworkError::TimeoutError, {}, + {})); }); } @@ -174,7 +175,7 @@ void loadUncached(const std::shared_ptr &data) } auto handleReply = [data, reply]() mutable { - if (data->hasCaller_ && !data->caller_.get()) + if (data->hasCaller_ && data->caller_.isNull()) { return; } @@ -218,8 +219,9 @@ void loadUncached(const std::shared_ptr &data) QString(data->payload_)); } // TODO: Should this always be run on the GUI thread? - postToThread([data, code = status.toInt(), reply] { - data->onError_(NetworkResult(reply->readAll(), code)); + postToThread([data, status, reply] { + data->onError_(NetworkResult(reply->error(), status, + reply->readAll())); }); } @@ -238,19 +240,23 @@ void loadUncached(const std::shared_ptr &data) auto status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); - NetworkResult result(bytes, status.toInt()); + NetworkResult result(reply->error(), status, bytes); DebugCount::increase("http request success"); // log("starting {}", data->request_.url().toString()); if (data->onSuccess_) { if (data->executeConcurrently_) + { QtConcurrent::run([onSuccess = std::move(data->onSuccess_), result = std::move(result)] { onSuccess(result); }); + } else + { data->onSuccess_(result); + } } // log("finished {}", data->request_.url().toString()); @@ -276,11 +282,15 @@ void loadUncached(const std::shared_ptr &data) if (data->finally_) { if (data->executeConcurrently_) + { QtConcurrent::run([finally = std::move(data->finally_)] { finally(); }); + } else + { data->finally_(); + } } }; @@ -316,87 +326,88 @@ void loadUncached(const std::shared_ptr &data) } // First tried to load cached, then uncached. -void loadCached(const std::shared_ptr &data) +void loadCached(std::shared_ptr &&data) { QFile cachedFile(getPaths()->cacheDirectory() + "/" + data->getHash()); if (!cachedFile.exists() || !cachedFile.open(QIODevice::ReadOnly)) { // File didn't exist OR File could not be opened - loadUncached(data); + loadUncached(std::move(data)); return; } - else - { - // XXX: check if bytes is empty? - QByteArray bytes = cachedFile.readAll(); - NetworkResult result(bytes, 200); - qCDebug(chatterinoHTTP) - << QString("%1 [CACHED] 200 %2") - .arg(networkRequestTypes.at(int(data->requestType_)), - data->request_.url().toString()); - if (data->onSuccess_) + // XXX: check if bytes is empty? + QByteArray bytes = cachedFile.readAll(); + NetworkResult result(NetworkResult::NetworkError::NoError, QVariant(200), + bytes); + + qCDebug(chatterinoHTTP) + << QString("%1 [CACHED] 200 %2") + .arg(networkRequestTypes.at(int(data->requestType_)), + data->request_.url().toString()); + if (data->onSuccess_) + { + if (data->executeConcurrently_ || isGuiThread()) { - if (data->executeConcurrently_ || isGuiThread()) + // XXX: If outcome is Failure, we should invalidate the cache file + // somehow/somewhere + /*auto outcome =*/ + if (data->hasCaller_ && data->caller_.isNull()) { - // XXX: If outcome is Failure, we should invalidate the cache file - // somehow/somewhere - /*auto outcome =*/ - if (data->hasCaller_ && !data->caller_.get()) + return; + } + data->onSuccess_(result); + } + else + { + postToThread([data, result]() { + if (data->hasCaller_ && data->caller_.isNull()) { return; } + data->onSuccess_(result); - } - else - { - postToThread([data, result]() { - if (data->hasCaller_ && !data->caller_.get()) - { - return; - } - - data->onSuccess_(result); - }); - } + }); } + } - if (data->finally_) + if (data->finally_) + { + if (data->executeConcurrently_ || isGuiThread()) { - if (data->executeConcurrently_ || isGuiThread()) + if (data->hasCaller_ && data->caller_.isNull()) { - if (data->hasCaller_ && !data->caller_.get()) + return; + } + + data->finally_(); + } + else + { + postToThread([data]() { + if (data->hasCaller_ && data->caller_.isNull()) { return; } data->finally_(); - } - else - { - postToThread([data]() { - if (data->hasCaller_ && !data->caller_.get()) - { - return; - } - - data->finally_(); - }); - } + }); } } } -void load(const std::shared_ptr &data) +void load(std::shared_ptr &&data) { if (data->cache_) { - QtConcurrent::run(loadCached, data); + QtConcurrent::run([data = std::move(data)]() mutable { + loadCached(std::move(data)); + }); } else { - loadUncached(data); + loadUncached(std::move(data)); } } diff --git a/src/common/NetworkPrivate.hpp b/src/common/NetworkPrivate.hpp index 03d4a705e..d48000aeb 100644 --- a/src/common/NetworkPrivate.hpp +++ b/src/common/NetworkPrivate.hpp @@ -1,10 +1,10 @@ #pragma once #include "common/NetworkCommon.hpp" -#include "util/QObjectRef.hpp" #include #include +#include #include #include @@ -38,7 +38,7 @@ struct NetworkData { QNetworkRequest request_; bool hasCaller_{}; - QObjectRef caller_; + QPointer caller_; bool cache_{}; bool executeConcurrently_{}; @@ -68,6 +68,6 @@ private: QString hash_; }; -void load(const std::shared_ptr &data); +void load(std::shared_ptr &&data); } // namespace chatterino diff --git a/src/common/NetworkRequest.cpp b/src/common/NetworkRequest.cpp index cfe0cd177..b4dc8cc46 100644 --- a/src/common/NetworkRequest.cpp +++ b/src/common/NetworkRequest.cpp @@ -1,14 +1,8 @@ #include "common/NetworkRequest.hpp" #include "common/NetworkPrivate.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" -#include "debug/AssertInGuiThread.hpp" -#include "providers/twitch/TwitchCommon.hpp" -#include "singletons/Paths.hpp" -#include "util/DebugCount.hpp" -#include "util/PostToThread.hpp" #include #include @@ -28,7 +22,7 @@ NetworkRequest::NetworkRequest(const std::string &url, this->initializeDefaultValues(); } -NetworkRequest::NetworkRequest(QUrl url, NetworkRequestType requestType) +NetworkRequest::NetworkRequest(const QUrl &url, NetworkRequestType requestType) : data(new NetworkData) { this->data->request_.setUrl(url); @@ -37,10 +31,7 @@ NetworkRequest::NetworkRequest(QUrl url, NetworkRequestType requestType) this->initializeDefaultValues(); } -NetworkRequest::~NetworkRequest() -{ - //assert(!this->data || this->executed_); -} +NetworkRequest::~NetworkRequest() = default; NetworkRequest NetworkRequest::type(NetworkRequestType newRequestType) && { @@ -63,25 +54,25 @@ NetworkRequest NetworkRequest::caller(const QObject *caller) && NetworkRequest NetworkRequest::onReplyCreated(NetworkReplyCreatedCallback cb) && { - this->data->onReplyCreated_ = cb; + this->data->onReplyCreated_ = std::move(cb); return std::move(*this); } NetworkRequest NetworkRequest::onError(NetworkErrorCallback cb) && { - this->data->onError_ = cb; + this->data->onError_ = std::move(cb); return std::move(*this); } NetworkRequest NetworkRequest::onSuccess(NetworkSuccessCallback cb) && { - this->data->onSuccess_ = cb; + this->data->onSuccess_ = std::move(cb); return std::move(*this); } NetworkRequest NetworkRequest::finally(NetworkFinallyCallback cb) && { - this->data->finally_ = cb; + this->data->finally_ = std::move(cb); return std::move(*this); } @@ -106,6 +97,13 @@ NetworkRequest NetworkRequest::header(const char *headerName, return std::move(*this); } +NetworkRequest NetworkRequest::header(QNetworkRequest::KnownHeaders header, + const QVariant &value) && +{ + this->data->request_.setHeader(header, value); + return std::move(*this); +} + NetworkRequest NetworkRequest::headerList( const std::vector> &headers) && { @@ -129,20 +127,6 @@ NetworkRequest NetworkRequest::concurrent() && return std::move(*this); } -NetworkRequest NetworkRequest::authorizeTwitchV5(const QString &clientID, - const QString &oauthToken) && -{ - // TODO: make two overloads, with and without oauth token - auto tmp = std::move(*this) - .header("Client-ID", clientID) - .header("Accept", "application/vnd.twitchtv.v5+json"); - - if (!oauthToken.isEmpty()) - return std::move(tmp).header("Authorization", "OAuth " + oauthToken); - else - return tmp; -} - NetworkRequest NetworkRequest::multiPart(QHttpMultiPart *payload) && { payload->setParent(this->data->lifetimeManager_); @@ -200,17 +184,36 @@ void NetworkRequest::execute() void NetworkRequest::initializeDefaultValues() { - const auto userAgent = QString("chatterino/%1 (%2)") - .arg(CHATTERINO_VERSION, CHATTERINO_GIT_HASH) + const auto userAgent = QStringLiteral("chatterino/%1 (%2)") + .arg(Version::instance().version(), + Version::instance().commitHash()) .toUtf8(); this->data->request_.setRawHeader("User-Agent", userAgent); } -// Helper creator functions -NetworkRequest NetworkRequest::twitchRequest(QUrl url) +NetworkRequest NetworkRequest::json(const QJsonArray &root) && { - return NetworkRequest(url).authorizeTwitchV5(getDefaultClientID()); + return std::move(*this).json(QJsonDocument(root)); +} + +NetworkRequest NetworkRequest::json(const QJsonObject &root) && +{ + return std::move(*this).json(QJsonDocument(root)); +} + +NetworkRequest NetworkRequest::json(const QJsonDocument &document) && +{ + return std::move(*this).json(document.toJson(QJsonDocument::Compact)); +} + +NetworkRequest NetworkRequest::json(const QByteArray &payload) && +{ + return std::move(*this) + .payload(payload) + .header(QNetworkRequest::ContentTypeHeader, "application/json") + .header(QNetworkRequest::ContentLengthHeader, payload.length()) + .header("Accept", "application/json"); } } // namespace chatterino diff --git a/src/common/NetworkRequest.hpp b/src/common/NetworkRequest.hpp index 85f34782a..9d55cf1b7 100644 --- a/src/common/NetworkRequest.hpp +++ b/src/common/NetworkRequest.hpp @@ -6,6 +6,10 @@ #include +class QJsonArray; +class QJsonObject; +class QJsonDocument; + namespace chatterino { struct NetworkData; @@ -24,8 +28,8 @@ public: explicit NetworkRequest( const std::string &url, NetworkRequestType requestType = NetworkRequestType::Get); - explicit NetworkRequest( - QUrl url, NetworkRequestType requestType = NetworkRequestType::Get); + explicit NetworkRequest(const QUrl &url, NetworkRequestType requestType = + NetworkRequestType::Get); // Enable move NetworkRequest(NetworkRequest &&other) = default; @@ -54,23 +58,25 @@ public: 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(QNetworkRequest::KnownHeaders header, + const QVariant &value) &&; NetworkRequest headerList( const std::vector> &headers) &&; NetworkRequest timeout(int ms) &&; NetworkRequest concurrent() &&; - NetworkRequest authorizeTwitchV5(const QString &clientID, - const QString &oauthToken = QString()) &&; NetworkRequest multiPart(QHttpMultiPart *payload) &&; /** * This will change `RedirectPolicyAttribute`. * `QNetworkRequest`'s defaults are used by default (Qt 5: no-follow, Qt 6: follow). */ NetworkRequest followRedirects(bool on) &&; + NetworkRequest json(const QJsonObject &root) &&; + NetworkRequest json(const QJsonArray &root) &&; + NetworkRequest json(const QJsonDocument &document) &&; + NetworkRequest json(const QByteArray &payload) &&; void execute(); - static NetworkRequest twitchRequest(QUrl url); - private: void initializeDefaultValues(); }; diff --git a/src/common/NetworkResult.cpp b/src/common/NetworkResult.cpp index 0a5295fdb..ae614bac0 100644 --- a/src/common/NetworkResult.cpp +++ b/src/common/NetworkResult.cpp @@ -3,15 +3,21 @@ #include "common/QLogging.hpp" #include +#include #include #include namespace chatterino { -NetworkResult::NetworkResult(const QByteArray &data, int status) - : data_(data) - , status_(status) +NetworkResult::NetworkResult(NetworkError error, const QVariant &httpStatusCode, + QByteArray data) + : data_(std::move(data)) + , error_(error) { + if (httpStatusCode.isValid()) + { + this->status_ = httpStatusCode.toInt(); + } } QJsonObject NetworkResult::parseJson() const @@ -59,9 +65,21 @@ const QByteArray &NetworkResult::getData() const return this->data_; } -int NetworkResult::status() const +QString NetworkResult::formatError() const { - return this->status_; + if (this->status_) + { + return QString::number(*this->status_); + } + + const auto *name = + QMetaEnum::fromType().valueToKey( + this->error_); + if (name == nullptr) + { + return QStringLiteral("unknown error (%1)").arg(this->error_); + } + return name; } } // namespace chatterino diff --git a/src/common/NetworkResult.hpp b/src/common/NetworkResult.hpp index 64baed5ea..9f0ada784 100644 --- a/src/common/NetworkResult.hpp +++ b/src/common/NetworkResult.hpp @@ -2,14 +2,20 @@ #include #include +#include #include +#include + namespace chatterino { class NetworkResult { public: - NetworkResult(const QByteArray &data, int status); + using NetworkError = QNetworkReply::NetworkError; + + NetworkResult(NetworkError error, const QVariant &httpStatusCode, + QByteArray data); /// Parses the result as json and returns the root as an object. /// Returns empty object if parsing failed. @@ -20,13 +26,29 @@ public: /// Parses the result as json and returns the document. rapidjson::Document parseRapidJson() const; const QByteArray &getData() const; - int status() const; - static constexpr int timedoutStatus = -2; + /// The error code of the reply. + /// In case of a successful reply, this will be NoError (0) + NetworkError error() const + { + return this->error_; + } + + /// The HTTP status code if a response was received. + std::optional status() const + { + return this->status_; + } + + /// Formats the error. + /// If a reply is received, returns the HTTP status otherwise, the network error. + QString formatError() const; private: QByteArray data_; - int status_; + + NetworkError error_; + std::optional status_; }; } // namespace chatterino diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index a3168a993..e05ce240d 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -48,8 +48,11 @@ Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); Q_LOGGING_CATEGORY(chatterinoTheme, "chatterino.theme", logThreshold); Q_LOGGING_CATEGORY(chatterinoTokenizer, "chatterino.tokenizer", logThreshold); Q_LOGGING_CATEGORY(chatterinoTwitch, "chatterino.twitch", logThreshold); +Q_LOGGING_CATEGORY(chatterinoTwitchLiveController, + "chatterino.twitch.livecontroller", logThreshold); Q_LOGGING_CATEGORY(chatterinoUpdate, "chatterino.update", logThreshold); Q_LOGGING_CATEGORY(chatterinoWebsocket, "chatterino.websocket", logThreshold); Q_LOGGING_CATEGORY(chatterinoWidget, "chatterino.widget", logThreshold); Q_LOGGING_CATEGORY(chatterinoWindowmanager, "chatterino.windowmanager", logThreshold); +Q_LOGGING_CATEGORY(chatterinoXDG, "chatterino.xdg", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index c2d0ae2ca..bd6ba9822 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -37,7 +37,9 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); Q_DECLARE_LOGGING_CATEGORY(chatterinoTheme); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitch); +Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitchLiveController); Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket); Q_DECLARE_LOGGING_CATEGORY(chatterinoWidget); Q_DECLARE_LOGGING_CATEGORY(chatterinoWindowmanager); +Q_DECLARE_LOGGING_CATEGORY(chatterinoXDG); diff --git a/src/common/Version.cpp b/src/common/Version.cpp index bbd99e176..76ef549a0 100644 --- a/src/common/Version.cpp +++ b/src/common/Version.cpp @@ -4,27 +4,14 @@ #include -#define UGLYMACROHACK1(s) #s -#define FROM_EXTERNAL_DEFINE(s) UGLYMACROHACK1(s) - namespace chatterino { Version::Version() + : version_(CHATTERINO_VERSION) + , commitHash_(QStringLiteral(CHATTERINO_GIT_HASH)) + , isModified_(CHATTERINO_GIT_MODIFIED == 1) + , dateOfBuild_(QStringLiteral(CHATTERINO_CMAKE_GEN_DATE)) { - this->version_ = CHATTERINO_VERSION; - - this->commitHash_ = - QString(FROM_EXTERNAL_DEFINE(CHATTERINO_GIT_HASH)).remove('"'); - -#ifdef CHATTERINO_GIT_MODIFIED - this->isModified_ = true; -#endif - -#ifdef CHATTERINO_CMAKE_GEN_DATE - this->dateOfBuild_ = - QString(FROM_EXTERNAL_DEFINE(CHATTERINO_CMAKE_GEN_DATE)).remove('"'); -#endif - this->fullVersion_ = "Chatterino "; if (Modes::instance().isNightly) { diff --git a/src/common/Version.hpp b/src/common/Version.hpp index 33a37b66a..ab7764e1a 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.3" +#define CHATTERINO_VERSION "2.4.4" #if defined(Q_OS_WIN) # define CHATTERINO_OS "win" diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a68f0545f..fa1255a9a 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -7,13 +7,16 @@ #include "common/QLogging.hpp" #include "common/SignalVector.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/builtin/chatterino/Debugging.hpp" #include "controllers/commands/builtin/twitch/ChatSettings.hpp" #include "controllers/commands/builtin/twitch/ShieldMode.hpp" +#include "controllers/commands/builtin/twitch/Shoutout.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" #include "controllers/plugins/PluginController.hpp" #include "controllers/userdata/UserDataController.hpp" +#include "messages/Image.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" @@ -36,6 +39,7 @@ #include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/IncognitoBrowser.hpp" +#include "util/PostToThread.hpp" #include "util/Qt.hpp" #include "util/StreamerMode.hpp" #include "util/StreamLink.hpp" @@ -646,7 +650,7 @@ void CommandController::initialize(Settings &, Paths &paths) target, [currentUser, channel, target](const HelixUser &targetUser) { getApp()->accounts->twitch.getCurrent()->blockUser( - targetUser.id, + targetUser.id, nullptr, [channel, target, targetUser] { channel->addMessage(makeSystemMessage( QString("You successfully blocked user %1") @@ -699,7 +703,7 @@ void CommandController::initialize(Settings &, Paths &paths) target, [currentUser, channel, target](const auto &targetUser) { getApp()->accounts->twitch.getCurrent()->unblockUser( - targetUser.id, + targetUser.id, nullptr, [channel, target, targetUser] { channel->addMessage(makeSystemMessage( QString("You successfully unblocked user %1") @@ -919,7 +923,8 @@ void CommandController::initialize(Settings &, Paths &paths) static_cast(&(getApp()->windows->getMainWindow())), currentSplit); userPopup->setData(userName, channel); - userPopup->move(QCursor::pos()); + userPopup->moveTo(QCursor::pos(), false, + BaseWindow::BoundsChecker::CursorPosition); userPopup->show(); return ""; }); @@ -3209,8 +3214,35 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + this->registerCommand( + "/debug-force-image-gc", + [](const QStringList & /*words*/, auto /*channel*/) -> QString { + runInGuiThread([] { + using namespace chatterino::detail; + auto &iep = ImageExpirationPool::instance(); + iep.freeOld(); + }); + return ""; + }); + + this->registerCommand( + "/debug-force-image-unload", + [](const QStringList & /*words*/, auto /*channel*/) -> QString { + runInGuiThread([] { + using namespace chatterino::detail; + auto &iep = ImageExpirationPool::instance(); + iep.freeAll(); + }); + return ""; + }); + this->registerCommand("/shield", &commands::shieldModeOn); this->registerCommand("/shieldoff", &commands::shieldModeOff); + + this->registerCommand("/shoutout", &commands::sendShoutout); + + this->registerCommand("/c2-set-logging-rules", &commands::setLoggingRules); + this->registerCommand("/c2-theme-autoreload", &commands::toggleThemeReload); } void CommandController::save() diff --git a/src/controllers/commands/builtin/chatterino/Debugging.cpp b/src/controllers/commands/builtin/chatterino/Debugging.cpp new file mode 100644 index 000000000..7ae1ce947 --- /dev/null +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -0,0 +1,66 @@ +#include "controllers/commands/builtin/chatterino/Debugging.hpp" + +#include "common/Channel.hpp" +#include "common/Literals.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "singletons/Theme.hpp" + +#include +#include + +namespace chatterino::commands { + +using namespace literals; + +QString setLoggingRules(const CommandContext &ctx) +{ + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /c2-set-logging-rules . To enable debug logging " + "for all categories from chatterino, use " + "'chatterino.*.debug=true'. For the format on the rules, see " + "https://doc.qt.io/qt-6/" + "qloggingcategory.html#configuring-categories")); + return {}; + } + + auto filterRules = ctx.words.mid(1).join('\n'); + + QLoggingCategory::setFilterRules(filterRules); + + auto message = + QStringLiteral("Updated filter rules to '%1'.").arg(filterRules); + + if (!qgetenv("QT_LOGGING_RULES").isEmpty()) + { + message += QStringLiteral( + " Warning: Logging rules were previously set by the " + "QT_LOGGING_RULES environment variable. This might cause " + "interference - see: " + "https://doc.qt.io/qt-6/qloggingcategory.html#setFilterRules"); + } + + ctx.channel->addMessage(makeSystemMessage(message)); + return {}; +} + +QString toggleThemeReload(const CommandContext &ctx) +{ + if (getTheme()->isAutoReloading()) + { + getTheme()->setAutoReload(false); + ctx.channel->addMessage( + makeSystemMessage(u"Disabled theme auto reloading."_s)); + return {}; + } + + getTheme()->setAutoReload(true); + ctx.channel->addMessage( + makeSystemMessage(u"Auto reloading theme every %1 ms."_s.arg( + Theme::AUTO_RELOAD_INTERVAL_MS))); + return {}; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/chatterino/Debugging.hpp b/src/controllers/commands/builtin/chatterino/Debugging.hpp new file mode 100644 index 000000000..8b531455f --- /dev/null +++ b/src/controllers/commands/builtin/chatterino/Debugging.hpp @@ -0,0 +1,17 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString setLoggingRules(const CommandContext &ctx); + +QString toggleThemeReload(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Shoutout.cpp b/src/controllers/commands/builtin/twitch/Shoutout.cpp new file mode 100644 index 000000000..4e3a7c7d6 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Shoutout.cpp @@ -0,0 +1,112 @@ +#include "controllers/commands/builtin/twitch/Shoutout.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString sendShoutout(const CommandContext &ctx) +{ + auto *twitchChannel = ctx.twitchChannel; + auto channel = ctx.channel; + auto words = &ctx.words; + + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /shoutout command only works in Twitch channels")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to send shoutout")); + return ""; + } + + if (words->size() < 2) + { + channel->addMessage( + makeSystemMessage("Usage: \"/shoutout \" - Sends a " + "shoutout to the specified twitch user")); + return ""; + } + + const auto target = words->at(1); + + using Error = HelixSendShoutoutError; + + getHelix()->getUserByName( + target, + [twitchChannel, channel, currentUser, &target](const auto targetUser) { + getHelix()->sendShoutout( + twitchChannel->roomId(), targetUser.id, + currentUser->getUserId(), + [channel, targetUser]() { + channel->addMessage(makeSystemMessage( + QString("Sent shoutout to %1").arg(targetUser.login))); + }, + [channel](auto error, auto message) { + QString errorMessage = "Failed to send shoutout - "; + + switch (error) + { + case Error::UserNotAuthorized: { + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. " + "Try again in a few seconds."; + } + break; + + case Error::UserIsBroadcaster: { + errorMessage += "The broadcaster may not give " + "themselves a Shoutout."; + } + break; + + case Error::BroadcasterNotLive: { + errorMessage += + "The broadcaster is not streaming live or " + "does not have one or more viewers."; + } + break; + + case Error::Unknown: { + errorMessage += message; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Shoutout.hpp b/src/controllers/commands/builtin/twitch/Shoutout.hpp new file mode 100644 index 000000000..ffeec03f8 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Shoutout.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendShoutout(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/filters/lang/expressions/BinaryOperation.cpp b/src/controllers/filters/lang/expressions/BinaryOperation.cpp index d3dbac4fe..d057ac783 100644 --- a/src/controllers/filters/lang/expressions/BinaryOperation.cpp +++ b/src/controllers/filters/lang/expressions/BinaryOperation.cpp @@ -2,6 +2,43 @@ #include +namespace { + +/// Loosely compares `lhs` with `rhs`. +/// This attempts to convert both variants to a common type if they're not equal. +bool looselyCompareVariants(QVariant &lhs, QVariant &rhs) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + // Qt 6 and later don't convert types as much as Qt 5 did when comparing. + // + // Based on QVariant::cmp from Qt 5.15 + // https://github.com/qt/qtbase/blob/29400a683f96867133b28299c0d0bd6bcf40df35/src/corelib/kernel/qvariant.cpp#L4039-L4071 + if (lhs.metaType() != rhs.metaType()) + { + if (rhs.canConvert(lhs.metaType())) + { + if (!rhs.convert(lhs.metaType())) + { + return false; + } + } + else + { + // try the opposite conversion, it might work + qSwap(lhs, rhs); + if (!rhs.convert(lhs.metaType())) + { + return false; + } + } + } +#endif + + return lhs == rhs; +} + +} // namespace + namespace chatterino::filters { BinaryOperation::BinaryOperation(TokenType op, ExpressionPtr left, @@ -60,14 +97,14 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return left.toString().compare(right.toString(), Qt::CaseInsensitive) == 0; } - return left == right; + return looselyCompareVariants(left, right); case NEQ: if (variantTypesMatch(left, right, QMetaType::QString)) { return left.toString().compare(right.toString(), Qt::CaseInsensitive) != 0; } - return left != right; + return !looselyCompareVariants(left, right); case LT: if (convertVariantTypes(left, right, QMetaType::Int)) return left.toInt() < right.toInt(); @@ -92,13 +129,13 @@ QVariant BinaryOperation::execute(const ContextMap &context) const Qt::CaseInsensitive); } - if (variantIs(left.type(), QMetaType::QVariantMap) && + if (variantIs(left, QMetaType::QVariantMap) && right.canConvert(QMetaType::QString)) { return left.toMap().contains(right.toString()); } - if (variantIs(left.type(), QMetaType::QVariantList)) + if (variantIs(left, QMetaType::QVariantList)) { return left.toList().contains(right); } @@ -112,7 +149,7 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; case STARTS_WITH: - if (variantIs(left.type(), QMetaType::QStringList) && + if (variantIs(left, QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { auto list = left.toStringList(); @@ -121,7 +158,7 @@ QVariant BinaryOperation::execute(const ContextMap &context) const Qt::CaseInsensitive) == 0; } - if (variantIs(left.type(), QMetaType::QVariantList)) + if (variantIs(left, QMetaType::QVariantList)) { return left.toList().startsWith(right); } @@ -136,7 +173,7 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; case ENDS_WITH: - if (variantIs(left.type(), QMetaType::QStringList) && + if (variantIs(left, QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { auto list = left.toStringList(); @@ -145,7 +182,7 @@ QVariant BinaryOperation::execute(const ContextMap &context) const Qt::CaseInsensitive) == 0; } - if (variantIs(left.type(), QMetaType::QVariantList)) + if (variantIs(left, QMetaType::QVariantList)) { return left.toList().endsWith(right); } diff --git a/src/controllers/filters/lang/expressions/ListExpression.cpp b/src/controllers/filters/lang/expressions/ListExpression.cpp index cd57a64ec..c0bf5ef8b 100644 --- a/src/controllers/filters/lang/expressions/ListExpression.cpp +++ b/src/controllers/filters/lang/expressions/ListExpression.cpp @@ -12,7 +12,7 @@ QVariant ListExpression::execute(const ContextMap &context) const for (const auto &exp : this->list_) { auto res = exp->execute(context); - if (allStrings && variantIsNot(res.type(), QMetaType::QString)) + if (allStrings && variantIsNot(res, QMetaType::QString)) { allStrings = false; } diff --git a/src/controllers/highlights/HighlightController.cpp b/src/controllers/highlights/HighlightController.cpp index bd517863f..8f7958dc6 100644 --- a/src/controllers/highlights/HighlightController.cpp +++ b/src/controllers/highlights/HighlightController.cpp @@ -163,7 +163,7 @@ void rebuildReplyThreadHighlight(Settings &settings, const auto & /*senderName*/, const auto & /*originalMessage*/, const auto &flags, const auto self) -> boost::optional { - if (flags.has(MessageFlag::ParticipatedThread) && !self) + if (flags.has(MessageFlag::SubscribedThread) && !self) { return HighlightResult{ highlightAlert, @@ -186,7 +186,8 @@ void rebuildMessageHighlights(Settings &settings, auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); QString currentUsername = currentUser->getUserName(); - if (settings.enableSelfHighlight && !currentUsername.isEmpty()) + if (settings.enableSelfHighlight && !currentUsername.isEmpty() && + !currentUser->isAnon()) { HighlightPhrase highlight( currentUsername, settings.showSelfHighlightInMentions, diff --git a/src/controllers/highlights/HighlightModel.cpp b/src/controllers/highlights/HighlightModel.cpp index 13ff5ec6b..b49f6fbb4 100644 --- a/src/controllers/highlights/HighlightModel.cpp +++ b/src/controllers/highlights/HighlightModel.cpp @@ -210,7 +210,7 @@ void HighlightModel::afterInit() std::vector threadMessageRow = this->createRow(); setBoolItem(threadMessageRow[Column::Pattern], getSettings()->enableThreadHighlight.getValue(), true, false); - threadMessageRow[Column::Pattern]->setData("Participated Reply Threads", + threadMessageRow[Column::Pattern]->setData("Subscribed Reply Threads", Qt::DisplayRole); setBoolItem(threadMessageRow[Column::ShowInMentions], getSettings()->showThreadHighlightInMentions.getValue(), true, diff --git a/src/controllers/hotkeys/ActionNames.hpp b/src/controllers/hotkeys/ActionNames.hpp index 8d5700ac4..03907ca6d 100644 --- a/src/controllers/hotkeys/ActionNames.hpp +++ b/src/controllers/hotkeys/ActionNames.hpp @@ -5,6 +5,20 @@ #include #include +#include + +inline const std::vector>> + HOTKEY_ARG_ON_OFF_TOGGLE = { + {"Toggle", {}}, + {"Set to on", {"on"}}, + {"Set to off", {"off"}}, +}; + +inline const std::vector>> + HOTKEY_ARG_WITH_OR_WITHOUT_SELECTION = { + {"No", {"withoutSelection"}}, + {"Yes", {"withSelection"}}, +}; namespace chatterino { @@ -13,6 +27,9 @@ struct ActionDefinition { // displayName is the value that would be shown to a user when they edit or create a hotkey for an action QString displayName; + // argumentDescription is a description of the arguments in a format of + // " [optional arg: possible + // values]" QString argumentDescription = ""; // minCountArguments is the minimum amount of arguments the action accepts @@ -21,6 +38,20 @@ struct ActionDefinition { // maxCountArguments is the maximum amount of arguments the action accepts uint8_t maxCountArguments = minCountArguments; + + // possibleArguments is empty or contains all possible argument values, + // it is an ordered mapping from option name (what the user sees) to + // arguments (what the action code will see). + // As std::map does not guarantee order this is a std::vector<...> + std::vector>> possibleArguments = + {}; + + // When possibleArguments are present this should be a string like + // "Direction:" which will be shown before the values from + // possibleArguments in the UI. Otherwise, it should be empty. + QString argumentsPrompt = ""; + // A more detailed description of what argumentsPrompt means + QString argumentsPromptHover = ""; }; using ActionDefinitionMap = std::map; @@ -39,15 +70,22 @@ inline const std::map actionNames{ }}, {"scrollPage", ActionDefinition{ - "Scroll", - "", - 1, + .displayName = "Scroll", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"Up", {"up"}}, + {"Down", {"down"}}, + }, + .argumentsPrompt = "Direction:", }}, {"search", ActionDefinition{"Focus search box"}}, {"execModeratorAction", ActionDefinition{ "Usercard: execute moderation action", "", 1}}, + {"pin", ActionDefinition{"Usercard, reply thread: pin window"}}, }}, {HotkeyCategory::Split, { @@ -57,24 +95,42 @@ inline const std::map actionNames{ {"delete", ActionDefinition{"Close"}}, {"focus", ActionDefinition{ - "Focus neighbouring split", - "", - 1, + .displayName = "Focus neighbouring split", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"Up", {"up"}}, + {"Down", {"down"}}, + {"Left", {"left"}}, + {"Right", {"right"}}, + }, + .argumentsPrompt = "Direction:", + .argumentsPromptHover = + "Which direction to look for a split to focus?", }}, {"openInBrowser", ActionDefinition{"Open channel in browser"}}, {"openInCustomPlayer", ActionDefinition{"Open stream in custom player"}}, {"openInStreamlink", ActionDefinition{"Open stream in streamlink"}}, {"openModView", ActionDefinition{"Open mod view in browser"}}, - {"openViewerList", ActionDefinition{"Open viewer list"}}, + {"openViewerList", ActionDefinition{"Open chatter list"}}, {"pickFilters", ActionDefinition{"Pick filters"}}, {"reconnect", ActionDefinition{"Reconnect to chat"}}, {"reloadEmotes", ActionDefinition{ - "Reload emotes", - "[channel or subscriber]", - 0, - 1, + .displayName = "Reload emotes", + .argumentDescription = + "[type: channel or subscriber; default: all emotes]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments{ + {"All emotes", {}}, + {"Channel emotes only", {"channel"}}, + {"Subscriber emotes only", {"subscriber"}}, + }, + .argumentsPrompt = "Emote type:", + .argumentsPromptHover = "Which emotes should Chatterino reload", }}, {"runCommand", ActionDefinition{ @@ -84,25 +140,41 @@ inline const std::map actionNames{ }}, {"scrollPage", ActionDefinition{ - "Scroll", - "", - 1, + .displayName = "Scroll", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"Up", {"up"}}, + {"Down", {"down"}}, + }, + .argumentsPrompt = "Direction:", + .argumentsPromptHover = + "Which direction do you want to see more messages", }}, {"scrollToBottom", ActionDefinition{"Scroll to the bottom"}}, {"scrollToTop", ActionDefinition{"Scroll to the top"}}, {"setChannelNotification", ActionDefinition{ - "Set channel live notification", - "[on or off. default: toggle]", - 0, - 1, + .displayName = "Set channel live notification", + .argumentDescription = "[on or off. default: toggle]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments = HOTKEY_ARG_ON_OFF_TOGGLE, + .argumentsPrompt = "New value:", + .argumentsPromptHover = "Should the channel live notification be " + "enabled, disabled or toggled", }}, {"setModerationMode", ActionDefinition{ - "Set moderation mode", - "[on or off. default: toggle]", - 0, - 1, + .displayName = "Set moderation mode", + .argumentDescription = "[on or off. default: toggle]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments = HOTKEY_ARG_ON_OFF_TOGGLE, + .argumentsPrompt = "New value:", + .argumentsPromptHover = + "Should the moderation mode be enabled, disabled or toggled", }}, {"showSearch", ActionDefinition{"Search current channel"}}, {"showGlobalSearch", ActionDefinition{"Search all channels"}}, @@ -114,21 +186,38 @@ inline const std::map actionNames{ {"clear", ActionDefinition{"Clear message"}}, {"copy", ActionDefinition{ - "Copy", - "", - 1, + .displayName = "Copy", + .argumentDescription = + "", + .minCountArguments = 1, + .possibleArguments{ + {"Automatic", {"auto"}}, + {"Split", {"split"}}, + {"Split Input", {"splitInput"}}, + }, + .argumentsPrompt = "Source of text:", }}, {"cursorToStart", ActionDefinition{ - "To start of message", - "", - 1, + .displayName = "To start of message", + .argumentDescription = + "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments = HOTKEY_ARG_WITH_OR_WITHOUT_SELECTION, + .argumentsPrompt = "Select text from cursor to start:", + // XXX: write a hover for this that doesn't suck }}, {"cursorToEnd", ActionDefinition{ - "To end of message", - "", - 1, + .displayName = "To end of message", + .argumentDescription = + "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments = HOTKEY_ARG_WITH_OR_WITHOUT_SELECTION, + .argumentsPrompt = "Select text from cursor to end:", + // XXX: write a hover for this that doesn't suck }}, {"nextMessage", ActionDefinition{"Choose next sent message"}}, {"openEmotesPopup", ActionDefinition{"Open emotes list"}}, @@ -140,10 +229,16 @@ inline const std::map actionNames{ {"selectWord", ActionDefinition{"Select word"}}, {"sendMessage", ActionDefinition{ - "Send message", - "[keepInput to not clear the text after sending]", - 0, - 1, + .displayName = "Send message", + .argumentDescription = + "[keepInput to not clear the text after sending]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments{ + {"Default behavior", {}}, + {"Keep message in input after sending it", {"keepInput"}}, + }, + .argumentsPrompt = "Behavior:", }}, {"undo", ActionDefinition{"Undo"}}, @@ -163,7 +258,7 @@ inline const std::map actionNames{ {"moveTab", ActionDefinition{ "Move tab", - "", + "", 1, }}, {"newSplit", ActionDefinition{"Create a new split"}}, @@ -172,40 +267,78 @@ inline const std::map actionNames{ {"openTab", ActionDefinition{ "Select tab", - "", + "", 1, }}, {"openQuickSwitcher", ActionDefinition{"Open the quick switcher"}}, {"popup", ActionDefinition{ - "New popup", - "", - 1, + .displayName = "New popup", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"Focused Split", {"split"}}, + {"Entire Tab", {"window"}}, + }, + .argumentsPrompt = "Include:", + .argumentsPromptHover = + "What should be included in the new popup", }}, {"quit", ActionDefinition{"Quit Chatterino"}}, {"removeTab", ActionDefinition{"Remove current tab"}}, {"reopenSplit", ActionDefinition{"Reopen closed split"}}, {"setStreamerMode", ActionDefinition{ - "Set streamer mode", - "[on, off, toggle, or auto. default: toggle]", - 0, - 1, + .displayName = "Set streamer mode", + .argumentDescription = + "[on, off, toggle, or auto. default: toggle]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments = + { + {"Toggle on/off", {}}, + {"Set to on", {"on"}}, + {"Set to off", {"off"}}, + {"Set to automatic", {"auto"}}, + }, + .argumentsPrompt = "New value:", + .argumentsPromptHover = + "Should streamer mode be enabled, disabled, toggled (on/off) " + "or set to auto", }}, {"toggleLocalR9K", ActionDefinition{"Toggle local R9K"}}, {"zoom", ActionDefinition{ - "Zoom in/out", - "", - 1, + .displayName = "Zoom in/out", + .argumentDescription = "Argument:", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments = + { + {"Zoom in", {"in"}}, + {"Zoom out", {"out"}}, + {"Reset zoom", {"reset"}}, + }, + .argumentsPrompt = "Option:", }}, {"setTabVisibility", ActionDefinition{ - "Set tab visibility", - "[on, off, or toggle. default: toggle]", - 0, - 1, - }}}}, + .displayName = "Set tab visibility", + .argumentDescription = "[on, off, toggle, liveOnly, or " + "toggleLiveOnly. default: toggle]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments{{"Toggle", {}}, + {"Set to on", {"on"}}, + {"Set to off", {"off"}}, + {"Live only on", {"liveOnly"}}, + {"Live only toggle", {"toggleLiveOnly"}}}, + .argumentsPrompt = "New value:", + .argumentsPromptHover = "Should the tabs be enabled, disabled, " + "toggled, or live-only.", + }}, + }}, }; } // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyController.cpp b/src/controllers/hotkeys/HotkeyController.cpp index df6639756..16a1e0356 100644 --- a/src/controllers/hotkeys/HotkeyController.cpp +++ b/src/controllers/hotkeys/HotkeyController.cpp @@ -500,6 +500,10 @@ void HotkeyController::addDefaults(std::set &addedHotkeys) this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, QKeySequence("Ctrl+U"), "setTabVisibility", {"toggle"}, "toggle tab visibility"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+Shift+L"), "setTabVisibility", + {"toggleLiveOnly"}, "toggle live tabs only"); } } diff --git a/src/controllers/hotkeys/HotkeyHelpers.cpp b/src/controllers/hotkeys/HotkeyHelpers.cpp index d998d7665..2859ad6d2 100644 --- a/src/controllers/hotkeys/HotkeyHelpers.cpp +++ b/src/controllers/hotkeys/HotkeyHelpers.cpp @@ -1,5 +1,9 @@ #include "controllers/hotkeys/HotkeyHelpers.hpp" +#include "controllers/hotkeys/ActionNames.hpp" +#include "controllers/hotkeys/HotkeyCategory.hpp" + +#include #include namespace chatterino { @@ -27,4 +31,20 @@ std::vector parseHotkeyArguments(QString argumentString) return arguments; } +boost::optional findHotkeyActionDefinition( + HotkeyCategory category, const QString &action) +{ + auto allActions = actionNames.find(category); + if (allActions != actionNames.end()) + { + const auto &actionsMap = allActions->second; + auto definition = actionsMap.find(action); + if (definition != actionsMap.end()) + { + return {definition->second}; + } + } + return {}; +} + } // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyHelpers.hpp b/src/controllers/hotkeys/HotkeyHelpers.hpp index 4e63569ff..dfbdb6f2d 100644 --- a/src/controllers/hotkeys/HotkeyHelpers.hpp +++ b/src/controllers/hotkeys/HotkeyHelpers.hpp @@ -1,5 +1,8 @@ #pragma once +#include "controllers/hotkeys/ActionNames.hpp" + +#include #include #include @@ -7,5 +10,7 @@ namespace chatterino { std::vector parseHotkeyArguments(QString argumentString); +boost::optional findHotkeyActionDefinition( + HotkeyCategory category, const QString &action); } // namespace chatterino diff --git a/src/controllers/ignores/IgnoreController.cpp b/src/controllers/ignores/IgnoreController.cpp index e3df12e47..6f3876401 100644 --- a/src/controllers/ignores/IgnoreController.cpp +++ b/src/controllers/ignores/IgnoreController.cpp @@ -32,10 +32,10 @@ bool isIgnoredMessage(IgnoredMessageParameters &¶ms) { auto sourceUserID = params.twitchUserID; - auto blocks = - getApp()->accounts->twitch.getCurrent()->accessBlockedUserIds(); - - if (auto it = blocks->find(sourceUserID); it != blocks->end()) + bool isBlocked = + getApp()->accounts->twitch.getCurrent()->blockedUserIds().contains( + sourceUserID); + if (isBlocked) { switch (static_cast( getSettings()->showBlockedUsersMessages.getValue())) diff --git a/src/controllers/nicknames/Nickname.hpp b/src/controllers/nicknames/Nickname.hpp index 529f5fa0d..fa8bdf6d0 100644 --- a/src/controllers/nicknames/Nickname.hpp +++ b/src/controllers/nicknames/Nickname.hpp @@ -3,6 +3,7 @@ #include "util/RapidjsonHelpers.hpp" #include "util/RapidJsonSerializeQString.hpp" +#include #include #include #include @@ -58,25 +59,25 @@ public: return this->isCaseSensitive_; } - [[nodiscard]] bool match(QString &usernameText) const + [[nodiscard]] boost::optional match( + const QString &usernameText) const { if (this->isRegex()) { if (!this->regex_.isValid()) { - return false; + return boost::none; } if (this->name().isEmpty()) { - return false; + return boost::none; } auto workingCopy = usernameText; workingCopy.replace(this->regex_, this->replace()); if (workingCopy != usernameText) { - usernameText = workingCopy; - return true; + return workingCopy; } } else @@ -85,12 +86,11 @@ public: this->name().compare(usernameText, this->caseSensitivity()); if (res == 0) { - usernameText = this->replace(); - return true; + return this->replace(); } } - return false; + return boost::none; } private: diff --git a/src/controllers/sound/SoundController.cpp b/src/controllers/sound/SoundController.cpp index 449bc3c47..da7871a79 100644 --- a/src/controllers/sound/SoundController.cpp +++ b/src/controllers/sound/SoundController.cpp @@ -20,7 +20,6 @@ constexpr const auto NUM_SOUNDS = 4; SoundController::SoundController() : context(std::make_unique()) , resourceManager(std::make_unique()) - , device(std::make_unique()) , engine(std::make_unique()) { } @@ -66,27 +65,9 @@ void SoundController::initialize(Settings &settings, Paths &paths) this->defaultPingData = defaultPingFile.readAll(); /// Initialize a sound device - auto deviceConfig = ma_device_config_init(ma_device_type_playback); - deviceConfig.playback.pDeviceID = nullptr; - deviceConfig.playback.format = this->resourceManager->config.decodedFormat; - deviceConfig.playback.channels = 0; - deviceConfig.pulse.pStreamNamePlayback = "Chatterino MA"; - deviceConfig.sampleRate = this->resourceManager->config.decodedSampleRate; - deviceConfig.dataCallback = ma_engine_data_callback_internal; - deviceConfig.pUserData = this->engine.get(); - - result = - ma_device_init(this->context.get(), &deviceConfig, this->device.get()); - if (result != MA_SUCCESS) + if (!this->recreateDevice()) { - qCWarning(chatterinoSound) << "Error initializing device:" << result; - return; - } - - result = ma_device_start(this->device.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error starting device:" << result; + qCWarning(chatterinoSound) << "Failed to create the initial device"; return; } @@ -172,7 +153,11 @@ SoundController::~SoundController() } ma_engine_uninit(this->engine.get()); - ma_device_uninit(this->device.get()); + if (this->device) + { + ma_device_uninit(this->device.get()); + this->device.reset(); + } ma_resource_manager_uninit(this->resourceManager.get()); ma_context_uninit(this->context.get()); } @@ -204,7 +189,12 @@ void SoundController::play(const QUrl &sound) { qCWarning(chatterinoSound) << "Failed to start the sound device" << result; - return; + + if (!this->recreateDevice()) + { + qCWarning(chatterinoSound) << "Failed to recreate device"; + return; + } } qCInfo(chatterinoSound) << "Successfully restarted the sound device"; @@ -234,4 +224,44 @@ void SoundController::play(const QUrl &sound) } } +bool SoundController::recreateDevice() +{ + ma_result result{}; + + if (this->device) + { + // Release the previous device first + qCDebug(chatterinoSound) << "Uniniting previously created device"; + ma_device_uninit(this->device.get()); + } + + this->device = std::make_unique(); + + auto deviceConfig = ma_device_config_init(ma_device_type_playback); + deviceConfig.playback.pDeviceID = nullptr; + deviceConfig.playback.format = this->resourceManager->config.decodedFormat; + deviceConfig.playback.channels = 0; + deviceConfig.pulse.pStreamNamePlayback = "Chatterino MA"; + deviceConfig.sampleRate = this->resourceManager->config.decodedSampleRate; + deviceConfig.dataCallback = ma_engine_data_callback_internal; + deviceConfig.pUserData = this->engine.get(); + + result = + ma_device_init(this->context.get(), &deviceConfig, this->device.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Error initializing device:" << result; + return false; + } + + result = ma_device_start(this->device.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Error starting device:" << result; + return false; + } + + return true; +} + } // namespace chatterino diff --git a/src/controllers/sound/SoundController.hpp b/src/controllers/sound/SoundController.hpp index 5591b982f..fbd3ac87f 100644 --- a/src/controllers/sound/SoundController.hpp +++ b/src/controllers/sound/SoundController.hpp @@ -45,7 +45,7 @@ private: // Used for storing & reusing sounds to be played std::unique_ptr resourceManager; // The sound device we're playing sound into - std::unique_ptr device; + std::unique_ptr device{nullptr}; // The engine is a high-level API for playing sounds from paths in a simple & efficient-enough manner std::unique_ptr engine; @@ -64,6 +64,13 @@ private: bool initialized{false}; + // Recreates the sound device + // This is used during initialization, and can also be used if the device + // needs to be recreated during playback + // + // Returns false on failure + bool recreateDevice(); + friend class Application; }; diff --git a/src/controllers/twitch/LiveController.cpp b/src/controllers/twitch/LiveController.cpp new file mode 100644 index 000000000..c531ebe6f --- /dev/null +++ b/src/controllers/twitch/LiveController.cpp @@ -0,0 +1,190 @@ +#include "controllers/twitch/LiveController.hpp" + +#include "common/QLogging.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Helpers.hpp" + +#include + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoTwitchLiveController; + +} // namespace + +namespace chatterino { + +TwitchLiveController::TwitchLiveController() +{ + QObject::connect(&this->refreshTimer, &QTimer::timeout, [this] { + this->request(); + }); + this->refreshTimer.start(TwitchLiveController::REFRESH_INTERVAL); + + QObject::connect(&this->immediateRequestTimer, &QTimer::timeout, [this] { + QStringList channelIDs; + + { + std::unique_lock immediateRequestsLock( + this->immediateRequestsMutex); + for (const auto &channelID : this->immediateRequests) + { + channelIDs.append(channelID); + } + this->immediateRequests.clear(); + } + + if (channelIDs.isEmpty()) + { + return; + } + + this->request(channelIDs); + }); + this->immediateRequestTimer.start( + TwitchLiveController::IMMEDIATE_REQUEST_INTERVAL); +} + +void TwitchLiveController::add(const std::shared_ptr &newChannel) +{ + assert(newChannel != nullptr); + + const auto channelID = newChannel->roomId(); + assert(!channelID.isEmpty()); + + { + std::unique_lock lock(this->channelsMutex); + this->channels[channelID] = newChannel; + } + + { + std::unique_lock immediateRequestsLock(this->immediateRequestsMutex); + this->immediateRequests.emplace(channelID); + } +} + +void TwitchLiveController::request(std::optional optChannelIDs) +{ + QStringList channelIDs; + + if (optChannelIDs) + { + channelIDs = *optChannelIDs; + } + else + { + std::shared_lock lock(this->channelsMutex); + + for (const auto &channelList : this->channels) + { + channelIDs.append(channelList.first); + } + } + + if (channelIDs.isEmpty()) + { + return; + } + + auto batches = + splitListIntoBatches(channelIDs, TwitchLiveController::BATCH_SIZE); + + qCDebug(LOG) << "Make" << batches.size() << "requests"; + + for (const auto &batch : batches) + { + // TODO: Explore making this concurrent + getHelix()->fetchStreams( + batch, {}, + [this, batch{batch}](const auto &streams) { + std::unordered_map> results; + + for (const auto &channelID : batch) + { + results[channelID] = std::nullopt; + } + + for (const auto &stream : streams) + { + results[stream.userId] = stream; + } + + QStringList deadChannels; + + { + std::shared_lock lock(this->channelsMutex); + for (const auto &result : results) + { + auto it = this->channels.find(result.first); + if (it != channels.end()) + { + if (auto channel = it->second.lock(); channel) + { + channel->updateStreamStatus(result.second); + } + else + { + deadChannels.append(result.first); + } + } + } + } + + if (!deadChannels.isEmpty()) + { + std::unique_lock lock(this->channelsMutex); + for (const auto &deadChannel : deadChannels) + { + this->channels.erase(deadChannel); + } + } + }, + [] { + qCWarning(LOG) << "Failed stream check request"; + }, + [] {}); + + // TODO: Explore making this concurrent + getHelix()->fetchChannels( + batch, + [this, batch{batch}](const auto &helixChannels) { + QStringList deadChannels; + + { + std::shared_lock lock(this->channelsMutex); + for (const auto &helixChannel : helixChannels) + { + auto it = this->channels.find(helixChannel.userId); + if (it != this->channels.end()) + { + if (auto channel = it->second.lock(); channel) + { + channel->updateStreamTitle(helixChannel.title); + channel->updateDisplayName(helixChannel.name); + } + else + { + deadChannels.append(helixChannel.userId); + } + } + } + } + + if (!deadChannels.isEmpty()) + { + std::unique_lock lock(this->channelsMutex); + for (const auto &deadChannel : deadChannels) + { + this->channels.erase(deadChannel); + } + } + }, + [] { + qCWarning(LOG) << "Failed stream check request"; + }); + } +} + +} // namespace chatterino diff --git a/src/controllers/twitch/LiveController.hpp b/src/controllers/twitch/LiveController.hpp new file mode 100644 index 000000000..79befd62d --- /dev/null +++ b/src/controllers/twitch/LiveController.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include "common/Singleton.hpp" +#include "util/QStringHash.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace chatterino { + +class TwitchChannel; + +class ITwitchLiveController +{ +public: + virtual ~ITwitchLiveController() = default; + + virtual void add(const std::shared_ptr &newChannel) = 0; +}; + +class TwitchLiveController : public ITwitchLiveController, public Singleton +{ +public: + // Controls how often all channels have their stream status refreshed + static constexpr std::chrono::seconds REFRESH_INTERVAL{30}; + + // Controls how quickly new channels have their stream status loaded + static constexpr std::chrono::seconds IMMEDIATE_REQUEST_INTERVAL{1}; + + /** + * How many channels to include in a single request + * + * Should not be more than 100 + **/ + static constexpr int BATCH_SIZE{100}; + + TwitchLiveController(); + + // Add a Twitch channel to be queried for live status + // A request is made within a few seconds if this is the first time this channel is added + void add(const std::shared_ptr &newChannel) override; + +private: + /** + * Run batched Helix Channels & Stream requests for channels + * + * If a list of channel IDs is passed to request, we only make a request for those channels + * + * If no list of channels is passed to request (the default behaviour), we make requests for all channels + * in the `channels` map. + **/ + void request(std::optional optChannelIDs = std::nullopt); + + /** + * List of channel IDs pointing to their Twitch Channel + * + * These channels will have their stream status updated every REFRESH_INTERVAL seconds + **/ + std::unordered_map> channels; + std::shared_mutex channelsMutex; + + /** + * List of channels that need an immediate live status update + * + * These channels will have their stream status updated after at most IMMEDIATE_REQUEST_INTERVAL seconds + **/ + std::unordered_set immediateRequests; + std::mutex immediateRequestsMutex; + + /** + * Timer responsible for refreshing `channels` + **/ + QTimer refreshTimer; + + /** + * Timer responsible for refreshing `immediateRequests` + **/ + QTimer immediateRequestTimer; +}; + +} // namespace chatterino diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index 2e8616b19..34bd7489a 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -76,6 +76,8 @@ namespace detail { 60000); } this->processOffset(); + DebugCount::increase("image bytes", this->memoryUsage()); + DebugCount::increase("image bytes (ever loaded)", this->memoryUsage()); } Frames::~Frames() @@ -91,10 +93,27 @@ namespace detail { { 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 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(); + + usage += memory; + } + return usage; + } + void Frames::advance() { this->durationOffset_ += GIF_FRAME_LENGTH; @@ -131,6 +150,9 @@ namespace detail { { DebugCount::decrease("loaded images"); } + DebugCount::decrease("image bytes", this->memoryUsage()); + DebugCount::increase("image bytes (ever unloaded)", + this->memoryUsage()); this->items_.clear(); this->index_ = 0; @@ -573,8 +595,8 @@ ImageExpirationPool::ImageExpirationPool() ImageExpirationPool &ImageExpirationPool::instance() { - static ImageExpirationPool instance; - return instance; + static auto *instance = new ImageExpirationPool; + return *instance; } void ImageExpirationPool::addImagePtr(ImagePtr imgPtr) @@ -589,14 +611,26 @@ void ImageExpirationPool::removeImagePtr(Image *rawPtr) this->allImages_.erase(rawPtr); } +void ImageExpirationPool::freeAll() +{ + { + std::lock_guard lock(this->mutex_); + for (auto it = this->allImages_.begin(); it != this->allImages_.end();) + { + auto img = it->second.lock(); + img->expireFrames(); + it = this->allImages_.erase(it); + } + } + this->freeOld(); +} + void ImageExpirationPool::freeOld() { std::lock_guard lock(this->mutex_); -# ifndef NDEBUG size_t numExpired = 0; size_t eligible = 0; -# endif auto now = std::chrono::steady_clock::now(); for (auto it = this->allImages_.begin(); it != this->allImages_.end();) @@ -617,17 +651,13 @@ void ImageExpirationPool::freeOld() continue; } -# ifndef NDEBUG ++eligible; -# endif // Check if image has expired and, if so, expire its frame data auto diff = now - img->lastUsed_; if (diff > IMAGE_POOL_IMAGE_LIFETIME) { -# ifndef NDEBUG ++numExpired; -# endif img->expireFrames(); // erase without mutex locking issue it = this->allImages_.erase(it); @@ -641,6 +671,9 @@ void ImageExpirationPool::freeOld() qCDebug(chatterinoImage) << "freed frame data for" << numExpired << "/" << eligible << "eligible images"; # endif + DebugCount::set("last image gc: expired", numExpired); + DebugCount::set("last image gc: eligible", eligible); + DebugCount::set("last image gc: left after gc", this->allImages_.size()); } #endif diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index 90159a442..98c964eac 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -41,6 +41,7 @@ namespace detail { boost::optional first() const; private: + int64_t memoryUsage() const; void processOffset(); QVector> items_; int index_{0}; @@ -111,6 +112,7 @@ class ImageExpirationPool { private: friend class Image; + friend class CommandController; ImageExpirationPool(); static ImageExpirationPool &instance(); @@ -126,6 +128,12 @@ private: */ void freeOld(); + /* + * Debug function that unloads all images in the pool. This is intended to + * test for possible memory leaks from tracked images. + */ + void freeAll(); + private: // Timer to periodically run freeOld() QTimer *freeTimer_; diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index bea40a1b1..83e311b1c 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -46,7 +46,7 @@ enum class MessageFlag : int64_t { FirstMessage = (1LL << 23), ReplyMessage = (1LL << 24), ElevatedMessage = (1LL << 25), - ParticipatedThread = (1LL << 26), + SubscribedThread = (1LL << 26), CheerMessage = (1LL << 27), LiveUpdatesAdd = (1LL << 28), LiveUpdatesRemove = (1LL << 29), diff --git a/src/messages/MessageThread.cpp b/src/messages/MessageThread.cpp index 0ffdc3f18..e1227ab09 100644 --- a/src/messages/MessageThread.cpp +++ b/src/messages/MessageThread.cpp @@ -1,4 +1,4 @@ -#include "MessageThread.hpp" +#include "messages/MessageThread.hpp" #include "messages/Message.hpp" #include "util/DebugCount.hpp" @@ -58,14 +58,26 @@ size_t MessageThread::liveCount( return count; } -bool MessageThread::participated() const +void MessageThread::markSubscribed() { - return this->participated_; + if (this->subscription_ == Subscription::Subscribed) + { + return; + } + + this->subscription_ = Subscription::Subscribed; + this->subscriptionUpdated(); } -void MessageThread::markParticipated() +void MessageThread::markUnsubscribed() { - this->participated_ = true; + if (this->subscription_ == Subscription::Unsubscribed) + { + return; + } + + this->subscription_ = Subscription::Unsubscribed; + this->subscriptionUpdated(); } } // namespace chatterino diff --git a/src/messages/MessageThread.hpp b/src/messages/MessageThread.hpp index ae0d24794..442db46a6 100644 --- a/src/messages/MessageThread.hpp +++ b/src/messages/MessageThread.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -11,6 +12,12 @@ struct Message; class MessageThread { public: + enum class Subscription : uint8_t { + None, + Subscribed, + Unsubscribed, + }; + MessageThread(std::shared_ptr rootMessage); ~MessageThread(); @@ -23,9 +30,22 @@ public: /// Returns the number of live reply references size_t liveCount(const std::shared_ptr &exclude) const; - bool participated() const; + bool subscribed() const + { + return this->subscription_ == Subscription::Subscribed; + } - void markParticipated(); + /// Returns true if and only if the user manually unsubscribed from the thread + /// @see #markUnsubscribed() + bool unsubscribed() const + { + return this->subscription_ == Subscription::Unsubscribed; + } + + /// Subscribe to this thread. + void markSubscribed(); + /// Unsubscribe from this thread. + void markUnsubscribed(); const QString &rootId() const { @@ -42,11 +62,14 @@ public: return replies_; } + boost::signals2::signal subscriptionUpdated; + private: const QString rootMessageId_; const std::shared_ptr rootMessage_; std::vector> replies_; - bool participated_ = false; + + Subscription subscription_ = Subscription::None; }; } // namespace chatterino diff --git a/src/messages/Selection.hpp b/src/messages/Selection.hpp index f16e6ce29..8c879c0ae 100644 --- a/src/messages/Selection.hpp +++ b/src/messages/Selection.hpp @@ -78,6 +78,7 @@ struct Selection { if (offset > this->selectionMin.messageIndex) { this->selectionMin.messageIndex = 0; + this->selectionMin.charIndex = 0; } else { @@ -87,6 +88,7 @@ struct Selection { if (offset > this->selectionMax.messageIndex) { this->selectionMax.messageIndex = 0; + this->selectionMax.charIndex = 0; } else { @@ -96,6 +98,7 @@ struct Selection { if (offset > this->start.messageIndex) { this->start.messageIndex = 0; + this->start.charIndex = 0; } else { @@ -105,6 +108,7 @@ struct Selection { if (offset > this->end.messageIndex) { this->end.messageIndex = 0; + this->end.charIndex = 0; } else { diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 9d0fda90c..97c795c3a 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -270,14 +270,9 @@ QString SharedMessageBuilder::stylizeUsername(const QString &username, break; } - auto nicknames = getCSettings().nicknames.readOnly(); - - for (const auto &nickname : *nicknames) + if (auto nicknameText = getCSettings().matchNickname(usernameText)) { - if (nickname.match(usernameText)) - { - break; - } + usernameText = *nicknameText; } return usernameText; diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index 00bc3b46d..e5b180091 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -1,16 +1,14 @@ #include "messages/layouts/MessageLayout.hpp" #include "Application.hpp" -#include "debug/Benchmark.hpp" #include "messages/layouts/MessageLayoutContainer.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/layouts/MessageLayoutElement.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" #include "messages/Selection.hpp" #include "providers/colors/ColorProvider.hpp" -#include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" -#include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/DebugCount.hpp" #include "util/StreamerMode.hpp" @@ -198,84 +196,77 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) } // Painting -void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, - Selection &selection, bool isLastReadMessage, - bool isWindowFocused, bool isMentions) +void MessageLayout::paint(const MessagePaintContext &ctx) { - auto app = getApp(); - QPixmap *pixmap = this->ensureBuffer(painter, width); + QPixmap *pixmap = this->ensureBuffer(ctx.painter, ctx.canvasWidth); - if (!this->bufferValid_ || !selection.isEmpty()) + if (!this->bufferValid_ || !ctx.selection.isEmpty()) { - this->updateBuffer(pixmap, messageIndex, selection); + this->updateBuffer(pixmap, ctx); } // draw on buffer - painter.drawPixmap(0, y, *pixmap); - // painter.drawPixmap(0, y, this->container.width, - // this->container.getHeight(), *pixmap); + ctx.painter.drawPixmap(0, ctx.y, *pixmap); // draw gif emotes - this->container_.paintAnimatedElements(painter, y); + this->container_.paintAnimatedElements(ctx.painter, ctx.y); // draw disabled if (this->message_->flags.has(MessageFlag::Disabled)) { - painter.fillRect(0, y, pixmap->width(), pixmap->height(), - app->themes->messages.disabled); - // painter.fillRect(0, y, pixmap->width(), pixmap->height(), - // QBrush(QColor(64, 64, 64, 64))); + ctx.painter.fillRect(0, ctx.y, pixmap->width(), pixmap->height(), + ctx.messageColors.disabled); } if (this->message_->flags.has(MessageFlag::RecentMessage)) { - painter.fillRect(0, y, pixmap->width(), pixmap->height(), - app->themes->messages.disabled); + ctx.painter.fillRect(0, ctx.y, pixmap->width(), pixmap->height(), + ctx.messageColors.disabled); } - if (!isMentions && + if (!ctx.isMentions && (this->message_->flags.has(MessageFlag::RedeemedChannelPointReward) || this->message_->flags.has(MessageFlag::RedeemedHighlight)) && - getSettings()->enableRedeemedHighlight.getValue()) + ctx.preferences.enableRedeemedHighlight) { - painter.fillRect( - 0, y, this->scale_ * 4, pixmap->height(), + ctx.painter.fillRect( + 0, ctx.y, int(this->scale_ * 4), pixmap->height(), *ColorProvider::instance().color(ColorType::RedeemedHighlight)); } // draw selection - if (!selection.isEmpty()) + if (!ctx.selection.isEmpty()) { - this->container_.paintSelection(painter, messageIndex, selection, y); + this->container_.paintSelection(ctx.painter, ctx.messageIndex, + ctx.selection, ctx.y); } // draw message seperation line - if (getSettings()->separateMessages.getValue()) + if (ctx.preferences.separateMessages) { - painter.fillRect(0, y, this->container_.getWidth() + 64, 1, - app->themes->splits.messageSeperator); + ctx.painter.fillRect(0, ctx.y, this->container_.getWidth() + 64, 1, + ctx.messageColors.messageSeperator); } // draw last read message line - if (isLastReadMessage) + if (ctx.isLastReadMessage) { QColor color; - if (getSettings()->lastMessageColor != QStringLiteral("")) + if (ctx.preferences.lastMessageColor.isValid()) { - color = QColor(getSettings()->lastMessageColor.getValue()); + color = ctx.preferences.lastMessageColor; } else { - color = isWindowFocused - ? app->themes->tabs.selected.backgrounds.regular - : app->themes->tabs.selected.backgrounds.unfocused; + color = ctx.isWindowFocused + ? ctx.messageColors.focusedLastMessageLine + : ctx.messageColors.unfocusedLastMessageLine; } - QBrush brush(color, static_cast( - getSettings()->lastMessagePattern.getValue())); + QBrush brush(color, ctx.preferences.lastMessagePattern); - painter.fillRect(0, y + this->container_.getHeight() - 1, - pixmap->width(), 1, brush); + ctx.painter.fillRect(0, ctx.y + this->container_.getHeight() - 1, + pixmap->width(), 1, brush); } this->bufferValid_ = true; @@ -305,45 +296,42 @@ QPixmap *MessageLayout::ensureBuffer(QPainter &painter, int width) return this->buffer_.get(); } -void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, - Selection & /*selection*/) +void MessageLayout::updateBuffer(QPixmap *buffer, + const MessagePaintContext &ctx) { if (buffer->isNull()) + { return; - - auto app = getApp(); - auto settings = getSettings(); + } QPainter painter(buffer); painter.setRenderHint(QPainter::SmoothPixmapTransform); // draw background - QColor backgroundColor = [this, &app] { - if (getSettings()->alternateMessages.getValue() && + QColor backgroundColor = [&] { + if (ctx.preferences.alternateMessages && this->flags.has(MessageLayoutFlag::AlternateBackground)) { - return app->themes->messages.backgrounds.alternate; - } - else - { - return app->themes->messages.backgrounds.regular; + return ctx.messageColors.alternate; } + + return ctx.messageColors.regular; }(); if (this->message_->flags.has(MessageFlag::ElevatedMessage) && - getSettings()->enableElevatedMessageHighlight.getValue()) - { - backgroundColor = blendColors(backgroundColor, - *ColorProvider::instance().color( - ColorType::ElevatedMessageHighlight)); - } - - else if (this->message_->flags.has(MessageFlag::FirstMessage) && - getSettings()->enableFirstMessageHighlight.getValue()) + ctx.preferences.enableElevatedMessageHighlight) { backgroundColor = blendColors( backgroundColor, - *ColorProvider::instance().color(ColorType::FirstMessageHighlight)); + *ctx.colorProvider.color(ColorType::ElevatedMessageHighlight)); + } + + else if (this->message_->flags.has(MessageFlag::FirstMessage) && + ctx.preferences.enableFirstMessageHighlight) + { + backgroundColor = blendColors( + backgroundColor, + *ctx.colorProvider.color(ColorType::FirstMessageHighlight)); } else if ((this->message_->flags.has(MessageFlag::Highlighted) || this->message_->flags.has(MessageFlag::HighlightedWhisper)) && @@ -354,22 +342,21 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, blendColors(backgroundColor, *this->message_->highlightColor); } else if (this->message_->flags.has(MessageFlag::Subscription) && - getSettings()->enableSubHighlight) + ctx.preferences.enableSubHighlight) { // Blend highlight color with usual background color backgroundColor = blendColors( - backgroundColor, - *ColorProvider::instance().color(ColorType::Subscription)); + backgroundColor, *ctx.colorProvider.color(ColorType::Subscription)); } else if ((this->message_->flags.has(MessageFlag::RedeemedHighlight) || this->message_->flags.has( MessageFlag::RedeemedChannelPointReward)) && - settings->enableRedeemedHighlight.getValue()) + ctx.preferences.enableRedeemedHighlight) { // Blend highlight color with usual background color - backgroundColor = blendColors( - backgroundColor, - *ColorProvider::instance().color(ColorType::RedeemedHighlight)); + backgroundColor = + blendColors(backgroundColor, + *ctx.colorProvider.color(ColorType::RedeemedHighlight)); } else if (this->message_->flags.has(MessageFlag::AutoMod)) { @@ -383,7 +370,7 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, painter.fillRect(buffer->rect(), backgroundColor); // draw message - this->container_.paintElements(painter); + this->container_.paintElements(painter, ctx); #ifdef FOURTF // debug diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index c0e64709f..81d7e4035 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -18,6 +18,7 @@ using MessagePtr = std::shared_ptr; struct Selection; struct MessageLayoutContainer; class MessageLayoutElement; +struct MessagePaintContext; enum class MessageElementFlag : int64_t; using MessageElementFlags = FlagsEnum; @@ -49,9 +50,7 @@ public: bool layout(int width, float scale_, MessageElementFlags flags); // Painting - void paint(QPainter &painter, int width, int y, int messageIndex, - Selection &selection, bool isLastReadMessage, - bool isWindowFocused, bool isMentions); + void paint(const MessagePaintContext &ctx); void invalidateBuffer(); void deleteBuffer(); void deleteCache(); @@ -72,7 +71,7 @@ public: private: // methods void actuallyLayout(int width, MessageElementFlags flags); - void updateBuffer(QPixmap *pixmap, int messageIndex, Selection &selection); + void updateBuffer(QPixmap *buffer, const MessagePaintContext &ctx); // Create new buffer if required, returning the buffer QPixmap *ensureBuffer(QPainter &painter, int width); diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index 540e9dfac..008175970 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -1,6 +1,7 @@ #include "MessageLayoutContainer.hpp" #include "Application.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/layouts/MessageLayoutElement.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" @@ -151,7 +152,7 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, } // top margin - if (this->elements_.size() == 0) + if (this->elements_.empty()) { this->currentY_ = int(this->margin.top * this->scale_); } @@ -276,17 +277,24 @@ void MessageLayoutContainer::reorderRTL(int firstTextIndex) // 2 - in LTR mode, the previous word should be RTL (i.e. reversed) for (int i = startIndex; i <= endIndex; i++) { - if (isNeutral(this->elements_[i]->getText()) && + auto &element = this->elements_[i]; + + const auto neutral = isNeutral(element->getText()); + const auto neutralOrUsername = + neutral || + element->getFlags().hasAny({MessageElementFlag::BoldUsername, + MessageElementFlag::NonBoldUsername}); + + if (neutral && ((this->first == FirstWord::RTL && !this->wasPrevReversed_) || (this->first == FirstWord::LTR && this->wasPrevReversed_))) { - this->elements_[i]->reversedNeutral = true; + element->reversedNeutral = true; } - if (((this->elements_[i]->getText().isRightToLeft() != + if (((element->getText().isRightToLeft() != (this->first == FirstWord::RTL)) && - !isNeutral(this->elements_[i]->getText())) || - (isNeutral(this->elements_[i]->getText()) && - this->wasPrevReversed_)) + !neutralOrUsername) || + (neutralOrUsername && this->wasPrevReversed_)) { swappedSequence.push(i); this->wasPrevReversed_ = true; @@ -379,7 +387,7 @@ void MessageLayoutContainer::breakLine() element->getRect().y() + this->lineHeight_ + yExtra)); } - if (this->lines_.size() != 0) + if (!this->lines_.empty()) { this->lines_.back().endIndex = this->lineStart_; this->lines_.back().endCharIndex = this->charIndex_; @@ -388,7 +396,7 @@ void MessageLayoutContainer::breakLine() {(int)lineStart_, 0, this->charIndex_, 0, QRect(-100000, this->currentY_, 200000, lineHeight_)}); - for (int i = this->lineStart_; i < this->elements_.size(); i++) + for (auto i = this->lineStart_; i < this->elements_.size(); i++) { this->charIndex_ += this->elements_[i]->getSelectionIndexCount(); } @@ -458,7 +466,7 @@ void MessageLayoutContainer::end() this->height_ += this->lineHeight_; - if (this->lines_.size() != 0) + if (!this->lines_.empty()) { this->lines_[0].rect.setTop(-100000); this->lines_.back().rect.setBottom(100000); @@ -473,7 +481,7 @@ bool MessageLayoutContainer::canCollapse() this->flags_.has(MessageFlag::Collapsed); } -bool MessageLayoutContainer::isCollapsed() +bool MessageLayoutContainer::isCollapsed() const { return this->isCollapsed_; } @@ -492,7 +500,8 @@ MessageLayoutElement *MessageLayoutContainer::getElementAt(QPoint point) } // painting -void MessageLayoutContainer::paintElements(QPainter &painter) +void MessageLayoutContainer::paintElements(QPainter &painter, + const MessagePaintContext &ctx) { for (const std::unique_ptr &element : this->elements_) { @@ -501,7 +510,7 @@ void MessageLayoutContainer::paintElements(QPainter &painter) painter.drawRect(element->getRect()); #endif - element->paint(painter); + element->paint(painter, ctx.messageColors); } } @@ -514,10 +523,12 @@ void MessageLayoutContainer::paintAnimatedElements(QPainter &painter, } } -void MessageLayoutContainer::paintSelection(QPainter &painter, int messageIndex, - Selection &selection, int yOffset) +void MessageLayoutContainer::paintSelection(QPainter &painter, + size_t messageIndex, + const Selection &selection, + int yOffset) { - auto app = getApp(); + auto *app = getApp(); QColor selectionColor = app->themes->messages.selection; // don't draw anything @@ -706,7 +717,7 @@ void MessageLayoutContainer::paintSelection(QPainter &painter, int messageIndex, // selection int MessageLayoutContainer::getSelectionIndex(QPoint point) { - if (this->elements_.size() == 0) + if (this->elements_.empty()) { return 0; } @@ -767,7 +778,7 @@ int MessageLayoutContainer::getSelectionIndex(QPoint point) // fourtf: no idea if this is acurate LOL int MessageLayoutContainer::getLastCharacterIndex() const { - if (this->lines_.size() == 0) + if (this->lines_.empty()) { return 0; } @@ -784,7 +795,7 @@ int MessageLayoutContainer::getFirstMessageCharacterIndex() const // Get the index of the first character of the real message int index = 0; - for (auto &element : this->elements_) + for (const auto &element : this->elements_) { if (element->getFlags().hasAny(skippedFlags)) { @@ -846,10 +857,8 @@ void MessageLayoutContainer::addSelectionText(QString &str, uint32_t from, element->addCopyTextToString(str, 0, to - index); break; } - else - { - element->addCopyTextToString(str); - } + + element->addCopyTextToString(str); } index += indexCount; diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index 41bb0d941..bdbe1b486 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -18,6 +18,7 @@ enum class FirstWord { Neutral, RTL, LTR }; using MessageFlags = FlagsEnum; class MessageLayoutElement; struct Selection; +struct MessagePaintContext; struct Margin { int top; @@ -73,10 +74,10 @@ struct MessageLayoutContainer { MessageLayoutElement *getElementAt(QPoint point); // painting - void paintElements(QPainter &painter); + void paintElements(QPainter &painter, const MessagePaintContext &ctx); void paintAnimatedElements(QPainter &painter, int yOffset); - void paintSelection(QPainter &painter, int messageIndex, - Selection &selection, int yOffset); + void paintSelection(QPainter &painter, size_t messageIndex, + const Selection &selection, int yOffset); // selection int getSelectionIndex(QPoint point); @@ -85,7 +86,7 @@ struct MessageLayoutContainer { void addSelectionText(QString &str, uint32_t from, uint32_t to, CopyMode copymode); - bool isCollapsed(); + bool isCollapsed() const; private: struct Line { diff --git a/src/messages/layouts/MessageLayoutContext.cpp b/src/messages/layouts/MessageLayoutContext.cpp new file mode 100644 index 000000000..82b158485 --- /dev/null +++ b/src/messages/layouts/MessageLayoutContext.cpp @@ -0,0 +1,82 @@ +#include "messages/layouts/MessageLayoutContext.hpp" + +#include "singletons/Settings.hpp" +#include "singletons/Theme.hpp" + +namespace chatterino { + +void MessageColors::applyTheme(Theme *theme) +{ + this->regular = theme->messages.backgrounds.regular; + this->alternate = theme->messages.backgrounds.alternate; + + this->disabled = theme->messages.disabled; + this->selection = theme->messages.selection; + this->system = theme->messages.textColors.system; + + this->messageSeperator = theme->splits.messageSeperator; + + this->focusedLastMessageLine = theme->tabs.selected.backgrounds.regular; + this->unfocusedLastMessageLine = theme->tabs.selected.backgrounds.unfocused; +} + +void MessagePreferences::connectSettings(Settings *settings, + pajlada::Signals::SignalHolder &holder) +{ + settings->enableRedeemedHighlight.connect( + [this](const auto &newValue) { + this->enableRedeemedHighlight = newValue; + }, + holder); + + settings->enableElevatedMessageHighlight.connect( + [this](const auto &newValue) { + this->enableElevatedMessageHighlight = newValue; + }, + holder); + + settings->enableFirstMessageHighlight.connect( + [this](const auto &newValue) { + this->enableFirstMessageHighlight = newValue; + }, + holder); + + settings->enableSubHighlight.connect( + [this](const auto &newValue) { + this->enableSubHighlight = newValue; + }, + holder); + + settings->alternateMessages.connect( + [this](const auto &newValue) { + this->alternateMessages = newValue; + }, + holder); + + settings->separateMessages.connect( + [this](const auto &newValue) { + this->separateMessages = newValue; + }, + holder); + + settings->lastMessageColor.connect( + [this](const auto &newValue) { + if (newValue.isEmpty()) + { + this->lastMessageColor = QColor(); + } + else + { + this->lastMessageColor = QColor(newValue); + } + }, + holder); + + settings->lastMessagePattern.connect( + [this](const auto &newValue) { + this->lastMessagePattern = static_cast(newValue); + }, + holder); +} + +} // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutContext.hpp b/src/messages/layouts/MessageLayoutContext.hpp new file mode 100644 index 000000000..a64d98bb4 --- /dev/null +++ b/src/messages/layouts/MessageLayoutContext.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include + +namespace pajlada::Signals { +class SignalHolder; +} // namespace pajlada::Signals + +namespace chatterino { + +class ColorProvider; +class Theme; +class Settings; +struct Selection; + +// TODO: Figure out if this could be a subset of Theme instead (e.g. Theme::MessageColors) +struct MessageColors { + QColor regular; + QColor alternate; + QColor disabled; + QColor selection; + QColor system; + + QColor messageSeperator; + + QColor focusedLastMessageLine; + QColor unfocusedLastMessageLine; + + void applyTheme(Theme *theme); +}; + +// TODO: Explore if we can let settings own this +struct MessagePreferences { + QColor lastMessageColor; + Qt::BrushStyle lastMessagePattern{}; + + bool enableRedeemedHighlight{}; + bool enableElevatedMessageHighlight{}; + bool enableFirstMessageHighlight{}; + bool enableSubHighlight{}; + + bool alternateMessages{}; + bool separateMessages{}; + + void connectSettings(Settings *settings, + pajlada::Signals::SignalHolder &holder); +}; + +struct MessagePaintContext { + QPainter &painter; + const Selection &selection; + const ColorProvider &colorProvider; + const MessageColors &messageColors; + const MessagePreferences &preferences; + + // width of the area we have to draw on + const int canvasWidth{}; + // whether the painting should be treated as if this view's window is focused + const bool isWindowFocused{}; + // whether the painting should be treated as if this view is the special mentions view + const bool isMentions{}; + + // y coordinate we're currently painting at + int y{}; + + // Index of the message that is currently being painted + // This index refers to the snapshot being used in the painting + size_t messageIndex{}; + + bool isLastReadMessage{}; +}; + +} // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index cd071b4f2..fc525d4e4 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -3,9 +3,9 @@ #include "Application.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/MessageElement.hpp" #include "providers/twitch/TwitchEmotes.hpp" -#include "singletons/Theme.hpp" #include "util/DebugCount.hpp" #include @@ -137,7 +137,8 @@ int ImageLayoutElement::getSelectionIndexCount() const return this->trailingSpace ? 2 : 1; } -void ImageLayoutElement::paint(QPainter &painter) +void ImageLayoutElement::paint(QPainter &painter, + const MessageColors & /*messageColors*/) { if (this->image_ == nullptr) { @@ -228,7 +229,8 @@ int LayeredImageLayoutElement::getSelectionIndexCount() const return this->trailingSpace ? 2 : 1; } -void LayeredImageLayoutElement::paint(QPainter &painter) +void LayeredImageLayoutElement::paint(QPainter &painter, + const MessageColors & /*messageColors*/) { auto fullRect = QRectF(this->getRect()); @@ -329,7 +331,8 @@ ImageWithBackgroundLayoutElement::ImageWithBackgroundLayoutElement( { } -void ImageWithBackgroundLayoutElement::paint(QPainter &painter) +void ImageWithBackgroundLayoutElement::paint( + QPainter &painter, const MessageColors & /*messageColors*/) { if (this->image_ == nullptr) { @@ -360,7 +363,8 @@ ImageWithCircleBackgroundLayoutElement::ImageWithCircleBackgroundLayoutElement( { } -void ImageWithCircleBackgroundLayoutElement::paint(QPainter &painter) +void ImageWithCircleBackgroundLayoutElement::paint( + QPainter &painter, const MessageColors & /*messageColors*/) { if (this->image_ == nullptr) { @@ -423,7 +427,8 @@ int TextLayoutElement::getSelectionIndexCount() const return this->getText().length() + (this->trailingSpace ? 1 : 0); } -void TextLayoutElement::paint(QPainter &painter) +void TextLayoutElement::paint(QPainter &painter, + const MessageColors & /*messageColors*/) { auto app = getApp(); QString text = this->getText(); @@ -532,13 +537,14 @@ int TextIconLayoutElement::getSelectionIndexCount() const return this->trailingSpace ? 2 : 1; } -void TextIconLayoutElement::paint(QPainter &painter) +void TextIconLayoutElement::paint(QPainter &painter, + const MessageColors &messageColors) { - auto app = getApp(); + auto *app = getApp(); QFont font = app->fonts->getFont(FontStyle::Tiny, this->scale); - painter.setPen(app->themes->messages.textColors.system); + painter.setPen(messageColors.system); painter.setFont(font); QTextOption option; @@ -598,7 +604,8 @@ ReplyCurveLayoutElement::ReplyCurveLayoutElement(MessageElement &creator, { } -void ReplyCurveLayoutElement::paint(QPainter &painter) +void ReplyCurveLayoutElement::paint(QPainter &painter, + const MessageColors & /*messageColors*/) { QRectF paintRect(this->getRect()); QPainterPath path; diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index e4c930845..2a4ec030e 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -21,6 +21,7 @@ class Image; using ImagePtr = std::shared_ptr; enum class FontStyle : uint8_t; enum class MessageElementFlag : int64_t; +struct MessageColors; class MessageLayoutElement : boost::noncopyable { @@ -44,7 +45,8 @@ public: virtual void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const = 0; virtual int getSelectionIndexCount() const = 0; - virtual void paint(QPainter &painter) = 0; + virtual void paint(QPainter &painter, + const MessageColors &messageColors) = 0; virtual void paintAnimated(QPainter &painter, int yOffset) = 0; virtual int getMouseOverIndex(const QPoint &abs) const = 0; virtual int getXFromIndex(int index) = 0; @@ -75,7 +77,7 @@ protected: void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const override; int getSelectionIndexCount() const override; - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; void paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(int index) override; @@ -94,7 +96,7 @@ protected: void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const override; int getSelectionIndexCount() const override; - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; void paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(int index) override; @@ -110,7 +112,7 @@ public: const QSize &size, QColor color); protected: - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; private: QColor color_; @@ -125,7 +127,7 @@ public: int padding); protected: - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; private: const QColor color_; @@ -147,7 +149,7 @@ protected: void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const override; int getSelectionIndexCount() const override; - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; void paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(int index) override; @@ -171,7 +173,7 @@ protected: void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const override; int getSelectionIndexCount() const override; - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; void paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(int index) override; @@ -189,7 +191,7 @@ public: float radius, float neededMargin); protected: - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; void paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(int index) override; diff --git a/src/providers/Crashpad.cpp b/src/providers/Crashpad.cpp index 4c2fb9760..f81cbe071 100644 --- a/src/providers/Crashpad.cpp +++ b/src/providers/Crashpad.cpp @@ -79,8 +79,8 @@ std::unique_ptr installCrashHandler() // See https://chromium.googlesource.com/crashpad/crashpad/+/HEAD/handler/crashpad_handler.md // for documentation on available options. - if (!client->StartHandler(handlerPath, databaseDir, {}, {}, {}, {}, true, - false)) + if (!client->StartHandler(handlerPath, databaseDir, {}, {}, {}, {}, {}, + true, false)) { qCDebug(chatterinoApp) << "Failed to start crashpad handler"; return nullptr; diff --git a/src/providers/IvrApi.cpp b/src/providers/IvrApi.cpp index 26b4088e5..868a9ff08 100644 --- a/src/providers/IvrApi.cpp +++ b/src/providers/IvrApi.cpp @@ -27,7 +27,7 @@ void IvrApi::getSubage(QString userName, QString channelName, }) .onError([failureCallback](auto result) { qCWarning(chatterinoIvr) - << "Failed IVR API Call!" << result.status() + << "Failed IVR API Call!" << result.formatError() << QString(result.getData()); failureCallback(); }) @@ -51,7 +51,7 @@ void IvrApi::getBulkEmoteSets(QString emoteSetList, }) .onError([failureCallback](auto result) { qCWarning(chatterinoIvr) - << "Failed IVR API Call!" << result.status() + << "Failed IVR API Call!" << result.formatError() << QString(result.getData()); failureCallback(); }) diff --git a/src/providers/RecentMessagesApi.cpp b/src/providers/RecentMessagesApi.cpp index 9fd524f68..05ee6e8c8 100644 --- a/src/providers/RecentMessagesApi.cpp +++ b/src/providers/RecentMessagesApi.cpp @@ -217,17 +217,20 @@ void RecentMessagesApi::loadRecentMessages(const QString &channelName, return Success; }) - .onError([channelPtr, onError](NetworkResult result) { + .onError([channelPtr, onError](const NetworkResult &result) { auto shared = channelPtr.lock(); if (!shared) + { return; + } qCDebug(chatterinoRecentMessages) << "Failed to load recent messages for" << shared->getName(); shared->addMessage(makeSystemMessage( - QString("Message history service unavailable (Error %1)") - .arg(result.status()))); + QStringLiteral( + "Message history service unavailable (Error: %1)") + .arg(result.formatError()))); onError(); }) diff --git a/src/providers/bttv/BttvEmotes.cpp b/src/providers/bttv/BttvEmotes.cpp index f214d4177..42bf51cba 100644 --- a/src/providers/bttv/BttvEmotes.cpp +++ b/src/providers/bttv/BttvEmotes.cpp @@ -193,7 +193,7 @@ void BttvEmotes::loadEmotes() { if (!Settings::instance().enableBTTVGlobalEmotes) { - this->global_.set(EMPTY_EMOTE_MAP); + this->setEmotes(EMPTY_EMOTE_MAP); return; } @@ -203,13 +203,18 @@ void BttvEmotes::loadEmotes() auto emotes = this->global_.get(); auto pair = parseGlobalEmotes(result.parseJsonArray(), *emotes); if (pair.first) - this->global_.set( + this->setEmotes( std::make_shared(std::move(pair.second))); return pair.first; }) .execute(); } +void BttvEmotes::setEmotes(std::shared_ptr emotes) +{ + this->global_.set(std::move(emotes)); +} + void BttvEmotes::loadChannel(std::weak_ptr channel, const QString &channelId, const QString &channelDisplayName, @@ -254,23 +259,17 @@ void BttvEmotes::loadChannel(std::weak_ptr channel, shared->addMessage( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } - else if (result.status() == NetworkResult::timedoutStatus) - { - // TODO: Auto retry in case of a timeout, with a delay - qCWarning(chatterinoBttv) - << "Fetching BTTV emotes for channel" << channelId - << "failed due to timeout"; - shared->addMessage(makeSystemMessage( - "Failed to fetch BetterTTV channel emotes. (timed out)")); - } else { + // TODO: Auto retry in case of a timeout, with a delay + auto errorString = result.formatError(); qCWarning(chatterinoBttv) << "Error fetching BTTV emotes for channel" << channelId - << ", error" << result.status(); - shared->addMessage( - makeSystemMessage("Failed to fetch BetterTTV channel " - "emotes. (unknown error)")); + << ", error" << errorString; + shared->addMessage(makeSystemMessage( + QStringLiteral("Failed to fetch BetterTTV channel " + "emotes. (Error: %1)") + .arg(errorString))); } }) .execute(); diff --git a/src/providers/bttv/BttvEmotes.hpp b/src/providers/bttv/BttvEmotes.hpp index bca2d4b65..bbdcacccb 100644 --- a/src/providers/bttv/BttvEmotes.hpp +++ b/src/providers/bttv/BttvEmotes.hpp @@ -29,6 +29,7 @@ public: std::shared_ptr emotes() const; boost::optional emote(const EmoteName &name) const; void loadEmotes(); + void setEmotes(std::shared_ptr emotes); static void loadChannel(std::weak_ptr channel, const QString &channelId, const QString &channelDisplayName, diff --git a/src/providers/emoji/Emojis.cpp b/src/providers/emoji/Emojis.cpp index 72c429837..7872d6e68 100644 --- a/src/providers/emoji/Emojis.cpp +++ b/src/providers/emoji/Emojis.cpp @@ -265,7 +265,7 @@ void Emojis::loadEmojiSet() } std::vector> Emojis::parse( - const QString &text) + const QString &text) const { auto result = std::vector>(); int lastParsedEmojiEndIndex = 0; @@ -359,7 +359,7 @@ std::vector> Emojis::parse( return result; } -QString Emojis::replaceShortCodes(const QString &text) +QString Emojis::replaceShortCodes(const QString &text) const { QString ret(text); auto it = this->findShortCodesRegex_.globalMatch(text); @@ -393,4 +393,14 @@ QString Emojis::replaceShortCodes(const QString &text) return ret; } +const EmojiMap &Emojis::getEmojis() const +{ + return this->emojis; +} + +const std::vector &Emojis::getShortCodes() const +{ + return this->shortCodes; +} + } // namespace chatterino diff --git a/src/providers/emoji/Emojis.hpp b/src/providers/emoji/Emojis.hpp index 2f1679b6e..217aa1f4a 100644 --- a/src/providers/emoji/Emojis.hpp +++ b/src/providers/emoji/Emojis.hpp @@ -37,16 +37,32 @@ struct EmojiData { using EmojiMap = ConcurrentMap>; -class Emojis +class IEmojis +{ +public: + virtual ~IEmojis() = default; + + virtual std::vector> parse( + const QString &text) const = 0; + virtual const EmojiMap &getEmojis() const = 0; + virtual const std::vector &getShortCodes() const = 0; + virtual QString replaceShortCodes(const QString &text) const = 0; +}; + +class Emojis : public IEmojis { public: void initialize(); void load(); - std::vector> parse(const QString &text); + std::vector> parse( + const QString &text) const override; EmojiMap emojis; std::vector shortCodes; - QString replaceShortCodes(const QString &text); + QString replaceShortCodes(const QString &text) const override; + + const EmojiMap &getEmojis() const override; + const std::vector &getShortCodes() const override; private: void loadEmojis(); diff --git a/src/providers/ffz/FfzEmotes.cpp b/src/providers/ffz/FfzEmotes.cpp index 180628545..0a28cc51b 100644 --- a/src/providers/ffz/FfzEmotes.cpp +++ b/src/providers/ffz/FfzEmotes.cpp @@ -188,7 +188,7 @@ void FfzEmotes::loadEmotes() { if (!Settings::instance().enableFFZGlobalEmotes) { - this->global_.set(EMPTY_EMOTE_MAP); + this->setEmotes(EMPTY_EMOTE_MAP); return; } @@ -199,13 +199,18 @@ void FfzEmotes::loadEmotes() .timeout(30000) .onSuccess([this](auto result) -> Outcome { auto parsedSet = parseGlobalEmotes(result.parseJson()); - this->global_.set(std::make_shared(std::move(parsedSet))); + this->setEmotes(std::make_shared(std::move(parsedSet))); return Success; }) .execute(); } +void FfzEmotes::setEmotes(std::shared_ptr emotes) +{ + this->global_.set(std::move(emotes)); +} + void FfzEmotes::loadChannel( std::weak_ptr channel, const QString &channelID, std::function emoteCallback, @@ -268,24 +273,17 @@ void FfzEmotes::loadChannel( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } } - else if (result.status() == NetworkResult::timedoutStatus) - { - // TODO: Auto retry in case of a timeout, with a delay - qCWarning(chatterinoFfzemotes) - << "Fetching FFZ emotes for channel" << channelID - << "failed due to timeout"; - shared->addMessage( - makeSystemMessage("Failed to fetch FrankerFaceZ channel " - "emotes. (timed out)")); - } else { + // TODO: Auto retry in case of a timeout, with a delay + auto errorString = result.formatError(); qCWarning(chatterinoFfzemotes) << "Error fetching FFZ emotes for channel" << channelID - << ", error" << result.status(); - shared->addMessage( - makeSystemMessage("Failed to fetch FrankerFaceZ channel " - "emotes. (unknown error)")); + << ", error" << errorString; + shared->addMessage(makeSystemMessage( + QStringLiteral("Failed to fetch FrankerFaceZ channel " + "emotes. (Error: %1)") + .arg(errorString))); } }) .execute(); diff --git a/src/providers/ffz/FfzEmotes.hpp b/src/providers/ffz/FfzEmotes.hpp index be0726f04..e2865fcb5 100644 --- a/src/providers/ffz/FfzEmotes.hpp +++ b/src/providers/ffz/FfzEmotes.hpp @@ -22,6 +22,7 @@ public: std::shared_ptr emotes() const; boost::optional emote(const EmoteName &name) const; void loadEmotes(); + void setEmotes(std::shared_ptr emotes); static void loadChannel( std::weak_ptr channel, const QString &channelId, std::function emoteCallback, diff --git a/src/providers/irc/IrcMessageBuilder.cpp b/src/providers/irc/IrcMessageBuilder.cpp index 0474bdb76..3b7fb30da 100644 --- a/src/providers/irc/IrcMessageBuilder.cpp +++ b/src/providers/irc/IrcMessageBuilder.cpp @@ -64,7 +64,11 @@ MessagePtr IrcMessageBuilder::build() // message this->addIrcMessageText(this->originalMessage_); - this->message().searchText = this->message().localizedName + " " + + QString stylizedUsername = + this->stylizeUsername(this->userName, this->message()); + + this->message().searchText = stylizedUsername + " " + + this->message().localizedName + " " + this->userName + ": " + this->originalMessage_; // highlights diff --git a/src/providers/irc/IrcServer.cpp b/src/providers/irc/IrcServer.cpp index 5ae01c56b..ad94d0305 100644 --- a/src/providers/irc/IrcServer.cpp +++ b/src/providers/irc/IrcServer.cpp @@ -11,9 +11,9 @@ #include "providers/twitch/TwitchIrcServer.hpp" // NOTE: Included to access the mentions channel #include "singletons/Settings.hpp" #include "util/IrcHelpers.hpp" -#include "util/QObjectRef.hpp" #include +#include #include #include @@ -151,7 +151,7 @@ void IrcServer::initializeConnection(IrcConnection *connection, [[fallthrough]]; case IrcAuthType::Pass: this->data_->getPassword( - this, [conn = new QObjectRef(connection) /* can't copy */, + this, [conn = new QPointer(connection) /* can't copy */, this](const QString &password) mutable { if (*conn) { diff --git a/src/providers/liveupdates/BasicPubSubManager.hpp b/src/providers/liveupdates/BasicPubSubManager.hpp index d596866bc..f82f70363 100644 --- a/src/providers/liveupdates/BasicPubSubManager.hpp +++ b/src/providers/liveupdates/BasicPubSubManager.hpp @@ -87,8 +87,11 @@ public: this->websocketClient_.set_fail_handler([this](auto hdl) { this->onConnectionFail(hdl); }); - this->websocketClient_.set_user_agent("Chatterino/" CHATTERINO_VERSION - " (" CHATTERINO_GIT_HASH ")"); + this->websocketClient_.set_user_agent( + QStringLiteral("Chatterino/%1 (%2)") + .arg(Version::instance().version(), + Version::instance().commitHash()) + .toStdString()); } virtual ~BasicPubSubManager() = default; diff --git a/src/providers/seventv/SeventvAPI.cpp b/src/providers/seventv/SeventvAPI.cpp new file mode 100644 index 000000000..265c420e1 --- /dev/null +++ b/src/providers/seventv/SeventvAPI.cpp @@ -0,0 +1,92 @@ +#include "providers/seventv/SeventvAPI.hpp" + +#include "common/Literals.hpp" +#include "common/NetworkRequest.hpp" +#include "common/NetworkResult.hpp" +#include "common/Outcome.hpp" + +namespace { + +using namespace chatterino::literals; + +const QString API_URL_USER = u"https://7tv.io/v3/users/twitch/%1"_s; +const QString API_URL_EMOTE_SET = u"https://7tv.io/v3/emote-sets/%1"_s; +const QString API_URL_PRESENCES = u"https://7tv.io/v3/users/%1/presences"_s; + +} // namespace + +// NOLINTBEGIN(readability-convert-member-functions-to-static) +namespace chatterino { + +void SeventvAPI::getUserByTwitchID( + const QString &twitchID, SuccessCallback &&onSuccess, + ErrorCallback &&onError) +{ + NetworkRequest(API_URL_USER.arg(twitchID), NetworkRequestType::Get) + .timeout(20000) + .onSuccess([callback = std::move(onSuccess)]( + const NetworkResult &result) -> Outcome { + auto json = result.parseJson(); + callback(json); + return Success; + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +void SeventvAPI::getEmoteSet(const QString &emoteSet, + SuccessCallback &&onSuccess, + ErrorCallback &&onError) +{ + NetworkRequest(API_URL_EMOTE_SET.arg(emoteSet), NetworkRequestType::Get) + .timeout(25000) + .onSuccess([callback = std::move(onSuccess)]( + const NetworkResult &result) -> Outcome { + auto json = result.parseJson(); + callback(json); + return Success; + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +void SeventvAPI::updatePresence(const QString &twitchChannelID, + const QString &seventvUserID, + SuccessCallback<> &&onSuccess, + ErrorCallback &&onError) +{ + QJsonObject payload{ + {u"kind"_s, 1}, // UserPresenceKindChannel + {u"data"_s, + QJsonObject{ + {u"id"_s, twitchChannelID}, + {u"platform"_s, u"TWITCH"_s}, + }}, + }; + + NetworkRequest(API_URL_PRESENCES.arg(seventvUserID), + NetworkRequestType::Post) + .json(payload) + .timeout(10000) + .onSuccess([callback = std::move(onSuccess)](const auto &) -> Outcome { + callback(); + return Success; + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +SeventvAPI &getSeventvAPI() +{ + static SeventvAPI instance; + return instance; +} + +} // namespace chatterino +// NOLINTEND(readability-convert-member-functions-to-static) diff --git a/src/providers/seventv/SeventvAPI.hpp b/src/providers/seventv/SeventvAPI.hpp new file mode 100644 index 000000000..fd75345f5 --- /dev/null +++ b/src/providers/seventv/SeventvAPI.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +class QString; +class QJsonObject; + +namespace chatterino { + +class NetworkResult; + +class SeventvAPI +{ + using ErrorCallback = std::function; + template + using SuccessCallback = std::function; + +public: + void getUserByTwitchID(const QString &twitchID, + SuccessCallback &&onSuccess, + ErrorCallback &&onError); + void getEmoteSet(const QString &emoteSet, + SuccessCallback &&onSuccess, + ErrorCallback &&onError); + + void updatePresence(const QString &twitchChannelID, + const QString &seventvUserID, + SuccessCallback<> &&onSuccess, ErrorCallback &&onError); +}; + +SeventvAPI &getSeventvAPI(); + +} // namespace chatterino diff --git a/src/providers/seventv/SeventvBadges.cpp b/src/providers/seventv/SeventvBadges.cpp index 1216d0863..e54682baa 100644 --- a/src/providers/seventv/SeventvBadges.cpp +++ b/src/providers/seventv/SeventvBadges.cpp @@ -1,10 +1,11 @@ #include "providers/seventv/SeventvBadges.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "messages/Emote.hpp" +#include "messages/Image.hpp" +#include "providers/seventv/SeventvAPI.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include #include #include @@ -12,66 +13,68 @@ namespace chatterino { -void SeventvBadges::initialize(Settings & /*settings*/, Paths & /*paths*/) -{ - this->loadSeventvBadges(); -} - -boost::optional SeventvBadges::getBadge(const UserId &id) +boost::optional SeventvBadges::getBadge(const UserId &id) const { std::shared_lock lock(this->mutex_); auto it = this->badgeMap_.find(id.string); if (it != this->badgeMap_.end()) { - return this->emotes_[it->second]; + return it->second; } return boost::none; } -void SeventvBadges::loadSeventvBadges() +void SeventvBadges::assignBadgeToUser(const QString &badgeID, + const UserId &userID) { - // Cosmetics will work differently in v3, until this is ready - // we'll use this endpoint. - static QUrl url("https://7tv.io/v2/cosmetics"); + const std::unique_lock lock(this->mutex_); - static QUrlQuery urlQuery; - // valid user_identifier values: "object_id", "twitch_id", "login" - urlQuery.addQueryItem("user_identifier", "twitch_id"); + const auto badgeIt = this->knownBadges_.find(badgeID); + if (badgeIt != this->knownBadges_.end()) + { + this->badgeMap_[userID.string] = badgeIt->second; + } +} - url.setQuery(urlQuery); +void SeventvBadges::clearBadgeFromUser(const QString &badgeID, + const UserId &userID) +{ + const std::unique_lock lock(this->mutex_); - NetworkRequest(url) - .onSuccess([this](const NetworkResult &result) -> Outcome { - auto root = result.parseJson(); + const auto it = this->badgeMap_.find(userID.string); + if (it != this->badgeMap_.end() && it->second->id.string == badgeID) + { + this->badgeMap_.erase(userID.string); + } +} - std::unique_lock lock(this->mutex_); +void SeventvBadges::registerBadge(const QJsonObject &badgeJson) +{ + const auto badgeID = badgeJson["id"].toString(); - int index = 0; - for (const auto &jsonBadge : root.value("badges").toArray()) - { - auto badge = jsonBadge.toObject(); - auto urls = badge.value("urls").toArray(); - auto emote = - Emote{EmoteName{}, - ImageSet{Url{urls.at(0).toArray().at(1).toString()}, - Url{urls.at(1).toArray().at(1).toString()}, - Url{urls.at(2).toArray().at(1).toString()}}, - Tooltip{badge.value("tooltip").toString()}, Url{}}; + const std::unique_lock lock(this->mutex_); - this->emotes_.push_back( - std::make_shared(std::move(emote))); + if (this->knownBadges_.find(badgeID) != this->knownBadges_.end()) + { + return; + } - for (const auto &user : badge.value("users").toArray()) - { - this->badgeMap_[user.toString()] = index; - } - ++index; - } + auto emote = Emote{ + .name = EmoteName{}, + .images = SeventvEmotes::createImageSet(badgeJson), + .tooltip = Tooltip{badgeJson["tooltip"].toString()}, + .homePage = Url{}, + .id = EmoteId{badgeID}, + }; - return Success; - }) - .execute(); + if (emote.images.getImage1()->isEmpty()) + { + return; // Bad images + } + + this->knownBadges_[badgeID] = + std::make_shared(std::move(emote)); } } // namespace chatterino diff --git a/src/providers/seventv/SeventvBadges.hpp b/src/providers/seventv/SeventvBadges.hpp index 98725d179..a6ed981ee 100644 --- a/src/providers/seventv/SeventvBadges.hpp +++ b/src/providers/seventv/SeventvBadges.hpp @@ -5,11 +5,11 @@ #include "util/QStringHash.hpp" #include +#include #include #include #include -#include namespace chatterino { @@ -19,18 +19,27 @@ using EmotePtr = std::shared_ptr; class SeventvBadges : public Singleton { public: - void initialize(Settings &settings, Paths &paths) override; + // Return the badge, if any, that is assigned to the user + boost::optional getBadge(const UserId &id) const; - boost::optional getBadge(const UserId &id); + // Assign the given badge to the user + void assignBadgeToUser(const QString &badgeID, const UserId &userID); + + // Remove the given badge from the user + void clearBadgeFromUser(const QString &badgeID, const UserId &userID); + + // Register a new known badge + // The json object will contain all information about the badge, like its ID & its images + void registerBadge(const QJsonObject &badgeJson); private: - void loadSeventvBadges(); + // Mutex for both `badgeMap_` and `knownBadges_` + mutable std::shared_mutex mutex_; - // Mutex for both `badgeMap_` and `emotes_` - std::shared_mutex mutex_; - - std::unordered_map badgeMap_; - std::vector emotes_; + // user-id => badge + std::unordered_map badgeMap_; + // badge-id => badge + std::unordered_map knownBadges_; }; } // namespace chatterino diff --git a/src/providers/seventv/SeventvCosmetics.hpp b/src/providers/seventv/SeventvCosmetics.hpp new file mode 100644 index 000000000..0d521ac03 --- /dev/null +++ b/src/providers/seventv/SeventvCosmetics.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace chatterino::seventv { + +enum class CosmeticKind { + Badge, + Paint, + EmoteSet, + + INVALID, +}; + +} // namespace chatterino::seventv + +template <> +constexpr magic_enum::customize::customize_t + magic_enum::customize::enum_name( + chatterino::seventv::CosmeticKind value) noexcept +{ + using chatterino::seventv::CosmeticKind; + switch (value) + { + case CosmeticKind::Badge: + return "BADGE"; + case CosmeticKind::Paint: + return "PAINT"; + case CosmeticKind::EmoteSet: + return "EMOTE_SET"; + + default: + return default_tag; + } +} diff --git a/src/providers/seventv/SeventvEmotes.cpp b/src/providers/seventv/SeventvEmotes.cpp index 2f7883abc..6d64cd37a 100644 --- a/src/providers/seventv/SeventvEmotes.cpp +++ b/src/providers/seventv/SeventvEmotes.cpp @@ -1,6 +1,6 @@ #include "providers/seventv/SeventvEmotes.hpp" -#include "common/NetworkRequest.hpp" +#include "common/Literals.hpp" #include "common/NetworkResult.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" @@ -8,6 +8,7 @@ #include "messages/ImageSet.hpp" #include "messages/MessageBuilder.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" @@ -36,10 +37,6 @@ using namespace seventv::eventapi; const QString CHANNEL_HAS_NO_EMOTES("This channel has no 7TV channel emotes."); const QString EMOTE_LINK_FORMAT("https://7tv.app/emotes/%1"); -const QString API_URL_USER("https://7tv.io/v3/users/twitch/%1"); -const QString API_URL_GLOBAL_EMOTE_SET("https://7tv.io/v3/emote-sets/global"); -const QString API_URL_EMOTE_SET("https://7tv.io/v3/emote-sets/%1"); - struct CreateEmoteResult { Emote emote; EmoteId id; @@ -77,71 +74,6 @@ bool isZeroWidthRecommended(const QJsonObject &emoteData) return flags.has(SeventvEmoteFlag::ZeroWidth); } -ImageSet makeImageSet(const QJsonObject &emoteData) -{ - auto host = emoteData["host"].toObject(); - // "//cdn.7tv[...]" - auto baseUrl = host["url"].toString(); - auto files = host["files"].toArray(); - - // TODO: emit four images - std::array sizes; - double baseWidth = 0.0; - int nextSize = 0; - - for (auto fileItem : files) - { - if (nextSize >= sizes.size()) - { - break; - } - - auto file = fileItem.toObject(); - if (file["format"].toString() != "WEBP") - { - continue; // We only use webp - } - - double width = file["width"].toDouble(); - double scale = 1.0; // in relation to first image - if (baseWidth > 0.0) - { - scale = baseWidth / width; - } - else - { - // => this is the first image - baseWidth = width; - } - - auto image = Image::fromUrl( - {QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, - scale); - - sizes.at(nextSize) = image; - nextSize++; - } - - if (nextSize < sizes.size()) - { - // this should be really rare - // this means we didn't get all sizes of an emote - if (nextSize == 0) - { - qCDebug(chatterinoSeventv) - << "Got file list without any eligible files"; - // When this emote is typed, chatterino will crash. - return ImageSet{}; - } - for (; nextSize < sizes.size(); nextSize++) - { - sizes.at(nextSize) = Image::getEmpty(); - } - } - - return ImageSet{sizes[0], sizes[1], sizes[2]}; -} - Tooltip createTooltip(const QString &name, const QString &author, bool isGlobal) { return Tooltip{QString("%1
%2 7TV Emote
By: %3") @@ -172,7 +104,7 @@ CreateEmoteResult createEmote(const QJsonObject &activeEmote, ? createAliasedTooltip(emoteName.string, baseEmoteName.string, author.string, isGlobal) : createTooltip(emoteName.string, author.string, isGlobal); - auto imageSet = makeImageSet(emoteData); + auto imageSet = SeventvEmotes::createImageSet(emoteData); auto emote = Emote({emoteName, imageSet, tooltip, @@ -247,6 +179,7 @@ EmotePtr createUpdatedEmote(const EmotePtr &oldEmote, namespace chatterino { using namespace seventv::eventapi; +using namespace literals; SeventvEmotes::SeventvEmotes() : global_(std::make_shared()) @@ -275,29 +208,32 @@ void SeventvEmotes::loadGlobalEmotes() { if (!Settings::instance().enableSevenTVGlobalEmotes) { - this->global_.set(EMPTY_EMOTE_MAP); + this->setGlobalEmotes(EMPTY_EMOTE_MAP); return; } qCDebug(chatterinoSeventv) << "Loading 7TV Global Emotes"; - NetworkRequest(API_URL_GLOBAL_EMOTE_SET, NetworkRequestType::Get) - .timeout(30000) - .onSuccess([this](const NetworkResult &result) -> Outcome { - QJsonArray parsedEmotes = result.parseJson()["emotes"].toArray(); + getSeventvAPI().getEmoteSet( + u"global"_s, + [this](const auto &json) { + QJsonArray parsedEmotes = json["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, true); qCDebug(chatterinoSeventv) << "Loaded" << emoteMap.size() << "7TV Global Emotes"; - this->global_.set(std::make_shared(std::move(emoteMap))); - - return Success; - }) - .onError([](const NetworkResult &result) { + this->setGlobalEmotes( + std::make_shared(std::move(emoteMap))); + }, + [](const auto &result) { qCWarning(chatterinoSeventv) << "Couldn't load 7TV global emotes" << result.getData(); - }) - .execute(); + }); +} + +void SeventvEmotes::setGlobalEmotes(std::shared_ptr emotes) +{ + this->global_.set(std::move(emotes)); } void SeventvEmotes::loadChannelEmotes( @@ -307,13 +243,12 @@ void SeventvEmotes::loadChannelEmotes( qCDebug(chatterinoSeventv) << "Reloading 7TV Channel Emotes" << channelId << manualRefresh; - NetworkRequest(API_URL_USER.arg(channelId), NetworkRequestType::Get) - .timeout(20000) - .onSuccess([callback = std::move(callback), channel, channelId, - manualRefresh](const NetworkResult &result) -> Outcome { - auto json = result.parseJson(); - auto emoteSet = json["emote_set"].toObject(); - auto parsedEmotes = emoteSet["emotes"].toArray(); + getSeventvAPI().getUserByTwitchID( + channelId, + [callback = std::move(callback), channel, channelId, + manualRefresh](const auto &json) { + const auto emoteSet = json["emote_set"].toObject(); + const auto parsedEmotes = emoteSet["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, false); bool hasEmotes = !emoteMap.empty(); @@ -344,7 +279,7 @@ void SeventvEmotes::loadChannelEmotes( auto shared = channel.lock(); if (!shared) { - return Success; + return; } if (manualRefresh) @@ -360,46 +295,37 @@ void SeventvEmotes::loadChannelEmotes( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } } - return Success; - }) - .onError( - [channelId, channel, manualRefresh](const NetworkResult &result) { - auto shared = channel.lock(); - if (!shared) + }, + [channelId, channel, manualRefresh](const auto &result) { + auto shared = channel.lock(); + if (!shared) + { + return; + } + if (result.status() == 404) + { + qCWarning(chatterinoSeventv) + << "Error occurred fetching 7TV emotes: " + << result.parseJson(); + if (manualRefresh) { - return; - } - if (result.status() == 404) - { - qCWarning(chatterinoSeventv) - << "Error occurred fetching 7TV emotes: " - << result.parseJson(); - if (manualRefresh) - { - shared->addMessage( - makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); - } - } - else if (result.status() == NetworkResult::timedoutStatus) - { - // TODO: Auto retry in case of a timeout, with a delay - qCWarning(chatterinoSeventv) - << "Fetching 7TV emotes for channel" << channelId - << "failed due to timeout"; - shared->addMessage(makeSystemMessage( - "Failed to fetch 7TV channel emotes. (timed out)")); - } - else - { - qCWarning(chatterinoSeventv) - << "Error fetching 7TV emotes for channel" << channelId - << ", error" << result.status(); shared->addMessage( - makeSystemMessage("Failed to fetch 7TV channel " - "emotes. (unknown error)")); + makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } - }) - .execute(); + } + else + { + // TODO: Auto retry in case of a timeout, with a delay + auto errorString = result.formatError(); + qCWarning(chatterinoSeventv) + << "Error fetching 7TV emotes for channel" << channelId + << ", error" << errorString; + shared->addMessage(makeSystemMessage( + QStringLiteral("Failed to fetch 7TV channel " + "emotes. (Error: %1)") + .arg(errorString))); + } + }); } boost::optional SeventvEmotes::addEmote( @@ -479,11 +405,9 @@ void SeventvEmotes::getEmoteSet( { qCDebug(chatterinoSeventv) << "Loading 7TV Emote Set" << emoteSetId; - NetworkRequest(API_URL_EMOTE_SET.arg(emoteSetId), NetworkRequestType::Get) - .timeout(20000) - .onSuccess([callback = std::move(successCallback), - emoteSetId](const NetworkResult &result) -> Outcome { - auto json = result.parseJson(); + getSeventvAPI().getEmoteSet( + emoteSetId, + [callback = std::move(successCallback), emoteSetId](const auto &json) { auto parsedEmotes = json["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, false); @@ -492,20 +416,74 @@ void SeventvEmotes::getEmoteSet( << "7TV Emotes from" << emoteSetId; callback(std::move(emoteMap), json["name"].toString()); - return Success; - }) - .onError([emoteSetId, callback = std::move(errorCallback)]( - const NetworkResult &result) { - if (result.status() == NetworkResult::timedoutStatus) - { - callback("timed out"); - } - else - { - callback(QString("status: %1").arg(result.status())); - } - }) - .execute(); + }, + [emoteSetId, callback = std::move(errorCallback)](const auto &result) { + callback(result.formatError()); + }); +} + +ImageSet SeventvEmotes::createImageSet(const QJsonObject &emoteData) +{ + auto host = emoteData["host"].toObject(); + // "//cdn.7tv[...]" + auto baseUrl = host["url"].toString(); + auto files = host["files"].toArray(); + + std::array sizes; + double baseWidth = 0.0; + size_t nextSize = 0; + + for (auto fileItem : files) + { + if (nextSize >= sizes.size()) + { + break; + } + + auto file = fileItem.toObject(); + if (file["format"].toString() != "WEBP") + { + continue; // We only use webp + } + + double width = file["width"].toDouble(); + double scale = 1.0; // in relation to first image + if (baseWidth > 0.0) + { + scale = baseWidth / width; + } + else + { + // => this is the first image + baseWidth = width; + } + + auto image = Image::fromUrl( + {QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, + scale); + + sizes.at(nextSize) = image; + nextSize++; + } + + if (nextSize < sizes.size()) + { + // this should be really rare + // this means we didn't get all sizes of an emote + if (nextSize == 0) + { + qCDebug(chatterinoSeventv) + << "Got file list without any eligible files"; + // When this emote is typed, chatterino will crash. + return ImageSet{}; + } + for (; nextSize < sizes.size(); nextSize++) + { + sizes.at(nextSize) = Image::getEmpty(); + } + } + + return ImageSet{sizes[0], sizes[1], sizes[2]}; } } // namespace chatterino diff --git a/src/providers/seventv/SeventvEmotes.hpp b/src/providers/seventv/SeventvEmotes.hpp index f978337be..e8a59f13b 100644 --- a/src/providers/seventv/SeventvEmotes.hpp +++ b/src/providers/seventv/SeventvEmotes.hpp @@ -5,12 +5,14 @@ #include "common/Atomic.hpp" #include "common/FlagsEnum.hpp" +#include + #include namespace chatterino { +class ImageSet; class Channel; - namespace seventv::eventapi { struct EmoteAddDispatch; struct EmoteUpdateDispatch; @@ -61,6 +63,20 @@ struct Emote; using EmotePtr = std::shared_ptr; class EmoteMap; +enum class SeventvEmoteSetKind : uint8_t { + Global, + Personal, + Channel, +}; + +enum class SeventvEmoteSetFlag : uint32_t { + Immutable = (1 << 0), + Privileged = (1 << 1), + Personal = (1 << 2), + Commercial = (1 << 3), +}; +using SeventvEmoteSetFlags = FlagsEnum; + class SeventvEmotes final { public: @@ -75,6 +91,7 @@ public: std::shared_ptr globalEmotes() const; boost::optional globalEmote(const EmoteName &name) const; void loadGlobalEmotes(); + void setGlobalEmotes(std::shared_ptr emotes); static void loadChannelEmotes( const std::weak_ptr &channel, const QString &channelId, std::function callback, @@ -119,6 +136,13 @@ public: std::function successCallback, std::function errorCallback); + /** + * Creates an image set from a 7TV emote or badge. + * + * @param emoteData { host: { files: [], url } } + */ + static ImageSet createImageSet(const QJsonObject &emoteData); + private: Atomic> global_; }; diff --git a/src/providers/seventv/SeventvEventAPI.cpp b/src/providers/seventv/SeventvEventAPI.cpp index 5cec6ed30..5b1ebaf77 100644 --- a/src/providers/seventv/SeventvEventAPI.cpp +++ b/src/providers/seventv/SeventvEventAPI.cpp @@ -1,8 +1,11 @@ #include "providers/seventv/SeventvEventAPI.hpp" +#include "Application.hpp" #include "providers/seventv/eventapi/Client.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" #include "providers/seventv/eventapi/Message.hpp" +#include "providers/seventv/SeventvBadges.hpp" +#include "providers/seventv/SeventvCosmetics.hpp" #include @@ -10,6 +13,7 @@ namespace chatterino { +using namespace seventv; using namespace seventv::eventapi; SeventvEventAPI::SeventvEventAPI( @@ -35,6 +39,25 @@ void SeventvEventAPI::subscribeUser(const QString &userID, } } +void SeventvEventAPI::subscribeTwitchChannel(const QString &id) +{ + if (this->subscribedTwitchChannels_.insert(id).second) + { + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::CreateCosmetic, + }); + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::CreateEntitlement, + }); + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::DeleteEntitlement, + }); + } +} + void SeventvEventAPI::unsubscribeEmoteSet(const QString &id) { if (this->subscribedEmoteSets_.erase(id) > 0) @@ -53,6 +76,25 @@ void SeventvEventAPI::unsubscribeUser(const QString &id) } } +void SeventvEventAPI::unsubscribeTwitchChannel(const QString &id) +{ + if (this->subscribedTwitchChannels_.erase(id) > 0) + { + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::CreateCosmetic, + }); + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::CreateEntitlement, + }); + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::DeleteEntitlement, + }); + } +} + std::shared_ptr> SeventvEventAPI::createClient( liveupdates::WebsocketClient &client, websocketpp::connection_hdl hdl) { @@ -144,9 +186,49 @@ void SeventvEventAPI::handleDispatch(const Dispatch &dispatch) this->onUserUpdate(dispatch); } break; + case SubscriptionType::CreateCosmetic: { + const CosmeticCreateDispatch cosmetic(dispatch); + if (cosmetic.validate()) + { + this->onCosmeticCreate(cosmetic); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid cosmetic dispatch" << dispatch.body; + } + } + break; + case SubscriptionType::CreateEntitlement: { + const EntitlementCreateDeleteDispatch entitlement(dispatch); + if (entitlement.validate()) + { + this->onEntitlementCreate(entitlement); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid entitlement create dispatch" << dispatch.body; + } + } + break; + case SubscriptionType::DeleteEntitlement: { + const EntitlementCreateDeleteDispatch entitlement(dispatch); + if (entitlement.validate()) + { + this->onEntitlementDelete(entitlement); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid entitlement delete dispatch" << dispatch.body; + } + } + break; default: { qCDebug(chatterinoSeventvEventAPI) - << "Unknown subscription type:" << (int)dispatch.type + << "Unknown subscription type:" + << magic_enum::enum_name(dispatch.type).data() << "body:" << dispatch.body; } break; @@ -261,4 +343,59 @@ void SeventvEventAPI::onUserUpdate(const Dispatch &dispatch) } } +// NOLINTBEGIN(readability-convert-member-functions-to-static) + +void SeventvEventAPI::onCosmeticCreate(const CosmeticCreateDispatch &cosmetic) +{ + // We're using `Application::instance` instead of getApp(), because we're not in the GUI thread. + // `seventvBadges` does its own locking. + auto *badges = Application::instance->seventvBadges; + switch (cosmetic.kind) + { + case CosmeticKind::Badge: { + badges->registerBadge(cosmetic.data); + } + break; + default: + break; + } +} + +void SeventvEventAPI::onEntitlementCreate( + const EntitlementCreateDeleteDispatch &entitlement) +{ + // We're using `Application::instance` instead of getApp(), because we're not in the GUI thread. + // `seventvBadges` does its own locking. + auto *badges = Application::instance->seventvBadges; + switch (entitlement.kind) + { + case CosmeticKind::Badge: { + badges->assignBadgeToUser(entitlement.refID, + UserId{entitlement.userID}); + } + break; + default: + break; + } +} + +void SeventvEventAPI::onEntitlementDelete( + const EntitlementCreateDeleteDispatch &entitlement) +{ + // We're using `Application::instance` instead of getApp(), because we're not in the GUI thread. + // `seventvBadges` does its own locking. + auto *badges = Application::instance->seventvBadges; + switch (entitlement.kind) + { + case CosmeticKind::Badge: { + badges->clearBadgeFromUser(entitlement.refID, + UserId{entitlement.userID}); + } + break; + default: + break; + } +} +// NOLINTEND(readability-convert-member-functions-to-static) + } // namespace chatterino diff --git a/src/providers/seventv/SeventvEventAPI.hpp b/src/providers/seventv/SeventvEventAPI.hpp index 5672e59b8..6a4827318 100644 --- a/src/providers/seventv/SeventvEventAPI.hpp +++ b/src/providers/seventv/SeventvEventAPI.hpp @@ -15,8 +15,12 @@ namespace seventv::eventapi { struct EmoteUpdateDispatch; struct EmoteRemoveDispatch; struct UserConnectionUpdateDispatch; + struct CosmeticCreateDispatch; + struct EntitlementCreateDeleteDispatch; } // namespace seventv::eventapi +class SeventvBadges; + class SeventvEventAPI : public BasicPubSubManager { @@ -44,11 +48,20 @@ public: * @param emoteSetID 7TV emote-set-id, may be empty. */ void subscribeUser(const QString &userID, const QString &emoteSetID); + /** + * Subscribes to cosmetics and entitlements in a Twitch channel + * if not already subscribed. + * + * @param id Twitch channel id + */ + void subscribeTwitchChannel(const QString &id); /** Unsubscribes from a user by its 7TV user id */ void unsubscribeUser(const QString &id); /** Unsubscribes from an emote-set by its id */ void unsubscribeEmoteSet(const QString &id); + /** Unsubscribes from cosmetics and entitlements in a Twitch channel */ + void unsubscribeTwitchChannel(const QString &id); protected: std::shared_ptr> @@ -64,11 +77,19 @@ private: void onEmoteSetUpdate(const seventv::eventapi::Dispatch &dispatch); void onUserUpdate(const seventv::eventapi::Dispatch &dispatch); + void onCosmeticCreate( + const seventv::eventapi::CosmeticCreateDispatch &cosmetic); + void onEntitlementCreate( + const seventv::eventapi::EntitlementCreateDeleteDispatch &entitlement); + void onEntitlementDelete( + const seventv::eventapi::EntitlementCreateDeleteDispatch &entitlement); /** emote-set ids */ std::unordered_set subscribedEmoteSets_; /** user ids */ std::unordered_set subscribedUsers_; + /** Twitch channel ids */ + std::unordered_set subscribedTwitchChannels_; std::chrono::milliseconds heartbeatInterval_; }; diff --git a/src/providers/seventv/eventapi/Dispatch.cpp b/src/providers/seventv/eventapi/Dispatch.cpp index bb4b4fa1d..03fbdac97 100644 --- a/src/providers/seventv/eventapi/Dispatch.cpp +++ b/src/providers/seventv/eventapi/Dispatch.cpp @@ -1,5 +1,7 @@ #include "providers/seventv/eventapi/Dispatch.hpp" +#include + #include namespace chatterino::seventv::eventapi { @@ -91,4 +93,45 @@ bool UserConnectionUpdateDispatch::validate() const !this->emoteSetID.isEmpty(); } +CosmeticCreateDispatch::CosmeticCreateDispatch(const Dispatch &dispatch) + : data(dispatch.body["object"]["data"].toObject()) + , kind(magic_enum::enum_cast( + dispatch.body["object"]["kind"].toString().toStdString()) + .value_or(CosmeticKind::INVALID)) +{ +} + +bool CosmeticCreateDispatch::validate() const +{ + return !this->data.empty() && this->kind != CosmeticKind::INVALID; +} + +EntitlementCreateDeleteDispatch::EntitlementCreateDeleteDispatch( + const Dispatch &dispatch) +{ + const auto obj = dispatch.body["object"].toObject(); + this->refID = obj["ref_id"].toString(); + this->kind = magic_enum::enum_cast( + obj["kind"].toString().toStdString()) + .value_or(CosmeticKind::INVALID); + + const auto userConnections = obj["user"]["connections"].toArray(); + for (const auto &connectionJson : userConnections) + { + const auto connection = connectionJson.toObject(); + if (connection["platform"].toString() == "TWITCH") + { + this->userID = connection["id"].toString(); + this->userName = connection["username"].toString(); + break; + } + } +} + +bool EntitlementCreateDeleteDispatch::validate() const +{ + return !this->userID.isEmpty() && !this->userName.isEmpty() && + !this->refID.isEmpty() && this->kind != CosmeticKind::INVALID; +} + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Dispatch.hpp b/src/providers/seventv/eventapi/Dispatch.hpp index 666f5c28a..04bad159b 100644 --- a/src/providers/seventv/eventapi/Dispatch.hpp +++ b/src/providers/seventv/eventapi/Dispatch.hpp @@ -1,6 +1,7 @@ #pragma once #include "providers/seventv/eventapi/Subscription.hpp" +#include "providers/seventv/SeventvCosmetics.hpp" #include #include @@ -67,4 +68,26 @@ struct UserConnectionUpdateDispatch { bool validate() const; }; +struct CosmeticCreateDispatch { + QJsonObject data; + CosmeticKind kind; + + CosmeticCreateDispatch(const Dispatch &dispatch); + + bool validate() const; +}; + +struct EntitlementCreateDeleteDispatch { + /** id of the user */ + QString userID; + QString userName; + /** id of the entitlement */ + QString refID; + CosmeticKind kind; + + EntitlementCreateDeleteDispatch(const Dispatch &dispatch); + + bool validate() const; +}; + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Subscription.cpp b/src/providers/seventv/eventapi/Subscription.cpp index 1de1f667e..91d330c5e 100644 --- a/src/providers/seventv/eventapi/Subscription.cpp +++ b/src/providers/seventv/eventapi/Subscription.cpp @@ -102,4 +102,34 @@ QDebug &operator<<(QDebug &dbg, const ObjectIDCondition &condition) return dbg; } +ChannelCondition::ChannelCondition(QString twitchID) + : twitchID(std::move(twitchID)) +{ +} + +QJsonObject ChannelCondition::encode() const +{ + QJsonObject obj; + obj["ctx"] = "channel"; + obj["platform"] = "TWITCH"; + obj["id"] = this->twitchID; + return obj; +} + +QDebug &operator<<(QDebug &dbg, const ChannelCondition &condition) +{ + dbg << "{ twitchID:" << condition.twitchID << '}'; + return dbg; +} + +bool ChannelCondition::operator==(const ChannelCondition &rhs) const +{ + return this->twitchID == rhs.twitchID; +} + +bool ChannelCondition::operator!=(const ChannelCondition &rhs) const +{ + return !(*this == rhs); +} + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Subscription.hpp b/src/providers/seventv/eventapi/Subscription.hpp index 53143fbd8..1a36811a5 100644 --- a/src/providers/seventv/eventapi/Subscription.hpp +++ b/src/providers/seventv/eventapi/Subscription.hpp @@ -12,9 +12,22 @@ namespace chatterino::seventv::eventapi { // https://github.com/SevenTV/EventAPI/tree/ca4ff15cc42b89560fa661a76c5849047763d334#subscription-types enum class SubscriptionType { + AnyEmoteSet, + CreateEmoteSet, UpdateEmoteSet, + UpdateUser, + AnyCosmetic, + CreateCosmetic, + UpdateCosmetic, + DeleteCosmetic, + + AnyEntitlement, + CreateEntitlement, + UpdateEntitlement, + DeleteEntitlement, + INVALID, }; @@ -46,7 +59,19 @@ struct ObjectIDCondition { bool operator!=(const ObjectIDCondition &rhs) const; }; -using Condition = std::variant; +struct ChannelCondition { + ChannelCondition(QString twitchID); + + QString twitchID; + + QJsonObject encode() const; + + friend QDebug &operator<<(QDebug &dbg, const ChannelCondition &condition); + bool operator==(const ChannelCondition &rhs) const; + bool operator!=(const ChannelCondition &rhs) const; +}; + +using Condition = std::variant; struct Subscription { bool operator==(const Subscription &rhs) const; @@ -70,10 +95,30 @@ constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< using chatterino::seventv::eventapi::SubscriptionType; switch (value) { + case SubscriptionType::AnyEmoteSet: + return "emote_set.*"; + case SubscriptionType::CreateEmoteSet: + return "emote_set.create"; case SubscriptionType::UpdateEmoteSet: return "emote_set.update"; case SubscriptionType::UpdateUser: return "user.update"; + case SubscriptionType::AnyCosmetic: + return "cosmetic.*"; + case SubscriptionType::CreateCosmetic: + return "cosmetic.create"; + case SubscriptionType::UpdateCosmetic: + return "cosmetic.update"; + case SubscriptionType::DeleteCosmetic: + return "cosmetic.delete"; + case SubscriptionType::AnyEntitlement: + return "entitlement.*"; + case SubscriptionType::CreateEntitlement: + return "entitlement.create"; + case SubscriptionType::UpdateEntitlement: + return "entitlement.update"; + case SubscriptionType::DeleteEntitlement: + return "entitlement.delete"; default: return default_tag; @@ -91,6 +136,15 @@ struct hash { } }; +template <> +struct hash { + size_t operator()( + const chatterino::seventv::eventapi::ChannelCondition &c) const + { + return qHash(c.twitchID); + } +}; + template <> struct hash { size_t operator()( diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 43fa8d49c..fa3cbaf7b 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -1,8 +1,10 @@ #include "IrcMessageHandler.hpp" #include "Application.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/ignores/IgnoreController.hpp" #include "messages/LimitedQueue.hpp" #include "messages/Link.hpp" #include "messages/Message.hpp" @@ -26,6 +28,8 @@ #include "util/StreamerMode.hpp" #include +#include +#include #include #include @@ -119,37 +123,60 @@ void updateReplyParticipatedStatus(const QVariantMap &tags, { const auto ¤tLogin = getApp()->accounts->twitch.getCurrent()->getUserName(); - if (thread->participated()) + + if (thread->subscribed()) { - builder.message().flags.set(MessageFlag::ParticipatedThread); + builder.message().flags.set(MessageFlag::SubscribedThread); return; } - if (isNew) + if (thread->unsubscribed()) { - if (const auto it = tags.find("reply-parent-user-login"); - it != tags.end()) - { - auto name = it.value().toString(); - if (name == currentLogin) - { - thread->markParticipated(); - builder.message().flags.set(MessageFlag::ParticipatedThread); - return; // already marked as participated - } - } + return; } - if (senderLogin == currentLogin) + if (getSettings()->autoSubToParticipatedThreads) { - thread->markParticipated(); - // don't set the highlight here + if (isNew) + { + if (const auto it = tags.find("reply-parent-user-login"); + it != tags.end()) + { + auto name = it.value().toString(); + if (name == currentLogin) + { + thread->markSubscribed(); + builder.message().flags.set(MessageFlag::SubscribedThread); + return; // already marked as participated + } + } + } + + if (senderLogin == currentLogin) + { + thread->markSubscribed(); + // don't set the highlight here + } } } +ChannelPtr channelOrEmptyByTarget(const QString &target, + TwitchIrcServer &server) +{ + QString channelName; + if (!trimChannelName(target, channelName)) + { + return Channel::getEmpty(); + } + + return server.getChannelOrEmpty(channelName); +} + } // namespace namespace chatterino { +using namespace literals; + static float relativeSimilarity(const QString &str1, const QString &str2) { // Longest Common Substring Problem @@ -305,6 +332,16 @@ std::vector IrcMessageHandler::parsePrivMessage( builtMessages.emplace_back(builder.build()); builder.triggerHighlights(); } + + if (message->tags().contains(u"pinned-chat-paid-amount"_s)) + { + auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + if (ptr) + { + builtMessages.emplace_back(std::move(ptr)); + } + } + return builtMessages; } @@ -321,6 +358,21 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, message, message->target(), message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, false, message->isAction()); + + auto chan = channelOrEmptyByTarget(message->target(), server); + if (chan->isEmpty()) + { + return; + } + + if (message->tags().contains(u"pinned-chat-paid-amount"_s)) + { + auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + if (ptr) + { + chan->addMessage(ptr); + } + } } std::vector IrcMessageHandler::parseMessageWithReply( @@ -433,13 +485,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, TwitchIrcServer &server, bool isSub, bool isAction) { - QString channelName; - if (!trimChannelName(target, channelName)) - { - return; - } - - auto chan = server.getChannelOrEmpty(channelName); + auto chan = channelOrEmptyByTarget(target, server); if (chan->isEmpty()) { @@ -847,6 +893,16 @@ std::vector IrcMessageHandler::parseUserNoticeMessage( content = parameters[1]; } + if (isIgnoredMessage({ + .message = content, + .twitchUserID = tags.value("user-id").toString(), + .isMod = channel->isMod(), + .isBroadcaster = channel->isBroadcaster(), + })) + { + return {}; + } + if (specialMessageTypes.contains(msgType)) { // Messages are not required, so they might be empty @@ -908,6 +964,17 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, content = parameters[1]; } + auto chn = server.getChannelOrEmpty(target); + if (isIgnoredMessage({ + .message = content, + .twitchUserID = tags.value("user-id").toString(), + .isMod = chn->isMod(), + .isBroadcaster = chn->isBroadcaster(), + })) + { + return; + } + if (specialMessageTypes.contains(msgType)) { // Messages are not required, so they might be empty @@ -1145,9 +1212,12 @@ void IrcMessageHandler::handleJoinMessage(Communi::IrcMessage *message) return; } - if (message->nick() != - getApp()->accounts->twitch.getCurrent()->getUserName() && - getSettings()->showJoins.getValue()) + if (message->nick() == + getApp()->accounts->twitch.getCurrent()->getUserName()) + { + twitchChannel->addMessage(makeSystemMessage("joined channel")); + } + else if (getSettings()->showJoins.getValue()) { twitchChannel->addJoinedUser(message->nick()); } diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 789ed8339..6f597c466 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -3,18 +3,20 @@ #include "Application.hpp" #include "common/Channel.hpp" #include "common/Env.hpp" -#include "common/NetworkRequest.hpp" +#include "common/NetworkResult.hpp" #include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "debug/AssertInGuiThread.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/irc/IrcMessageBuilder.hpp" #include "providers/IvrApi.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchCommon.hpp" -#include "providers/twitch/TwitchUser.hpp" #include "singletons/Emotes.hpp" +#include "util/CancellationToken.hpp" #include "util/Helpers.hpp" #include "util/QStringHash.hpp" #include "util/RapidjsonHelpers.hpp" @@ -100,77 +102,79 @@ bool TwitchAccount::isAnon() const void TwitchAccount::loadBlocks() { + assertInGuiThread(); + + auto token = CancellationToken(false); + this->blockToken_ = token; + this->ignores_.clear(); + this->ignoresUserIds_.clear(); + getHelix()->loadBlocks( getIApp()->getAccounts()->twitch.getCurrent()->userId_, - [this](std::vector blocks) { - auto ignores = this->ignores_.access(); - auto userIds = this->ignoresUserIds_.access(); - ignores->clear(); - userIds->clear(); + [this](const std::vector &blocks) { + assertInGuiThread(); for (const HelixBlock &block : blocks) { TwitchUser blockedUser; blockedUser.fromHelixBlock(block); - ignores->insert(blockedUser); - userIds->insert(blockedUser.id); + this->ignores_.insert(blockedUser); + this->ignoresUserIds_.insert(blockedUser.id); } }, - [] { - qCWarning(chatterinoTwitch) << "Fetching blocks failed!"; - }); + [](auto error) { + qCWarning(chatterinoTwitch).noquote() + << "Fetching blocks failed:" << error; + }, + std::move(token)); } -void TwitchAccount::blockUser(QString userId, std::function onSuccess, +void TwitchAccount::blockUser(const QString &userId, const QObject *caller, + std::function onSuccess, std::function onFailure) { getHelix()->blockUser( - userId, - [this, userId, onSuccess] { + userId, caller, + [this, userId, onSuccess = std::move(onSuccess)] { + assertInGuiThread(); + TwitchUser blockedUser; blockedUser.id = userId; - { - auto ignores = this->ignores_.access(); - auto userIds = this->ignoresUserIds_.access(); - - ignores->insert(blockedUser); - userIds->insert(blockedUser.id); - } + this->ignores_.insert(blockedUser); + this->ignoresUserIds_.insert(blockedUser.id); onSuccess(); }, std::move(onFailure)); } -void TwitchAccount::unblockUser(QString userId, std::function onSuccess, +void TwitchAccount::unblockUser(const QString &userId, const QObject *caller, + std::function onSuccess, std::function onFailure) { getHelix()->unblockUser( - userId, - [this, userId, onSuccess] { + userId, caller, + [this, userId, onSuccess = std::move(onSuccess)] { + assertInGuiThread(); + TwitchUser ignoredUser; ignoredUser.id = userId; - { - auto ignores = this->ignores_.access(); - auto userIds = this->ignoresUserIds_.access(); - - ignores->erase(ignoredUser); - userIds->erase(ignoredUser.id); - } + this->ignores_.erase(ignoredUser); + this->ignoresUserIds_.erase(ignoredUser.id); onSuccess(); }, std::move(onFailure)); } -SharedAccessGuard> TwitchAccount::accessBlocks() - const +const std::unordered_set &TwitchAccount::blocks() const { - return this->ignores_.accessConst(); + assertInGuiThread(); + return this->ignores_; } -SharedAccessGuard> TwitchAccount::accessBlockedUserIds() - const +const std::unordered_set &TwitchAccount::blockedUserIds() const { - return this->ignoresUserIds_.accessConst(); + assertInGuiThread(); + return this->ignoresUserIds_; } void TwitchAccount::loadEmotes(std::weak_ptr weakChannel) @@ -442,4 +446,36 @@ void TwitchAccount::autoModDeny(const QString msgID, ChannelPtr channel) }); } +const QString &TwitchAccount::getSeventvUserID() const +{ + return this->seventvUserID_; +} + +void TwitchAccount::loadSeventvUserID() +{ + if (this->isAnon()) + { + return; + } + if (!this->seventvUserID_.isEmpty()) + { + return; + } + + getSeventvAPI().getUserByTwitchID( + this->getUserId(), + [this](const auto &json) { + const auto id = json["user"]["id"].toString(); + if (!id.isEmpty()) + { + this->seventvUserID_ = id; + } + return Success; + }, + [](const auto &result) { + qCDebug(chatterinoSeventv) + << "Failed to load 7TV user-id:" << result.formatError(); + }); +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index 7e4b69d2e..00e220ca8 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -5,21 +5,23 @@ #include "common/UniqueAccess.hpp" #include "controllers/accounts/Account.hpp" #include "messages/Emote.hpp" +#include "providers/twitch/TwitchUser.hpp" +#include "util/CancellationToken.hpp" #include "util/QStringHash.hpp" #include #include +#include #include #include #include #include -#include +#include #include namespace chatterino { -struct TwitchUser; class Channel; using ChannelPtr = std::shared_ptr; @@ -57,6 +59,12 @@ public: const QString &getOAuthClient() const; const QString &getUserId() const; + /** + * The Seventv user-id of the current user. + * Empty if there's no associated Seventv user with this twitch user. + */ + const QString &getSeventvUserID() const; + QColor color(); void setColor(QColor color); @@ -71,13 +79,15 @@ public: bool isAnon() const; void loadBlocks(); - void blockUser(QString userId, std::function onSuccess, + void blockUser(const QString &userId, const QObject *caller, + std::function onSuccess, std::function onFailure); - void unblockUser(QString userId, std::function onSuccess, + void unblockUser(const QString &userId, const QObject *caller, + std::function onSuccess, std::function onFailure); - SharedAccessGuard> accessBlockedUserIds() const; - SharedAccessGuard> accessBlocks() const; + [[nodiscard]] const std::unordered_set &blocks() const; + [[nodiscard]] const std::unordered_set &blockedUserIds() const; void loadEmotes(std::weak_ptr weakChannel = {}); // loadUserstateEmotes loads emote sets that are part of the USERSTATE emote-sets key @@ -94,6 +104,8 @@ public: void autoModAllow(const QString msgID, ChannelPtr channel); void autoModDeny(const QString msgID, ChannelPtr channel); + void loadSeventvUserID(); + private: QString oauthClient_; QString oauthToken_; @@ -102,14 +114,17 @@ private: const bool isAnon_; Atomic color_; - mutable std::mutex ignoresMutex_; QStringList userstateEmoteSets_; - UniqueAccess> ignores_; - UniqueAccess> ignoresUserIds_; + + ScopedCancellationToken blockToken_; + std::unordered_set ignores_; + std::unordered_set ignoresUserIds_; // std::map emotes; UniqueAccess emotes_; UniqueAccess> localEmotes_; + + QString seventvUserID_; }; } // namespace chatterino diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index 6b02d3bb2..65a5f3a39 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -17,6 +17,7 @@ TwitchAccountManager::TwitchAccountManager() this->currentUserChanged.connect([this] { auto currentUser = this->getCurrent(); currentUser->loadBlocks(); + currentUser->loadSeventvUserID(); }); this->accounts.itemRemoved.connect([this](const auto &acc) { diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 4a81da243..c34a3a039 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -8,6 +8,7 @@ #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "controllers/twitch/LiveController.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/Link.hpp" @@ -19,6 +20,7 @@ #include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp" #include "providers/RecentMessagesApi.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/seventv/SeventvEmotes.hpp" #include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/api/Helix.hpp" @@ -39,6 +41,7 @@ #include #include +#include #include #include #include @@ -72,7 +75,7 @@ namespace { TwitchChannel::TwitchChannel(const QString &name) : Channel(name, Channel::Type::Twitch) , ChannelChatters(*static_cast(this)) - , nameOptions{name, 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=" + @@ -98,14 +101,15 @@ TwitchChannel::TwitchChannel(const QString &name) // room id loaded -> refresh live status this->roomIdChanged.connect([this]() { this->refreshPubSub(); - this->refreshTitle(); - this->refreshLiveStatus(); this->refreshBadges(); this->refreshCheerEmotes(); this->refreshFFZChannelEmotes(false); this->refreshBTTVChannelEmotes(false); this->refreshSevenTVChannelEmotes(false); this->joinBttvChannel(); + this->listenSevenTVCosmetics(); + getIApp()->getTwitchLiveController()->add( + std::dynamic_pointer_cast(shared_from_this())); }); this->connected.connect([this]() { @@ -151,6 +155,85 @@ TwitchChannel::TwitchChannel(const QString &name) }); this->threadClearTimer_.start(5 * 60 * 1000); + auto onLiveStatusChanged = [this](auto isLive) { + if (isLive) + { + qCDebug(chatterinoTwitch) + << "[TwitchChannel" << this->getName() << "] Online"; + if (getApp()->notifications->isChannelNotified(this->getName(), + Platform::Twitch)) + { + if (Toasts::isEnabled()) + { + getApp()->toasts->sendChannelNotification( + this->getName(), this->accessStreamStatus()->title, + Platform::Twitch); + } + if (getSettings()->notificationPlaySound) + { + getApp()->notifications->playSound(); + } + if (getSettings()->notificationFlashTaskbar) + { + getApp()->windows->sendAlert(); + } + } + // Channel live message + MessageBuilder builder; + TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), + &builder); + this->addMessage(builder.release()); + + // Message in /live channel + MessageBuilder builder2; + TwitchMessageBuilder::liveMessage(this->getDisplayName(), + &builder2); + getApp()->twitch->liveChannel->addMessage(builder2.release()); + + // Notify on all channels with a ping sound + if (getSettings()->notificationOnAnyChannel && + !(isInStreamerMode() && + getSettings()->streamerModeSuppressLiveNotifications)) + { + getApp()->notifications->playSound(); + } + } + else + { + qCDebug(chatterinoTwitch) + << "[TwitchChannel" << this->getName() << "] Offline"; + // Channel offline message + MessageBuilder builder; + TwitchMessageBuilder::offlineSystemMessage(this->getDisplayName(), + &builder); + this->addMessage(builder.release()); + + // "delete" old 'CHANNEL is live' message + LimitedQueueSnapshot snapshot = + getApp()->twitch->liveChannel->getMessageSnapshot(); + int snapshotLength = snapshot.size(); + + // MSVC hates this code if the parens are not there + int end = (std::max)(0, snapshotLength - 200); + auto liveMessageSearchText = + QString("%1 is live!").arg(this->getDisplayName()); + + for (int i = snapshotLength - 1; i >= end; --i) + { + const auto &s = snapshot[i]; + + if (s->messageText == liveMessageSearchText) + { + s->flags.set(MessageFlag::Disabled); + break; + } + } + } + }; + + this->signalHolder_.managedConnect(this->liveStatusChanged, + onLiveStatusChanged); + // debugging #if 0 for (int i = 0; i < 1000; i++) { @@ -168,11 +251,16 @@ TwitchChannel::~TwitchChannel() { getApp()->twitch->bttvLiveUpdates->partChannel(this->roomId()); } + + if (getApp()->twitch->seventvEventAPI) + { + getApp()->twitch->seventvEventAPI->unsubscribeTwitchChannel( + this->roomId()); + } } void TwitchChannel::initialize() { - this->fetchDisplayName(); this->refreshChatters(); this->refreshBadges(); } @@ -340,6 +428,91 @@ boost::optional TwitchChannel::channelPointReward( return it->second; } +void TwitchChannel::updateStreamStatus( + const std::optional &helixStream) +{ + if (helixStream) + { + auto stream = *helixStream; + { + auto status = this->streamStatus_.access(); + status->viewerCount = stream.viewerCount; + status->gameId = stream.gameId; + status->game = stream.gameName; + status->title = stream.title; + QDateTime since = + QDateTime::fromString(stream.startedAt, Qt::ISODate); + auto diff = since.secsTo(QDateTime::currentDateTime()); + status->uptime = QString::number(diff / 3600) + "h " + + QString::number(diff % 3600 / 60) + "m"; + + status->rerun = false; + status->streamType = stream.type; + } + if (this->setLive(true)) + { + this->liveStatusChanged.invoke(true); + } + this->streamStatusChanged.invoke(); + } + else + { + if (this->setLive(false)) + { + this->liveStatusChanged.invoke(false); + this->streamStatusChanged.invoke(); + } + } +} + +void TwitchChannel::updateStreamTitle(const QString &title) +{ + { + auto status = this->streamStatus_.access(); + if (status->title == title) + { + // Title has not changed + return; + } + status->title = title; + } + this->streamStatusChanged.invoke(); +} + +void TwitchChannel::updateDisplayName(const QString &displayName) +{ + if (displayName == this->nameOptions.actualDisplayName) + { + // Display name has not changed + return; + } + + // Display name has changed + + this->nameOptions.actualDisplayName = displayName; + + if (QString::compare(displayName, this->getName(), Qt::CaseInsensitive) == + 0) + { + // Display name is only a case variation of the login name + this->setDisplayName(displayName); + + this->setLocalizedName(displayName); + } + else + { + // Display name contains Chinese, Japanese, or Korean characters + this->setDisplayName(this->getName()); + + this->setLocalizedName( + QString("%1(%2)").arg(this->getName()).arg(displayName)); + } + + this->addRecentChatter(this->getDisplayName()); + + this->displayNameChanged.invoke(); +} + void TwitchChannel::showLoginMessage() { const auto linkColor = MessageColor(MessageColor::Link); @@ -436,6 +609,7 @@ void TwitchChannel::sendMessage(const QString &message) bool messageSent = false; this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent); + this->updateSevenTVActivity(); if (messageSent) { @@ -909,183 +1083,16 @@ int TwitchChannel::chatterCount() return this->chatterCount_; } -void TwitchChannel::setLive(bool newLiveStatus) +bool TwitchChannel::setLive(bool newLiveStatus) { + auto guard = this->streamStatus_.access(); + if (guard->live == newLiveStatus) { - auto guard = this->streamStatus_.access(); - if (guard->live == newLiveStatus) - { - return; - } - guard->live = newLiveStatus; + return false; } + guard->live = newLiveStatus; - if (newLiveStatus) - { - if (getApp()->notifications->isChannelNotified(this->getName(), - Platform::Twitch)) - { - if (Toasts::isEnabled()) - { - getApp()->toasts->sendChannelNotification( - this->getName(), this->accessStreamStatus()->title, - Platform::Twitch); - } - if (getSettings()->notificationPlaySound) - { - getApp()->notifications->playSound(); - } - if (getSettings()->notificationFlashTaskbar) - { - getApp()->windows->sendAlert(); - } - } - // Channel live message - MessageBuilder builder; - TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), - &builder); - this->addMessage(builder.release()); - - // Message in /live channel - MessageBuilder builder2; - TwitchMessageBuilder::liveMessage(this->getDisplayName(), &builder2); - getApp()->twitch->liveChannel->addMessage(builder2.release()); - - // Notify on all channels with a ping sound - if (getSettings()->notificationOnAnyChannel && - !(isInStreamerMode() && - getSettings()->streamerModeSuppressLiveNotifications)) - { - getApp()->notifications->playSound(); - } - } - else - { - // Channel offline message - MessageBuilder builder; - TwitchMessageBuilder::offlineSystemMessage(this->getDisplayName(), - &builder); - this->addMessage(builder.release()); - - // "delete" old 'CHANNEL is live' message - LimitedQueueSnapshot snapshot = - getApp()->twitch->liveChannel->getMessageSnapshot(); - int snapshotLength = snapshot.size(); - - // MSVC hates this code if the parens are not there - int end = (std::max)(0, snapshotLength - 200); - auto liveMessageSearchText = - QString("%1 is live!").arg(this->getDisplayName()); - - for (int i = snapshotLength - 1; i >= end; --i) - { - auto &s = snapshot[i]; - - if (s->messageText == liveMessageSearchText) - { - s->flags.set(MessageFlag::Disabled); - break; - } - } - } - - this->liveStatusChanged.invoke(); -} - -void TwitchChannel::refreshTitle() -{ - // timer has never started, proceed and start it - if (!this->titleRefreshedTimer_.isValid()) - { - this->titleRefreshedTimer_.start(); - } - else if (this->roomId().isEmpty() || - this->titleRefreshedTimer_.elapsed() < TITLE_REFRESH_PERIOD) - { - return; - } - this->titleRefreshedTimer_.restart(); - - getHelix()->getChannel( - this->roomId(), - [this, weak = weakOf(this)](HelixChannel channel) { - ChannelPtr shared = weak.lock(); - - if (!shared) - { - return; - } - - { - auto status = this->streamStatus_.access(); - status->title = channel.title; - } - - this->liveStatusChanged.invoke(); - }, - [] { - // failure - }); -} - -void TwitchChannel::refreshLiveStatus() -{ - auto roomID = this->roomId(); - - if (roomID.isEmpty()) - { - qCDebug(chatterinoTwitch) << "[TwitchChannel" << this->getName() - << "] Refreshing live status (Missing ID)"; - this->setLive(false); - return; - } - - getHelix()->getStreamById( - roomID, - [this, weak = weakOf(this)](bool live, const auto &stream) { - ChannelPtr shared = weak.lock(); - if (!shared) - { - return; - } - - this->parseLiveStatus(live, stream); - }, - [] { - // failure - }, - [] { - // finally - }); -} - -void TwitchChannel::parseLiveStatus(bool live, const HelixStream &stream) -{ - if (!live) - { - this->setLive(false); - return; - } - - { - auto status = this->streamStatus_.access(); - status->viewerCount = stream.viewerCount; - status->gameId = stream.gameId; - status->game = stream.gameName; - status->title = stream.title; - QDateTime since = QDateTime::fromString(stream.startedAt, Qt::ISODate); - auto diff = since.secsTo(QDateTime::currentDateTime()); - status->uptime = QString::number(diff / 3600) + "h " + - QString::number(diff % 3600 / 60) + "m"; - - status->rerun = false; - status->streamType = stream.type; - } - - this->setLive(true); - - // Signal all listeners that the stream status has been updated - this->liveStatusChanged.invoke(); + return true; } void TwitchChannel::loadRecentMessages() @@ -1114,6 +1121,21 @@ void TwitchChannel::loadRecentMessages() tc->addMessagesAtStart(messages); tc->loadingRecentMessages_.clear(); + + std::vector msgs; + for (MessagePtr msg : messages) + { + const auto highlighted = + msg->flags.has(MessageFlag::Highlighted); + const auto showInMentions = + msg->flags.has(MessageFlag::ShowInMentions); + if (highlighted && showInMentions) + { + msgs.push_back(msg); + } + } + + getApp()->twitch->mentionsChannel->fillInMissingMessages(msgs); }, [weak]() { auto shared = weak.lock(); @@ -1220,33 +1242,6 @@ void TwitchChannel::refreshChatters() [](auto error, auto message) {}); } -void TwitchChannel::fetchDisplayName() -{ - getHelix()->getUserByName( - this->getName(), - [weak = weakOf(this)](const auto &user) { - auto shared = weak.lock(); - if (!shared) - return; - auto channel = static_cast(shared.get()); - if (QString::compare(user.displayName, channel->getName(), - Qt::CaseInsensitive) == 0) - { - channel->setDisplayName(user.displayName); - channel->setLocalizedName(user.displayName); - } - else - { - channel->setLocalizedName(QString("%1(%2)") - .arg(channel->getName()) - .arg(user.displayName)); - } - channel->addRecentChatter(channel->getDisplayName()); - channel->displayNameChanged.invoke(); - }, - [] {}); -} - void TwitchChannel::addReplyThread(const std::shared_ptr &thread) { this->threads_[thread->rootId()] = thread; @@ -1594,4 +1589,60 @@ boost::optional TwitchChannel::cheerEmote(const QString &string) return boost::none; } +void TwitchChannel::updateSevenTVActivity() +{ + static const QString seventvActivityUrl = + QStringLiteral("https://7tv.io/v3/users/%1/presences"); + + const auto currentSeventvUserID = + getApp()->accounts->twitch.getCurrent()->getSeventvUserID(); + if (currentSeventvUserID.isEmpty()) + { + return; + } + + if (!getSettings()->enableSevenTVEventAPI || + !getSettings()->sendSevenTVActivity) + { + return; + } + + if (this->nextSeventvActivity_.isValid() && + QDateTime::currentDateTimeUtc() < this->nextSeventvActivity_) + { + return; + } + // Make sure to not send activity again before receiving the response + this->nextSeventvActivity_ = this->nextSeventvActivity_.addSecs(300); + + qCDebug(chatterinoSeventv) << "Sending activity in" << this->getName(); + + getSeventvAPI().updatePresence( + this->roomId(), currentSeventvUserID, + [chan = weakOf(this)]() { + const auto self = + std::dynamic_pointer_cast(chan.lock()); + if (!self) + { + return Success; + } + self->nextSeventvActivity_ = + QDateTime::currentDateTimeUtc().addSecs(60); + return Success; + }, + [](const auto &result) { + qCDebug(chatterinoSeventv) + << "Failed to update 7TV activity:" << result.formatError(); + }); +} + +void TwitchChannel::listenSevenTVCosmetics() +{ + if (getApp()->twitch->seventvEventAPI) + { + getApp()->twitch->seventvEventAPI->subscribeTwitchChannel( + this->roomId()); + } +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 746b54ed6..9d3e3e49a 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -18,6 +18,7 @@ #include #include +#include #include namespace chatterino { @@ -120,7 +121,6 @@ public: virtual bool hasHighRateLimit() const override; virtual bool canReconnect() const override; virtual void reconnect() override; - void refreshTitle(); void createClip(); // Data @@ -194,7 +194,23 @@ public: // Signals pajlada::Signals::NoArgSignal roomIdChanged; pajlada::Signals::NoArgSignal userStateChanged; - pajlada::Signals::NoArgSignal liveStatusChanged; + + /** + * This signals fires whenever the live status is changed + * + * Streams are counted as offline by default, so if a stream does not go online + * this signal will never fire + **/ + pajlada::Signals::Signal liveStatusChanged; + + /** + * This signal fires whenever the stream status is changed + * + * This includes when the stream goes from offline to online, + * or the viewer count changes, or the title has been updated + **/ + pajlada::Signals::NoArgSignal streamStatusChanged; + pajlada::Signals::NoArgSignal roomModesChanged; // Channel point rewards @@ -205,29 +221,52 @@ public: boost::optional channelPointReward( const QString &rewardId) const; + // Live status + void updateStreamStatus(const std::optional &helixStream); + void updateStreamTitle(const QString &title); + + void updateDisplayName(const QString &displayName); + private: struct NameOptions { + // displayName is the non-CJK-display name for this user + // This will always be the same as their `name_`, but potentially with different casing QString displayName; + + // localizedName is their display name that *may* contain CJK characters + // If the display name does not contain any CJK characters, this will be + // the same as `displayName` QString localizedName; + + // actualDisplayName is the raw display name string received from Twitch + QString actualDisplayName; } nameOptions; private: // Methods - void refreshLiveStatus(); - void parseLiveStatus(bool live, const HelixStream &stream); void refreshPubSub(); void refreshChatters(); void refreshBadges(); void refreshCheerEmotes(); void loadRecentMessages(); void loadRecentMessagesReconnect(); - void fetchDisplayName(); void cleanUpReplyThreads(); void showLoginMessage(); /** Joins (subscribes to) a Twitch channel for updates on BTTV. */ void joinBttvChannel() const; + /** + * Indicates an activity to 7TV in this channel for this user. + * This is done at most once every 60s. + */ + void updateSevenTVActivity(); + void listenSevenTVCosmetics(); - void setLive(bool newLiveStatus); + /** + * @brief Sets the live status of this Twitch channel + * + * Returns true if the live status changed with this call + **/ + bool setLive(bool newLiveStatus); void setMod(bool value); void setVIP(bool value); void setStaff(bool value); @@ -236,7 +275,16 @@ private: void setDisplayName(const QString &name); void setLocalizedName(const QString &name); + /** + * Returns the display name of the user + * + * If the display name contained chinese, japenese, or korean characters, the user's login name is returned instead + **/ const QString &getDisplayName() const override; + + /** + * Returns the localized name of the user + **/ const QString &getLocalizedName() const override; QString prepareMessage(const QString &message) const; @@ -330,6 +378,12 @@ private: */ size_t seventvUserTwitchConnectionIndex_; + /** + * The next moment in time to signal activity in this channel to 7TV. + * Or: Up until this moment we don't need to send activity. + */ + QDateTime nextSeventvActivity_; + /** The platform of the last live emote update ("7TV", "BTTV", "FFZ"). */ QString lastLiveUpdateEmotePlatform_; /** The actor name of the last live emote update. */ diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index f2b30aef3..bcefb58dd 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -80,12 +80,6 @@ void TwitchIrcServer::initialize(Settings &settings, Paths &paths) this->reloadBTTVGlobalEmotes(); this->reloadFFZGlobalEmotes(); this->reloadSevenTVGlobalEmotes(); - - /* Refresh all twitch channel's live status in bulk every 30 seconds after starting chatterino */ - QObject::connect(&this->bulkLiveStatusTimer_, &QTimer::timeout, [this] { - this->bulkRefreshLiveStatus(); - }); - this->bulkLiveStatusTimer_.start(30 * 1000); } void TwitchIrcServer::initializeConnection(IrcConnection *connection, @@ -274,24 +268,102 @@ std::shared_ptr TwitchIrcServer::getCustomChannel( return this->liveChannel; } - if (channelName == "$$$") - { - static auto channel = - std::make_shared("$$$", chatterino::Channel::Type::Misc); - static auto getTimer = [&] { + static auto getTimer = [](ChannelPtr channel, int msBetweenMessages, + bool addInitialMessages) { + if (addInitialMessages) + { for (auto i = 0; i < 1000; i++) { channel->addMessage(makeSystemMessage(QString::number(i + 1))); } + } - auto timer = new QTimer; - QObject::connect(timer, &QTimer::timeout, [] { - channel->addMessage( - makeSystemMessage(QTime::currentTime().toString())); - }); - timer->start(500); - return timer; - }(); + auto *timer = new QTimer; + QObject::connect(timer, &QTimer::timeout, [channel] { + channel->addMessage( + makeSystemMessage(QTime::currentTime().toString())); + }); + timer->start(msBetweenMessages); + return timer; + }; + + if (channelName == "$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 500, true); + + return channel; + } + if (channelName == "$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 500, false); + + return channel; + } + if (channelName == "$$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 250, true); + + return channel; + } + if (channelName == "$$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 250, false); + + return channel; + } + if (channelName == "$$$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 100, true); + + return channel; + } + if (channelName == "$$$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 100, false); + + return channel; + } + if (channelName == "$$$$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 50, true); + + return channel; + } + if (channelName == "$$$$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 50, false); + + return channel; + } + if (channelName == "$$$$$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 25, true); + + return channel; + } + if (channelName == "$$$$$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 25, false); return channel; } @@ -334,59 +406,6 @@ std::shared_ptr TwitchIrcServer::getChannelOrEmptyByID( return Channel::getEmpty(); } -void TwitchIrcServer::bulkRefreshLiveStatus() -{ - auto twitchChans = std::make_shared>(); - - this->forEachChannel([twitchChans](ChannelPtr chan) { - auto tc = dynamic_cast(chan.get()); - if (tc && !tc->roomId().isEmpty()) - { - twitchChans->insert(tc->roomId(), tc); - } - }); - - // iterate over batches of channel IDs - for (const auto &batch : splitListIntoBatches(twitchChans->keys())) - { - getHelix()->fetchStreams( - batch, {}, - [twitchChans](std::vector streams) { - for (const auto &stream : streams) - { - // remaining channels will be used later to set their stream status as offline - // so we use take(id) to remove it - auto tc = twitchChans->take(stream.userId); - if (tc == nullptr) - { - continue; - } - - tc->parseLiveStatus(true, stream); - } - }, - []() { - // failure - }, - [batch, twitchChans] { - // All the channels that were not present in fetchStreams response should be assumed to be offline - // It is necessary to update their stream status in case they've gone live -> offline - // Otherwise some of them will be marked as live forever - for (const auto &chID : batch) - { - auto tc = twitchChans->value(chID); - // early out in case channel does not exist anymore - if (tc == nullptr) - { - continue; - } - - tc->parseLiveStatus(false, {}); - } - }); - } -} - QString TwitchIrcServer::cleanChannelName(const QString &dirtyChannelName) { if (dirtyChannelName.startsWith('#')) diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index 9a9a22800..d60554fa3 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -23,7 +23,21 @@ class TwitchChannel; class BttvLiveUpdates; class SeventvEventAPI; -class TwitchIrcServer final : public AbstractIrcServer, public Singleton +class ITwitchIrcServer +{ +public: + virtual ~ITwitchIrcServer() = default; + + virtual const BttvEmotes &getBttvEmotes() const = 0; + virtual const FfzEmotes &getFfzEmotes() const = 0; + virtual const SeventvEmotes &getSeventvEmotes() const = 0; + + // Update this interface with TwitchIrcServer methods as needed +}; + +class TwitchIrcServer final : public AbstractIrcServer, + public Singleton, + public ITwitchIrcServer { public: TwitchIrcServer(); @@ -35,8 +49,6 @@ public: std::shared_ptr getChannelOrEmptyByID(const QString &channelID); - void bulkRefreshLiveStatus(); - void reloadBTTVGlobalEmotes(); void reloadAllBTTVChannelEmotes(); void reloadFFZGlobalEmotes(); @@ -70,9 +82,9 @@ public: std::unique_ptr bttvLiveUpdates; std::unique_ptr seventvEventAPI; - const BttvEmotes &getBttvEmotes() const; - const FfzEmotes &getFfzEmotes() const; - const SeventvEmotes &getSeventvEmotes() const; + const BttvEmotes &getBttvEmotes() const override; + const FfzEmotes &getFfzEmotes() const override; + const SeventvEmotes &getSeventvEmotes() const override; protected: virtual void initializeConnection(IrcConnection *connection, @@ -110,7 +122,6 @@ private: BttvEmotes bttv; FfzEmotes ffz; SeventvEmotes seventv_; - QTimer bulkLiveStatusTimer_; pajlada::Signals::SignalHolder signalHolder_; }; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 98888fa48..69bb5ed69 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "common/LinkParser.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/ignores/IgnoreController.hpp" @@ -28,8 +29,10 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" +#include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" +#include "util/QStringHash.hpp" #include "util/Qt.hpp" #include "widgets/Window.hpp" @@ -38,8 +41,15 @@ #include #include +#include +#include + +using namespace chatterino::literals; + namespace { +using namespace std::chrono_literals; + const QString regexHelpString("(\\w+)[.,!?;:]*?$"); // matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username" @@ -53,6 +63,19 @@ const QSet zeroWidthEmotes{ "ReinDeer", "CandyCane", "cvMask", "cvHazmat", }; +struct HypeChatPaidLevel { + std::chrono::seconds duration; + uint8_t numeric; +}; + +const std::unordered_map HYPE_CHAT_PAID_LEVEL{ + {u"ONE"_s, {30s, 1}}, {u"TWO"_s, {2min + 30s, 2}}, + {u"THREE"_s, {5min, 3}}, {u"FOUR"_s, {10min, 4}}, + {u"FIVE"_s, {30min, 5}}, {u"SIX"_s, {1h, 6}}, + {u"SEVEN"_s, {2h, 7}}, {u"EIGHT"_s, {3h, 8}}, + {u"NINE"_s, {4h, 9}}, {u"TEN"_s, {5h, 10}}, +}; + } // namespace namespace chatterino { @@ -294,8 +317,12 @@ MessagePtr TwitchMessageBuilder::build() this->addWords(splits, twitchEmotes); + QString stylizedUsername = + this->stylizeUsername(this->userName, this->message()); + this->message().messageText = this->originalMessage_; - this->message().searchText = this->message().localizedName + " " + + this->message().searchText = stylizedUsername + " " + + this->message().localizedName + " " + this->userName + ": " + this->originalMessage_; // highlights @@ -1463,6 +1490,7 @@ void TwitchMessageBuilder::appendChannelPointRewardMessage( textList.append({redeemed, reward.title, QString::number(reward.cost)}); builder->message().messageText = textList.join(" "); builder->message().searchText = textList.join(" "); + builder->message().loginName = reward.user.login; } void TwitchMessageBuilder::liveMessage(const QString &channelName, @@ -1735,6 +1763,45 @@ void TwitchMessageBuilder::listOfUsersSystemMessage( builder->message().searchText = text; } +MessagePtr TwitchMessageBuilder::buildHypeChatMessage( + Communi::IrcPrivateMessage *message) +{ + auto level = message->tag(u"pinned-chat-paid-level"_s).toString(); + auto currency = message->tag(u"pinned-chat-paid-currency"_s).toString(); + bool okAmount = false; + auto amount = message->tag(u"pinned-chat-paid-amount"_s).toInt(&okAmount); + bool okExponent = false; + auto exponent = + message->tag(u"pinned-chat-paid-exponent"_s).toInt(&okExponent); + if (!okAmount || !okExponent || currency.isEmpty()) + { + return {}; + } + // additionally, there's `pinned-chat-paid-is-system-message` which isn't used by Chatterino. + + QString subtitle; + auto levelIt = HYPE_CHAT_PAID_LEVEL.find(level); + if (levelIt != HYPE_CHAT_PAID_LEVEL.end()) + { + const auto &level = levelIt->second; + subtitle = u"Level %1 Hype Chat (%2) "_s.arg(level.numeric) + .arg(formatTime(level.duration)); + } + else + { + subtitle = u"Hype Chat "_s; + } + + // actualAmount = amount * 10^(-exponent) + double actualAmount = std::pow(10.0, double(-exponent)) * double(amount); + subtitle += QLocale::system().toCurrencyString(actualAmount, currency); + + MessageBuilder builder(systemMessage, parseTagString(subtitle), + calculateMessageTime(message).time()); + builder->flags.set(MessageFlag::ElevatedMessage); + return builder.release(); +} + void TwitchMessageBuilder::setThread(std::shared_ptr thread) { this->thread_ = std::move(thread); diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 480e18688..0d1f34726 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -86,6 +86,8 @@ public: QString prefix, const std::vector &users, Channel *channel, MessageBuilder *builder); + static MessagePtr buildHypeChatMessage(Communi::IrcPrivateMessage *message); + // Shares some common logic from SharedMessageBuilder::parseBadgeTag static std::unordered_map parseBadgeInfoTag( const QVariantMap &tags); diff --git a/src/providers/twitch/TwitchUser.hpp b/src/providers/twitch/TwitchUser.hpp index 7593abd3c..d615c95b0 100644 --- a/src/providers/twitch/TwitchUser.hpp +++ b/src/providers/twitch/TwitchUser.hpp @@ -1,5 +1,6 @@ #pragma once +#include "util/QStringHash.hpp" #include "util/RapidjsonHelpers.hpp" #include @@ -31,6 +32,16 @@ struct TwitchUser { { return this->id < rhs.id; } + + bool operator==(const TwitchUser &rhs) const + { + return this->id == rhs.id; + } + + bool operator!=(const TwitchUser &rhs) const + { + return !(*this == rhs); + } }; } // namespace chatterino @@ -75,3 +86,11 @@ struct Deserialize { }; } // namespace pajlada + +template <> +struct std::hash { + inline size_t operator()(const chatterino::TwitchUser &user) const noexcept + { + return std::hash{}(user.id); + } +}; diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 4dc4f1222..fac3b886c 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1,9 +1,11 @@ #include "providers/twitch/api/Helix.hpp" +#include "common/Literals.hpp" #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" #include "common/Outcome.hpp" #include "common/QLogging.hpp" +#include "util/CancellationToken.hpp" #include #include @@ -20,6 +22,8 @@ static constexpr auto NUM_CHATTERS_TO_FETCH = 1000; namespace chatterino { +using namespace literals; + static IHelix *instance = nullptr; HelixChatters::HelixChatters(const QJsonObject &jsonObject) @@ -52,7 +56,7 @@ void Helix::fetchUsers(QStringList userIds, QStringList userLogins, } // TODO: set on success and on error - this->makeRequest("users", urlQuery) + this->makeGet("users", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); auto data = root.value("data"); @@ -142,7 +146,7 @@ void Helix::fetchUsersFollows( } // TODO: set on success and on error - this->makeRequest("users/follows", urlQuery) + this->makeGet("users/follows", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); if (root.empty()) @@ -186,7 +190,7 @@ void Helix::fetchStreams( } // TODO: set on success and on error - this->makeRequest("streams", urlQuery) + this->makeGet("streams", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); auto data = root.value("data"); @@ -279,7 +283,7 @@ void Helix::fetchGames(QStringList gameIds, QStringList gameNames, } // TODO: set on success and on error - this->makeRequest("games", urlQuery) + this->makeGet("games", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); auto data = root.value("data"); @@ -315,7 +319,7 @@ void Helix::searchGames(QString gameName, QUrlQuery urlQuery; urlQuery.addQueryItem("query", gameName); - this->makeRequest("search/categories", urlQuery) + this->makeGet("search/categories", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); auto data = root.value("data"); @@ -372,8 +376,7 @@ void Helix::createClip(QString channelId, QUrlQuery urlQuery; urlQuery.addQueryItem("broadcaster_id", channelId); - this->makeRequest("clips", urlQuery) - .type(NetworkRequestType::Post) + this->makePost("clips", urlQuery) .header("Content-Type", "application/json") .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); @@ -391,7 +394,7 @@ void Helix::createClip(QString channelId, return Success; }) .onError([failureCallback](auto result) { - switch (result.status()) + switch (result.status().value_or(0)) { case 503: { // Channel has disabled clip-creation, or channel has made cliops only creatable by followers and the user is not a follower (or subscriber) @@ -407,7 +410,7 @@ void Helix::createClip(QString channelId, default: { qCDebug(chatterinoTwitch) - << "Failed to create a clip: " << result.status() + << "Failed to create a clip: " << result.formatError() << result.getData(); failureCallback(HelixClipError::Unknown); } @@ -418,6 +421,45 @@ void Helix::createClip(QString channelId, .execute(); } +void Helix::fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) +{ + QUrlQuery urlQuery; + + for (const auto &userID : userIDs) + { + urlQuery.addQueryItem("broadcaster_id", userID); + } + + this->makeGet("channels", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + auto data = root.value("data"); + + if (!data.isArray()) + { + failureCallback(); + return Failure; + } + + std::vector channels; + + for (const auto &unparsedChannel : data.toArray()) + { + channels.emplace_back(unparsedChannel.toObject()); + } + + successCallback(channels); + return Success; + }) + .onError([failureCallback](auto /*result*/) { + failureCallback(); + }) + .execute(); +} + void Helix::getChannel(QString broadcasterId, ResultCallback successCallback, HelixFailureCallback failureCallback) @@ -425,7 +467,7 @@ void Helix::getChannel(QString broadcasterId, QUrlQuery urlQuery; urlQuery.addQueryItem("broadcaster_id", broadcasterId); - this->makeRequest("channels", urlQuery) + this->makeGet("channels", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); auto data = root.value("data"); @@ -460,10 +502,8 @@ void Helix::createStreamMarker( } payload.insert("user_id", QJsonValue(broadcasterId)); - this->makeRequest("streams/markers", QUrlQuery()) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + this->makePost("streams/markers", QUrlQuery()) + .json(payload) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); auto data = root.value("data"); @@ -480,7 +520,7 @@ void Helix::createStreamMarker( return Success; }) .onError([failureCallback](NetworkResult result) { - switch (result.status()) + switch (result.status().value_or(0)) { case 403: { // User isn't a Channel Editor, so he can't create markers @@ -498,7 +538,7 @@ void Helix::createStreamMarker( default: { qCDebug(chatterinoTwitch) << "Failed to create a stream marker: " - << result.status() << result.getData(); + << result.formatError() << result.getData(); failureCallback(HelixStreamMarkerError::Unknown); } break; @@ -508,51 +548,64 @@ void Helix::createStreamMarker( }; void Helix::loadBlocks(QString userId, - ResultCallback> successCallback, - HelixFailureCallback failureCallback) + ResultCallback> pageCallback, + FailureCallback failureCallback, + CancellationToken &&token) { - QUrlQuery urlQuery; - urlQuery.addQueryItem("broadcaster_id", userId); - urlQuery.addQueryItem("first", "100"); + constexpr const size_t blockLimit = 1000; - this->makeRequest("users/blocks", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { - auto root = result.parseJson(); - auto data = root.value("data"); + // TODO(Qt 5.13): use initializer list + QUrlQuery query; + query.addQueryItem(u"broadcaster_id"_s, userId); + query.addQueryItem(u"first"_s, u"100"_s); - if (!data.isArray()) + size_t receivedItems = 0; + this->paginate( + u"users/blocks"_s, query, + [pageCallback, receivedItems](const QJsonObject &json) mutable { + const auto data = json["data"_L1].toArray(); + + if (data.isEmpty()) { - failureCallback(); - return Failure; + return false; } std::vector ignores; + ignores.reserve(data.count()); - for (const auto &jsonStream : data.toArray()) + for (const auto &ignore : data) { - ignores.emplace_back(jsonStream.toObject()); + ignores.emplace_back(ignore.toObject()); } - successCallback(ignores); + pageCallback(ignores); - return Success; - }) - .onError([failureCallback](auto /*result*/) { - // TODO: make better xd - failureCallback(); - }) - .execute(); + receivedItems += data.count(); + + if (receivedItems >= blockLimit) + { + qCInfo(chatterinoTwitch) << "Reached the limit of" << blockLimit + << "Twitch blocks fetched"; + return false; + } + + return true; + }, + [failureCallback](const NetworkResult &result) { + failureCallback(result.formatError()); + }, + std::move(token)); } -void Helix::blockUser(QString targetUserId, +void Helix::blockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) { QUrlQuery urlQuery; urlQuery.addQueryItem("target_user_id", targetUserId); - this->makeRequest("users/blocks", urlQuery) - .type(NetworkRequestType::Put) + this->makePut("users/blocks", urlQuery) + .caller(caller) .onSuccess([successCallback](auto /*result*/) -> Outcome { successCallback(); return Success; @@ -564,15 +617,15 @@ void Helix::blockUser(QString targetUserId, .execute(); } -void Helix::unblockUser(QString targetUserId, +void Helix::unblockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) { QUrlQuery urlQuery; urlQuery.addQueryItem("target_user_id", targetUserId); - this->makeRequest("users/blocks", urlQuery) - .type(NetworkRequestType::Delete) + this->makeDelete("users/blocks", urlQuery) + .caller(caller) .onSuccess([successCallback](auto /*result*/) -> Outcome { successCallback(); return Success; @@ -590,7 +643,6 @@ void Helix::updateChannel(QString broadcasterId, QString gameId, HelixFailureCallback failureCallback) { QUrlQuery urlQuery; - auto data = QJsonDocument(); auto obj = QJsonObject(); if (!gameId.isEmpty()) { @@ -611,12 +663,9 @@ void Helix::updateChannel(QString broadcasterId, QString gameId, return; } - data.setObject(obj); urlQuery.addQueryItem("broadcaster_id", broadcasterId); - this->makeRequest("channels", urlQuery) - .type(NetworkRequestType::Patch) - .header("Content-Type", "application/json") - .payload(data.toJson()) + this->makePatch("channels", urlQuery) + .json(obj) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { successCallback(result); return Success; @@ -638,16 +687,14 @@ void Helix::manageAutoModMessages( payload.insert("msg_id", msgID); payload.insert("action", action); - this->makeRequest("moderation/automod/message", QUrlQuery()) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + this->makePost("moderation/automod/message", QUrlQuery()) + .json(payload) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { successCallback(); return Success; }) .onError([failureCallback, msgID, action](NetworkResult result) { - switch (result.status()) + switch (result.status().value_or(0)) { case 400: { // Message was already processed @@ -679,7 +726,7 @@ void Helix::manageAutoModMessages( default: { qCDebug(chatterinoTwitch) << "Failed to manage automod message: " << action - << msgID << result.status() << result.getData(); + << msgID << result.formatError() << result.getData(); failureCallback(HelixAutoModMessageError::Unknown); } break; @@ -697,7 +744,7 @@ void Helix::getCheermotes( urlQuery.addQueryItem("broadcaster_id", broadcasterId); - this->makeRequest("bits/cheermotes", urlQuery) + this->makeGet("bits/cheermotes", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); auto data = root.value("data"); @@ -721,7 +768,7 @@ void Helix::getCheermotes( .onError([broadcasterId, failureCallback](NetworkResult result) { qCDebug(chatterinoTwitch) << "Failed to get cheermotes(broadcaster_id=" << broadcasterId - << "): " << result.status() << result.getData(); + << "): " << result.formatError() << result.getData(); failureCallback(); }) .execute(); @@ -735,7 +782,7 @@ void Helix::getEmoteSetData(QString emoteSetId, urlQuery.addQueryItem("emote_set_id", emoteSetId); - this->makeRequest("chat/emotes/set", urlQuery) + this->makeGet("chat/emotes/set", urlQuery) .onSuccess([successCallback, failureCallback, emoteSetId](auto result) -> Outcome { QJsonObject root = result.parseJson(); @@ -767,7 +814,7 @@ void Helix::getChannelEmotes( QUrlQuery urlQuery; urlQuery.addQueryItem("broadcaster_id", broadcasterId); - this->makeRequest("chat/emotes", urlQuery) + this->makeGet("chat/emotes", urlQuery) .onSuccess([successCallback, failureCallback](NetworkResult result) -> Outcome { QJsonObject root = result.parseJson(); @@ -807,27 +854,32 @@ void Helix::updateUserChatColor( payload.insert("user_id", QJsonValue(userID)); payload.insert("color", QJsonValue(color)); - this->makeRequest("chat/color", QUrlQuery()) - .type(NetworkRequestType::Put) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + this->makePut("chat/color", QUrlQuery()) + .json(payload) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto obj = result.parseJson(); if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for updating chat color was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { if (message.startsWith("invalid color", @@ -860,7 +912,7 @@ void Helix::updateUserChatColor( default: { qCDebug(chatterinoTwitch) << "Unhandled error changing user color:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -887,24 +939,30 @@ void Helix::deleteChatMessages( urlQuery.addQueryItem("message_id", messageID); } - this->makeRequest("moderation/chat", urlQuery) - .type(NetworkRequestType::Delete) + this->makeDelete("moderation/chat", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for deleting chat messages was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 404: { // A 404 on this endpoint means message id is invalid or unable to be deleted. @@ -946,7 +1004,7 @@ void Helix::deleteChatMessages( default: { qCDebug(chatterinoTwitch) << "Unhandled error deleting chat messages:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -966,24 +1024,30 @@ void Helix::addChannelModerator( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("moderation/moderators", urlQuery) - .type(NetworkRequestType::Post) + this->makePost("moderation/moderators", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for adding a moderator was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 401: { if (message.startsWith("Missing scope", @@ -1035,7 +1099,7 @@ void Helix::addChannelModerator( default: { qCDebug(chatterinoTwitch) << "Unhandled error adding channel moderator:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1055,24 +1119,30 @@ void Helix::removeChannelModerator( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("moderation/moderators", urlQuery) - .type(NetworkRequestType::Delete) + this->makeDelete("moderation/moderators", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for unmodding user was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { if (message.compare("user is not a mod", @@ -1114,8 +1184,8 @@ void Helix::removeChannelModerator( default: { qCDebug(chatterinoTwitch) - << "Unhandled error unmodding user:" << result.status() - << result.getData() << obj; + << "Unhandled error unmodding user:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1142,26 +1212,31 @@ void Helix::sendChatAnnouncement( std::string{magic_enum::enum_name(color)}; body.insert("color", QString::fromStdString(colorStr).toLower()); - this->makeRequest("chat/announcements", urlQuery) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(body).toJson(QJsonDocument::Compact)) + this->makePost("chat/announcements", urlQuery) + .json(body) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for sending an announcement was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { // These errors are generally well formatted, so we just forward them. @@ -1194,7 +1269,7 @@ void Helix::sendChatAnnouncement( default: { qCDebug(chatterinoTwitch) << "Unhandled error sending an announcement:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1214,24 +1289,30 @@ void Helix::addChannelVIP( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("channels/vips", urlQuery) - .type(NetworkRequestType::Post) + this->makePost("channels/vips", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for adding channel VIP was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: case 409: @@ -1273,7 +1354,7 @@ void Helix::addChannelVIP( default: { qCDebug(chatterinoTwitch) << "Unhandled error adding channel VIP:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1293,24 +1374,30 @@ void Helix::removeChannelVIP( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("channels/vips", urlQuery) - .type(NetworkRequestType::Delete) + this->makeDelete("channels/vips", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for removing channel VIP was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: case 409: @@ -1351,7 +1438,7 @@ void Helix::removeChannelVIP( default: { qCDebug(chatterinoTwitch) << "Unhandled error removing channel VIP:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1383,24 +1470,30 @@ void Helix::unbanUser( urlQuery.addQueryItem("moderator_id", moderatorID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("moderation/bans", urlQuery) - .type(NetworkRequestType::Delete) + this->makeDelete("moderation/bans", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for unbanning user was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { if (message.startsWith("The user in the user_id query " @@ -1456,8 +1549,8 @@ void Helix::unbanUser( default: { qCDebug(chatterinoTwitch) - << "Unhandled error unbanning user:" << result.status() - << result.getData() << obj; + << "Unhandled error unbanning user:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1489,18 +1582,23 @@ void Helix::startRaid( urlQuery.addQueryItem("from_broadcaster_id", fromBroadcasterID); urlQuery.addQueryItem("to_broadcaster_id", toBroadcasterID); - this->makeRequest("raids", urlQuery) - .type(NetworkRequestType::Post) + this->makePost("raids", urlQuery) .onSuccess( [successCallback, failureCallback](auto /*result*/) -> Outcome { successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { if (message.compare("The IDs in from_broadcaster_id and " @@ -1551,7 +1649,7 @@ void Helix::startRaid( default: { qCDebug(chatterinoTwitch) << "Unhandled error while starting a raid:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1570,24 +1668,30 @@ void Helix::cancelRaid( urlQuery.addQueryItem("broadcaster_id", broadcasterID); - this->makeRequest("raids", urlQuery) - .type(NetworkRequestType::Delete) + this->makeDelete("raids", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for canceling the raid was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 401: { if (message.startsWith("Missing scope", @@ -1624,7 +1728,7 @@ void Helix::cancelRaid( default: { qCDebug(chatterinoTwitch) << "Unhandled error while canceling the raid:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1731,27 +1835,31 @@ void Helix::updateChatSettings( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("moderator_id", moderatorID); - this->makeRequest("chat/settings", urlQuery) - .type(NetworkRequestType::Patch) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + this->makePatch("chat/settings", urlQuery) + .json(payload) .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for updating chat settings was" - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); successCallback(HelixChatSettings( response.value("data").toArray().first().toObject())); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { if (message.contains("must be in the range")) @@ -1798,7 +1906,7 @@ void Helix::updateChatSettings( default: { qCDebug(chatterinoTwitch) << "Unhandled error updating chat settings:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1857,24 +1965,30 @@ void Helix::fetchChatters( urlQuery.addQueryItem("after", after); } - this->makeRequest("chat/chatters", urlQuery) + this->makeGet("chat/chatters", urlQuery) .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for getting chatters was " - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); successCallback(HelixChatters(response)); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { failureCallback(Error::Forwarded, message); @@ -1905,7 +2019,7 @@ void Helix::fetchChatters( default: { qCDebug(chatterinoTwitch) - << "Unhandled error data:" << result.status() + << "Unhandled error data:" << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } @@ -1966,24 +2080,30 @@ void Helix::fetchModerators( urlQuery.addQueryItem("after", after); } - this->makeRequest("moderation/moderators", urlQuery) + this->makeGet("moderation/moderators", urlQuery) .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for getting moderators was " - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); successCallback(HelixModerators(response)); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { failureCallback(Error::Forwarded, message); @@ -2014,7 +2134,7 @@ void Helix::fetchModerators( default: { qCDebug(chatterinoTwitch) - << "Unhandled error data:" << result.status() + << "Unhandled error data:" << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } @@ -2051,26 +2171,30 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, payload["data"] = data; } - this->makeRequest("moderation/bans", urlQuery) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + this->makePost("moderation/bans", urlQuery) + .json(payload) .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for banning a user was" - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } // we don't care about the response successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { if (message.startsWith("The user specified in the user_id " @@ -2124,8 +2248,8 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, default: { qCDebug(chatterinoTwitch) - << "Unhandled error banning user:" << result.status() - << result.getData() << obj; + << "Unhandled error banning user:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2150,26 +2274,30 @@ void Helix::sendWhisper( QJsonObject payload; payload["message"] = message; - this->makeRequest("whispers", urlQuery) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + this->makePost("whispers", urlQuery) + .json(payload) .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for sending a whisper was" - << result.status() << "but we expected it to be 204"; + << result.formatError() << "but we expected it to be 204"; } // we don't care about the response successCallback(); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { if (message.startsWith("A user cannot whisper themself", @@ -2230,8 +2358,8 @@ void Helix::sendWhisper( default: { qCDebug(chatterinoTwitch) - << "Unhandled error banning user:" << result.status() - << result.getData() << obj; + << "Unhandled error banning user:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2295,15 +2423,14 @@ void Helix::getChannelVIPs( // as the mod list can go over 100 (I assume, I see no limit) urlQuery.addQueryItem("first", "100"); - this->makeRequest("channels/vips", urlQuery) - .type(NetworkRequestType::Get) + this->makeGet("channels/vips", urlQuery) .header("Content-Type", "application/json") .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 200) { qCWarning(chatterinoTwitch) - << "Success result for getting VIPs was" << result.status() - << "but we expected it to be 200"; + << "Success result for getting VIPs was" + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); @@ -2317,11 +2444,17 @@ void Helix::getChannelVIPs( successCallback(channelVips); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { failureCallback(Error::Forwarded, message); @@ -2361,8 +2494,8 @@ void Helix::getChannelVIPs( default: { qCDebug(chatterinoTwitch) - << "Unhandled error listing VIPs:" << result.status() - << result.getData() << obj; + << "Unhandled error listing VIPs:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2383,10 +2516,8 @@ void Helix::startCommercial( payload.insert("broadcaster_id", QJsonValue(broadcasterID)); payload.insert("length", QJsonValue(length)); - this->makeRequest("channels/commercial", QUrlQuery()) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + this->makePost("channels/commercial", QUrlQuery()) + .json(payload) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto obj = result.parseJson(); if (obj.isEmpty()) @@ -2400,11 +2531,17 @@ void Helix::startCommercial( successCallback(HelixStartCommercialResponse(obj)); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: { if (message.startsWith("Missing scope", @@ -2459,7 +2596,7 @@ void Helix::startCommercial( default: { qCDebug(chatterinoTwitch) << "Unhandled error starting commercial:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2476,24 +2613,30 @@ void Helix::getGlobalBadges( { using Error = HelixGetGlobalBadgesError; - this->makeRequest("chat/badges/global", QUrlQuery()) + this->makeGet("chat/badges/global", QUrlQuery()) .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for getting global badges was " - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); successCallback(HelixGlobalBadges(response)); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 401: { failureCallback(Error::Forwarded, message); @@ -2503,7 +2646,7 @@ void Helix::getGlobalBadges( default: { qCWarning(chatterinoTwitch) << "Helix global badges, unhandled error data:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2523,24 +2666,30 @@ void Helix::getChannelBadges( QUrlQuery urlQuery; urlQuery.addQueryItem("broadcaster_id", broadcasterID); - this->makeRequest("chat/badges", urlQuery) + this->makeGet("chat/badges", urlQuery) .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for getting badges was " - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); successCallback(HelixChannelBadges(response)); return Success; }) - .onError([failureCallback](auto result) { + .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()) + switch (*result.status()) { case 400: case 401: { @@ -2551,7 +2700,7 @@ void Helix::getChannelBadges( default: { qCWarning(chatterinoTwitch) << "Helix channel badges, unhandled error data:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2575,16 +2724,14 @@ void Helix::updateShieldMode( QJsonObject payload; payload["is_active"] = isActive; - this->makeRequest("moderation/shield_mode", urlQuery) - .type(NetworkRequestType::Put) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + this->makePut("moderation/shield_mode", urlQuery) + .json(payload) .onSuccess([successCallback](auto result) -> Outcome { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for updating shield mode was " - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } const auto response = result.parseJson(); @@ -2592,11 +2739,17 @@ void Helix::updateShieldMode( HelixShieldModeStatus(response["data"][0].toObject())); return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + const auto obj = result.parseJson(); auto message = obj["message"].toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.startsWith("Missing scope", @@ -2622,7 +2775,7 @@ void Helix::updateShieldMode( default: { qCWarning(chatterinoTwitch) << "Helix shield mode, unhandled error data:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2631,7 +2784,115 @@ void Helix::updateShieldMode( .execute(); } -NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) +// https://dev.twitch.tv/docs/api/reference/#send-a-shoutout +void Helix::sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixSendShoutoutError; + + QUrlQuery urlQuery; + urlQuery.addQueryItem("from_broadcaster_id", fromBroadcasterID); + urlQuery.addQueryItem("to_broadcaster_id", toBroadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + this->makePost("chat/shoutouts", urlQuery) + .header("Content-Type", "application/json") + .onSuccess([successCallback](NetworkResult result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for sending shoutout was " + << result.formatError() << "but we expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](const NetworkResult &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + + const auto obj = result.parseJson(); + auto message = obj["message"].toString(); + + switch (*result.status()) + { + case 400: { + if (message.startsWith("The broadcaster may not give " + "themselves a Shoutout.", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserIsBroadcaster, message); + } + else if (message.startsWith( + "The broadcaster is not streaming live or " + "does not have one or more viewers.", + Qt::CaseInsensitive)) + { + failureCallback(Error::BroadcasterNotLive, message); + } + else + { + failureCallback(Error::UserNotAuthorized, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::UserNotAuthorized, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + case 500: { + // Helix returns 500 when user is not mod, + if (message.isEmpty()) + { + failureCallback(Error::Unknown, + "Twitch internal server error"); + } + else + { + failureCallback(Error::Unknown, message); + } + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix send shoutout, unhandled error data:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + } + }) + .execute(); +} + +NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery, + NetworkRequestType type) { assert(!url.startsWith("/")); @@ -2655,13 +2916,91 @@ NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) fullUrl.setQuery(urlQuery); - return NetworkRequest(fullUrl) + return NetworkRequest(fullUrl, type) .timeout(5 * 1000) .header("Accept", "application/json") .header("Client-ID", this->clientId) .header("Authorization", "Bearer " + this->oauthToken); } +NetworkRequest Helix::makeGet(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Get); +} + +NetworkRequest Helix::makeDelete(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Delete); +} + +NetworkRequest Helix::makePost(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Post); +} + +NetworkRequest Helix::makePut(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Put); +} + +NetworkRequest Helix::makePatch(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Patch); +} + +void Helix::paginate(const QString &url, const QUrlQuery &baseQuery, + std::function onPage, + std::function onError, + CancellationToken &&cancellationToken) +{ + auto onSuccess = + std::make_shared>(nullptr); + // This is the actual callback passed to NetworkRequest. + // It wraps the shared-ptr. + auto onSuccessCb = [onSuccess](const auto &res) -> Outcome { + return (*onSuccess)(res); + }; + + *onSuccess = [this, onPage = std::move(onPage), onError, onSuccessCb, + url{url}, baseQuery{baseQuery}, + cancellationToken = std::move(cancellationToken)]( + const NetworkResult &res) -> Outcome { + if (cancellationToken.isCancelled()) + { + return Success; + } + + const auto json = res.parseJson(); + if (!onPage(json)) + { + // The consumer doesn't want any more pages + return Success; + } + + auto cursor = json["pagination"_L1]["cursor"_L1].toString(); + if (cursor.isEmpty()) + { + return Success; + } + + auto query = baseQuery; + query.removeAllQueryItems(u"after"_s); + query.addQueryItem(u"after"_s, cursor); + + this->makeGet(url, query) + .onSuccess(onSuccessCb) + .onError(onError) + .execute(); + + return Success; + }; + + this->makeGet(url, baseQuery) + .onSuccess(std::move(onSuccessCb)) + .onError(std::move(onError)) + .execute(); +} + void Helix::update(QString clientId, QString oauthToken) { this->clientId = std::move(clientId); diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 0492b0560..dc8593815 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -24,6 +24,8 @@ using HelixFailureCallback = std::function; template using ResultCallback = std::function; +class CancellationToken; + struct HelixUser { QString id; QString login; @@ -637,6 +639,18 @@ enum class HelixListVIPsError { // /vips Forwarded, }; // /vips +enum class HelixSendShoutoutError { + Unknown, + // 400 + UserIsBroadcaster, + BroadcasterNotLive, + // 401 + UserNotAuthorized, + UserMissingScope, + + Ratelimited, +}; + struct HelixStartCommercialResponse { // Length of the triggered commercial int length; @@ -776,6 +790,12 @@ public: std::function failureCallback, std::function finallyCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-channel-information + virtual void fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-channel-information virtual void getChannel(QString broadcasterId, ResultCallback successCallback, @@ -789,16 +809,17 @@ public: // https://dev.twitch.tv/docs/api/reference#get-user-block-list virtual void loadBlocks( - QString userId, ResultCallback> successCallback, - HelixFailureCallback failureCallback) = 0; + QString userId, ResultCallback> pageCallback, + FailureCallback failureCallback, + CancellationToken &&token) = 0; // https://dev.twitch.tv/docs/api/reference#block-user - virtual void blockUser(QString targetUserId, + virtual void blockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) = 0; // https://dev.twitch.tv/docs/api/reference#unblock-user - virtual void unblockUser(QString targetUserId, + virtual void unblockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) = 0; @@ -1013,6 +1034,12 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + virtual void sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1083,6 +1110,12 @@ public: std::function failureCallback, std::function finallyCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-channel-information + void fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-channel-information void getChannel(QString broadcasterId, ResultCallback successCallback, @@ -1096,15 +1129,17 @@ public: // https://dev.twitch.tv/docs/api/reference#get-user-block-list void loadBlocks(QString userId, - ResultCallback> successCallback, - HelixFailureCallback failureCallback) final; + ResultCallback> pageCallback, + FailureCallback failureCallback, + CancellationToken &&token) final; // https://dev.twitch.tv/docs/api/reference#block-user - void blockUser(QString targetUserId, std::function successCallback, + void blockUser(QString targetUserId, const QObject *caller, + std::function successCallback, HelixFailureCallback failureCallback) final; // https://dev.twitch.tv/docs/api/reference#unblock-user - void unblockUser(QString targetUserId, + void unblockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) final; @@ -1318,6 +1353,12 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + void sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); @@ -1361,7 +1402,20 @@ protected: FailureCallback failureCallback); private: - NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); + NetworkRequest makeRequest(const QString &url, const QUrlQuery &urlQuery, + NetworkRequestType type); + NetworkRequest makeGet(const QString &url, const QUrlQuery &urlQuery); + NetworkRequest makeDelete(const QString &url, const QUrlQuery &urlQuery); + NetworkRequest makePost(const QString &url, const QUrlQuery &urlQuery); + NetworkRequest makePut(const QString &url, const QUrlQuery &urlQuery); + NetworkRequest makePatch(const QString &url, const QUrlQuery &urlQuery); + + /// Paginate the `url` endpoint and use `baseQuery` as the starting point for pagination. + /// @param onPage returns true while a new page is expected. Once false is returned, pagination will stop. + void paginate(const QString &url, const QUrlQuery &baseQuery, + std::function onPage, + std::function onError, + CancellationToken &&token); QString clientId; QString oauthToken; diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index b05be517e..e300e5440 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -12,7 +12,7 @@ If you're adding support for a new endpoint, these are the things you should kno 1. Add a virtual function in the `IHelix` class. Naming should reflect the API name as best as possible. 1. Override the virtual function in the `Helix` class. -1. Mock the function in the `MockHelix` class in the `tests/src/HighlightController.cpp` file. +1. Mock the function in the `mock::Helix` class in the `mocks/include/mocks/Helix.hpp` file. 1. (Optional) Make a new error enum for the failure callback. For a simple example, see the `updateUserChatColor` function and its error enum `HelixUpdateUserChatColorError`. @@ -43,7 +43,7 @@ URL: https://dev.twitch.tv/docs/api/reference#get-streams Used in: -- `TwitchChannel` to get live status, game, title, and viewer count of a channel +- `LiveController` to get live status, game, title, and viewer count of a channel - `NotificationController` to provide notifications for channels you might not have open in Chatterino, but are still interested in getting notifications for ### Create Clip @@ -61,7 +61,7 @@ URL: https://dev.twitch.tv/docs/api/reference#get-channel-information Used in: -- `TwitchChannel` to refresh stream title +- `LiveController` to refresh stream title & display name ### Update Channel @@ -164,13 +164,70 @@ URL: https://dev.twitch.tv/docs/api/reference#get-channel-emotes Not used anywhere at the moment. -## TMI - -The TMI api is undocumented. - ### Get Chatters -**Undocumented** +URL: https://dev.twitch.tv/docs/api/reference/#get-chatters -- We use this in `widgets/splits/Split.cpp showViewerList` -- We use this in `providers/twitch/TwitchChannel.cpp refreshChatters` +Used for the viewer list for moderators/broadcasters. + +### Send Shoutout + +URL: https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + +Used in: + +- `controllers/commands/CommandController.cpp` to send Twitch native shoutout using "/shoutout " + +## PubSub + +### Whispers + +We listen to the `whispers.` PubSub topic to receive information about incoming whispers to the user + +No EventSub alternative available. + +### Chat Moderator Actions + +We listen to the `chat_moderator_actions..` PubSub topic to receive information about incoming moderator events in a channel. + +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 + +### AutoMod Queue + +We listen to the `automod-queue..` PubSub topic to receive information about incoming automod events in a channel. + +We listen to this topic in every channel the user is a moderator. + +No EventSub alternative available yet. + +### Channel Point Rewards + +We listen to the `community-points-channel-v1.` PubSub topic to receive information about incoming channel points redemptions in a channel. + +The EventSub alternative requires broadcaster auth, which is not a feasible alternative. + +### Low Trust Users + +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. diff --git a/src/singletons/Emotes.hpp b/src/singletons/Emotes.hpp index 1a65a17d0..f74dab873 100644 --- a/src/singletons/Emotes.hpp +++ b/src/singletons/Emotes.hpp @@ -16,6 +16,7 @@ public: virtual ~IEmotes() = default; virtual ITwitchEmotes *getTwitchEmotes() = 0; + virtual IEmojis *getEmojis() = 0; }; class Emotes final : public IEmotes, public Singleton @@ -32,6 +33,11 @@ public: return &this->twitch; } + IEmojis *getEmojis() final + { + return &this->emojis; + } + TwitchEmotes twitch; Emojis emojis; diff --git a/src/singletons/NativeMessaging.cpp b/src/singletons/NativeMessaging.cpp index 620e5ee37..a7165c1f7 100644 --- a/src/singletons/NativeMessaging.cpp +++ b/src/singletons/NativeMessaging.cpp @@ -1,38 +1,39 @@ #include "singletons/NativeMessaging.hpp" #include "Application.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" +#include "debug/AssertInGuiThread.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Paths.hpp" +#include "util/IpcQueue.hpp" #include "util/PostToThread.hpp" -#include -#include #include #include #include #include #include #include - -namespace ipc = boost::interprocess; +#include #ifdef Q_OS_WIN -// clang-format off -# include -# include -// clang-format on -# include "singletons/WindowManager.hpp" # include "widgets/AttachedWindow.hpp" #endif -#include +namespace { -#define EXTENSION_ID "glknmaideaikkmemifbfkhnomoknepka" -#define MESSAGE_SIZE 1024 +using namespace chatterino::literals; + +const QString EXTENSION_ID = u"glknmaideaikkmemifbfkhnomoknepka"_s; +constexpr const size_t MESSAGE_SIZE = 1024; + +} // namespace namespace chatterino { +using namespace literals; + void registerNmManifest(Paths &paths, const QString &manifestFilename, const QString ®istryKeyName, const QJsonDocument &document); @@ -40,47 +41,42 @@ void registerNmManifest(Paths &paths, const QString &manifestFilename, void registerNmHost(Paths &paths) { if (paths.isPortable()) + { return; + } - auto getBaseDocument = [&] { - QJsonObject obj; - obj.insert("name", "com.chatterino.chatterino"); - obj.insert("description", "Browser interaction with chatterino."); - obj.insert("path", QCoreApplication::applicationFilePath()); - obj.insert("type", "stdio"); - - return obj; + auto getBaseDocument = [] { + return QJsonObject{ + {u"name"_s, "com.chatterino.chatterino"_L1}, + {u"description"_s, "Browser interaction with chatterino."_L1}, + {u"path"_s, QCoreApplication::applicationFilePath()}, + {u"type"_s, "stdio"_L1}, + }; }; // chrome { - QJsonDocument document; - auto obj = getBaseDocument(); - QJsonArray allowed_origins_arr = {"chrome-extension://" EXTENSION_ID - "/"}; - obj.insert("allowed_origins", allowed_origins_arr); - document.setObject(obj); + QJsonArray allowedOriginsArr = { + u"chrome-extension://%1/"_s.arg(EXTENSION_ID)}; + obj.insert("allowed_origins", allowedOriginsArr); registerNmManifest(paths, "/native-messaging-manifest-chrome.json", "HKCU\\Software\\Google\\Chrome\\NativeMessagingHost" "s\\com.chatterino.chatterino", - document); + QJsonDocument(obj)); } // firefox { - QJsonDocument document; - auto obj = getBaseDocument(); - QJsonArray allowed_extensions = {"chatterino_native@chatterino.com"}; - obj.insert("allowed_extensions", allowed_extensions); - document.setObject(obj); + QJsonArray allowedExtensions = {"chatterino_native@chatterino.com"}; + obj.insert("allowed_extensions", allowedExtensions); registerNmManifest(paths, "/native-messaging-manifest-firefox.json", "HKCU\\Software\\Mozilla\\NativeMessagingHosts\\com." "chatterino.chatterino", - document); + QJsonDocument(obj)); } } @@ -112,182 +108,208 @@ std::string &getNmQueueName(Paths &paths) // CLIENT -void NativeMessagingClient::sendMessage(const QByteArray &array) -{ - try +namespace nm::client { + + void sendMessage(const QByteArray &array) { - ipc::message_queue messageQueue(ipc::open_only, "chatterino_gui"); - - messageQueue.try_send(array.data(), size_t(array.size()), 1); - // messageQueue.timed_send(array.data(), size_t(array.size()), 1, - // boost::posix_time::second_clock::local_time() + - // boost::posix_time::seconds(10)); + ipc::sendMessage("chatterino_gui", array); } - catch (ipc::interprocess_exception &ex) + + void writeToCout(const QByteArray &array) { - qCDebug(chatterinoNativeMessage) << "send to gui process:" << ex.what(); + const auto *data = array.data(); + auto size = uint32_t(array.size()); + + // We're writing the raw bytes to cout. + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + std::cout.write(reinterpret_cast(&size), 4); + std::cout.write(data, size); + std::cout.flush(); } -} -void NativeMessagingClient::writeToCout(const QByteArray &array) -{ - auto *data = array.data(); - auto size = uint32_t(array.size()); - - std::cout.write(reinterpret_cast(&size), 4); - std::cout.write(data, size); - std::cout.flush(); -} +} // namespace nm::client // SERVER +NativeMessagingServer::NativeMessagingServer() + : thread(*this) +{ +} void NativeMessagingServer::start() { this->thread.start(); } +NativeMessagingServer::ReceiverThread::ReceiverThread( + NativeMessagingServer &parent) + : parent_(parent) +{ +} + void NativeMessagingServer::ReceiverThread::run() { - try - { - ipc::message_queue::remove("chatterino_gui"); - ipc::message_queue messageQueue(ipc::open_or_create, "chatterino_gui", - 100, MESSAGE_SIZE); + auto [messageQueue, error] = + ipc::IpcQueue::tryReplaceOrCreate("chatterino_gui", 100, MESSAGE_SIZE); - while (true) - { - try - { - auto buf = std::make_unique(MESSAGE_SIZE); - auto retSize = ipc::message_queue::size_type(); - auto priority = static_cast(0); - - messageQueue.receive(buf.get(), MESSAGE_SIZE, retSize, - priority); - - auto document = QJsonDocument::fromJson( - QByteArray::fromRawData(buf.get(), retSize)); - - this->handleMessage(document.object()); - } - catch (ipc::interprocess_exception &ex) - { - qCDebug(chatterinoNativeMessage) - << "received from gui process:" << ex.what(); - } - } - } - catch (ipc::interprocess_exception &ex) + if (!error.isEmpty()) { qCDebug(chatterinoNativeMessage) - << "run ipc message queue:" << ex.what(); + << "Failed to create message queue:" << error; - nmIpcError().set(QString::fromLatin1(ex.what())); + nmIpcError().set(error); + return; + } + + while (true) + { + auto buf = messageQueue->receive(); + if (buf.isEmpty()) + { + continue; + } + auto document = QJsonDocument::fromJson(buf); + + this->handleMessage(document.object()); } } void NativeMessagingServer::ReceiverThread::handleMessage( const QJsonObject &root) { - auto app = getApp(); - QString action = root.value("action").toString(); - - if (action.isNull()) - { - qCDebug(chatterinoNativeMessage) << "NM action was null"; - return; - } + QString action = root["action"_L1].toString(); if (action == "select") { - QString _type = root.value("type").toString(); - bool attach = root.value("attach").toBool(); - bool attachFullscreen = root.value("attach_fullscreen").toBool(); - QString name = root.value("name").toString(); - -#ifdef USEWINSDK - AttachedWindow::GetArgs args; - args.winId = root.value("winId").toString(); - args.yOffset = root.value("yOffset").toInt(-1); - - { - const auto sizeObject = root.value("size").toObject(); - args.x = sizeObject.value("x").toDouble(-1.0); - args.pixelRatio = sizeObject.value("pixelRatio").toDouble(-1.0); - args.width = sizeObject.value("width").toInt(-1); - args.height = sizeObject.value("height").toInt(-1); - } - - args.fullscreen = attachFullscreen; - - qCDebug(chatterinoNativeMessage) - << args.x << args.pixelRatio << args.width << args.height - << args.winId; - - if (_type.isNull() || args.winId.isNull()) - { - qCDebug(chatterinoNativeMessage) - << "NM type, name or winId missing"; - attach = false; - attachFullscreen = false; - return; - } -#endif - - if (_type == "twitch") - { - postToThread([=] { - if (!name.isEmpty()) - { - auto channel = app->twitch->getOrAddChannel(name); - if (app->twitch->watchingChannel.get() != channel) - { - app->twitch->watchingChannel.reset(channel); - } - } - - if (attach || attachFullscreen) - { -#ifdef USEWINSDK - // if (args.height != -1) { - auto *window = - AttachedWindow::get(::GetForegroundWindow(), args); - if (!name.isEmpty()) - { - window->setChannel(app->twitch->getOrAddChannel(name)); - } -// } -// window->show(); -#endif - } - }); - } - else - { - qCDebug(chatterinoNativeMessage) << "NM unknown channel type"; - } + this->handleSelect(root); + return; } - else if (action == "detach") + if (action == "detach") { - QString winId = root.value("winId").toString(); + this->handleDetach(root); + return; + } + if (action == "sync") + { + this->handleSync(root); + return; + } - if (winId.isNull()) - { - qCDebug(chatterinoNativeMessage) << "NM winId missing"; - return; - } + qCDebug(chatterinoNativeMessage) << "NM unknown action" << action; +} + +// NOLINTBEGIN(readability-convert-member-functions-to-static) +void NativeMessagingServer::ReceiverThread::handleSelect( + const QJsonObject &root) +{ + QString type = root["type"_L1].toString(); + bool attach = root["attach"_L1].toBool(); + bool attachFullscreen = root["attach_fullscreen"_L1].toBool(); + QString name = root["name"_L1].toString(); #ifdef USEWINSDK - postToThread([winId] { - qCDebug(chatterinoNativeMessage) << "NW detach"; - AttachedWindow::detach(winId); - }); -#endif - } - else + const auto sizeObject = root["size"_L1].toObject(); + AttachedWindow::GetArgs args = { + .winId = root["winId"_L1].toString(), + .yOffset = root["yOffset"_L1].toInt(-1), + .x = sizeObject["x"_L1].toDouble(-1.0), + .pixelRatio = sizeObject["pixelRatio"_L1].toDouble(-1.0), + .width = sizeObject["width"_L1].toInt(-1), + .height = sizeObject["height"_L1].toInt(-1), + .fullscreen = attachFullscreen, + }; + + qCDebug(chatterinoNativeMessage) + << args.x << args.pixelRatio << args.width << args.height << args.winId; + + if (args.winId.isNull()) { - qCDebug(chatterinoNativeMessage) << "NM unknown action " + action; + qCDebug(chatterinoNativeMessage) << "winId in select is missing"; + return; } +#endif + + if (type != u"twitch"_s) + { + qCDebug(chatterinoNativeMessage) << "NM unknown channel type"; + return; + } + + postToThread([=] { + auto *app = getApp(); + + if (!name.isEmpty()) + { + auto channel = app->twitch->getOrAddChannel(name); + if (app->twitch->watchingChannel.get() != channel) + { + app->twitch->watchingChannel.reset(channel); + } + } + + if (attach || attachFullscreen) + { +#ifdef USEWINSDK + auto *window = AttachedWindow::getForeground(args); + if (!name.isEmpty()) + { + window->setChannel(app->twitch->getOrAddChannel(name)); + } +#endif + } + }); +} + +void NativeMessagingServer::ReceiverThread::handleDetach( + const QJsonObject &root) +{ + QString winId = root["winId"_L1].toString(); + + if (winId.isNull()) + { + qCDebug(chatterinoNativeMessage) << "NM winId missing"; + return; + } + +#ifdef USEWINSDK + postToThread([winId] { + qCDebug(chatterinoNativeMessage) << "NW detach"; + AttachedWindow::detach(winId); + }); +#endif +} +// NOLINTEND(readability-convert-member-functions-to-static) + +void NativeMessagingServer::ReceiverThread::handleSync(const QJsonObject &root) +{ + // Structure: + // { action: 'sync', twitchChannels?: string[] } + postToThread([&parent = this->parent_, + twitch = root["twitchChannels"_L1].toArray()] { + parent.syncChannels(twitch); + }); +} + +void NativeMessagingServer::syncChannels(const QJsonArray &twitchChannels) +{ + assertInGuiThread(); + + auto *app = getApp(); + + std::vector updated; + updated.reserve(twitchChannels.size()); + for (const auto &value : twitchChannels) + { + auto name = value.toString(); + if (name.isEmpty()) + { + continue; + } + // the deduping is done on the extension side + updated.emplace_back(app->twitch->getOrAddChannel(name)); + } + + // This will destroy channels that aren't used anymore. + this->channelWarmer_ = std::move(updated); } Atomic> &nmIpcError() diff --git a/src/singletons/NativeMessaging.hpp b/src/singletons/NativeMessaging.hpp index 9c46a08a1..4ed46ad0d 100644 --- a/src/singletons/NativeMessaging.hpp +++ b/src/singletons/NativeMessaging.hpp @@ -6,39 +6,65 @@ #include #include +#include + namespace chatterino { class Application; class Paths; +class Channel; + +using ChannelPtr = std::shared_ptr; void registerNmHost(Paths &paths); std::string &getNmQueueName(Paths &paths); Atomic> &nmIpcError(); -class NativeMessagingClient final -{ -public: +namespace nm::client { + void sendMessage(const QByteArray &array); void writeToCout(const QByteArray &array); -}; + +} // namespace nm::client class NativeMessagingServer final { public: + NativeMessagingServer(); + NativeMessagingServer(const NativeMessagingServer &) = delete; + NativeMessagingServer(NativeMessagingServer &&) = delete; + NativeMessagingServer &operator=(const NativeMessagingServer &) = delete; + NativeMessagingServer &operator=(NativeMessagingServer &&) = delete; + void start(); private: class ReceiverThread : public QThread { public: + ReceiverThread(NativeMessagingServer &parent); + void run() override; private: void handleMessage(const QJsonObject &root); + void handleSelect(const QJsonObject &root); + void handleDetach(const QJsonObject &root); + void handleSync(const QJsonObject &root); + + NativeMessagingServer &parent_; }; + void syncChannels(const QJsonArray &twitchChannels); + ReceiverThread thread; + + /// This vector contains all channels that are open the user's browser. + /// These channels are joined to be able to switch channels more quickly. + std::vector channelWarmer_; + + friend ReceiverThread; }; } // namespace chatterino diff --git a/src/singletons/Paths.cpp b/src/singletons/Paths.cpp index 79344ac72..f2fa55335 100644 --- a/src/singletons/Paths.cpp +++ b/src/singletons/Paths.cpp @@ -142,6 +142,7 @@ void Paths::initSubDirectories() this->miscDirectory = makePath("Misc"); this->twitchProfileAvatars = makePath("ProfileAvatars"); this->pluginsDirectory = makePath("Plugins"); + this->themesDirectory = makePath("Themes"); this->crashdumpDirectory = makePath("Crashes"); //QDir().mkdir(this->twitchProfileAvatars + "/twitch"); } diff --git a/src/singletons/Paths.hpp b/src/singletons/Paths.hpp index f20195fef..d7f00e19e 100644 --- a/src/singletons/Paths.hpp +++ b/src/singletons/Paths.hpp @@ -37,6 +37,9 @@ public: // Plugin files live here. /Plugins QString pluginsDirectory; + // Custom themes live here. /Themes + QString themesDirectory; + bool createFolder(const QString &folderPath); bool isPortable(); diff --git a/src/singletons/Settings.cpp b/src/singletons/Settings.cpp index b32623c42..e01f316a1 100644 --- a/src/singletons/Settings.cpp +++ b/src/singletons/Settings.cpp @@ -1,5 +1,6 @@ #include "singletons/Settings.hpp" +#include "Application.hpp" #include "controllers/filters/FilterRecord.hpp" #include "controllers/highlights/HighlightBadge.hpp" #include "controllers/highlights/HighlightBlacklistUser.hpp" @@ -80,6 +81,22 @@ bool ConcurrentSettings::isMutedChannel(const QString &channelName) return false; } +boost::optional ConcurrentSettings::matchNickname( + const QString &usernameText) +{ + auto nicknames = getCSettings().nicknames.readOnly(); + + for (const auto &nickname : *nicknames) + { + if (auto nicknameText = nickname.match(usernameText)) + { + return nicknameText; + } + } + + return boost::none; +} + void ConcurrentSettings::mute(const QString &channelName) { mutedChannels.append(channelName); @@ -136,6 +153,11 @@ Settings::Settings(const QString &settingsDirectory) }, false); #endif + this->enableStreamerMode.connect( + []() { + getApp()->streamerModeChanged.invoke(); + }, + false); } Settings &Settings::instance() diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 4ff10a071..d3a1e3894 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -47,6 +47,7 @@ public: bool isBlacklistedUser(const QString &username); bool isMutedChannel(const QString &channelName); bool toggleMutedChannel(const QString &channelName); + boost::optional matchNickname(const QString &username); private: void mute(const QString &channelName); @@ -82,6 +83,12 @@ enum ThumbnailPreviewMode : int { ShowOnShift = 2, }; +enum UsernameRightClickBehavior : int { + Reply = 0, + Mention = 1, + Ignore = 2, +}; + /// Settings which are availlable for reading and writing on the gui thread. // These settings are still accessed concurrently in the code but it is bad practice. class Settings : public ABSettings, public ConcurrentSettings @@ -125,6 +132,10 @@ public: EnumSetting tabDirection = {"/appearance/tabDirection", NotebookTabLocation::Top}; + EnumSetting tabVisibility = { + "/appearance/tabVisibility", + NotebookTabVisibility::AllTabs, + }; // BoolSetting collapseLongMessages = // {"/appearance/messages/collapseLongMessages", false}; @@ -189,6 +200,24 @@ public: BoolSetting autoCloseUserPopup = {"/behaviour/autoCloseUserPopup", true}; BoolSetting autoCloseThreadPopup = {"/behaviour/autoCloseThreadPopup", false}; + + EnumSetting usernameRightClickBehavior = { + "/behaviour/usernameRightClickBehavior", + UsernameRightClickBehavior::Mention, + }; + EnumSetting usernameRightClickModifierBehavior = + { + "/behaviour/usernameRightClickBehaviorWithModifier", + UsernameRightClickBehavior::Reply, + }; + EnumSetting usernameRightClickModifier = { + "/behaviour/usernameRightClickModifier", + Qt::KeyboardModifier::ShiftModifier}; + + BoolSetting autoSubToParticipatedThreads = { + "/behaviour/autoSubToParticipatedThreads", + true, + }; // BoolSetting twitchSeperateWriteConnection = // {"/behaviour/twitchSeperateWriteConnection", false}; @@ -239,6 +268,7 @@ public: BoolSetting enableSevenTVGlobalEmotes = {"/emotes/seventv/global", true}; BoolSetting enableSevenTVChannelEmotes = {"/emotes/seventv/channel", true}; BoolSetting enableSevenTVEventAPI = {"/emotes/seventv/eventapi", true}; + BoolSetting sendSevenTVActivity = {"/emotes/seventv/sendActivity", true}; /// Links BoolSetting linksDoubleClickOnly = {"/links/doubleClickToOpen", false}; diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index 1b4f40cd2..e8a110d53 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -2,19 +2,26 @@ #include "singletons/Theme.hpp" #include "Application.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" +#include "singletons/Paths.hpp" #include "singletons/Resources.hpp" #include +#include +#include #include #include -#include #include #include namespace { -void parseInto(const QJsonObject &obj, const QLatin1String &key, QColor &color) + +using namespace chatterino; +using namespace literals; + +void parseInto(const QJsonObject &obj, QLatin1String key, QColor &color) { const auto &jsonValue = obj[key]; if (!jsonValue.isString()) [[unlikely]] @@ -36,8 +43,9 @@ void parseInto(const QJsonObject &obj, const QLatin1String &key, QColor &color) } // NOLINTBEGIN(cppcoreguidelines-macro-usage) +#define _c2StringLit(s, ty) s##ty #define parseColor(to, from, key) \ - parseInto(from, QLatin1String(#key), (to).from.key) + parseInto(from, _c2StringLit(#key, _L1), (to).from.key) // NOLINTEND(cppcoreguidelines-macro-usage) void parseWindow(const QJsonObject &window, chatterino::Theme &theme) @@ -48,32 +56,32 @@ void parseWindow(const QJsonObject &window, chatterino::Theme &theme) void parseTabs(const QJsonObject &tabs, chatterino::Theme &theme) { - const auto parseTabColors = [](auto json, auto &tab) { - parseInto(json, QLatin1String("text"), tab.text); + const auto parseTabColors = [](const auto &json, auto &tab) { + parseInto(json, "text"_L1, tab.text); { - const auto backgrounds = json["backgrounds"].toObject(); + const auto backgrounds = json["backgrounds"_L1].toObject(); parseColor(tab, backgrounds, regular); parseColor(tab, backgrounds, hover); parseColor(tab, backgrounds, unfocused); } { - const auto line = json["line"].toObject(); + const auto line = json["line"_L1].toObject(); parseColor(tab, line, regular); parseColor(tab, line, hover); parseColor(tab, line, unfocused); } }; parseColor(theme, tabs, dividerLine); - parseTabColors(tabs["regular"].toObject(), theme.tabs.regular); - parseTabColors(tabs["newMessage"].toObject(), theme.tabs.newMessage); - parseTabColors(tabs["highlighted"].toObject(), theme.tabs.highlighted); - parseTabColors(tabs["selected"].toObject(), theme.tabs.selected); + parseTabColors(tabs["regular"_L1].toObject(), theme.tabs.regular); + parseTabColors(tabs["newMessage"_L1].toObject(), theme.tabs.newMessage); + parseTabColors(tabs["highlighted"_L1].toObject(), theme.tabs.highlighted); + parseTabColors(tabs["selected"_L1].toObject(), theme.tabs.selected); } void parseMessages(const QJsonObject &messages, chatterino::Theme &theme) { { - const auto textColors = messages["textColors"].toObject(); + const auto textColors = messages["textColors"_L1].toObject(); parseColor(theme.messages, textColors, regular); parseColor(theme.messages, textColors, caret); parseColor(theme.messages, textColors, link); @@ -81,7 +89,7 @@ void parseMessages(const QJsonObject &messages, chatterino::Theme &theme) parseColor(theme.messages, textColors, chatPlaceholder); } { - const auto backgrounds = messages["backgrounds"].toObject(); + const auto backgrounds = messages["backgrounds"_L1].toObject(); parseColor(theme.messages, backgrounds, regular); parseColor(theme.messages, backgrounds, alternate); } @@ -110,7 +118,7 @@ void parseSplits(const QJsonObject &splits, chatterino::Theme &theme) parseColor(theme, splits, resizeHandleBackground); { - const auto header = splits["header"].toObject(); + const auto header = splits["header"_L1].toObject(); parseColor(theme.splits, header, border); parseColor(theme.splits, header, focusedBorder); parseColor(theme.splits, header, background); @@ -119,7 +127,7 @@ void parseSplits(const QJsonObject &splits, chatterino::Theme &theme) parseColor(theme.splits, header, focusedText); } { - const auto input = splits["input"].toObject(); + const auto input = splits["input"_L1].toObject(); parseColor(theme.splits, input, background); parseColor(theme.splits, input, text); } @@ -127,74 +135,244 @@ void parseSplits(const QJsonObject &splits, chatterino::Theme &theme) void parseColors(const QJsonObject &root, chatterino::Theme &theme) { - const auto colors = root["colors"].toObject(); + const auto colors = root["colors"_L1].toObject(); - parseInto(colors, QLatin1String("accent"), theme.accent); + parseInto(colors, "accent"_L1, theme.accent); - parseWindow(colors["window"].toObject(), theme); - parseTabs(colors["tabs"].toObject(), theme); - parseMessages(colors["messages"].toObject(), theme); - parseScrollbars(colors["scrollbars"].toObject(), theme); - parseSplits(colors["splits"].toObject(), theme); + parseWindow(colors["window"_L1].toObject(), theme); + parseTabs(colors["tabs"_L1].toObject(), theme); + parseMessages(colors["messages"_L1].toObject(), theme); + parseScrollbars(colors["scrollbars"_L1].toObject(), theme); + parseSplits(colors["splits"_L1].toObject(), theme); } #undef parseColor +#undef _c2StringLit -QString getThemePath(const QString &name) +std::optional loadThemeFromPath(const QString &path) { - static QSet knownThemes = {"White", "Light", "Dark", "Black"}; - - if (knownThemes.contains(name)) + QFile file(path); + if (!file.open(QFile::ReadOnly)) { - return QStringLiteral(":/themes/%1.json").arg(name); + qCWarning(chatterinoTheme) + << "Failed to open" << file.fileName() << "at" << path; + return std::nullopt; } - return name; + + QJsonParseError error{}; + auto json = QJsonDocument::fromJson(file.readAll(), &error); + if (!json.isObject()) + { + qCWarning(chatterinoTheme) << "Failed to parse" << file.fileName() + << "error:" << error.errorString(); + return std::nullopt; + } + + // TODO: Validate JSON schema? + + return json.object(); +} + +/** + * Load the given theme descriptor from its path + * + * Returns a JSON object containing theme data if the theme is valid, otherwise nullopt + * + * NOTE: No theme validation is done by this function + **/ +std::optional loadTheme(const ThemeDescriptor &theme) +{ + return loadThemeFromPath(theme.path); } } // namespace namespace chatterino { +const std::vector Theme::builtInThemes{ + { + .key = "White", + .path = ":/themes/White.json", + .name = "White", + }, + { + .key = "Light", + .path = ":/themes/Light.json", + .name = "Light", + }, + { + .key = "Dark", + .path = ":/themes/Dark.json", + .name = "Dark", + }, + { + .key = "Black", + .path = ":/themes/Black.json", + .name = "Black", + }, +}; + +// Dark is our default & fallback theme +const ThemeDescriptor Theme::fallbackTheme = Theme::builtInThemes.at(2); + bool Theme::isLightTheme() const { return this->isLight_; } -Theme::Theme() +void Theme::initialize(Settings &settings, Paths &paths) { - this->update(); - - this->themeName.connectSimple( - [this](auto) { + this->themeName.connect( + [this](auto themeName) { + qCInfo(chatterinoTheme) << "Theme updated to" << themeName; this->update(); }, false); + + this->loadAvailableThemes(); + + this->update(); } void Theme::update() { - this->parse(); + auto oTheme = this->findThemeByKey(this->themeName); + + constexpr const double nsToMs = 1.0 / 1000000.0; + QElapsedTimer timer; + timer.start(); + + std::optional themeJSON; + QString themePath; + if (!oTheme) + { + qCWarning(chatterinoTheme) + << "Theme" << this->themeName + << "not found, falling back to the fallback theme"; + + themeJSON = loadTheme(fallbackTheme); + themePath = fallbackTheme.path; + } + else + { + const auto &theme = *oTheme; + + themeJSON = loadTheme(theme); + themePath = theme.path; + + if (!themeJSON) + { + qCWarning(chatterinoTheme) + << "Theme" << this->themeName + << "not valid, falling back to the fallback theme"; + + // Parsing the theme failed, fall back + themeJSON = loadTheme(fallbackTheme); + themePath = fallbackTheme.path; + } + } + auto loadTs = double(timer.nsecsElapsed()) * nsToMs; + + if (!themeJSON) + { + qCWarning(chatterinoTheme) + << "Failed to load" << this->themeName << "or the fallback theme"; + return; + } + + if (this->isAutoReloading() && this->currentThemeJson_ == *themeJSON) + { + return; + } + + this->parseFrom(*themeJSON); + this->currentThemePath_ = themePath; + + auto parseTs = double(timer.nsecsElapsed()) * nsToMs; + this->updated.invoke(); + auto updateTs = double(timer.nsecsElapsed()) * nsToMs; + qCDebug(chatterinoTheme).nospace().noquote() + << "Updated theme in " << QString::number(updateTs, 'f', 2) + << "ms (load: " << QString::number(loadTs, 'f', 2) + << "ms, parse: " << QString::number(parseTs - loadTs, 'f', 2) + << "ms, update: " << QString::number(updateTs - parseTs, 'f', 2) + << "ms)"; + + if (this->isAutoReloading()) + { + this->currentThemeJson_ = *themeJSON; + } } -void Theme::parse() +std::vector> Theme::availableThemes() const { - QFile file(getThemePath(this->themeName)); - if (!file.open(QFile::ReadOnly)) + std::vector> packagedThemes; + + for (const auto &theme : this->availableThemes_) { - qCWarning(chatterinoTheme) << "Failed to open" << file.fileName(); - return; + if (theme.custom) + { + auto p = std::make_pair( + QStringLiteral("Custom: %1").arg(theme.name), theme.key); + + packagedThemes.emplace_back(p); + } + else + { + auto p = std::make_pair(theme.name, theme.key); + + packagedThemes.emplace_back(p); + } } - QJsonParseError error{}; - auto json = QJsonDocument::fromJson(file.readAll(), &error); - if (json.isNull()) + return packagedThemes; +} + +void Theme::loadAvailableThemes() +{ + this->availableThemes_ = Theme::builtInThemes; + + auto dir = QDir(getPaths()->themesDirectory); + for (const auto &info : + dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot, QDir::Name)) { - qCWarning(chatterinoTheme) << "Failed to parse" << file.fileName() - << "error:" << error.errorString(); - return; + if (!info.isFile()) + { + continue; + } + + if (!info.fileName().endsWith(".json")) + { + continue; + } + + auto themeName = info.baseName(); + + auto themeDescriptor = ThemeDescriptor{ + info.fileName(), info.absoluteFilePath(), themeName, true}; + + auto theme = loadTheme(themeDescriptor); + if (!theme) + { + qCWarning(chatterinoTheme) << "Failed to parse theme at" << info; + continue; + } + + this->availableThemes_.emplace_back(std::move(themeDescriptor)); + } +} + +std::optional Theme::findThemeByKey(const QString &key) +{ + for (const auto &theme : this->availableThemes_) + { + if (theme.key == key) + { + return theme; + } } - this->parseFrom(json.object()); + return std::nullopt; } void Theme::parseFrom(const QJsonObject &root) @@ -202,15 +380,20 @@ void Theme::parseFrom(const QJsonObject &root) parseColors(root, *this); this->isLight_ = - root["metadata"]["iconTheme"].toString() == QStringLiteral("dark"); + root["metadata"_L1]["iconTheme"_L1].toString() == u"dark"_s; - this->splits.input.styleSheet = - "background:" + this->splits.input.background.name() + ";" + - "border:" + this->tabs.selected.backgrounds.regular.name() + ";" + - "color:" + this->messages.textColors.regular.name() + ";" + - "selection-background-color:" + - (this->isLightTheme() ? "#68B1FF" - : this->tabs.selected.backgrounds.regular.name()); + this->splits.input.styleSheet = uR"( + background: %1; + border: %2; + color: %3; + selection-background-color: %4; + )"_s.arg( + this->splits.input.background.name(QColor::HexArgb), + this->tabs.selected.backgrounds.regular.name(QColor::HexArgb), + this->messages.textColors.regular.name(QColor::HexArgb), + this->isLightTheme() + ? u"#68B1FF"_s + : this->tabs.selected.backgrounds.regular.name(QColor::HexArgb)); // Usercard buttons if (this->isLightTheme()) @@ -225,6 +408,35 @@ void Theme::parseFrom(const QJsonObject &root) } } +bool Theme::isAutoReloading() const +{ + return this->themeReloadTimer_ != nullptr; +} + +void Theme::setAutoReload(bool autoReload) +{ + if (autoReload == this->isAutoReloading()) + { + return; + } + + if (!autoReload) + { + this->themeReloadTimer_.reset(); + this->currentThemeJson_ = {}; + return; + } + + this->themeReloadTimer_ = std::make_unique(); + QObject::connect(this->themeReloadTimer_.get(), &QTimer::timeout, [this]() { + this->update(); + }); + this->themeReloadTimer_->setInterval(Theme::AUTO_RELOAD_INTERVAL_MS); + this->themeReloadTimer_->start(); + + qCDebug(chatterinoTheme) << "Enabled theme watcher"; +} + void Theme::normalizeColor(QColor &color) const { if (this->isLightTheme()) diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index 034bb799e..d2165543c 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -6,16 +6,44 @@ #include #include +#include #include +#include +#include +#include + +#include +#include +#include namespace chatterino { class WindowManager; +struct ThemeDescriptor { + QString key; + + // Path to the theme on disk + // Can be a Qt resource path + QString path; + + // Name of the theme + QString name; + + bool custom{}; +}; + class Theme final : public Singleton { public: - Theme(); + static const std::vector builtInThemes; + + // The built in theme that will be used if some theme parsing fails + static const ThemeDescriptor fallbackTheme; + + static const int AUTO_RELOAD_INTERVAL_MS = 500; + + void initialize(Settings &settings, Paths &paths) final; bool isLightTheme() const; @@ -114,6 +142,14 @@ public: void normalizeColor(QColor &color) const; void update(); + bool isAutoReloading() const; + void setAutoReload(bool autoReload); + + /** + * Return a list of available themes + **/ + std::vector> availableThemes() const; + pajlada::Signals::NoArgSignal updated; QStringSetting themeName{"/appearance/theme/name", "Dark"}; @@ -121,7 +157,22 @@ public: private: bool isLight_ = false; - void parse(); + std::vector availableThemes_; + + QString currentThemePath_; + std::unique_ptr themeReloadTimer_; + // This will only be populated when auto-reloading themes + QJsonObject currentThemeJson_; + + /** + * Figure out which themes are available in the Themes directory + * + * NOTE: This is currently not built to be reloadable + **/ + void loadAvailableThemes(); + + std::optional findThemeByKey(const QString &key); + void parseFrom(const QJsonObject &root); pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_; diff --git a/src/singletons/Updates.cpp b/src/singletons/Updates.cpp index 3d1ce23a3..fa0f664ad 100644 --- a/src/singletons/Updates.cpp +++ b/src/singletons/Updates.cpp @@ -130,8 +130,8 @@ void Updates::installUpdates() auto *box = new QMessageBox( QMessageBox::Information, windowName, QStringLiteral("The update couldn't be downloaded " - "(HTTP status %1).") - .arg(result.status())); + "(Error: %1).") + .arg(result.formatError())); box->setAttribute(Qt::WA_DeleteOnClose); box->exec(); return Failure; @@ -191,8 +191,8 @@ void Updates::installUpdates() auto *box = new QMessageBox( QMessageBox::Information, windowName, QStringLiteral("The update couldn't be downloaded " - "(HTTP status %1).") - .arg(result.status())); + "(Error: %1).") + .arg(result.formatError())); box->setAttribute(Qt::WA_DeleteOnClose); box->exec(); return Failure; diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index 989b33a57..b9e5d5f5e 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -634,6 +634,10 @@ void WindowManager::encodeChannel(IndirectChannel channel, QJsonObject &obj) } } break; + case Channel::Type::Misc: { + obj.insert("type", "misc"); + obj.insert("name", channel.get()->getName()); + } } } @@ -679,6 +683,10 @@ IndirectChannel WindowManager::decodeChannel(const SplitDescriptor &descriptor) return Irc::instance().getOrAddChannel(descriptor.server_, descriptor.channelName_); } + else if (descriptor.type_ == "misc") + { + return app->twitch->getChannelOrEmpty(descriptor.channelName_); + } return Channel::getEmpty(); } diff --git a/src/singletons/helper/LoggingChannel.cpp b/src/singletons/helper/LoggingChannel.cpp index 9f10f87c3..b47aec19d 100644 --- a/src/singletons/helper/LoggingChannel.cpp +++ b/src/singletons/helper/LoggingChannel.cpp @@ -105,20 +105,39 @@ void LoggingChannel::addMessage(MessagePtr message) str.append(now.toString("HH:mm:ss")); str.append("] "); - QString messageSearchText = message->searchText; + QString messageText; + if (message->loginName.isEmpty()) + { + // This accounts for any messages not explicitly sent by a user, like + // system messages, parts of announcements, subs etc. + messageText = message->messageText; + } + else + { + if (message->localizedName.isEmpty()) + { + messageText = message->loginName + ": " + message->messageText; + } + else + { + messageText = message->localizedName + " " + message->loginName + + ": " + message->messageText; + } + } + if ((message->flags.has(MessageFlag::ReplyMessage) && getSettings()->stripReplyMention) && !getSettings()->hideReplyContext) { - qsizetype colonIndex = messageSearchText.indexOf(':'); + qsizetype colonIndex = messageText.indexOf(':'); if (colonIndex != -1) { QString rootMessageChatter = message->replyThread->root()->loginName; - messageSearchText.insert(colonIndex + 1, " @" + rootMessageChatter); + messageText.insert(colonIndex + 1, " @" + rootMessageChatter); } } - str.append(messageSearchText); + str.append(messageText); str.append(endline); this->appendLine(str); diff --git a/src/util/CancellationToken.hpp b/src/util/CancellationToken.hpp new file mode 100644 index 000000000..12c26f6b1 --- /dev/null +++ b/src/util/CancellationToken.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +namespace chatterino { + +/// The CancellationToken is a thread-safe way for worker(s) +/// to know if the task they want to continue doing should be cancelled. +class CancellationToken +{ +public: + CancellationToken() = default; + explicit CancellationToken(bool isCancelled) + : isCancelled_(new std::atomic(isCancelled)) + { + } + + CancellationToken(const CancellationToken &) = default; + CancellationToken(CancellationToken &&other) + : isCancelled_(std::move(other.isCancelled_)){}; + + CancellationToken &operator=(CancellationToken &&other) + { + this->isCancelled_ = std::move(other.isCancelled_); + return *this; + } + CancellationToken &operator=(const CancellationToken &) = default; + + void cancel() + { + if (this->isCancelled_ != nullptr) + { + this->isCancelled_->store(true, std::memory_order_release); + } + } + + bool isCancelled() const + { + return this->isCancelled_ == nullptr || + this->isCancelled_->load(std::memory_order_acquire); + } + +private: + std::shared_ptr> isCancelled_; +}; + +/// The ScopedCancellationToken is a way to automatically cancel a CancellationToken when it goes out of scope +class ScopedCancellationToken +{ +public: + ScopedCancellationToken() = default; + ScopedCancellationToken(CancellationToken &&backingToken) + : backingToken_(std::move(backingToken)) + { + } + ScopedCancellationToken(CancellationToken backingToken) + : backingToken_(std::move(backingToken)) + { + } + + ~ScopedCancellationToken() + { + this->backingToken_.cancel(); + } + + ScopedCancellationToken(const ScopedCancellationToken &) = delete; + ScopedCancellationToken(ScopedCancellationToken &&other) + : backingToken_(std::move(other.backingToken_)){}; + ScopedCancellationToken &operator=(ScopedCancellationToken &&other) + { + this->backingToken_ = std::move(other.backingToken_); + return *this; + } + ScopedCancellationToken &operator=(const ScopedCancellationToken &) = + delete; + +private: + CancellationToken backingToken_; +}; + +} // namespace chatterino diff --git a/src/util/DebugCount.hpp b/src/util/DebugCount.hpp index 629cb5fbe..35f9fc621 100644 --- a/src/util/DebugCount.hpp +++ b/src/util/DebugCount.hpp @@ -27,6 +27,22 @@ public: reinterpret_cast(it.value())++; } } + + static void set(const QString &name, const int64_t &amount) + { + auto counts = counts_.access(); + + auto it = counts->find(name); + if (it == counts->end()) + { + counts->insert(name, amount); + } + else + { + reinterpret_cast(it.value()) = amount; + } + } + static void increase(const QString &name, const int64_t &amount) { auto counts = counts_.access(); diff --git a/src/util/FormatTime.cpp b/src/util/FormatTime.cpp index 6d6f2e525..1bc0e07b6 100644 --- a/src/util/FormatTime.cpp +++ b/src/util/FormatTime.cpp @@ -1,4 +1,7 @@ -#include "FormatTime.hpp" +#include "util/FormatTime.hpp" + +#include +#include namespace chatterino { @@ -57,4 +60,15 @@ QString formatTime(QString totalSecondsString) return "n/a"; } +QString formatTime(std::chrono::seconds totalSeconds) +{ + auto count = totalSeconds.count(); + + return formatTime(static_cast(std::clamp( + count, + static_cast(std::numeric_limits::min()), + static_cast( + std::numeric_limits::max())))); +} + } // namespace chatterino diff --git a/src/util/FormatTime.hpp b/src/util/FormatTime.hpp index 0e4eb2725..c9bb12cae 100644 --- a/src/util/FormatTime.hpp +++ b/src/util/FormatTime.hpp @@ -2,10 +2,13 @@ #include +#include + namespace chatterino { // format: 1h 23m 42s QString formatTime(int totalSeconds); QString formatTime(QString totalSecondsString); +QString formatTime(std::chrono::seconds totalSeconds); } // namespace chatterino diff --git a/src/util/IncognitoBrowser.cpp b/src/util/IncognitoBrowser.cpp index 074f07c1b..93ae2983b 100644 --- a/src/util/IncognitoBrowser.cpp +++ b/src/util/IncognitoBrowser.cpp @@ -1,88 +1,93 @@ #include "util/IncognitoBrowser.hpp" #ifdef USEWINSDK # include "util/WindowsHelper.hpp" +#elif defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) +# include "util/XDGHelper.hpp" #endif #include -#include #include namespace { using namespace chatterino; -#ifdef USEWINSDK -QString injectPrivateSwitch(QString command) +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", "-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"}, }; - // transform into regex and replacement string - std::vector> replacers; + // compare case-insensitively + auto lowercasedBrowserExecutable = browserExecutable.toLower(); + +#ifdef Q_OS_WINDOWS + if (lowercasedBrowserExecutable.endsWith(".exe")) + { + lowercasedBrowserExecutable.chop(4); + } +#endif + for (const auto &switch_ : switches) { - replacers.emplace_back( - QRegularExpression("(" + switch_.first + "\\.exe\"?).*", - QRegularExpression::CaseInsensitiveOption), - "\\1 " + switch_.second); - } - - // try to find matching regex and apply it - for (const auto &replacement : replacers) - { - if (replacement.first.match(command).hasMatch()) + if (lowercasedBrowserExecutable.endsWith(switch_.first)) { - command.replace(replacement.first, replacement.second); - return command; + return switch_.second; } } // couldn't match any browser -> unknown browser - return QString(); + return {}; } -QString getCommand() +QString getDefaultBrowserExecutable() { +#ifdef USEWINSDK // get default browser start command, by protocol if possible, falling back to extension if not QString command = - getAssociatedCommand(AssociationQueryType::Protocol, L"http"); + getAssociatedExecutable(AssociationQueryType::Protocol, L"http"); if (command.isNull()) { // failed to fetch default browser by protocol, try by file extension instead - command = - getAssociatedCommand(AssociationQueryType::FileExtension, L".html"); + command = getAssociatedExecutable(AssociationQueryType::FileExtension, + L".html"); } if (command.isNull()) { // also try the equivalent .htm extension - command = - getAssociatedCommand(AssociationQueryType::FileExtension, L".htm"); - } - - if (command.isNull()) - { - // failed to find browser command - return QString(); - } - - // inject switch to enable private browsing - command = injectPrivateSwitch(command); - if (command.isNull()) - { - return QString(); + command = getAssociatedExecutable(AssociationQueryType::FileExtension, + L".htm"); } return command; -} +#elif defined(Q_OS_UNIX) && !defined(Q_OS_DARWIN) + static QString defaultBrowser = []() -> QString { + auto desktopFile = getDefaultBrowserDesktopFile(); + if (desktopFile.has_value()) + { + auto entry = desktopFile->getEntries("Desktop Entry"); + auto exec = entry.find("Exec"); + if (exec != entry.end()) + { + return parseDesktopExecProgram(exec->second.trimmed()); + } + } + return {}; + }(); + + return defaultBrowser; +#else + return {}; #endif +} } // namespace @@ -90,23 +95,15 @@ namespace chatterino { bool supportsIncognitoLinks() { -#ifdef USEWINSDK - return !getCommand().isNull(); -#else - return false; -#endif + auto browserExe = getDefaultBrowserExecutable(); + return !browserExe.isNull() && !getPrivateSwitch(browserExe).isNull(); } bool openLinkIncognito(const QString &link) { -#ifdef USEWINSDK - auto command = getCommand(); - - // TODO: split command into program path and incognito argument - return QProcess::startDetached(command, {link}); -#else - return false; -#endif + auto browserExe = getDefaultBrowserExecutable(); + return QProcess::startDetached(browserExe, + {getPrivateSwitch(browserExe), link}); } } // namespace chatterino diff --git a/src/util/IpcQueue.cpp b/src/util/IpcQueue.cpp new file mode 100644 index 000000000..0efc878fc --- /dev/null +++ b/src/util/IpcQueue.cpp @@ -0,0 +1,87 @@ +#include "util/IpcQueue.hpp" + +#include "common/QLogging.hpp" + +#include +#include +#include +#include + +namespace boost_ipc = boost::interprocess; + +namespace chatterino::ipc { + +void sendMessage(const char *name, const QByteArray &data) +{ + try + { + boost_ipc::message_queue messageQueue(boost_ipc::open_only, name); + + messageQueue.try_send(data.data(), size_t(data.size()), 1); + } + catch (boost_ipc::interprocess_exception &ex) + { + qCDebug(chatterinoNativeMessage) + << "Failed to send message:" << ex.what(); + } +} + +class IpcQueuePrivate +{ +public: + IpcQueuePrivate(const char *name, size_t maxMessages, size_t maxMessageSize) + : queue(boost_ipc::open_or_create, name, maxMessages, maxMessageSize) + { + } + + boost_ipc::message_queue queue; +}; + +IpcQueue::IpcQueue(IpcQueuePrivate *priv) + : private_(priv){}; +IpcQueue::~IpcQueue() = default; + +std::pair, QString> IpcQueue::tryReplaceOrCreate( + const char *name, size_t maxMessages, size_t maxMessageSize) +{ + try + { + boost_ipc::message_queue::remove(name); + return std::make_pair( + std::unique_ptr(new IpcQueue( + new IpcQueuePrivate(name, maxMessages, maxMessageSize))), + QString()); + } + catch (boost_ipc::interprocess_exception &ex) + { + return {nullptr, QString::fromLatin1(ex.what())}; + } +} + +QByteArray IpcQueue::receive() +{ + try + { + auto *d = this->private_.get(); + + QByteArray buf; + // The new storage is uninitialized + buf.resize(static_cast(d->queue.get_max_msg_size())); + + size_t messageSize = 0; + unsigned int priority = 0; + d->queue.receive(buf.data(), buf.size(), messageSize, priority); + + // truncate to the initialized storage + buf.truncate(static_cast(messageSize)); + return buf; + } + catch (boost_ipc::interprocess_exception &ex) + { + qCDebug(chatterinoNativeMessage) + << "Failed to receive message:" << ex.what(); + } + return {}; +} + +} // namespace chatterino::ipc diff --git a/src/util/IpcQueue.hpp b/src/util/IpcQueue.hpp new file mode 100644 index 000000000..467aa2873 --- /dev/null +++ b/src/util/IpcQueue.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +class QByteArray; +class QString; + +namespace chatterino::ipc { + +void sendMessage(const char *name, const QByteArray &data); + +class IpcQueuePrivate; +class IpcQueue +{ +public: + ~IpcQueue(); + + static std::pair, QString> tryReplaceOrCreate( + const char *name, size_t maxMessages, size_t maxMessageSize); + + // TODO: use std::expected + /// Try to receive a message. + /// In the case of an error, the buffer is empty. + QByteArray receive(); + +private: + IpcQueue(IpcQueuePrivate *priv); + + std::unique_ptr private_; + + friend class IpcQueuePrivate; +}; + +} // namespace chatterino::ipc diff --git a/src/util/NuulsUploader.cpp b/src/util/NuulsUploader.cpp index 79f7b3794..8627ef8fd 100644 --- a/src/util/NuulsUploader.cpp +++ b/src/util/NuulsUploader.cpp @@ -208,7 +208,7 @@ void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, .onError([channel](NetworkResult result) -> bool { auto errorMessage = QString("An error happened while uploading your image: %1") - .arg(result.status()); + .arg(result.formatError()); // Try to read more information from the result body auto obj = result.parseJson(); diff --git a/src/util/QObjectRef.hpp b/src/util/QObjectRef.hpp deleted file mode 100644 index 07444d085..000000000 --- a/src/util/QObjectRef.hpp +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace chatterino { -/// Holds a pointer to a QObject and resets it to nullptr if the QObject -/// gets destroyed. -template -class QObjectRef -{ -public: - QObjectRef() - { - static_assert(std::is_base_of_v); - } - - explicit QObjectRef(T *t) - { - static_assert(std::is_base_of_v); - - this->set(t); - } - - QObjectRef(const QObjectRef &other) - { - this->set(other.t_); - } - - ~QObjectRef() - { - this->set(nullptr); - } - - QObjectRef &operator=(T *t) - { - this->set(t); - - return *this; - } - - operator bool() - { - return t_; - } - - T *operator->() - { - return t_; - } - - T *get() - { - return t_; - } - -private: - void set(T *other) - { - // old - if (this->conn_) - { - QObject::disconnect(this->conn_); - } - - // new - if (other) - { - // the cast here should absolutely not be necessary, but gcc still requires it - this->conn_ = - QObject::connect((QObject *)other, &QObject::destroyed, qApp, - [this](QObject *) { - this->set(nullptr); - }, - Qt::DirectConnection); - } - - this->t_ = other; - } - - std::atomic t_{}; - QMetaObject::Connection conn_; -}; -} // namespace chatterino diff --git a/src/util/SampleData.cpp b/src/util/SampleData.cpp index f68305a1b..74c0b1850 100644 --- a/src/util/SampleData.cpp +++ b/src/util/SampleData.cpp @@ -108,7 +108,13 @@ const QStringList &getSampleMiscMessages() R"(@badge-info=subscriber/47;badges=broadcaster/1,subscriber/3012,twitchconAmsterdam2020/1;color=#FF0000;display-name=Supinic;emotes=;flags=;id=8c26e1ab-b50c-4d9d-bc11-3fd57a941d90;login=supinic;mod=0;msg-id=announcement;msg-param-color=PRIMARY;room-id=31400525;subscriber=1;system-msg=;tmi-sent-ts=1648762219962;user-id=31400525;user-type= :tmi.twitch.tv USERNOTICE #supinic :mm test lol)", // Elevated Message (Paid option for keeping a message in chat longer) + // no level R"(@badge-info=subscriber/3;badges=subscriber/0,bits-charity/1;color=#0000FF;display-name=SnoopyTheBot;emotes=;first-msg=0;flags=;id=8779a9e5-cf1b-47b3-b9fe-67a5b1b605f6;mod=0;pinned-chat-paid-amount=500;pinned-chat-paid-canonical-amount=5;pinned-chat-paid-currency=USD;pinned-chat-paid-exponent=2;returning-chatter=0;room-id=36340781;subscriber=1;tmi-sent-ts=1664505974154;turbo=0;user-id=136881249;user-type= :snoopythebot!snoopythebot@snoopythebot.tmi.twitch.tv PRIVMSG #pajlada :-$5)", + // level 1 + R"(@pinned-chat-paid-level=ONE;mod=0;flags=;pinned-chat-paid-amount=1400;pinned-chat-paid-exponent=2;tmi-sent-ts=1687970631828;subscriber=1;user-type=;color=#9DA364;emotes=;badges=predictions/blue-1,subscriber/60,twitchconAmsterdam2020/1;pinned-chat-paid-canonical-amount=1400;turbo=0;user-id=26753388;id=e6681ba0-cdc6-4482-93a3-515b74361e8b;room-id=36340781;first-msg=0;returning-chatter=0;pinned-chat-paid-currency=NOK;pinned-chat-paid-is-system-message=0;badge-info=predictions/Day\s53/53\sforsenSmug,subscriber/67;display-name=matrHS :matrhs!matrhs@matrhs.tmi.twitch.tv PRIVMSG #pajlada :Title: Beating the record. but who is recordingLOL)", + R"(@flags=;pinned-chat-paid-amount=8761;turbo=0;user-id=35669184;pinned-chat-paid-level=ONE;user-type=;pinned-chat-paid-canonical-amount=8761;badge-info=subscriber/2;badges=subscriber/2,sub-gifter/1;emotes=;pinned-chat-paid-exponent=2;subscriber=1;mod=0;room-id=36340781;returning-chatter=0;id=289b614d-1837-4cff-ac22-ce33a9735323;first-msg=0;tmi-sent-ts=1687631719188;color=#00FF7F;pinned-chat-paid-currency=RUB;display-name=Danis;pinned-chat-paid-is-system-message=0 :danis!danis@danis.tmi.twitch.tv PRIVMSG #pajlada :-1 lulw)", + // level 2 + R"(@room-id=36340781;tmi-sent-ts=1687970634371;flags=;id=39a80a3d-c16e-420f-9bbb-faba4976a3bb;badges=subscriber/6,premium/1;emotes=;display-name=rickharrisoncoc;pinned-chat-paid-level=TWO;turbo=0;pinned-chat-paid-amount=500;pinned-chat-paid-is-system-message=0;color=#FF69B4;subscriber=1;user-type=;first-msg=0;pinned-chat-paid-currency=USD;pinned-chat-paid-canonical-amount=500;user-id=518404689;badge-info=subscriber/10;pinned-chat-paid-exponent=2;returning-chatter=0;mod=0 :rickharrisoncoc!rickharrisoncoc@rickharrisoncoc.tmi.twitch.tv PRIVMSG #pajlada :forsen please read my super chat. Please.)", }; return list; } diff --git a/src/util/StreamerMode.cpp b/src/util/StreamerMode.cpp index 03f3f59aa..c905b88a0 100644 --- a/src/util/StreamerMode.cpp +++ b/src/util/StreamerMode.cpp @@ -71,6 +71,7 @@ bool isInStreamerMode() p.exitStatus() == QProcess::NormalExit) { cache = (p.exitCode() == 0); + getApp()->streamerModeChanged.invoke(); return (p.exitCode() == 0); } @@ -89,6 +90,7 @@ bool isInStreamerMode() qCWarning(chatterinoStreamerMode) << "pgrep execution timed out!"; cache = false; + getApp()->streamerModeChanged.invoke(); return false; #endif @@ -122,6 +124,7 @@ bool isInStreamerMode() if (broadcastingBinaries().contains(processName)) { cache = true; + getApp()->streamerModeChanged.invoke(); return true; } } @@ -133,6 +136,7 @@ bool isInStreamerMode() } cache = false; + getApp()->streamerModeChanged.invoke(); #endif return false; } diff --git a/src/util/WindowsHelper.cpp b/src/util/WindowsHelper.cpp index d46d29158..73a4d591d 100644 --- a/src/util/WindowsHelper.cpp +++ b/src/util/WindowsHelper.cpp @@ -88,7 +88,7 @@ void setRegisteredForStartup(bool isRegistered) } } -QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query) +QString getAssociatedExecutable(AssociationQueryType queryType, LPCWSTR query) { static HINSTANCE shlwapi = LoadLibrary(L"shlwapi"); if (shlwapi == nullptr) @@ -122,7 +122,7 @@ QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query) } DWORD resultSize = 0; - if (FAILED(assocQueryString(flags, ASSOCSTR_COMMAND, query, nullptr, + if (FAILED(assocQueryString(flags, ASSOCSTR_EXECUTABLE, query, nullptr, nullptr, &resultSize))) { return QString(); @@ -137,8 +137,8 @@ QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query) QString result; auto buf = new wchar_t[resultSize]; - if (SUCCEEDED(assocQueryString(flags, ASSOCSTR_COMMAND, query, nullptr, buf, - &resultSize))) + if (SUCCEEDED(assocQueryString(flags, ASSOCSTR_EXECUTABLE, query, nullptr, + buf, &resultSize))) { // QString::fromWCharArray expects the length in characters *not // including* the null terminator, but AssocQueryStringW calculates diff --git a/src/util/WindowsHelper.hpp b/src/util/WindowsHelper.hpp index 0cf2cbd2d..ff569265f 100644 --- a/src/util/WindowsHelper.hpp +++ b/src/util/WindowsHelper.hpp @@ -16,7 +16,7 @@ void flushClipboard(); bool isRegisteredForStartup(); void setRegisteredForStartup(bool isRegistered); -QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query); +QString getAssociatedExecutable(AssociationQueryType queryType, LPCWSTR query); } // namespace chatterino diff --git a/src/util/XDGDesktopFile.cpp b/src/util/XDGDesktopFile.cpp new file mode 100644 index 000000000..886a921dc --- /dev/null +++ b/src/util/XDGDesktopFile.cpp @@ -0,0 +1,118 @@ +#include "util/XDGDesktopFile.hpp" + +#include "util/XDGDirectory.hpp" + +#include +#include + +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +namespace chatterino { + +XDGDesktopFile::XDGDesktopFile(const QString &filename) +{ + QFile file(filename); + if (!file.open(QIODevice::ReadOnly)) + { + return; + } + this->valid = true; + + std::optional> entries; + + while (!file.atEnd()) + { + auto lineBytes = file.readLine().trimmed(); + + // Ignore comments & empty lines + if (lineBytes.startsWith('#') || lineBytes.size() == 0) + { + continue; + } + + auto line = QString::fromUtf8(lineBytes); + + if (line.startsWith('[')) + { + // group header + auto end = line.indexOf(']', 1); + if (end == -1 || end == 1) + { + // malformed header - either empty or no closing bracket + continue; + } + auto groupName = line.mid(1, end - 1); + + // it is against spec for the group name to already exist, but the + // parsing behavior for that case is not specified. operator[] will + // result in duplicate groups being merged, which makes the most + // sense for a read-only parser + entries = this->groups[groupName]; + + continue; + } + + // group entry + if (!entries.has_value()) + { + // no group header yet, entry before a group header is against spec + // and should be ignored + continue; + } + + auto delimiter = line.indexOf('='); + if (delimiter == -1) + { + // line is not a group header or a key value pair, ignore it + continue; + } + + auto key = QStringView(line).left(delimiter).trimmed().toString(); + // QStringView.mid() does not do bounds checking before qt 5.15, so + // we have to do it ourselves + auto valueStart = delimiter + 1; + QString value; + if (valueStart < line.size()) + { + value = QStringView(line).mid(valueStart).trimmed().toString(); + } + + // existing keys are against spec, so we can overwrite them with + // wild abandon + entries->get().emplace(key, value); + } +} + +XDGEntries XDGDesktopFile::getEntries(const QString &groupHeader) const +{ + auto group = this->groups.find(groupHeader); + if (group != this->groups.end()) + { + return group->second; + } + + return {}; +} + +std::optional XDGDesktopFile::findDesktopFile( + const QString &desktopFileID) +{ + for (const auto &dataDir : getXDGDirectories(XDGDirectoryType::Data)) + { + auto fileName = + QDir::cleanPath(dataDir + QDir::separator() + "applications" + + QDir::separator() + desktopFileID); + XDGDesktopFile desktopFile(fileName); + if (desktopFile.isValid()) + { + return desktopFile; + } + } + return {}; +} + +} // namespace chatterino + +#endif diff --git a/src/util/XDGDesktopFile.hpp b/src/util/XDGDesktopFile.hpp new file mode 100644 index 000000000..d61705c80 --- /dev/null +++ b/src/util/XDGDesktopFile.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "util/QStringHash.hpp" + +#include +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +namespace chatterino { + +// See https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#group-header +using XDGEntries = std::unordered_map; + +class XDGDesktopFile +{ +public: + // Read the file at `filename` as an XDG desktop file, parsing its groups & their entries + // + // Use the `isValid` function to check if the file was read properly + explicit XDGDesktopFile(const QString &filename); + + /// Returns a map of entries for the given group header + XDGEntries getEntries(const QString &groupHeader) const; + + /// isValid returns true if the file exists and is readable + bool isValid() const + { + return valid; + } + + /// Find the first desktop file based on the given desktop file ID + /// + /// This will look through all Data XDG directories + /// + /// Can return std::nullopt if no desktop file was found for the given desktop file ID + /// + /// References: https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id + static std::optional findDesktopFile( + const QString &desktopFileID); + +private: + bool valid{}; + std::unordered_map groups; +}; + +} // namespace chatterino + +#endif diff --git a/src/util/XDGDirectory.cpp b/src/util/XDGDirectory.cpp new file mode 100644 index 000000000..3bfef95b5 --- /dev/null +++ b/src/util/XDGDirectory.cpp @@ -0,0 +1,77 @@ +#include "util/XDGDirectory.hpp" + +#include "util/CombinePath.hpp" +#include "util/Qt.hpp" + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +QStringList getXDGDirectories(XDGDirectoryType directory) +{ + // User XDG directory environment variables with defaults + // Defaults fetched from https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables 2023-08-05 + static std::unordered_map> + userDirectories = { + { + XDGDirectoryType::Config, + { + "XDG_CONFIG_HOME", + combinePath(QDir::homePath(), ".config/"), + }, + }, + { + XDGDirectoryType::Data, + { + "XDG_DATA_HOME", + combinePath(QDir::homePath(), ".local/share/"), + }, + }, + }; + + // Base (or system) XDG directory environment variables with defaults + // Defaults fetched from https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables 2023-08-05 + static std::unordered_map> + baseDirectories = { + { + XDGDirectoryType::Config, + { + "XDG_CONFIG_DIRS", + {"/etc/xdg"}, + }, + }, + { + XDGDirectoryType::Data, + { + "XDG_DATA_DIRS", + {"/usr/local/share/", "/usr/share/"}, + }, + }, + }; + + QStringList paths; + + const auto &[userEnvVar, userDefaultValue] = userDirectories.at(directory); + auto userEnvPath = qEnvironmentVariable(userEnvVar, userDefaultValue); + paths.push_back(userEnvPath); + + const auto &[baseEnvVar, baseDefaultValue] = baseDirectories.at(directory); + auto baseEnvPaths = + qEnvironmentVariable(baseEnvVar).split(':', Qt::SkipEmptyParts); + if (baseEnvPaths.isEmpty()) + { + paths.append(baseDefaultValue); + } + else + { + paths.append(baseEnvPaths); + } + + return paths; +} + +#endif + +} // namespace chatterino diff --git a/src/util/XDGDirectory.hpp b/src/util/XDGDirectory.hpp new file mode 100644 index 000000000..9a18ea25f --- /dev/null +++ b/src/util/XDGDirectory.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +enum class XDGDirectoryType { + Config, + Data, +}; + +/// getXDGDirectories returns a list of directories given a directory type +/// +/// This will attempt to read the relevant environment variable (e.g. XDG_CONFIG_HOME and XDG_CONFIG_DIRS) and merge them, with sane defaults +QStringList getXDGDirectories(XDGDirectoryType directory); + +#endif + +} // namespace chatterino diff --git a/src/util/XDGHelper.cpp b/src/util/XDGHelper.cpp new file mode 100644 index 000000000..588c46166 --- /dev/null +++ b/src/util/XDGHelper.cpp @@ -0,0 +1,259 @@ +#include "util/XDGHelper.hpp" + +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "util/CombinePath.hpp" +#include "util/Qt.hpp" +#include "util/XDGDesktopFile.hpp" +#include "util/XDGDirectory.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +using namespace chatterino::literals; + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoXDG; + +using namespace chatterino; + +const auto HTTPS_MIMETYPE = u"x-scheme-handler/https"_s; + +/// Read the given mimeapps file and try to find an association for the HTTPS_MIMETYPE +/// +/// If the mimeapps file is invalid (i.e. wasn't read), return nullopt +/// If the file is valid, look for the default Desktop File ID handler for the HTTPS_MIMETYPE +/// If no default Desktop File ID handler is found, populate `associations` +/// and `denyList` with Desktop File IDs from "Added Associations" and "Removed Associations" respectively +std::optional processMimeAppsList( + const QString &mimeappsPath, QStringList &associations, + std::unordered_set &denyList) +{ + XDGDesktopFile mimeappsFile(mimeappsPath); + if (!mimeappsFile.isValid()) + { + return {}; + } + + // get the list of Desktop File IDs for the given mimetype under the "Default + // Applications" group in the mimeapps.list file + auto defaultGroup = mimeappsFile.getEntries("Default Applications"); + auto defaultApps = defaultGroup.find(HTTPS_MIMETYPE); + if (defaultApps != defaultGroup.cend()) + { + // for each desktop ID in the list: + auto desktopIds = defaultApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + auto desktopId = entry.trimmed(); + + // if a valid desktop file is found, verify that it is associated + // with the type. being in the default list gives it an implicit + // association, so just check that it's not in the denylist + if (!denyList.contains(desktopId)) + { + auto desktopFile = XDGDesktopFile::findDesktopFile(desktopId); + // if a valid association is found, we have found the default + // application + if (desktopFile.has_value()) + { + return desktopFile; + } + } + } + } + + // no definitive default application found. process added and removed + // associations, then return empty + + // load any removed associations into the denylist + auto removedGroup = mimeappsFile.getEntries("Removed Associations"); + auto removedApps = removedGroup.find(HTTPS_MIMETYPE); + if (removedApps != removedGroup.end()) + { + auto desktopIds = removedApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + denyList.insert(entry.trimmed()); + } + } + + // append any created associations to the associations list + auto addedGroup = mimeappsFile.getEntries("Added Associations"); + auto addedApps = addedGroup.find(HTTPS_MIMETYPE); + if (addedApps != addedGroup.end()) + { + auto desktopIds = addedApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + associations.push_back(entry.trimmed()); + } + } + + return {}; +} + +std::optional searchMimeAppsListsInDirectory( + const QString &directory, QStringList &associations, + std::unordered_set &denyList) +{ + static auto desktopNames = qEnvironmentVariable("XDG_CURRENT_DESKTOP") + .split(':', Qt::SkipEmptyParts); + static const QString desktopFilename = QStringLiteral("%1-mimeapps.list"); + static const QString nonDesktopFilename = QStringLiteral("mimeapps.list"); + + // try desktop specific mimeapps.list files first + for (const auto &desktopName : desktopNames) + { + auto fileName = + combinePath(directory, desktopFilename.arg(desktopName)); + auto defaultApp = processMimeAppsList(fileName, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // try the generic mimeapps.list + auto fileName = combinePath(directory, nonDesktopFilename); + auto defaultApp = processMimeAppsList(fileName, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + + // no definitive default application found + return {}; +} + +} // namespace + +namespace chatterino { + +/// Try to figure out the most reasonably default web browser to use +/// +/// If the `xdg-settings` program is available, use that +/// If not, read through all possible mimapps files in the order specified here: https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-1.0.1.html#file +/// If no mimeapps file has a default, try to use the Added Associations in those files +std::optional getDefaultBrowserDesktopFile() +{ + // no xdg-utils, find it manually by searching mimeapps.list files + QStringList associations; + std::unordered_set denyList; + + // config dirs first + for (const auto &configDir : getXDGDirectories(XDGDirectoryType::Config)) + { + auto defaultApp = + searchMimeAppsListsInDirectory(configDir, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // data dirs for backwards compatibility + for (const auto &dataDir : getXDGDirectories(XDGDirectoryType::Data)) + { + auto appsDir = combinePath(dataDir, "applications"); + auto defaultApp = + searchMimeAppsListsInDirectory(appsDir, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // no mimeapps.list has an explicit default, use the most preferred added + // association that exists. We could search here for one we support... + if (!associations.empty()) + { + for (const auto &desktopId : associations) + { + auto desktopFile = XDGDesktopFile::findDesktopFile(desktopId); + if (desktopFile.has_value()) + { + return desktopFile; + } + } + } + + // use xdg-settings if installed + QProcess xdgSettings; + xdgSettings.start("xdg-settings", {"get", "default-web-browser"}, + QIODevice::ReadOnly); + xdgSettings.waitForFinished(1000); + if (xdgSettings.exitStatus() == QProcess::ExitStatus::NormalExit && + xdgSettings.error() == QProcess::UnknownError && + xdgSettings.exitCode() == 0) + { + return XDGDesktopFile::findDesktopFile( + xdgSettings.readAllStandardOutput().trimmed()); + } + + return {}; +} + +QString parseDesktopExecProgram(const QString &execKey) +{ + static const QRegularExpression unescapeReservedCharacters( + R"(\\(["`$\\]))"); + + QString program = execKey; + + // string values in desktop files escape all backslashes. This is an + // independent escaping scheme that must be processed first + program.replace(u"\\\\"_s, u"\\"_s); + + if (!program.startsWith('"')) + { + // not quoted, trim after the first space (if any) + auto end = program.indexOf(' '); + if (end != -1) + { + program = program.left(end); + } + } + else + { + // quoted + auto endQuote = program.indexOf('"', 1); + if (endQuote == -1) + { + // No end quote found, the returned program might be malformed + program = program.mid(1); + qCWarning(LOG).noquote().nospace() + << "Malformed desktop entry key " << program << ", originally " + << execKey << ", you might run into issues"; + } + else + { + // End quote found + program = program.mid(1, endQuote - 1); + } + } + + // program now contains the first token of the command line. + // this is either the program name with an absolute path, or just the program name + // denoting it's a relative path. Either will be handled by QProcess cleanly + // now, there is a second escaping scheme specific to the + // exec key that must be applied. + program.replace(unescapeReservedCharacters, "\\1"); + + return program; +} + +} // namespace chatterino + +#endif diff --git a/src/util/XDGHelper.hpp b/src/util/XDGHelper.hpp new file mode 100644 index 000000000..c862af936 --- /dev/null +++ b/src/util/XDGHelper.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "util/XDGDesktopFile.hpp" + +#include + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +std::optional getDefaultBrowserDesktopFile(); + +/// Parses the given `execKey` and returns the resulting program name, ignoring all arguments +/// +/// Parsing is done in accordance to https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html +/// +/// Note: We do *NOT* support field codes +QString parseDesktopExecProgram(const QString &execKey); + +#endif + +} // namespace chatterino diff --git a/src/widgets/AttachedWindow.cpp b/src/widgets/AttachedWindow.cpp index 57a879641..acb00263b 100644 --- a/src/widgets/AttachedWindow.cpp +++ b/src/widgets/AttachedWindow.cpp @@ -136,6 +136,13 @@ AttachedWindow *AttachedWindow::get(void *target, const GetArgs &args) return window; } +#ifdef USEWINSDK +AttachedWindow *AttachedWindow::getForeground(const GetArgs &args) +{ + return AttachedWindow::get(::GetForegroundWindow(), args); +} +#endif + void AttachedWindow::detach(const QString &winId) { for (Item &item : items) diff --git a/src/widgets/AttachedWindow.hpp b/src/widgets/AttachedWindow.hpp index 2f4774502..630f9d4ae 100644 --- a/src/widgets/AttachedWindow.hpp +++ b/src/widgets/AttachedWindow.hpp @@ -31,6 +31,9 @@ public: virtual ~AttachedWindow() override; static AttachedWindow *get(void *target_, const GetArgs &args); +#ifdef USEWINSDK + static AttachedWindow *getForeground(const GetArgs &args); +#endif static void detach(const QString &winId); void setChannel(ChannelPtr channel); diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index 60365081e..f02cdf472 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -10,6 +10,7 @@ #include "widgets/helper/EffectLabel.hpp" #include "widgets/Label.hpp" #include "widgets/TooltipWidget.hpp" +#include "widgets/Window.hpp" #include #include @@ -240,18 +241,6 @@ void BaseWindow::init() #endif } -void BaseWindow::setStayInScreenRect(bool value) -{ - this->stayInScreenRect_ = value; - - this->moveIntoDesktopRect(this->pos()); -} - -bool BaseWindow::getStayInScreenRect() const -{ - return this->stayInScreenRect_; -} - void BaseWindow::setActionOnFocusLoss(ActionOnFocusLoss value) { this->actionOnFocusLoss_ = value; @@ -514,7 +503,7 @@ void BaseWindow::leaveEvent(QEvent *) TooltipWidget::instance()->hide(); } -void BaseWindow::moveTo(QWidget *parent, QPoint point, bool offset) +void BaseWindow::moveTo(QPoint point, bool offset, BoundsChecker boundsChecker) { if (offset) { @@ -522,7 +511,26 @@ void BaseWindow::moveTo(QWidget *parent, QPoint point, bool offset) point.ry() += 16; } - this->moveIntoDesktopRect(point); + switch (boundsChecker) + { + case BoundsChecker::Off: { + // The bounds checker is off, *just* move the window + this->move(point); + } + break; + + case BoundsChecker::CursorPosition: { + // The bounds checker is on, use the cursor position as the origin + this->moveWithinScreen(point, QCursor::pos()); + } + break; + + case BoundsChecker::DesiredPosition: { + // The bounds checker is on, use the desired position as the origin + this->moveWithinScreen(point, point); + } + break; + } } void BaseWindow::resizeEvent(QResizeEvent *) @@ -576,24 +584,13 @@ void BaseWindow::closeEvent(QCloseEvent *) void BaseWindow::showEvent(QShowEvent *) { - this->moveIntoDesktopRect(this->pos()); - if (this->frameless_) - { - QTimer::singleShot(30, this, [this] { - this->moveIntoDesktopRect(this->pos()); - }); - } } -void BaseWindow::moveIntoDesktopRect(QPoint point) +void BaseWindow::moveWithinScreen(QPoint point, QPoint origin) { - if (!this->stayInScreenRect_) - { - return; - } - // move the widget into the screen geometry if it's not already in there - auto *screen = QApplication::screenAt(point); + auto *screen = QApplication::screenAt(origin); + if (screen == nullptr) { screen = QApplication::primaryScreen(); @@ -603,6 +600,9 @@ void BaseWindow::moveIntoDesktopRect(QPoint point) bool stickRight = false; bool stickBottom = false; + const auto w = this->frameGeometry().width(); + const auto h = this->frameGeometry().height(); + if (point.x() < bounds.left()) { point.setX(bounds.left()); @@ -611,15 +611,15 @@ void BaseWindow::moveIntoDesktopRect(QPoint point) { point.setY(bounds.top()); } - if (point.x() + this->width() > bounds.right()) + if (point.x() + w > bounds.right()) { stickRight = true; - point.setX(bounds.right() - this->width()); + point.setX(bounds.right() - w); } - if (point.y() + this->height() > bounds.bottom()) + if (point.y() + h > bounds.bottom()) { stickBottom = true; - point.setY(bounds.bottom() - this->height()); + point.setY(bounds.bottom() - h); } if (stickRight && stickBottom) diff --git a/src/widgets/BaseWindow.hpp b/src/widgets/BaseWindow.hpp index 106cc1a3e..ec043f694 100644 --- a/src/widgets/BaseWindow.hpp +++ b/src/widgets/BaseWindow.hpp @@ -36,6 +36,17 @@ public: DisableLayoutSave = 128, }; + enum class BoundsChecker { + // Don't attempt to do any "stay in screen" stuff, just move me! + Off, + + // Attempt to keep the window within bounds of the screen the cursor is on + CursorPosition, + + // Attempt to keep the window within bounds of the screen the desired position is on + DesiredPosition, + }; + enum ActionOnFocusLoss { Nothing, Delete, Close, Hide }; explicit BaseWindow(FlagsEnum flags_ = None, @@ -51,15 +62,12 @@ public: std::function onClicked); EffectLabel *addTitleBarLabel(std::function onClicked); - void setStayInScreenRect(bool value); - bool getStayInScreenRect() const; - void setActionOnFocusLoss(ActionOnFocusLoss value); ActionOnFocusLoss getActionOnFocusLoss() const; - void moveTo(QWidget *widget, QPoint point, bool offset = true); + void moveTo(QPoint point, bool offset, BoundsChecker boundsChecker); - virtual float scale() const override; + float scale() const override; float qtFontScale() const; pajlada::Signals::NoArgSignal closing; @@ -101,7 +109,12 @@ protected: private: void init(); - void moveIntoDesktopRect(QPoint point); + + /** + * + **/ + void moveWithinScreen(QPoint point, QPoint origin); + void calcButtonsSizes(); void drawCustomWindowFrame(QPainter &painter); void onFocusLost(); @@ -121,7 +134,6 @@ private: bool enableCustomFrame_; ActionOnFocusLoss actionOnFocusLoss_ = Nothing; bool frameless_; - bool stayInScreenRect_ = false; bool shown_ = false; FlagsEnum flags_; float nativeScale_ = 1; diff --git a/src/widgets/DraggablePopup.cpp b/src/widgets/DraggablePopup.cpp index ca015282b..1a38d13f1 100644 --- a/src/widgets/DraggablePopup.cpp +++ b/src/widgets/DraggablePopup.cpp @@ -1,4 +1,8 @@ -#include "DraggablePopup.hpp" +#include "widgets/DraggablePopup.hpp" + +#include "singletons/Resources.hpp" +#include "singletons/Theme.hpp" +#include "widgets/helper/Button.hpp" #include @@ -90,4 +94,30 @@ void DraggablePopup::mouseMoveEvent(QMouseEvent *event) } } +void DraggablePopup::togglePinned() +{ + this->isPinned_ = !isPinned_; + if (isPinned_) + { + this->setActionOnFocusLoss(BaseWindow::Nothing); + this->pinButton_->setPixmap(getResources().buttons.pinEnabled); + } + else + { + this->setActionOnFocusLoss(BaseWindow::Delete); + this->pinButton_->setPixmap(getTheme()->buttons.pin); + } +} +Button *DraggablePopup::createPinButton() +{ + this->pinButton_ = new Button(this); + this->pinButton_->setPixmap(getTheme()->buttons.pin); + this->pinButton_->setScaleIndependantSize(18, 18); + this->pinButton_->setToolTip("Pin Window"); + + QObject::connect(this->pinButton_, &Button::leftClicked, this, + &DraggablePopup::togglePinned); + return this->pinButton_; +} + } // namespace chatterino diff --git a/src/widgets/DraggablePopup.hpp b/src/widgets/DraggablePopup.hpp index 65050e15b..bf82af008 100644 --- a/src/widgets/DraggablePopup.hpp +++ b/src/widgets/DraggablePopup.hpp @@ -26,9 +26,18 @@ protected: void mouseReleaseEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; + /// Creates a pin button that is scoped to this window. + /// When clicked, the user can toggle whether the window is pinned. + /// The window is considered unpinned at the start. + Button *createPinButton(); + // lifetimeHack_ is used to check that the window hasn't been destroyed yet std::shared_ptr lifetimeHack_; + // Toggles pin status updates action on focus loss, isPinned_ and the pin + // button pixmap + void togglePinned(); + private: // isMoving_ is set to true if the user is holding the left mouse button down and has moved the mouse a small amount away from the original click point (startPosDrag_) bool isMoving_ = false; @@ -42,6 +51,9 @@ private: // dragTimer_ is called ~60 times per second once the user has initiated dragging QTimer dragTimer_; + + Button *pinButton_ = nullptr; + bool isPinned_ = false; }; } // namespace chatterino diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index 3f9a9c67b..89f1e202e 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -4,10 +4,12 @@ #include "common/QLogging.hpp" #include "controllers/hotkeys/HotkeyCategory.hpp" #include "controllers/hotkeys/HotkeyController.hpp" +#include "singletons/Resources.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/InitUpdateButton.hpp" +#include "util/StreamerMode.hpp" #include "widgets/dialogs/SettingsDialog.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/NotebookButton.hpp" @@ -90,9 +92,7 @@ NotebookTab *Notebook::addPage(QWidget *page, QString title, bool select) } this->performLayout(); - - tab->show(); - + tab->setVisible(this->shouldShowTab(tab)); return tab; } @@ -214,6 +214,7 @@ void Notebook::select(QWidget *page, bool focusPage) this->selectedPage_ = page; this->performLayout(); + this->updateTabVisibility(); } bool Notebook::containsPage(QWidget *page) @@ -260,45 +261,121 @@ void Notebook::selectIndex(int index, bool focusPage) this->select(this->items_[index].page, focusPage); } -void Notebook::selectNextTab(bool focusPage) +void Notebook::selectVisibleIndex(int index, bool focusPage) { - if (this->items_.size() <= 1) + if (!this->tabVisibilityFilter_) { + this->selectIndex(index, focusPage); return; } - auto index = - (this->indexOf(this->selectedPage_) + 1) % this->items_.count(); + int i = 0; + for (auto &item : this->items_) + { + if (this->tabVisibilityFilter_(item.tab)) + { + if (i == index) + { + // found the index'th visible page + this->select(item.page, focusPage); + return; + } + ++i; + } + } +} - this->select(this->items_[index].page, focusPage); +void Notebook::selectNextTab(bool focusPage) +{ + const int size = this->items_.size(); + + if (!this->tabVisibilityFilter_) + { + if (size <= 1) + { + return; + } + + auto index = (this->indexOf(this->selectedPage_) + 1) % size; + this->select(this->items_[index].page, focusPage); + return; + } + + // find next tab that is permitted by filter + const int startIndex = this->indexOf(this->selectedPage_); + + auto index = (startIndex + 1) % size; + while (index != startIndex) + { + if (this->tabVisibilityFilter_(this->items_[index].tab)) + { + this->select(this->items_[index].page, focusPage); + return; + } + index = (index + 1) % size; + } } void Notebook::selectPreviousTab(bool focusPage) { - if (this->items_.size() <= 1) + const int size = this->items_.size(); + + if (!this->tabVisibilityFilter_) { + if (size <= 1) + { + return; + } + + int index = this->indexOf(this->selectedPage_) - 1; + if (index < 0) + { + index += size; + } + + this->select(this->items_[index].page, focusPage); return; } - int index = this->indexOf(this->selectedPage_) - 1; + // find next previous tab that is permitted by filter + const int startIndex = this->indexOf(this->selectedPage_); - if (index < 0) + auto index = startIndex == 0 ? size - 1 : startIndex - 1; + while (index != startIndex) { - index += this->items_.count(); - } + if (this->tabVisibilityFilter_(this->items_[index].tab)) + { + this->select(this->items_[index].page, focusPage); + return; + } - this->select(this->items_[index].page, focusPage); + index = index == 0 ? size - 1 : index - 1; + } } void Notebook::selectLastTab(bool focusPage) { - const auto size = this->items_.size(); - if (size <= 1) + if (!this->tabVisibilityFilter_) { + const auto size = this->items_.size(); + if (size <= 1) + { + return; + } + + this->select(this->items_[size - 1].page, focusPage); return; } - this->select(this->items_[size - 1].page, focusPage); + // find first tab permitted by filter starting from the end + for (auto it = this->items_.rbegin(); it != this->items_.rend(); ++it) + { + if (this->tabVisibilityFilter_(it->tab)) + { + this->select(it->page, focusPage); + return; + } + } } int Notebook::getPageCount() const @@ -327,6 +404,12 @@ QWidget *Notebook::tabAt(QPoint point, int &index, int maxWidth) for (auto &item : this->items_) { + if (!item.tab->isVisible()) + { + i++; + continue; + } + auto rect = item.tab->getDesiredRect(); rect.setHeight(int(this->scale() * 24)); @@ -379,59 +462,73 @@ void Notebook::setShowTabs(bool value) { this->showTabs_ = value; - this->performLayout(); - for (auto &item : this->items_) - { - item.tab->setHidden(!value); - } - this->setShowAddButton(value); + this->performLayout(); + + this->updateTabVisibility(); + this->updateTabVisibilityMenuAction(); // show a popup upon hiding tabs if (!value && getSettings()->informOnTabVisibilityToggle.getValue()) { - auto unhideSeq = getApp()->hotkeys->getDisplaySequence( - HotkeyCategory::Window, "setTabVisibility", - {std::vector()}); - if (unhideSeq.isEmpty()) - { - unhideSeq = getApp()->hotkeys->getDisplaySequence( - HotkeyCategory::Window, "setTabVisibility", {{"toggle"}}); - } - if (unhideSeq.isEmpty()) - { - unhideSeq = getApp()->hotkeys->getDisplaySequence( - HotkeyCategory::Window, "setTabVisibility", {{"on"}}); - } - QString hotkeyInfo = "(currently unbound)"; - if (!unhideSeq.isEmpty()) - { - hotkeyInfo = - "(" + - unhideSeq.toString(QKeySequence::SequenceFormat::NativeText) + - ")"; - } - QMessageBox msgBox(this->window()); - msgBox.window()->setWindowTitle("Chatterino - hidden tabs"); - msgBox.setText("You've just hidden your tabs."); - msgBox.setInformativeText( - "You can toggle tabs by using the keyboard shortcut " + hotkeyInfo + - " or right-clicking the tab area and selecting \"Toggle " - "visibility of tabs\"."); - msgBox.addButton(QMessageBox::Ok); - auto *dsaButton = - msgBox.addButton("Don't show again", QMessageBox::YesRole); - - msgBox.setDefaultButton(QMessageBox::Ok); - - msgBox.exec(); - - if (msgBox.clickedButton() == dsaButton) - { - getSettings()->informOnTabVisibilityToggle.setValue(false); - } + this->showTabVisibilityInfoPopup(); + } +} + +void Notebook::showTabVisibilityInfoPopup() +{ + auto unhideSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {std::vector()}); + if (unhideSeq.isEmpty()) + { + unhideSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{"toggle"}}); + } + if (unhideSeq.isEmpty()) + { + unhideSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{"on"}}); + } + QString hotkeyInfo = "(currently unbound)"; + if (!unhideSeq.isEmpty()) + { + hotkeyInfo = + "(" + unhideSeq.toString(QKeySequence::SequenceFormat::NativeText) + + ")"; + } + QMessageBox msgBox(this->window()); + msgBox.window()->setWindowTitle("Chatterino - hidden tabs"); + msgBox.setText("You've just hidden your tabs."); + msgBox.setInformativeText( + "You can toggle tabs by using the keyboard shortcut " + hotkeyInfo + + " or right-clicking the tab area and selecting \"Toggle " + "visibility of tabs\"."); + msgBox.addButton(QMessageBox::Ok); + auto *dsaButton = + msgBox.addButton("Don't show again", QMessageBox::YesRole); + + msgBox.setDefaultButton(QMessageBox::Ok); + + msgBox.exec(); + + if (msgBox.clickedButton() == dsaButton) + { + getSettings()->informOnTabVisibilityToggle.setValue(false); + } +} + +void Notebook::refresh() +{ + this->performLayout(); + this->updateTabVisibility(); +} + +void Notebook::updateTabVisibility() +{ + for (auto &item : this->items_) + { + item.tab->setVisible(this->shouldShowTab(item.tab)); } - updateTabVisibilityMenuAction(); } void Notebook::updateTabVisibilityMenuAction() @@ -508,6 +605,21 @@ void Notebook::performLayout(bool animated) const auto buttonWidth = tabHeight; const auto buttonHeight = tabHeight - 1; + std::vector filteredItems; + filteredItems.reserve(this->items_.size()); + if (this->tabVisibilityFilter_) + { + std::copy_if(this->items_.begin(), this->items_.end(), + std::back_inserter(filteredItems), + [this](const auto &item) { + return this->tabVisibilityFilter_(item.tab); + }); + } + else + { + filteredItems.assign(this->items_.begin(), this->items_.end()); + } + if (this->tabLocation_ == NotebookTabLocation::Top) { auto x = left; @@ -533,14 +645,14 @@ void Notebook::performLayout(bool animated) { // layout tabs /// Notebook tabs need to know if they are in the last row. - auto firstInBottomRow = - this->items_.size() ? &this->items_.front() : nullptr; + auto *firstInBottomRow = + filteredItems.empty() ? nullptr : &filteredItems.front(); - for (auto &item : this->items_) + for (auto &item : filteredItems) { /// Break line if element doesn't fit. - auto isFirst = &item == &this->items_.front(); - auto isLast = &item == &this->items_.back(); + auto isFirst = &item == &filteredItems.front(); + auto isLast = &item == &filteredItems.back(); auto fitsInLine = ((isLast ? addButtonWidth : 0) + x + item.tab->width()) <= width(); @@ -560,7 +672,7 @@ void Notebook::performLayout(bool animated) /// Update which tabs are in the last row auto inLastRow = false; - for (const auto &item : this->items_) + for (const auto &item : filteredItems) { if (&item == firstInBottomRow) { @@ -631,7 +743,7 @@ void Notebook::performLayout(bool animated) { return; } - int count = this->items_.size() + (this->showAddButton_ ? 1 : 0); + int count = filteredItems.size() + (this->showAddButton_ ? 1 : 0); int columnCount = ceil((float)count / tabsPerColumn); // only add width of all the tabs if they are not hidden @@ -642,13 +754,15 @@ void Notebook::performLayout(bool animated) bool isLastColumn = col == columnCount - 1; auto largestWidth = 0; int tabStart = col * tabsPerColumn; - int tabEnd = std::min((col + 1) * tabsPerColumn, - (int)this->items_.size()); + int tabEnd = + std::min(static_cast((col + 1) * tabsPerColumn), + filteredItems.size()); for (int i = tabStart; i < tabEnd; i++) { - largestWidth = std::max( - this->items_.at(i).tab->normalTabWidth(), largestWidth); + largestWidth = + std::max(filteredItems.at(i).tab->normalTabWidth(), + largestWidth); } if (isLastColumn && this->showAddButton_) @@ -662,7 +776,7 @@ void Notebook::performLayout(bool animated) for (int i = tabStart; i < tabEnd; i++) { - auto item = this->items_.at(i); + auto item = filteredItems.at(i); /// Layout tab item.tab->growWidth(largestWidth); @@ -733,7 +847,7 @@ void Notebook::performLayout(bool animated) { return; } - int count = this->items_.size() + (this->showAddButton_ ? 1 : 0); + int count = filteredItems.size() + (this->showAddButton_ ? 1 : 0); int columnCount = ceil((float)count / tabsPerColumn); // only add width of all the tabs if they are not hidden @@ -744,13 +858,15 @@ void Notebook::performLayout(bool animated) bool isLastColumn = col == columnCount - 1; auto largestWidth = 0; int tabStart = col * tabsPerColumn; - int tabEnd = std::min((col + 1) * tabsPerColumn, - (int)this->items_.size()); + int tabEnd = + std::min(static_cast((col + 1) * tabsPerColumn), + filteredItems.size()); for (int i = tabStart; i < tabEnd; i++) { - largestWidth = std::max( - this->items_.at(i).tab->normalTabWidth(), largestWidth); + largestWidth = + std::max(filteredItems.at(i).tab->normalTabWidth(), + largestWidth); } if (isLastColumn && this->showAddButton_) @@ -769,7 +885,7 @@ void Notebook::performLayout(bool animated) for (int i = tabStart; i < tabEnd; i++) { - auto item = this->items_.at(i); + auto item = filteredItems.at(i); /// Layout tab item.tab->growWidth(largestWidth); @@ -838,14 +954,14 @@ void Notebook::performLayout(bool animated) // layout tabs /// Notebook tabs need to know if they are in the last row. - auto firstInBottomRow = - this->items_.size() ? &this->items_.front() : nullptr; + auto *firstInBottomRow = + filteredItems.empty() ? nullptr : &filteredItems.front(); - for (auto &item : this->items_) + for (auto &item : filteredItems) { /// Break line if element doesn't fit. - auto isFirst = &item == &this->items_.front(); - auto isLast = &item == &this->items_.back(); + auto isFirst = &item == &filteredItems.front(); + auto isLast = &item == &filteredItems.back(); auto fitsInLine = ((isLast ? addButtonWidth : 0) + x + item.tab->width()) <= width(); @@ -865,7 +981,7 @@ void Notebook::performLayout(bool animated) /// Update which tabs are in the last row auto inLastRow = false; - for (const auto &item : this->items_) + for (const auto &item : filteredItems) { if (&item == firstInBottomRow) { @@ -1044,6 +1160,28 @@ size_t Notebook::visibleButtonCount() const return i; } +void Notebook::setTabVisibilityFilter(TabVisibilityFilter filter) +{ + this->tabVisibilityFilter_ = std::move(filter); + this->performLayout(); + this->updateTabVisibility(); +} + +bool Notebook::shouldShowTab(const NotebookTab *tab) const +{ + if (!this->showTabs_) + { + return false; + } + + if (this->tabVisibilityFilter_) + { + return this->tabVisibilityFilter_(tab); + } + + return true; +} + SplitNotebook::SplitNotebook(Window *parent) : Notebook(parent) { @@ -1059,6 +1197,24 @@ SplitNotebook::SplitNotebook(Window *parent) this->addCustomButtons(); } + getSettings()->tabVisibility.connect( + [this](int val, auto) { + auto visibility = NotebookTabVisibility(val); + switch (visibility) + { + case NotebookTabVisibility::LiveOnly: + this->setTabVisibilityFilter([](const NotebookTab *tab) { + return tab->isLive() || tab->isSelected(); + }); + break; + case NotebookTabVisibility::AllTabs: + default: + this->setTabVisibilityFilter(nullptr); + break; + } + }, + this->signalHolder_, true); + this->signalHolder_.managedConnect( getApp()->windows->selectSplit, [this](Split *split) { for (auto &&item : this->items()) @@ -1155,6 +1311,45 @@ void SplitNotebook::addCustomButtons() auto updateBtn = this->addCustomButton(); initUpdateButton(*updateBtn, this->signalHolder_); + + // streamer mode + this->streamerModeIcon_ = this->addCustomButton(); + QObject::connect(this->streamerModeIcon_, &NotebookButton::leftClicked, + [this] { + getApp()->windows->showSettingsDialog( + this, SettingsDialogPreference::StreamerMode); + }); + this->signalHolder_.managedConnect(getApp()->streamerModeChanged, [this]() { + this->updateStreamerModeIcon(); + }); + this->updateStreamerModeIcon(); +} + +void SplitNotebook::updateStreamerModeIcon() +{ + if (this->streamerModeIcon_ == nullptr) + { + return; + } + // A duplicate of this code is in Window class + // That copy handles the TitleBar icon in Window (main window on Windows) + // This one is the one near splits (on linux and mac or non-main windows on Windows) + if (getTheme()->isLightTheme()) + { + this->streamerModeIcon_->setPixmap( + getResources().buttons.streamerModeEnabledLight); + } + else + { + this->streamerModeIcon_->setPixmap( + getResources().buttons.streamerModeEnabledDark); + } + this->streamerModeIcon_->setVisible(isInStreamerMode()); +} + +void SplitNotebook::themeChangedEvent() +{ + this->updateStreamerModeIcon(); } SplitContainer *SplitNotebook::addPage(bool select) @@ -1163,7 +1358,6 @@ SplitContainer *SplitNotebook::addPage(bool select) auto tab = Notebook::addPage(container, QString(), select); container->setTab(tab); tab->setParent(this); - tab->setVisible(this->getShowTabs()); return container; } diff --git a/src/widgets/Notebook.hpp b/src/widgets/Notebook.hpp index 7cc49cfa6..eb41af63a 100644 --- a/src/widgets/Notebook.hpp +++ b/src/widgets/Notebook.hpp @@ -9,6 +9,8 @@ #include #include +#include + namespace chatterino { class Window; @@ -19,6 +21,17 @@ class SplitContainer; enum NotebookTabLocation { Top = 0, Left = 1, Right = 2, Bottom = 3 }; +// Controls the visibility of tabs in this notebook +enum NotebookTabVisibility : int { + // Show all tabs + AllTabs = 0, + + // Only show tabs containing splits that are live + LiveOnly = 1, +}; + +using TabVisibilityFilter = std::function; + class Notebook : public BaseWidget { Q_OBJECT @@ -35,6 +48,7 @@ public: int indexOf(QWidget *page) const; virtual void select(QWidget *page, bool focusPage = true); void selectIndex(int index, bool focusPage = true); + void selectVisibleIndex(int index, bool focusPage = true); void selectNextTab(bool focusPage = true); void selectPreviousTab(bool focusPage = true); void selectLastTab(bool focusPage = true); @@ -56,8 +70,6 @@ public: bool getShowAddButton() const; void setShowAddButton(bool value); - void performLayout(bool animate = false); - void setTabLocation(NotebookTabLocation location); bool isNotebookLayoutLocked() const; @@ -65,6 +77,9 @@ public: void addNotebookActionsToMenu(QMenu *menu); + // Update layout and tab visibility + void refresh(); + protected: virtual void scaleChangedEvent(float scale_) override; virtual void resizeEvent(QResizeEvent *) override; @@ -85,7 +100,32 @@ protected: return items_; } + /** + * @brief Apply the given tab visibility filter + * + * An empty function can be provided to denote that no filter will be applied + * + * Tabs will be redrawn after this function is called. + **/ + void setTabVisibilityFilter(TabVisibilityFilter filter); + + /** + * @brief shouldShowTab has the final say whether a tab should be visible right now. + **/ + bool shouldShowTab(const NotebookTab *tab) const; + private: + void performLayout(bool animate = false); + + /** + * @brief Show a popup informing the user of some big tab visibility changes + **/ + void showTabVisibilityInfoPopup(); + + /** + * @brief Updates the visibility state of all tabs + **/ + void updateTabVisibility(); void updateTabVisibilityMenuAction(); void resizeAddButton(); @@ -113,6 +153,10 @@ private: NotebookTabLocation tabLocation_ = NotebookTabLocation::Top; QAction *lockNotebookLayoutAction_; QAction *showTabsAction_; + + // This filter, if set, is used to figure out the visibility of + // the tabs in this notebook. + TabVisibilityFilter tabVisibilityFilter_; }; class SplitNotebook : public Notebook @@ -123,6 +167,7 @@ public: SplitContainer *addPage(bool select = false); SplitContainer *getOrAddSelectedPage(); void select(QWidget *page, bool focusPage = true) override; + void themeChangedEvent() override; protected: void showEvent(QShowEvent *event) override; @@ -131,6 +176,11 @@ private: void addCustomButtons(); pajlada::Signals::SignalHolder signalHolder_; + + // Main window on Windows has basically a duplicate of this in Window + NotebookButton *streamerModeIcon_{}; + + void updateStreamerModeIcon(); }; } // namespace chatterino diff --git a/src/widgets/Scrollbar.cpp b/src/widgets/Scrollbar.cpp index b63e57227..6ca0e248e 100644 --- a/src/widgets/Scrollbar.cpp +++ b/src/widgets/Scrollbar.cpp @@ -4,6 +4,7 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" +#include "util/Clamp.hpp" #include "widgets/helper/ChannelView.hpp" #include @@ -21,12 +22,14 @@ Scrollbar::Scrollbar(size_t messagesLimit, ChannelView *parent) , currentValueAnimation_(this, "currentValue_") , highlights_(messagesLimit) { - resize(int(16 * this->scale()), 100); + this->resize(int(16 * this->scale()), 100); this->currentValueAnimation_.setDuration(150); this->currentValueAnimation_.setEasingCurve( QEasingCurve(QEasingCurve::OutCubic)); + connect(&this->currentValueAnimation_, &QAbstractAnimation::finished, this, + &Scrollbar::resetMaximum); - setMouseTracking(true); + this->setMouseTracking(true); } void Scrollbar::addHighlight(ScrollbarHighlight highlight) @@ -72,12 +75,12 @@ LimitedQueueSnapshot &Scrollbar::getHighlightSnapshot() void Scrollbar::scrollToBottom(bool animate) { - this->setDesiredValue(this->maximum_ - this->getLargeChange(), animate); + this->setDesiredValue(this->getBottom(), animate); } void Scrollbar::scrollToTop(bool animate) { - this->setDesiredValue(this->minimum_ - this->getLargeChange(), animate); + this->setDesiredValue(this->minimum_, animate); } bool Scrollbar::isAtBottom() const @@ -89,80 +92,95 @@ void Scrollbar::setMaximum(qreal value) { this->maximum_ = value; - updateScroll(); + this->updateScroll(); +} + +void Scrollbar::offsetMaximum(qreal value) +{ + this->maximum_ += value; + + this->updateScroll(); +} + +void Scrollbar::resetMaximum() +{ + if (this->minimum_ > 0) + { + this->maximum_ -= this->minimum_; + this->desiredValue_ -= this->minimum_; + this->currentValue_ -= this->minimum_; + this->minimum_ = 0; + } } void Scrollbar::setMinimum(qreal value) { this->minimum_ = value; - updateScroll(); + this->updateScroll(); +} + +void Scrollbar::offsetMinimum(qreal value) +{ + this->minimum_ += value; + + if (this->minimum_ > this->desiredValue_) + { + this->scrollToTop(); + } + + this->updateScroll(); } void Scrollbar::setLargeChange(qreal value) { this->largeChange_ = value; - updateScroll(); + this->updateScroll(); } void Scrollbar::setSmallChange(qreal value) { this->smallChange_ = value; - updateScroll(); + this->updateScroll(); } void Scrollbar::setDesiredValue(qreal value, bool animated) { - animated &= getSettings()->enableSmoothScrolling; - value = std::max(this->minimum_, - std::min(this->maximum_ - this->largeChange_, value)); + value = std::max(this->minimum_, std::min(this->getBottom(), value)); - if (std::abs(this->currentValue_ + this->smoothScrollingOffset_ - value) > - 0.0001) + if (std::abs(this->currentValue_ - value) <= 0.0001) { - if (animated) - { - this->currentValueAnimation_.stop(); - this->currentValueAnimation_.setStartValue( - this->currentValue_ + this->smoothScrollingOffset_); - - // if (((this->getMaximum() - this->getLargeChange()) - - // value) <= 0.01) { - // value += 1; - // } - - this->currentValueAnimation_.setEndValue(value); - this->smoothScrollingOffset_ = 0; - this->atBottom_ = ((this->getMaximum() - this->getLargeChange()) - - value) <= 0.0001; - this->currentValueAnimation_.start(); - } - else - { - if (this->currentValueAnimation_.state() == - QPropertyAnimation::Running) - { - this->currentValueAnimation_.setEndValue(value); - } - else - { - this->smoothScrollingOffset_ = 0; - this->desiredValue_ = value; - this->currentValueAnimation_.stop(); - this->atBottom_ = - ((this->getMaximum() - this->getLargeChange()) - value) <= - 0.0001; - setCurrentValue(value); - } - } + return; } - this->smoothScrollingOffset_ = 0; this->desiredValue_ = value; this->desiredValueChanged_.invoke(); + + this->atBottom_ = (this->getBottom() - value) <= 0.0001; + + if (animated && getSettings()->enableSmoothScrolling) + { + // stop() does not emit QAbstractAnimation::finished(). + this->currentValueAnimation_.stop(); + this->currentValueAnimation_.setStartValue(this->currentValue_); + this->currentValueAnimation_.setEndValue(value); + this->currentValueAnimation_.start(); + } + else + { + if (this->currentValueAnimation_.state() != QPropertyAnimation::Stopped) + { + this->currentValueAnimation_.setEndValue(value); + } + else + { + this->setCurrentValue(value); + this->resetMaximum(); + } + } } qreal Scrollbar::getMaximum() const @@ -180,6 +198,11 @@ qreal Scrollbar::getLargeChange() const return this->largeChange_; } +qreal Scrollbar::getBottom() const +{ + return this->maximum_ - this->largeChange_; +} + qreal Scrollbar::getSmallChange() const { return this->smallChange_; @@ -187,7 +210,7 @@ qreal Scrollbar::getSmallChange() const qreal Scrollbar::getDesiredValue() const { - return this->desiredValue_ + this->smoothScrollingOffset_; + return this->desiredValue_; } qreal Scrollbar::getCurrentValue() const @@ -195,21 +218,17 @@ qreal Scrollbar::getCurrentValue() const return this->currentValue_; } -const QPropertyAnimation &Scrollbar::getCurrentValueAnimation() const +qreal Scrollbar::getRelativeCurrentValue() const { - return this->currentValueAnimation_; + // 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_); } void Scrollbar::offset(qreal value) { - if (this->currentValueAnimation_.state() == QPropertyAnimation::Running) - { - this->smoothScrollingOffset_ += value; - } - else - { - this->setDesiredValue(this->getDesiredValue() + value); - } + this->setDesiredValue(this->desiredValue_ + value); } pajlada::Signals::NoArgSignal &Scrollbar::getCurrentValueChanged() @@ -224,19 +243,17 @@ pajlada::Signals::NoArgSignal &Scrollbar::getDesiredValueChanged() void Scrollbar::setCurrentValue(qreal value) { - value = std::max(this->minimum_, - std::min(this->maximum_ - this->largeChange_, - value + this->smoothScrollingOffset_)); + value = std::max(this->minimum_, std::min(this->getBottom(), value)); - if (std::abs(this->currentValue_ - value) > 0.0001) + if (std::abs(this->currentValue_ - value) <= 0.0001) { - this->currentValue_ = value; - - this->updateScroll(); - this->currentValueChanged_.invoke(); - - this->update(); + return; } + + this->currentValue_ = value; + + this->updateScroll(); + this->currentValueChanged_.invoke(); } void Scrollbar::printCurrentState(const QString &prefix) const @@ -381,14 +398,14 @@ void Scrollbar::mouseMoveEvent(QMouseEvent *event) if (oldIndex != this->mouseOverIndex_) { - update(); + this->update(); } } else if (this->mouseDownIndex_ == 2) { int delta = event->pos().y() - this->lastMousePosition_.y(); - setDesiredValue( + this->setDesiredValue( this->desiredValue_ + (qreal(delta) / std::max(0.00000002, this->trackHeight_)) * this->maximum_); @@ -431,14 +448,16 @@ void Scrollbar::mouseReleaseEvent(QMouseEvent *event) { if (this->mouseDownIndex_ == 0) { - setDesiredValue(this->desiredValue_ - this->smallChange_, true); + this->setDesiredValue(this->desiredValue_ - this->smallChange_, + true); } } else if (y < this->thumbRect_.y()) { if (this->mouseDownIndex_ == 1) { - setDesiredValue(this->desiredValue_ - this->smallChange_, true); + this->setDesiredValue(this->desiredValue_ - this->smallChange_, + true); } } else if (this->thumbRect_.contains(2, y)) @@ -449,26 +468,29 @@ void Scrollbar::mouseReleaseEvent(QMouseEvent *event) { if (this->mouseDownIndex_ == 3) { - setDesiredValue(this->desiredValue_ + this->smallChange_, true); + this->setDesiredValue(this->desiredValue_ + this->smallChange_, + true); } } else { if (this->mouseDownIndex_ == 4) { - setDesiredValue(this->desiredValue_ + this->smallChange_, true); + this->setDesiredValue(this->desiredValue_ + this->smallChange_, + true); } } this->mouseDownIndex_ = -1; - update(); + + this->update(); } void Scrollbar::leaveEvent(QEvent *) { this->mouseOverIndex_ = -1; - update(); + this->update(); } void Scrollbar::updateScroll() @@ -476,11 +498,11 @@ void Scrollbar::updateScroll() this->trackHeight_ = this->height() - this->buttonHeight_ - this->buttonHeight_ - MIN_THUMB_HEIGHT - 1; - auto div = std::max(0.0000001, this->maximum_); + auto div = std::max(0.0000001, this->maximum_ - this->minimum_); this->thumbRect_ = QRect( 0, - int(this->currentValue_ / div * this->trackHeight_) + 1 + + int((this->getRelativeCurrentValue()) / div * this->trackHeight_) + 1 + this->buttonHeight_, this->width(), int(this->largeChange_ / div * this->trackHeight_) + MIN_THUMB_HEIGHT); diff --git a/src/widgets/Scrollbar.hpp b/src/widgets/Scrollbar.hpp index 41ea34b0c..d025539c1 100644 --- a/src/widgets/Scrollbar.hpp +++ b/src/widgets/Scrollbar.hpp @@ -34,18 +34,21 @@ public: bool isAtBottom() const; void setMaximum(qreal value); + void offsetMaximum(qreal value); + void resetMaximum(); void setMinimum(qreal value); + void offsetMinimum(qreal value); void setLargeChange(qreal value); void setSmallChange(qreal value); void setDesiredValue(qreal value, bool animated = false); qreal getMaximum() const; qreal getMinimum() const; qreal getLargeChange() const; + qreal getBottom() const; qreal getSmallChange() const; qreal getDesiredValue() const; qreal getCurrentValue() const; - - const QPropertyAnimation &getCurrentValueAnimation() const; + qreal getRelativeCurrentValue() const; // offset the desired value without breaking smooth scolling void offset(qreal value); @@ -96,7 +99,6 @@ private: qreal smallChange_ = 5; qreal desiredValue_ = 0; qreal currentValue_ = 0; - qreal smoothScrollingOffset_ = 0; pajlada::Signals::NoArgSignal currentValueChanged_; pajlada::Signals::NoArgSignal desiredValueChanged_; diff --git a/src/widgets/TooltipWidget.cpp b/src/widgets/TooltipWidget.cpp index 98ad4b971..f979c03d2 100644 --- a/src/widgets/TooltipWidget.cpp +++ b/src/widgets/TooltipWidget.cpp @@ -27,8 +27,6 @@ TooltipWidget::TooltipWidget(BaseWidget *parent) this->setAttribute(Qt::WA_TranslucentBackground); this->setWindowFlag(Qt::WindowStaysOnTopHint, true); - this->setStayInScreenRect(true); - // Default to using vertical layout this->initializeVLayout(); this->setLayout(this->vLayout_); diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index dfac8ba6e..504ee746e 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -9,6 +9,7 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Resources.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/Updates.hpp" @@ -151,7 +152,7 @@ void Window::closeEvent(QCloseEvent *) void Window::addLayout() { - QVBoxLayout *layout = new QVBoxLayout(this); + auto *layout = new QVBoxLayout(); layout->addWidget(this->notebook_); this->getLayoutContainer()->setLayout(layout); @@ -186,6 +187,54 @@ void Window::addCustomTitlebarButtons() this->userLabel_->rect().bottomLeft())); }); this->userLabel_->setMinimumWidth(20 * scale()); + + // streamer mode + this->streamerModeTitlebarIcon_ = + this->addTitleBarButton(TitleBarButtonStyle::StreamerMode, [this] { + getApp()->windows->showSettingsDialog( + this, SettingsDialogPreference::StreamerMode); + }); + this->signalHolder_.managedConnect(getApp()->streamerModeChanged, [this]() { + this->updateStreamerModeIcon(); + }); + + // Update initial state + this->updateStreamerModeIcon(); +} + +void Window::updateStreamerModeIcon() +{ + // A duplicate of this code is in SplitNotebook class (in Notebook.{c,h}pp) + // That one is the one near splits (on linux and mac or non-main windows on Windows) + // This copy handles the TitleBar icon in Window (main window on Windows) + if (this->streamerModeTitlebarIcon_ == nullptr) + { + return; + } +#ifdef Q_OS_WIN + assert(this->getType() == WindowType::Main); + if (getTheme()->isLightTheme()) + { + this->streamerModeTitlebarIcon_->setPixmap( + getResources().buttons.streamerModeEnabledLight); + } + else + { + this->streamerModeTitlebarIcon_->setPixmap( + getResources().buttons.streamerModeEnabledDark); + } + this->streamerModeTitlebarIcon_->setVisible(isInStreamerMode()); +#else + // clang-format off + assert(false && "Streamer mode TitleBar icon should not exist on non-Windows OSes"); + // clang-format on +#endif +} + +void Window::themeChangedEvent() +{ + this->updateStreamerModeIcon(); + BaseWindow::themeChangedEvent(); } void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) @@ -308,7 +357,7 @@ void Window::addShortcuts() int result = target.toInt(&ok); if (ok) { - this->notebook_->selectIndex(result); + this->notebook_->selectVisibleIndex(result); } else { @@ -573,46 +622,61 @@ void Window::addShortcuts() }}, {"setTabVisibility", [this](std::vector arguments) -> QString { - auto mode = 2; - if (arguments.size() != 0) + QString arg = arguments.empty() ? "toggle" : arguments.front(); + + if (arg == "off") { - auto arg = arguments.at(0); - if (arg == "off") + this->notebook_->setShowTabs(false); + getSettings()->tabVisibility.setValue( + NotebookTabVisibility::AllTabs); + } + else if (arg == "on") + { + this->notebook_->setShowTabs(true); + getSettings()->tabVisibility.setValue( + NotebookTabVisibility::AllTabs); + } + else if (arg == "toggle") + { + this->notebook_->setShowTabs(!this->notebook_->getShowTabs()); + getSettings()->tabVisibility.setValue( + NotebookTabVisibility::AllTabs); + } + else if (arg == "liveOnly") + { + this->notebook_->setShowTabs(true); + getSettings()->tabVisibility.setValue( + NotebookTabVisibility::LiveOnly); + } + else if (arg == "toggleLiveOnly") + { + if (!this->notebook_->getShowTabs()) { - mode = 0; - } - else if (arg == "on") - { - mode = 1; - } - else if (arg == "toggle") - { - mode = 2; + // 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 { - qCWarning(chatterinoHotkeys) - << "Invalid argument for setStreamerMode hotkey: " - << arg; - return QString("Invalid argument for setTabVisibility " - "hotkey: %1. Use \"on\", \"off\" or " - "\"toggle\".") - .arg(arg); + getSettings()->tabVisibility.setValue( + getSettings()->tabVisibility.getEnum() == + NotebookTabVisibility::LiveOnly + ? NotebookTabVisibility::AllTabs + : NotebookTabVisibility::LiveOnly); } } + else + { + qCWarning(chatterinoHotkeys) + << "Invalid argument for setTabVisibility hotkey: " << arg; + return QString("Invalid argument for setTabVisibility hotkey: " + "%1. Use \"on\", \"off\", \"toggle\", " + "\"liveOnly\", or \"toggleLiveOnly\".") + .arg(arg); + } - if (mode == 0) - { - this->notebook_->setShowTabs(false); - } - else if (mode == 1) - { - this->notebook_->setShowTabs(true); - } - else if (mode == 2) - { - this->notebook_->setShowTabs(!this->notebook_->getShowTabs()); - } return ""; }}, }; diff --git a/src/widgets/Window.hpp b/src/widgets/Window.hpp index d99747591..6d25d875c 100644 --- a/src/widgets/Window.hpp +++ b/src/widgets/Window.hpp @@ -31,6 +31,7 @@ public: protected: void closeEvent(QCloseEvent *event) override; bool event(QEvent *event) override; + void themeChangedEvent() override; private: void addCustomTitlebarButtons(); @@ -51,6 +52,10 @@ private: pajlada::Signals::SignalHolder signalHolder_; std::vector bSignals_; + // this is only used on Windows and only on the main window, for the one used otherwise, see SplitNotebook in Notebook.hpp + TitleBarButton *streamerModeTitlebarIcon_ = nullptr; + void updateStreamerModeIcon(); + friend class Notebook; }; diff --git a/src/widgets/dialogs/EditHotkeyDialog.cpp b/src/widgets/dialogs/EditHotkeyDialog.cpp index d1bac90cc..e0fe23291 100644 --- a/src/widgets/dialogs/EditHotkeyDialog.cpp +++ b/src/widgets/dialogs/EditHotkeyDialog.cpp @@ -17,6 +17,14 @@ EditHotkeyDialog::EditHotkeyDialog(const std::shared_ptr hotkey, , data_(hotkey) { this->ui_->setupUi(this); + this->setStyleSheet(R"(QToolTip { + padding: 2px; + background-color: #333333; + border: 1px solid #545454; + color: white; +})"); + this->ui_->easyArgsPicker->setVisible(false); + this->ui_->easyArgsLabel->setVisible(false); // dynamically add category names to the category picker for (const auto &[_, hotkeyCategory] : getApp()->hotkeys->categories()) { @@ -28,34 +36,7 @@ EditHotkeyDialog::EditHotkeyDialog(const std::shared_ptr hotkey, if (hotkey) { - if (!hotkey->validAction()) - { - this->showEditError("Invalid action, make sure you select the " - "correct action before saving."); - } - - // editing a hotkey - - // update pickers/input boxes to values from Hotkey object - this->ui_->categoryPicker->setCurrentIndex(size_t(hotkey->category())); - this->ui_->keyComboEdit->setKeySequence( - QKeySequence::fromString(hotkey->keySequence().toString())); - this->ui_->nameEdit->setText(hotkey->name()); - // update arguments - QString argsText; - bool first = true; - for (const auto &arg : hotkey->arguments()) - { - if (!first) - { - argsText += '\n'; - } - - argsText += arg; - - first = false; - } - this->ui_->argumentsEdit->setPlainText(argsText); + this->setFromHotkey(hotkey); } else { @@ -66,6 +47,96 @@ EditHotkeyDialog::EditHotkeyDialog(const std::shared_ptr hotkey, this->ui_->argumentsEdit->setPlainText(""); } } +void EditHotkeyDialog::setFromHotkey(std::shared_ptr hotkey) +{ + if (!hotkey->validAction()) + { + this->showEditError("Invalid action, make sure you select the " + "correct action before saving."); + } + + // editing a hotkey + + // update pickers/input boxes to values from Hotkey object + this->ui_->categoryPicker->setCurrentIndex(size_t(hotkey->category())); + this->ui_->keyComboEdit->setKeySequence( + QKeySequence::fromString(hotkey->keySequence().toString())); + this->ui_->nameEdit->setText(hotkey->name()); + + auto def = findHotkeyActionDefinition(hotkey->category(), hotkey->action()); + if (def.has_value() && !def->possibleArguments.empty()) + { + qCDebug(chatterinoHotkeys) << "Enabled easy picker and arg edit " + "because we have arguments from hotkey"; + this->ui_->easyArgsLabel->setVisible(true); + this->ui_->easyArgsPicker->setVisible(true); + + this->ui_->argumentsEdit->setVisible(false); + this->ui_->argumentsLabel->setVisible(false); + this->ui_->argumentsDescription->setVisible(false); + + this->ui_->easyArgsPicker->clear(); + this->ui_->easyArgsLabel->setText(def->argumentsPrompt); + this->ui_->easyArgsLabel->setToolTip(def->argumentsPromptHover); + int matchIdx = -1; + for (int i = 0; i < def->possibleArguments.size(); i++) + { + const auto &[displayText, argData] = def->possibleArguments.at(i); + this->ui_->easyArgsPicker->addItem(displayText); + + // check if matches + if (argData.size() != hotkey->arguments().size()) + { + continue; + } + bool matches = true; + for (int j = 0; j < argData.size(); j++) + { + if (argData.at(j) != hotkey->arguments().at(j)) + { + matches = false; + break; + } + } + if (matches) + { + matchIdx = i; + } + } + if (matchIdx != -1) + { + this->ui_->easyArgsPicker->setCurrentIndex(matchIdx); + return; + } + + qCDebug(chatterinoHotkeys) + << "Did not match hotkey arguments for " << hotkey->toString() + << "using text edit instead of easy picker"; + this->showEditError("Arguments do not match what's expected. The " + "argument picker is not available."); + this->ui_->easyArgsLabel->setVisible(false); + this->ui_->easyArgsPicker->setVisible(false); + + this->ui_->argumentsEdit->setVisible(true); + this->ui_->argumentsLabel->setVisible(true); + this->ui_->argumentsDescription->setVisible(true); + } + // update arguments + QString argsText; + bool first = true; + for (const auto &arg : hotkey->arguments()) + { + if (!first) + { + argsText += '\n'; + } + + argsText += arg; + + first = false; + } + this->ui_->argumentsEdit->setPlainText(argsText); +} EditHotkeyDialog::~EditHotkeyDialog() { @@ -151,6 +222,14 @@ void EditHotkeyDialog::afterEdit() action = actionTemp.toString(); } + auto def = findHotkeyActionDefinition(*category, action); + if (def.has_value() && this->ui_->easyArgsPicker->isVisible()) + { + arguments = + def->possibleArguments.at(this->ui_->easyArgsPicker->currentIndex()) + .second; + } + auto hotkey = std::make_shared( *category, this->ui_->keyComboEdit->keySequence(), action, arguments, nameText); @@ -263,44 +342,69 @@ void EditHotkeyDialog::updateArgumentsInput() } const ActionDefinition &def = definition->second; - if (def.maxCountArguments != 0) - { - QString text = - "Arguments wrapped in <> are required.\nArguments wrapped in " - "[] " - "are optional.\nArguments are separated by a newline."; - if (!def.argumentDescription.isEmpty()) - { - this->ui_->argumentsDescription->setVisible(true); - this->ui_->argumentsDescription->setText( - def.argumentDescription); - } - else - { - this->ui_->argumentsDescription->setVisible(false); - } - - text = QString("Arguments wrapped in <> are required."); - if (def.maxCountArguments != def.minCountArguments) - { - text += QString("\nArguments wrapped in [] are optional."); - } - - text += "\nArguments are separated by a newline."; - - this->ui_->argumentsEdit->setEnabled(true); - this->ui_->argumentsEdit->setPlaceholderText(text); - - this->ui_->argumentsLabel->setVisible(true); - this->ui_->argumentsDescription->setVisible(true); - this->ui_->argumentsEdit->setVisible(true); - } - else + if (def.maxCountArguments == 0) { + qCDebug(chatterinoHotkeys) << "Disabled easy picker and arg edit " + "because we don't have any arguments"; this->ui_->argumentsLabel->setVisible(false); this->ui_->argumentsDescription->setVisible(false); this->ui_->argumentsEdit->setVisible(false); + + this->ui_->easyArgsLabel->setVisible(false); + this->ui_->easyArgsPicker->setVisible(false); + return; } + if (!def.argumentDescription.isEmpty()) + { + this->ui_->argumentsDescription->setVisible(true); + this->ui_->argumentsDescription->setText(def.argumentDescription); + } + else + { + this->ui_->argumentsDescription->setVisible(false); + } + + QString text = "Arguments wrapped in <> are required."; + if (def.maxCountArguments != def.minCountArguments) + { + text += QString("\nArguments wrapped in [] are optional."); + } + + text += "\nArguments are separated by a newline."; + + this->ui_->argumentsEdit->setEnabled(true); + this->ui_->argumentsEdit->setPlaceholderText(text); + + this->ui_->argumentsLabel->setVisible(true); + this->ui_->argumentsDescription->setVisible(true); + this->ui_->argumentsEdit->setVisible(true); + + // update easy picker + if (def.possibleArguments.empty()) + { + qCDebug(chatterinoHotkeys) + << "Disabled easy picker because we have possible arguments"; + this->ui_->easyArgsPicker->setVisible(false); + this->ui_->easyArgsLabel->setVisible(false); + return; + } + qCDebug(chatterinoHotkeys) + << "Enabled easy picker because we have possible arguments"; + this->ui_->easyArgsPicker->setVisible(true); + this->ui_->easyArgsLabel->setVisible(true); + + this->ui_->argumentsLabel->setVisible(false); + this->ui_->argumentsEdit->setVisible(false); + this->ui_->argumentsDescription->setVisible(false); + + this->ui_->easyArgsPicker->clear(); + for (const auto &[displayText, _] : def.possibleArguments) + { + this->ui_->easyArgsPicker->addItem(displayText); + } + this->ui_->easyArgsPicker->setCurrentIndex(0); + this->ui_->easyArgsLabel->setText(def.argumentsPrompt); + this->ui_->easyArgsLabel->setToolTip(def.argumentsPromptHover); } } diff --git a/src/widgets/dialogs/EditHotkeyDialog.hpp b/src/widgets/dialogs/EditHotkeyDialog.hpp index d2c1f5e85..3f8bed158 100644 --- a/src/widgets/dialogs/EditHotkeyDialog.hpp +++ b/src/widgets/dialogs/EditHotkeyDialog.hpp @@ -49,6 +49,7 @@ protected slots: private: void showEditError(QString errorText); + void setFromHotkey(std::shared_ptr hotkey); Ui::EditHotkeyDialog *ui_; std::shared_ptr data_; diff --git a/src/widgets/dialogs/EditHotkeyDialog.ui b/src/widgets/dialogs/EditHotkeyDialog.ui index d7f265b0d..7ddb8a21d 100644 --- a/src/widgets/dialogs/EditHotkeyDialog.ui +++ b/src/widgets/dialogs/EditHotkeyDialog.ui @@ -7,7 +7,7 @@ 0 0 400 - 300 + 400 @@ -42,6 +42,9 @@ see this message :) + + Set a name for the hotkey so you will be able to identify it later + Name: @@ -76,6 +79,9 @@ see this message :) + + + @@ -95,6 +101,9 @@ see this message :) + + Pressing this keybinding will invoke the hotkey + Keybinding: @@ -107,6 +116,16 @@ see this message :) + + + You are not supposed to see this, please report this! + + + Argument: + + + + Arguments: @@ -116,7 +135,7 @@ see this message :) - + You should never see this message :) @@ -126,7 +145,7 @@ see this message :) - + @@ -136,8 +155,18 @@ see this message :) - - + + + + + 0 + 0 + + + + + + @@ -169,8 +198,8 @@ see this message :) afterEdit() - 257 - 290 + 263 + 352 157 @@ -185,8 +214,8 @@ see this message :) reject() - 325 - 290 + 331 + 352 286 @@ -201,8 +230,8 @@ see this message :) updatePossibleActions() - 246 - 85 + 172 + 118 75 @@ -217,8 +246,8 @@ see this message :) updateArgumentsInput() - 148 - 119 + 172 + 156 74 diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index c81abd04d..2876d88ca 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -206,10 +206,11 @@ EmotePopup::EmotePopup(QWidget *parent) , search_(new QLineEdit()) , notebook_(new Notebook(this)) { - this->setStayInScreenRect(true); - this->moveTo(this, getApp()->windows->emotePopupPos(), false); + // this->setStayInScreenRect(true); + this->moveTo(getApp()->windows->emotePopupPos(), false, + BaseWindow::BoundsChecker::DesiredPosition); - auto *layout = new QVBoxLayout(this); + auto *layout = new QVBoxLayout(); this->getLayoutContainer()->setLayout(layout); QRegularExpression searchRegex("\\S*"); @@ -218,7 +219,7 @@ EmotePopup::EmotePopup(QWidget *parent) layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - auto *layout2 = new QHBoxLayout(this); + auto *layout2 = new QHBoxLayout(); layout2->setContentsMargins(8, 8, 8, 8); layout2->setSpacing(8); @@ -310,7 +311,7 @@ void EmotePopup::addShortcuts() int result = target.toInt(&ok); if (ok) { - this->notebook_->selectIndex(result, false); + this->notebook_->selectVisibleIndex(result, false); } else { diff --git a/src/widgets/dialogs/ReplyThreadPopup.cpp b/src/widgets/dialogs/ReplyThreadPopup.cpp index fc92da917..5d688d7f2 100644 --- a/src/widgets/dialogs/ReplyThreadPopup.cpp +++ b/src/widgets/dialogs/ReplyThreadPopup.cpp @@ -7,16 +7,18 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "messages/Message.hpp" #include "messages/MessageThread.hpp" -#include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" +#include "singletons/Settings.hpp" #include "util/LayoutCreator.hpp" +#include "widgets/helper/Button.hpp" #include "widgets/helper/ChannelView.hpp" -#include "widgets/helper/ResizingTextEdit.hpp" #include "widgets/Scrollbar.hpp" #include "widgets/splits/Split.hpp" #include "widgets/splits/SplitInput.hpp" +#include + const QString TEXT_TITLE("Reply Thread - @%1 in #%2"); namespace chatterino { @@ -27,7 +29,6 @@ ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent, , split_(split) { this->setWindowTitle(QStringLiteral("Reply Thread")); - this->setStayInScreenRect(true); HotkeyController::HotkeyMap actions{ {"delete", @@ -60,6 +61,11 @@ ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent, } return ""; }}, + {"pin", + [this](std::vector /*arguments*/) -> QString { + this->togglePinned(); + return ""; + }}, // these actions make no sense in the context of a reply thread, so they aren't implemented {"execModeratorAction", nullptr}, @@ -72,9 +78,6 @@ ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent, this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( HotkeyCategory::PopupWindow, actions, this); - auto layout = LayoutCreator(this->getLayoutContainer()) - .setLayoutType(); - // initialize UI this->ui_.threadView = new ChannelView(this, this->split_, ChannelView::Context::ReplyThread); @@ -110,10 +113,58 @@ ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent, } }); + auto layout = LayoutCreator(this->getLayoutContainer()) + .setLayoutType(); + layout->setSpacing(0); // provide draggable margin if frameless auto marginPx = closeAutomatically ? 15 : 1; layout->setContentsMargins(marginPx, marginPx, marginPx, marginPx); + + // Top Row + bool addCheckbox = getSettings()->enableThreadHighlight; + if (addCheckbox || closeAutomatically) + { + auto *hbox = new QHBoxLayout(); + + if (addCheckbox) + { + this->ui_.notificationCheckbox = + new QCheckBox("Subscribe to thread", this); + QObject::connect(this->ui_.notificationCheckbox, + &QCheckBox::toggled, [this](bool checked) { + if (!this->thread_ || + this->thread_->subscribed() == checked) + { + return; + } + + if (checked) + { + this->thread_->markSubscribed(); + } + else + { + this->thread_->markUnsubscribed(); + } + }); + hbox->addWidget(this->ui_.notificationCheckbox, 1); + this->ui_.notificationCheckbox->setFocusPolicy(Qt::ClickFocus); + } + + if (closeAutomatically) + { + hbox->addWidget(this->createPinButton(), 0, Qt::AlignRight); + hbox->setContentsMargins(0, 0, 0, 5); + } + else + { + hbox->setContentsMargins(10, 0, 0, 4); + } + + layout->addLayout(hbox, 1); + } + layout->addWidget(this->ui_.threadView, 1); layout->addWidget(this->ui_.replyInput); } @@ -124,6 +175,24 @@ void ReplyThreadPopup::setThread(std::shared_ptr thread) this->ui_.replyInput->setReply(this->thread_); this->addMessagesFromThread(); this->updateInputUI(); + + if (!this->thread_) [[unlikely]] + { + this->replySubscriptionSignal_ = boost::signals2::scoped_connection{}; + return; + } + + auto updateCheckbox = [this]() { + if (this->ui_.notificationCheckbox) + { + this->ui_.notificationCheckbox->setChecked( + this->thread_->subscribed()); + } + }; + updateCheckbox(); + + this->replySubscriptionSignal_ = + this->thread_->subscriptionUpdated.connect(updateCheckbox); } void ReplyThreadPopup::addMessagesFromThread() @@ -138,26 +207,25 @@ void ReplyThreadPopup::addMessagesFromThread() this->setWindowTitle(TEXT_TITLE.arg(this->thread_->root()->loginName, sourceChannel->getName())); - ChannelPtr virtualChannel; if (sourceChannel->isTwitchChannel()) { - virtualChannel = + this->virtualChannel_ = std::make_shared(sourceChannel->getName()); } else { - virtualChannel = std::make_shared(sourceChannel->getName(), - Channel::Type::None); + this->virtualChannel_ = std::make_shared( + sourceChannel->getName(), Channel::Type::None); } - this->ui_.threadView->setChannel(virtualChannel); + this->ui_.threadView->setChannel(this->virtualChannel_); this->ui_.threadView->setSourceChannel(sourceChannel); auto overrideFlags = boost::optional(this->thread_->root()->flags); overrideFlags->set(MessageFlag::DoNotLog); - virtualChannel->addMessage(this->thread_->root(), overrideFlags); + this->virtualChannel_->addMessage(this->thread_->root(), overrideFlags); for (const auto &msgRef : this->thread_->replies()) { if (auto msg = msgRef.lock()) @@ -165,24 +233,24 @@ void ReplyThreadPopup::addMessagesFromThread() auto overrideFlags = boost::optional(msg->flags); overrideFlags->set(MessageFlag::DoNotLog); - virtualChannel->addMessage(msg, overrideFlags); + this->virtualChannel_->addMessage(msg, overrideFlags); } } this->messageConnection_ = std::make_unique( - sourceChannel->messageAppended.connect( - [this, virtualChannel](MessagePtr &message, auto) { - if (message->replyThread == this->thread_) - { - auto overrideFlags = - boost::optional(message->flags); - overrideFlags->set(MessageFlag::DoNotLog); + sourceChannel->messageAppended.connect([this](MessagePtr &message, + auto) { + if (message->replyThread == this->thread_) + { + auto overrideFlags = + boost::optional(message->flags); + overrideFlags->set(MessageFlag::DoNotLog); - // same reply thread, add message - virtualChannel->addMessage(message, overrideFlags); - } - })); + // same reply thread, add message + this->virtualChannel_->addMessage(message, overrideFlags); + } + })); } void ReplyThreadPopup::updateInputUI() diff --git a/src/widgets/dialogs/ReplyThreadPopup.hpp b/src/widgets/dialogs/ReplyThreadPopup.hpp index 863274e5f..567812ce0 100644 --- a/src/widgets/dialogs/ReplyThreadPopup.hpp +++ b/src/widgets/dialogs/ReplyThreadPopup.hpp @@ -7,6 +7,8 @@ #include #include +class QCheckBox; + namespace chatterino { class MessageThread; @@ -34,15 +36,20 @@ private: std::shared_ptr thread_; // The channel that the reply thread is in ChannelPtr channel_; + // The channel for the `threadView` + ChannelPtr virtualChannel_; Split *split_; struct { ChannelView *threadView = nullptr; SplitInput *replyInput = nullptr; + + QCheckBox *notificationCheckbox = nullptr; } ui_; std::unique_ptr messageConnection_; std::vector bSignals_; + boost::signals2::scoped_connection replySubscriptionSignal_; }; } // namespace chatterino diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index ddf7583cd..05c2e2435 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -196,6 +196,11 @@ void SettingsDialog::filterElements(const QString &text) } } +void SettingsDialog::setElementFilter(const QString &query) +{ + this->ui_.search->setText(query); +} + void SettingsDialog::addTabs() { this->ui_.tabContainer->setSpacing(0); @@ -204,7 +209,7 @@ void SettingsDialog::addTabs() // Constructors are wrapped in std::function to remove some strain from first time loading. // clang-format off - this->addTab([]{return new GeneralPage;}, "General", ":/settings/about.svg"); + this->addTab([]{return new GeneralPage;}, "General", ":/settings/about.svg", SettingsTabId::General); this->ui_.tabContainer->addSpacing(16); this->addTab([]{return new AccountsPage;}, "Accounts", ":/settings/accounts.svg", SettingsTabId::Accounts); this->addTab([]{return new NicknamesPage;}, "Nicknames", ":/settings/accounts.svg"); @@ -317,10 +322,20 @@ void SettingsDialog::showDialog(QWidget *parent, } break; + case SettingsDialogPreference::StreamerMode: { + instance->selectTab(SettingsTabId::General); + } + break; + default:; } instance->show(); + if (preferredTab == SettingsDialogPreference::StreamerMode) + { + // this is needed because each time the settings are opened, the query is reset + instance->setElementFilter("Streamer Mode"); + } instance->activateWindow(); instance->raise(); instance->setFocus(); diff --git a/src/widgets/dialogs/SettingsDialog.hpp b/src/widgets/dialogs/SettingsDialog.hpp index ad8f873d3..303842af2 100644 --- a/src/widgets/dialogs/SettingsDialog.hpp +++ b/src/widgets/dialogs/SettingsDialog.hpp @@ -27,6 +27,7 @@ class PageHeader : public QFrame enum class SettingsDialogPreference { NoPreference, + StreamerMode, Accounts, ModerationActions, }; @@ -57,6 +58,7 @@ private: void selectTab(SettingsDialogTab *tab, const bool byUser = true); void selectTab(SettingsTabId id); void filterElements(const QString &query); + void setElementFilter(const QString &query); void onOkClicked(); void onCancelClicked(); diff --git a/src/widgets/dialogs/UpdateDialog.cpp b/src/widgets/dialogs/UpdateDialog.cpp index dcce88d1e..888d35827 100644 --- a/src/widgets/dialogs/UpdateDialog.cpp +++ b/src/widgets/dialogs/UpdateDialog.cpp @@ -41,7 +41,7 @@ UpdateDialog::UpdateDialog() }); this->setScaleIndependantHeight(150); - this->setScaleIndependantWidth(500); + this->setScaleIndependantWidth(250); } void UpdateDialog::updateStatusChanged(Updates::Status status) @@ -51,18 +51,18 @@ void UpdateDialog::updateStatusChanged(Updates::Status status) switch (status) { case Updates::UpdateAvailable: { - this->ui_.label->setText( - (Updates::instance().isDowngrade() - ? QString( - "The version online (%1) seems to be lower than the " - "current (%2).\nEither a version was reverted or " - "you are running a newer build.\n\nDo you want to " - "download and install it?") - .arg(Updates::instance().getOnlineVersion(), - Updates::instance().getCurrentVersion()) - : QString("An update (%1) is available.\n\nDo you want to " - "download and install it?") - .arg(Updates::instance().getOnlineVersion()))); + this->ui_.label->setText(( + Updates::instance().isDowngrade() + ? QString( + "The version online (%1) seems to be\nlower than the " + "current (%2).\nEither a version was reverted or " + "you are\nrunning a newer build.\n\nDo you want to " + "download and install it?") + .arg(Updates::instance().getOnlineVersion(), + Updates::instance().getCurrentVersion()) + : QString("An update (%1) is available.\n\nDo you want to " + "download and install it?") + .arg(Updates::instance().getOnlineVersion()))); this->updateGeometry(); } break; diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 9c0541d63..d3d9f952e 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -36,6 +36,7 @@ #include #include #include +#include const QString TEXT_FOLLOWERS("Followers: %1"); const QString TEXT_CREATED("Created: %1"); @@ -141,8 +142,6 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent, assert(split != nullptr && "split being nullptr causes lots of bugs down the road"); this->setWindowTitle("Usercard"); - this->setStayInScreenRect(true); - this->updateFocusLoss(); HotkeyController::HotkeyMap actions{ {"delete", @@ -231,6 +230,11 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent, this->underlyingChannel_->sendMessage(msg); return ""; }}, + {"pin", + [this](std::vector /*arguments*/) -> QString { + this->togglePinned(); + return ""; + }}, // these actions make no sense in the context of a usercard, so they aren't implemented {"reject", nullptr}, @@ -361,17 +365,7 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent, // button to pin the window (only if we close automatically) if (this->closeAutomatically_) { - this->ui_.pinButton = box.emplace