Merge remote-tracking branch 'origin/master' into chore/eventsub

This commit is contained in:
Rasmus Karlsson 2024-02-25 13:03:30 +01:00
commit 5616dc48b3
111 changed files with 3837 additions and 1001 deletions

View file

@ -21,15 +21,12 @@ deb_path="Chatterino-ubuntu-${ubuntu_release}-x86_64.deb"
# Refactor opportunity:
case "$ubuntu_release" in
20.04)
dependencies="libc6, libstdc++6, libqt5core5a, libqt5concurrent5, libqt5dbus5, libqt5gui5, libqt5network5, libqt5svg5, libqt5widgets5, qt5-image-formats-plugins, libboost-filesystem1.71.0"
# Qt6 static-linked deb, see https://github.com/Chatterino/docker
dependencies="libc6, libstdc++6, libblkid1, libbsd0, libc6, libexpat1, libffi7, libfontconfig1, libfreetype6, libglib2.0-0, libglvnd0, libglx0, libgraphite2-3, libharfbuzz0b, libicu66, libjpeg-turbo8, libmount1, libopengl0, libpcre2-16-0, libpcre3, libpng16-16, libselinux1, libssl1.1, libstdc++6, libuuid1, libx11-xcb1, libxau6, libxcb1, libxcb-cursor0, libxcb-glx0, libxcb-icccm4, libxcb-image0, libxcb-keysyms1, libxcb-randr0, libxcb-render0, libxcb-render-util0, libxcb-shape0, libxcb-shm0, libxcb-sync1, libxcb-util1, libxcb-xfixes0, libxcb-xkb1, libxdmcp6, libxkbcommon0, libxkbcommon-x11-0, zlib1g"
;;
22.04)
if [ -n "$Qt6_DIR" ]; then
echo "Qt6_DIR set, assuming Qt6"
dependencies="libc6, libstdc++6, libqt6core6, libqt6widgets6, libqt6network6, libqt6core5compat6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins"
else
dependencies="libc6, libstdc++6, libqt5core5a, libqt5concurrent5, libqt5dbus5, libqt5gui5, libqt5network5, libqt5svg5, libqt5widgets5, qt5-image-formats-plugins, libboost-filesystem1.74.0"
fi
# Qt6 static-linked deb, see https://github.com/Chatterino/docker
dependencies="libc6, libstdc++6, libglx0, libopengl0, libpng16-16, libharfbuzz0b, libfreetype6, libfontconfig1, libjpeg-turbo8, libxcb-glx0, libegl1, libx11-6, libxkbcommon0, libx11-xcb1, libxkbcommon-x11-0, libxcb-cursor0, libxcb-icccm4, libxcb-image0, libxcb-keysyms1, libxcb-randr0, libxcb-render-util0, libxcb-shm0, libxcb-sync1, libxcb-xfixes0, libxcb-render0, libxcb-shape0, libxcb-xkb1, libxcb1, libbrotli1, libglib2.0-0, zlib1g, libicu70, libpcre2-16-0, libssl3, libgraphite2-3, libexpat1, libuuid1, libxcb-util1, libxau6, libxdmcp6, libbrotli1, libffi8, libmount1, libselinux1, libpcre3, libicu70, libbsd0, libblkid1, libpcre2-8-0, libmd0"
;;
*)
echo "Unsupported Ubuntu release $ubuntu_release"

30
.CI/full-ubuntu-build.sh Executable file
View file

@ -0,0 +1,30 @@
#!/bin/sh
# TODO: Investigate if the -fno-sized-deallocation flag is still necessary
# TODO: Test appimage/deb creation
set -e
env
rm -rf build
mkdir build
cmake \
-B build \
-DCMAKE_INSTALL_PREFIX=appdir/usr/ \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_APP=On \
-DBUILD_TESTS=On \
-DBUILD_BENCHMARKS=On \
-DUSE_PRECOMPILED_HEADERS=OFF \
-DCMAKE_EXPORT_COMPILE_COMMANDS=On \
-DCHATTERINO_PLUGINS="$C2_PLUGINS" \
-DCMAKE_PREFIX_PATH="$Qt6_DIR/lib/cmake" \
-DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \
-DCHATTERINO_STATIC_QT_BUILD=On \
-DCMAKE_CXX_FLAGS="-fno-sized-deallocation" \
.
cmake --build build
# sh ./../.CI/CreateAppImage.sh
# sh ./../.CI/CreateUbuntuDeb.sh

View file

@ -24,33 +24,93 @@ env:
CONAN_VERSION: 2.0.2
jobs:
build-ubuntu-docker:
name: "Build Ubuntu in Docker"
runs-on: ubuntu-latest
container: ${{ matrix.container }}
strategy:
matrix:
include:
- os: ubuntu-20.04
container: ghcr.io/chatterino/chatterino2-build-ubuntu-20.04:latest
qt-version: 6.6.1
force-lto: false
plugins: true
skip-artifact: false
skip-crashpad: false
build-appimage: false
build-deb: true
- os: ubuntu-22.04
container: ghcr.io/chatterino/chatterino2-build-ubuntu-22.04:latest
qt-version: 6.6.1
force-lto: false
plugins: true
skip-artifact: false
skip-crashpad: false
build-appimage: true
build-deb: true
env:
C2_ENABLE_LTO: ${{ matrix.force-lto }}
C2_PLUGINS: ${{ matrix.plugins }}
C2_ENABLE_CRASHPAD: ${{ matrix.skip-crashpad == false }}
C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0 # allows for tags access
- name: Build
run: |
mkdir build
cd build
CXXFLAGS=-fno-sized-deallocation cmake \
-DCMAKE_INSTALL_PREFIX=appdir/usr/ \
-DCMAKE_BUILD_TYPE=Release \
-DPAJLADA_SETTINGS_USE_BOOST_FILESYSTEM=On \
-DUSE_PRECOMPILED_HEADERS=OFF \
-DCMAKE_EXPORT_COMPILE_COMMANDS=On \
-DCHATTERINO_LTO="$C2_ENABLE_LTO" \
-DCHATTERINO_PLUGINS="$C2_PLUGINS" \
-DCMAKE_PREFIX_PATH="$Qt6_DIR/lib/cmake" \
-DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \
-DCHATTERINO_STATIC_QT_BUILD=On \
..
make -j"$(nproc)"
- name: Package - AppImage (Ubuntu)
if: matrix.build-appimage
run: |
cd build
sh ./../.CI/CreateAppImage.sh
- name: Upload artifact - AppImage (Ubuntu)
if: matrix.build-appimage
uses: actions/upload-artifact@v4
with:
name: Chatterino-x86_64-Qt-${{ matrix.qt-version }}.AppImage
path: build/Chatterino-x86_64.AppImage
- name: Package - .deb (Ubuntu)
if: matrix.build-deb
run: |
cd build
sh ./../.CI/CreateUbuntuDeb.sh
- name: Upload artifact - .deb (Ubuntu)
if: matrix.build-deb
uses: actions/upload-artifact@v4
with:
name: Chatterino-${{ matrix.os }}-Qt-${{ matrix.qt-version }}.deb
path: build/Chatterino-${{ matrix.os }}-x86_64.deb
build:
name: "Build ${{ matrix.os }}, Qt ${{ matrix.qt-version }} (LTO:${{ matrix.force-lto }}, crashpad:${{ matrix.skip-crashpad && 'off' || 'on' }})"
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
# Ubuntu 20.04, Qt 5.12
- os: ubuntu-20.04
qt-version: 5.12.12
force-lto: false
plugins: false
skip-artifact: false
skip-crashpad: false
# Ubuntu 22.04, Qt 5.15
- os: ubuntu-22.04
qt-version: 5.15.2
force-lto: false
plugins: false
skip-artifact: false
skip-crashpad: false
# Ubuntu 22.04, Qt 6.2.4 - tests LTO & plugins
- os: ubuntu-22.04
qt-version: 6.2.4
force-lto: true
plugins: true
skip-artifact: false
skip-crashpad: false
# macOS
- os: macos-latest
qt-version: 5.15.2
@ -74,38 +134,13 @@ jobs:
skip-crashpad: true
fail-fast: false
env:
C2_ENABLE_LTO: ${{ matrix.force-lto }}
C2_PLUGINS: ${{ matrix.plugins }}
C2_ENABLE_CRASHPAD: ${{ matrix.skip-crashpad == false }}
C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') }}
steps:
- name: Force LTO
if: matrix.force-lto
run: |
echo "C2_ENABLE_LTO=ON" >> "$GITHUB_ENV"
shell: bash
- name: Enable plugin support
if: matrix.plugins
run: |
echo "C2_PLUGINS=ON" >> "$GITHUB_ENV"
shell: bash
- name: Set Crashpad
if: matrix.skip-crashpad == false
run: |
echo "C2_ENABLE_CRASHPAD=ON" >> "$GITHUB_ENV"
shell: bash
- name: Set environment variables for windows-latest
if: matrix.os == 'windows-latest'
run: |
echo "vs_version=2022" >> "$GITHUB_ENV"
shell: bash
- name: Set BUILD_WITH_QT6
if: startsWith(matrix.qt-version, '6.')
run: |
echo "C2_BUILD_WITH_QT6=ON" >> "$GITHUB_ENV"
shell: bash
- uses: actions/checkout@v4
with:
submodules: recursive
@ -274,83 +309,6 @@ jobs:
run: conan cache clean --source --build --download "*"
shell: bash
# LINUX
- name: Install dependencies (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get -y install \
cmake \
virtualenv \
rapidjson-dev \
libfuse2 \
libssl-dev \
libboost-dev \
libxcb-randr0-dev \
libboost-system-dev \
libboost-filesystem-dev \
libpulse-dev \
libxkbcommon-x11-0 \
build-essential \
libgl1-mesa-dev \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-render-util0 \
libxcb-xinerama0
- name: Apply Qt patches (Ubuntu)
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.qt-version, '5.')
run: |
patch "$Qt5_DIR/include/QtConcurrent/qtconcurrentthreadengine.h" .patches/qt5-on-newer-gcc.patch
shell: bash
- name: Build (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: |
mkdir build
cd build
CXXFLAGS=-fno-sized-deallocation cmake \
-DCMAKE_INSTALL_PREFIX=appdir/usr/ \
-DCMAKE_BUILD_TYPE=Release \
-DPAJLADA_SETTINGS_USE_BOOST_FILESYSTEM=On \
-DUSE_PRECOMPILED_HEADERS=OFF \
-DCMAKE_EXPORT_COMPILE_COMMANDS=On \
-DCHATTERINO_LTO="$C2_ENABLE_LTO" \
-DCHATTERINO_PLUGINS="$C2_PLUGINS" \
-DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \
..
make -j"$(nproc)"
shell: bash
- name: Package - AppImage (Ubuntu)
if: startsWith(matrix.os, 'ubuntu-20.04') && !matrix.skip-artifact
run: |
cd build
sh ./../.CI/CreateAppImage.sh
shell: bash
- name: Package - .deb (Ubuntu)
if: startsWith(matrix.os, 'ubuntu') && !matrix.skip-artifact
run: |
cd build
sh ./../.CI/CreateUbuntuDeb.sh
shell: bash
- name: Upload artifact - AppImage (Ubuntu)
if: startsWith(matrix.os, 'ubuntu-20.04') && !matrix.skip-artifact
uses: actions/upload-artifact@v4
with:
name: Chatterino-x86_64-${{ matrix.qt-version }}.AppImage
path: build/Chatterino-x86_64.AppImage
- name: Upload artifact - .deb (Ubuntu)
if: startsWith(matrix.os, 'ubuntu') && !matrix.skip-artifact
uses: actions/upload-artifact@v4
with:
name: Chatterino-${{ matrix.os }}-Qt-${{ matrix.qt-version }}.deb
path: build/Chatterino-${{ matrix.os }}-x86_64.deb
# MACOS
- name: Install dependencies (MacOS)
if: startsWith(matrix.os, 'macos')
@ -394,8 +352,9 @@ jobs:
with:
name: chatterino-macos-Qt-${{ matrix.qt-version }}.dmg
path: build/chatterino-macos-Qt-${{ matrix.qt-version }}.dmg
create-release:
needs: build
needs: [build-ubuntu-docker, build]
runs-on: ubuntu-latest
if: (github.event_name == 'push' && github.ref == 'refs/heads/master')
@ -404,12 +363,7 @@ jobs:
with:
fetch-depth: 0 # allows for tags access
- uses: actions/download-artifact@v4
name: Ubuntu 22.04 Qt6.2.4 deb
with:
name: Chatterino-ubuntu-22.04-Qt-6.2.4.deb
path: release-artifacts/
# Windows
- uses: actions/download-artifact@v4
name: Windows Qt6.5.0
with:
@ -428,28 +382,23 @@ jobs:
name: chatterino-windows-x86-64-Qt-5.15.2.zip
path: release-artifacts/
# Linux
- uses: actions/download-artifact@v4
name: Linux Qt5.12.12 AppImage
name: Linux AppImage
with:
name: Chatterino-x86_64-5.12.12.AppImage
name: Chatterino-x86_64-Qt-6.6.1.AppImage
path: release-artifacts/
- uses: actions/download-artifact@v4
name: Ubuntu 20.04 Qt5.12.12 deb
name: Ubuntu 20.04 deb
with:
name: Chatterino-ubuntu-20.04-Qt-5.12.12.deb
name: Chatterino-ubuntu-20.04-Qt-6.6.1.deb
path: release-artifacts/
- uses: actions/download-artifact@v4
name: Ubuntu 22.04 Qt5.15.2 deb
name: Ubuntu 22.04 deb
with:
name: Chatterino-ubuntu-22.04-Qt-5.15.2.deb
path: release-artifacts/
- uses: actions/download-artifact@v4
name: macOS x86_64 Qt5.15.2 dmg
with:
name: chatterino-macos-Qt-5.15.2.dmg
name: Chatterino-ubuntu-22.04-Qt-6.6.1.deb
path: release-artifacts/
- name: Copy flatpakref
@ -457,21 +406,26 @@ jobs:
cp .CI/chatterino-nightly.flatpakref release-artifacts/
shell: bash
# macOS
- uses: actions/download-artifact@v4
name: macOS x86_64 Qt5.15.2 dmg
with:
name: chatterino-macos-Qt-5.15.2.dmg
path: release-artifacts/
- name: Rename artifacts
run: |
ls -l
# Rename the macos build to indicate that it's for macOS 10.15 users
mv chatterino-macos-Qt-5.15.2.dmg Chatterino-macOS-10.15.dmg
mv Chatterino-ubuntu-22.04-x86_64.deb EXPERIMENTAL-Chatterino-ubuntu-22.04-Qt-6.2.4.deb
# Mark all Windows Qt5 builds as old
mv chatterino-windows-x86-64-Qt-5.15.2.zip chatterino-windows-old-x86-64-Qt-5.15.2.zip
working-directory: release-artifacts
shell: bash
- name: Create release
uses: ncipollo/release-action@v1.13.0
uses: ncipollo/release-action@v1.14.0
with:
replacesArtifacts: true
allowUpdates: true

View file

@ -119,7 +119,7 @@ jobs:
- name: clang-tidy review
timeout-minutes: 20
uses: ZedThree/clang-tidy-review@v0.17.0
uses: ZedThree/clang-tidy-review@v0.17.1
with:
build_dir: build-clang-tidy
config_file: ".clang-tidy"
@ -145,4 +145,4 @@ jobs:
libbenchmark-dev
- name: clang-tidy-review upload
uses: ZedThree/clang-tidy-review/upload@v0.17.0
uses: ZedThree/clang-tidy-review/upload@v0.17.1

View file

@ -14,6 +14,6 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: ZedThree/clang-tidy-review/post@v0.17.0
- uses: ZedThree/clang-tidy-review/post@v0.17.1
with:
lgtm_comment_body: ""

View file

@ -1,5 +1,5 @@
---
name: Test
name: Test Ubuntu
on:
pull_request:
@ -7,7 +7,7 @@ on:
merge_group:
env:
TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.6
TWITCH_PUBSUB_SERVER_TAG: v1.0.7
QT_QPA_PLATFORM: minimal
concurrency:
@ -16,91 +16,71 @@ concurrency:
jobs:
test:
runs-on: ${{ matrix.os }}
name: "${{ matrix.os }}"
runs-on: ubuntu-latest
container: ${{ matrix.container }}
strategy:
matrix:
include:
- os: "ubuntu-22.04"
qt-version: "5.15.2"
- os: "ubuntu-22.04"
qt-version: "5.12.12"
- os: "ubuntu-22.04"
qt-version: "6.2.4"
container: ghcr.io/chatterino/chatterino2-build-ubuntu-22.04:latest
qt-version: 6.6.1
plugins: true
fail-fast: false
env:
C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }}
QT_MODULES: ${{ startsWith(matrix.qt-version, '6.') && 'qt5compat qtimageformats' || '' }}
C2_PLUGINS: ${{ matrix.plugins }}
C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Qt
uses: jurplel/install-qt-action@v3.3.0
with:
cache: true
cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2
modules: ${{ env.QT_MODULES }}
version: ${{ matrix.qt-version }}
- name: Apply Qt patches (Ubuntu)
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.qt-version, '5.')
run: |
patch "$Qt5_DIR/include/QtConcurrent/qtconcurrentthreadengine.h" .patches/qt5-on-newer-gcc.patch
shell: bash
# LINUX
- name: Install dependencies (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get -y install \
libbenchmark-dev \
cmake \
rapidjson-dev \
libssl-dev \
libboost-dev \
libboost-system-dev \
libboost-filesystem-dev \
libpulse-dev \
libxkbcommon-x11-0 \
libgstreamer-plugins-base1.0-0 \
build-essential \
libgl1-mesa-dev \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-render-util0 \
libxcb-xinerama0
- name: Create build directory (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: mkdir build-test
- name: Install googlebench
run: |
mkdir build-test
shell: bash
sudo apt update
sudo apt -y install \
libbenchmark-dev
- name: Build (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: |
cmake \
-DBUILD_TESTS=On \
-DBUILD_BENCHMARKS=On \
-DBUILD_APP=OFF \
-DCHATTERINO_PLUGINS="$C2_PLUGINS" \
-DCMAKE_PREFIX_PATH="$Qt6_DIR/lib/cmake" \
-DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \
-DCHATTERINO_STATIC_QT_BUILD=On \
..
cmake --build .
working-directory: build-test
shell: bash
- name: Test (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
- name: Download and extract Twitch PubSub Server Test
run: |
mkdir pubsub-server-test
curl -L -o pubsub-server.tar.gz "https://github.com/Chatterino/twitch-pubsub-server-test/releases/download/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/server-${{ env.TWITCH_PUBSUB_SERVER_TAG }}-linux-amd64.tar.gz"
tar -xzf pubsub-server.tar.gz -C pubsub-server-test
rm pubsub-server.tar.gz
cd pubsub-server-test
curl -L -o server.crt "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.crt"
curl -L -o server.key "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.key"
cd ..
- uses: dtolnay/rust-toolchain@stable
- name: Cargo Install httpbox
run: |
cargo install --git https://github.com/kevinastone/httpbox --rev 89b971f
- name: Test
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
ctest --repeat until-pass:4 --output-on-failure
httpbox --port 9051 &
cd ../pubsub-server-test
./server 127.0.0.1:9050 &
cd ../build-test
ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering
working-directory: build-test
shell: bash

View file

@ -77,19 +77,6 @@ Note: This installation will take about 2.1 GB of disk space.
</details>
<details>
<summary>OpenSSL</summary>
For our websocket library, we need OpenSSL 1.1.
1. Download OpenSSL for windows, version `1.1.1s`: **[Download](https://web.archive.org/web/20221101204129/https://slproweb.com/download/Win64OpenSSL-1_1_1s.exe)**
2. When prompted, install OpenSSL to `C:\local\openssl`
3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory".
Note: This installation will take about 200 MB of disk space.
</details>
## Building
### Using CMake

View file

@ -16,17 +16,29 @@
- Minor: All sound capabilities can now be disabled by setting your "Sound backend" setting to "Null" and restarting Chatterino. (#4978)
- Minor: Add an option to use new experimental smarter emote completion. (#4987)
- Minor: Add `--safe-mode` command line option that can be used for troubleshooting when Chatterino is misbehaving or is misconfigured. It disables hiding the settings button & prevents plugins from loading. (#4985)
- Minor: Added support for FrankerFaceZ channel badges. These can be configured at https://www.frankerfacez.com/channel/mine - right now only supporting bot badges for your chat bots. (#5119)
- Minor: Updated the flatpakref link included with nightly builds to point to up-to-date flathub-beta builds. (#5008)
- Minor: Add a new completion API for experimental plugins feature. (#5000, #5047)
- Minor: Re-enabled _Restart on crash_ option on Windows. (#5012)
- Minor: The whisper highlight color can now be configured through the settings. (#5053)
- Minor: Added an option to always include the broadcaster in user completions. This is enabled by default. (#5193)
- Minor: Added missing periods at various moderator messages and commands. (#5061)
- Minor: Improved color selection and display. (#5057)
- Minor: Improved Streamlink documentation in the settings dialog. (#5076)
- Minor: Normalized the input padding between light & dark themes. (#5095)
- Minor: Add `--activate <channel>` (or `-a`) command line option to activate or add a Twitch channel. (#5111)
- Minor: Chatters from recent-messages are now added to autocompletion. (#5116)
- Minor: Added a _System_ theme that updates according to the system's color scheme (requires Qt 6.5). (#5118)
- Minor: Added icons for newer versions of macOS. (#5148)
- Minor: Added the `--incognito/--no-incognito` options to the `/openurl` command, allowing you to override the "Open links in incognito/private mode" setting. (#5149, #5197)
- Minor: Added support for the `{input.text}` placeholder in the **Split** -> **Run a command** hotkey. (#5130)
- Minor: Add a new Channel API for experimental plugins feature. (#5141, #5184, #5187)
- Minor: Added the ability to change the top-most status of a window regardless of the _Always on top_ setting (right click the notebook). (#5135)
- Minor: Introduce `c2.later()` function to Lua API. (#5154)
- Minor: Live streams that are marked as reruns now mark a tab as yellow instead of red. (#5176)
- Minor: Updated to Emoji v15.1. Google emojis are now used as the fallback instead of Twitter emojis. (#5182)
- Minor: Allow theming of tab live and rerun indicators. (#5188)
- Minor: Added a fallback theme field to custom themes that will be used in case the custom theme does not contain a color Chatterino needs. If no fallback theme is specified, we'll pull the color from the included Dark or Light theme. (#5198)
- Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840)
- Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848)
- Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834)
@ -41,26 +53,28 @@
- Bugfix: Fixed too much text being copied when copying chat messages. (#4812, #4830, #4839)
- Bugfix: Fixed an issue where the setting `Only search for emote autocompletion at the start of emote names` wouldn't disable if it was enabled when the client started. (#4855)
- Bugfix: Fixed empty page being added when showing out of bounds dialog. (#4849)
- Bugfix: Fixed an issue preventing searching a redemption by it's title when the redemption contained text input. (#5117)
- Bugfix: Fixed issue on Windows preventing the title bar from being dragged in the top left corner. (#4873)
- Bugfix: Fixed an issue where reply context didn't render correctly if an emoji was touching text. (#4875, #4977)
- Bugfix: Fixed an issue where reply context didn't render correctly if an emoji was touching text. (#4875, #4977, #5174)
- Bugfix: Fixed the input completion popup from disappearing when clicking on it on Windows and macOS. (#4876)
- Bugfix: Fixed double-click text selection moving its position with each new message. (#4898)
- Bugfix: Fixed an issue where notifications on Windows would contain no or an old avatar. (#4899)
- Bugfix: Fixed headers of tables in the settings switching to bold text when selected. (#4913)
- Bugfix: Fixed an issue in the `/live` split that caused some channels to not get grayed-out when they went offline. (#5172)
- Bugfix: Fixed tooltips appearing too large and/or away from the cursor. (#4920)
- Bugfix: Fixed a crash when clicking `More messages below` button in a usercard and closing it quickly. (#4933)
- Bugfix: Fixed thread popup window missing messages for nested threads. (#4923)
- Bugfix: Fixed an occasional crash for channel point redemptions with text input. (#4949)
- Bugfix: Fixed triple click on message also selecting moderation buttons. (#4961)
- Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965)
- Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965, #5126)
- Bugfix: Fixed badge highlight changes not immediately being reflected. (#5110)
- Bugfix: Fixed some emotes not appearing when using _Ignores_. (#4965)
- Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965)
- Bugfix: Fixed some emotes not appearing when using _Ignores_. (#4965, #5126)
- Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965, #5126)
- Bugfix: Fixed Image Uploader accidentally deleting images with some hosts when link resolver was enabled. (#4971)
- Bugfix: Fixed rare crash with Image Uploader when closing a split right after starting an upload. (#4971)
- Bugfix: Fixed an issue on macOS where the image uploader would keep prompting the user even after they clicked "Yes, don't ask again". (#5011)
- Bugfix: Hide the Usercard button in the User Info Popup when in special channels. (#4972)
- Bugfix: Fixed support for Windows 11 Snap layouts. (#4994)
- Bugfix: Fixed support for Windows 11 Snap layouts. (#4994, #5175)
- Bugfix: Fixed some windows appearing between screens. (#4797)
- Bugfix: Fixed a crash that could occur when using certain features in a Usercard after closing the split from which it was created. (#5034, #5051)
- Bugfix: Fixed a crash that could occur when using certain features in a Reply popup after closing the split from which it was created. (#5036, #5051)
@ -70,9 +84,15 @@
- Bugfix: Fixed avatar in usercard and moderation button triggering when releasing the mouse outside their area. (#5052)
- Bugfix: Fixed moderator-only topics being subscribed to for non-moderators. (#5056)
- Bugfix: Fixed a bug where buttons would remain in a hovered state after leaving them. (#5077)
- Bugfix: Fixed an issue where you had to click the `reply` button twice if you already had that users @ in your input box. (#5173)
- Bugfix: Fixed popup windows not persisting between restarts. (#5081)
- Bugfix: Fixed splits not retaining their focus after minimizing. (#5080)
- Bugfix: Fixed _Copy message_ copying the channel name in global search. (#5106)
- Bugfix: Reply contexts now use the color of the replied-to message. (#5145)
- Bugfix: Fixed top-level window getting stuck after opening settings. (#5161, #5166)
- Bugfix: Fixed link info not updating without moving the cursor. (#5178)
- Bugfix: Fixed an upload sometimes failing when copying an image from a browser if it contained extra properties. (#5156)
- Bugfix: Fixed tooltips getting out of bounds when loading images. (#5186)
- Dev: Run miniaudio in a separate thread, and simplify it to not manage the device ourselves. There's a chance the simplification is a bad idea. (#4978)
- Dev: Change clang-format from v14 to v16. (#4929)
- Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791)
@ -96,6 +116,7 @@
- Dev: Replace `boost::optional` with `std::optional`. (#4877)
- Dev: Improve performance of selecting text. (#4889, #4911)
- Dev: Removed direct dependency on Qt 5 compatibility module. (#4906)
- Dev: Added unit test capabilities to SplitInput. (#5179)
- Dev: Refactor `Emoji`'s EmojiMap into a vector. (#4980)
- Dev: Refactor `DebugCount` and add copy button to debug popup. (#4921)
- Dev: Refactor `common/Credentials`. (#4979)
@ -110,11 +131,14 @@
- Dev: `Details` file properties tab is now populated on Windows. (#4912)
- Dev: Removed `Outcome` from network requests. (#4959)
- Dev: Added Tests for Windows and MacOS in CI. (#4970, #5032)
- Dev: Added "Copy message as JSON" option when shift-right-clicking a message. (#5150)
- Dev: Windows now builds with Qt6 by default. (#5155)
- Dev: Conan now uses OpenSSL 3 by default. (#5159)
- Dev: Move `clang-tidy` checker to its own CI job. (#4996)
- Dev: Refactored the Image Uploader feature. (#4971)
- Dev: Refactored the SplitOverlay code. (#5082)
- Dev: Refactored the TwitchBadges structure, making it less of a singleton. (#5096)
- Dev: Refactored emotes out of TwitchIrcServer. (#5120)
- Dev: Refactored the TwitchBadges structure, making it less of a singleton. (#5096, #5144)
- Dev: Refactored emotes out of TwitchIrcServer. (#5120, #5146)
- Dev: Refactored the ChatterinoBadges structure, making it less of a singleton. (#5103)
- Dev: Refactored the ColorProvider class a bit. (#5112)
- Dev: Moved the Network files to their own folder. (#5089)
@ -123,11 +147,12 @@
- Dev: Load less message history upon reconnects. (#5001, #5018)
- Dev: Removed the `NullablePtr` class. (#5091)
- Dev: BREAKING: Replace custom `import()` with normal Lua `require()`. (#5014, #5108)
- Dev: Fixed most compiler warnings. (#5028)
- Dev: Fixed most compiler warnings. (#5028, #5137)
- Dev: Added the ability to show `ChannelView`s without a `Split`. (#4747)
- Dev: Refactor Args to be less of a singleton. (#5041)
- Dev: Channels without any animated elements on screen will skip updates from the GIF timer. (#5042, #5043, #5045)
- Dev: Autogenerate docs/plugin-meta.lua. (#5055)
- Dev: Changed Ubuntu & AppImage builders to statically link Qt. (#5151)
- Dev: Refactor `NetworkPrivate`. (#5063)
- Dev: Refactor `Paths` & `Updates`, focusing on reducing their singletoniability. (#5092, #5102)
- Dev: Removed duplicate scale in settings dialog. (#5069)
@ -136,6 +161,7 @@
- Dev: Boost is depended on as a header-only library when using conan. (#5107)
- Dev: Added signal to invalidate paint buffers of channel views without forcing a relayout. (#5123)
- Dev: Specialize `Atomic<std::shared_ptr<T>>` if underlying standard library supports it. (#5133)
- Dev: Added the `developer_name` field to the Linux AppData specification. (#5138)
## 2.4.6

View file

@ -18,7 +18,11 @@ option(BUILD_WITH_QTKEYCHAIN "Build Chatterino with support for your system key
option(USE_SYSTEM_MINIAUDIO "Build Chatterino with your system miniaudio" OFF)
option(BUILD_WITH_CRASHPAD "Build chatterino with crashpad" OFF)
option(USE_PRECOMPILED_HEADERS "Use precompiled headers" ON)
option(BUILD_WITH_QT6 "Use Qt6 instead of default Qt5" OFF)
if(WIN32)
option(BUILD_WITH_QT6 "Build with Qt6, default on for Windows" On)
else()
option(BUILD_WITH_QT6 "Use Qt6 instead of default Qt5" OFF)
endif()
option(CHATTERINO_GENERATE_COVERAGE "Generate coverage files" OFF)
# We don't use translations, and we don't want qtkeychain to build translations
option(BUILD_TRANSLATIONS "" OFF)

View file

@ -32,3 +32,10 @@ set_target_properties(${PROJECT_NAME}
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin"
AUTORCC ON
)
if (CHATTERINO_STATIC_QT_BUILD)
qt_import_plugins(${PROJECT_NAME} INCLUDE_BY_TYPE
platforms Qt::QXcbIntegrationPlugin
Qt::QMinimalIntegrationPlugin
)
endif ()

View file

@ -12,6 +12,7 @@
#include "providers/recentmessages/Impl.hpp"
#include "providers/seventv/SeventvBadges.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
#include "providers/twitch/TwitchBadges.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "singletons/Emotes.hpp"
#include "singletons/Resources.hpp"
@ -72,6 +73,26 @@ public:
return &this->highlights;
}
TwitchBadges *getTwitchBadges() override
{
return &this->twitchBadges;
}
BttvEmotes *getBttvEmotes() override
{
return &this->bttvEmotes;
}
FfzEmotes *getFfzEmotes() override
{
return &this->ffzEmotes;
}
SeventvEmotes *getSeventvEmotes() override
{
return &this->seventvEmotes;
}
AccountController accounts;
Emotes emotes;
mock::UserDataController userData;
@ -80,6 +101,10 @@ public:
FfzBadges ffzBadges;
SeventvBadges seventvBadges;
HighlightController highlights;
TwitchBadges twitchBadges;
BttvEmotes bttvEmotes;
FfzEmotes ffzEmotes;
SeventvEmotes seventvEmotes;
};
std::optional<QJsonDocument> tryReadJsonFile(const QString &path)

View file

@ -9,7 +9,7 @@ class Chatterino(ConanFile):
settings = "os", "compiler", "build_type", "arch"
default_options = {
"with_benchmark": False,
"with_openssl3": False,
"with_openssl3": True,
"openssl*:shared": True,
"boost*:header_only": True,
}

View file

@ -339,6 +339,8 @@
"type": "object",
"additionalProperties": false,
"properties": {
"liveIndicator": { "$ref": "#/definitions/qt-color" },
"rerunIndicator": { "$ref": "#/definitions/qt-color" },
"dividerLine": { "$ref": "#/definitions/qt-color" },
"highlighted": {
"$ref": "#/definitions/tab-colors"
@ -388,6 +390,11 @@
"$comment": "Determines which icons to use. 'dark' will use dark icons (best for a light theme). 'light' will use light icons.",
"enum": ["light", "dark"],
"default": "light"
},
"fallbackTheme": {
"$comment": "Determines which built-in Chatterino theme to use as a fallback in case a color isn't configured.",
"enum": ["White", "Light", "Dark", "Black"],
"default": "Dark"
}
},
"required": ["iconTheme"]

62
docs/chatterino.d.ts vendored
View file

@ -9,7 +9,64 @@ declare module c2 {
}
class CommandContext {
words: String[];
channel_name: String;
channel: Channel;
}
enum Platform {
Twitch,
}
enum ChannelType {
None,
Direct,
Twitch,
TwitchWhispers,
TwitchWatching,
TwitchMentions,
TwitchLive,
TwitchAutomod,
Irc,
Misc,
}
interface IWeakResource {
is_valid(): boolean;
}
class RoomModes {
unique_chat: boolean;
subscriber_only: boolean;
emotes_only: boolean;
follower_only: null | number;
slow_mode: null | number;
}
class StreamStatus {
live: boolean;
viewer_count: number;
uptime: number;
title: string;
game_name: string;
game_id: string;
}
class Channel implements IWeakResource {
is_valid(): boolean;
get_name(): string;
get_type(): ChannelType;
get_display_name(): string;
send_message(message: string, execute_commands: boolean): void;
add_system_message(message: string): void;
is_twitch_channel(): boolean;
get_room_modes(): RoomModes;
get_stream_status(): StreamStatus;
get_twitch_id(): string;
is_broadcaster(): boolean;
is_mod(): boolean;
is_vip(): boolean;
static by_name(name: string, platform: Platform): null | Channel;
static by_twitch_id(id: string): null | Channel;
}
function log(level: LogLevel, ...data: any[]): void;
@ -17,8 +74,6 @@ declare module c2 {
name: String,
handler: (ctx: CommandContext) => void
): boolean;
function send_msg(channel: String, text: String): boolean;
function system_msg(channel: String, text: String): boolean;
class CompletionList {
values: String[];
@ -40,4 +95,5 @@ declare module c2 {
: never;
function register_callback<T>(type: T, func: CbFunc<T>): void;
function later(callback: () => void, msec: number): void;
}

View file

@ -6,6 +6,15 @@
c2 = {}
---@class IWeakResource
--- Returns true if the channel this object points to is valid.
--- If the object expired, returns false
--- If given a non-Channel object, it errors.
---@return boolean
function IWeakResource:is_valid() end
---@alias LogLevel integer
---@type { Debug: LogLevel, Info: LogLevel, Warning: LogLevel, Critical: LogLevel }
c2.LogLevel = {}
@ -15,11 +24,147 @@ c2.LogLevel = {}
c2.EventType = {}
---@class CommandContext
---@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`.
---@field channel_name string The name of the channel the command was executed in.
---@field channel Channel The channel the command was executed in.
---@class CompletionList
---@field values string[] The completions
---@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored.
-- Now including data from src/common/Channel.hpp.
---@alias ChannelType integer
---@type { None: ChannelType }
ChannelType = {}
-- Back to src/controllers/plugins/LuaAPI.hpp.
-- Now including data from src/controllers/plugins/api/ChannelRef.hpp.
--- This enum describes a platform for the purpose of searching for a channel.
--- Currently only Twitch is supported because identifying IRC channels is tricky.
---@alias Platform integer
---@type { Twitch: Platform }
Platform = {}
---@class Channel: IWeakResource
--- Returns true if the channel this object points to is valid.
--- If the object expired, returns false
--- If given a non-Channel object, it errors.
---
---@return boolean success
function Channel:is_valid() end
--- Gets the channel's name. This is the lowercase login name.
---
---@return string name
function Channel:get_name() end
--- Gets the channel's type
---
---@return ChannelType
function Channel:get_type() end
--- Get the channel owner's display name. This may contain non-lowercase ascii characters.
---
---@return string name
function Channel:get_display_name() end
--- Sends a message to the target channel.
--- Note that this does not execute client-commands.
---
---@param message string
---@param execute_commands boolean Should commands be run on the text?
function Channel:send_message(message, execute_commands) end
--- Adds a system message client-side
---
---@param message string
function Channel:add_system_message(message) end
--- Returns true for twitch channels.
--- Compares the channel Type. Note that enum values aren't guaranteed, just
--- that they are equal to the exposed enum.
---
---@return bool
function Channel:is_twitch_channel() end
--- Twitch Channel specific functions
--- Returns a copy of the channel mode settings (subscriber only, r9k etc.)
---
---@return RoomModes
function Channel:get_room_modes() end
--- Returns a copy of the stream status.
---
---@return StreamStatus
function Channel:get_stream_status() end
--- Returns the Twitch user ID of the owner of the channel.
---
---@return string
function Channel:get_twitch_id() end
--- Returns true if the channel is a Twitch channel and the user owns it
---
---@return boolean
function Channel:is_broadcaster() end
--- Returns true if the channel is a Twitch channel and the user is a moderator in the channel
--- Returns false for broadcaster.
---
---@return boolean
function Channel:is_mod() end
--- Returns true if the channel is a Twitch channel and the user is a VIP in the channel
--- Returns false for broadcaster.
---
---@return boolean
function Channel:is_vip() end
--- Misc
---@return string
function Channel:__tostring() end
--- Static functions
--- Finds a channel by name.
---
--- Misc channels are marked as Twitch:
--- - /whispers
--- - /mentions
--- - /watching
--- - /live
--- - /automod
---
---@param name string Which channel are you looking for?
---@param platform Platform Where to search for the channel?
---@return Channel?
function Channel.by_name(name, platform) end
--- Finds a channel by the Twitch user ID of its owner.
---
---@param string id ID of the owner of the channel.
---@return Channel?
function Channel.by_twitch_id(string) end
---@class RoomModes
---@field unique_chat boolean You might know this as r9kbeta or robot9000.
---@field subscriber_only boolean
---@field emotes_only boolean Whether or not text is allowed in messages.
--- Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes
---@field unique_chat number? Time in minutes you need to follow to chat or nil.
---@field slow_mode number? Time in seconds you need to wait before sending messages or nil.
---@class StreamStatus
---@field live boolean
---@field viewer_count number
---@field uptime number Seconds since the stream started.
---@field title string Stream title or last stream title
---@field game_name string
---@field game_id string
-- Back to src/controllers/plugins/LuaAPI.hpp.
--- Registers a new command called `name` which when executed will call `handler`.
---
@ -34,25 +179,15 @@ function c2.register_command(name, handler) end
---@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked.
function c2.register_callback(type, func) end
--- Sends a message to `channel` with the specified text. Also executes commands.
---
--- **Warning**: It is possible to trigger your own Lua command with this causing a potentially infinite loop.
---
---@param channel string The name of the Twitch channel
---@param text string The text to be sent
---@return boolean ok
function c2.send_msg(channel, text) end
--- Creates a system message (gray message) and adds it to the Twitch channel specified by `channel`.
---
---@param channel string
---@param text string
---@return boolean ok
function c2.system_msg(channel, text) end
--- Writes a message to the Chatterino log.
---
---@param level LogLevel The desired level.
---@param ... any Values to log. Should be convertible to a string with `tostring()`.
function c2.log(level, ...) end
--- Calls callback around msec milliseconds later. Does not freeze Chatterino.
---
---@param callback fun() The callback that will be called.
---@param msec number How long to wait.
function c2.later(callback, msec) end

View file

@ -48,6 +48,13 @@ to typecheck your plugins. There is a `chatterino.d.ts` file describing the API
in this directory. However, this has several drawbacks like harder debugging at
runtime.
## LuaLS type definitions
Type definitions for LuaLS are available in
[the `/plugin-meta.lua` file](./plugin-meta.lua). These are generated from [the C++
headers](../src/controllers/plugins/LuaAPI.hpp) of Chatterino using [a
script](../scripts/make_luals_meta.py).
## API
The following parts of the Lua standard library are loaded:
@ -97,14 +104,14 @@ command with this name.
Example:
```lua
function cmdWords(ctx)
function cmd_words(ctx)
-- ctx contains:
-- words - table of words supplied to the command including the trigger
-- channel_name - name of the channel the command is being run in
c2.system_msg(ctx.channel_name, "Words are: " .. table.concat(ctx.words, " "))
-- channel - the channel the command is being run in
channel:add_system_message("Words are: " .. table.concat(ctx.words, " "))
end
c2.register_command("/words", cmdWords)
c2.register_command("/words", cmd_words)
```
Limitations/known issues:
@ -149,41 +156,175 @@ c2.register_callback(
)
```
#### `send_msg(channel, text)`
#### `Platform` enum
Sends a message to `channel` with the specified text. Also executes commands.
This table describes platforms that can be accessed. Chatterino supports IRC
however plugins do not yet have explicit access to get IRC channels objects.
The values behind the names may change, do not count on them. It has the
following keys:
- `Twitch`
#### `ChannelType` enum
This table describes channel types Chatterino supports. The values behind the
names may change, do not count on them. It has the following keys:
- `None`
- `Direct`
- `Twitch`
- `TwitchWhispers`
- `TwitchWatching`
- `TwitchMentions`
- `TwitchLive`
- `TwitchAutomod`
- `TwitchEnd`
- `Irc`
- `Misc`
#### `Channel`
This is a type that represents a channel. Existence of this object doesn't
force Chatterino to hold the channel open. Should the user close the last split
holding this channel open, your Channel object will expire. You can check for
this using the `Channel:is_valid()` function. Using any other function on an
expired Channel yields an error. Using any `Channel` member function on a
non-`Channel` table also yields an error.
Some functions make sense only for Twitch channel, these yield an error when
used on non-Twitch channels. Special channels while marked as
`is_twitch_channel() = true` do not have these functions. To check if a channel
is an actual Twitch chatroom use `Channel:get_type()` instead of
`Channel:is_twitch_channel()`.
##### `Channel:by_name(name, platform)`
Finds a channel given by `name` on `platform` (see [`Platform` enum](#Platform-enum)). Returns the channel or `nil` if not open.
Some miscellaneous channels are marked as if they are specifically Twitch channels:
- `/whispers`
- `/mentions`
- `/watching`
- `/live`
- `/automod`
Example:
```lua
function cmdShout(ctx)
local pajladas = c2.Channel.by_name("pajlada", c2.Platform.Twitch)
```
##### `Channel:by_twitch_id(id)`
Finds a channel given by the string representation of the owner's Twitch user ID. Returns the channel or `nil` if not open.
Example:
```lua
local pajladas = c2.Channel.by_twitch_id("11148817")
```
##### `Channel:get_name()`
On Twitch returns the lowercase login name of the channel owner. On IRC returns the normalized channel name.
Example:
```lua
-- Note: if the channel is not open this errors
pajladas:get_name() -- "pajlada"
```
##### `Channel:get_type()`
Returns the channel's type. See [`ChannelType` enum](#ChannelType-enum).
##### `Channel:get_display_name()`
Returns the channel owner's display name. This can contain characters that are not lowercase and even non-ASCII.
Example:
```lua
local saddummys = c2.Channel.by_name("saddummy")
saddummys:get_display_name() -- "서새봄냥"
```
<!-- F Korean Twitch, apparently you were not profitable enough -->
##### `Channel:send_message(message[, execute_commands])`
Sends a message to the channel with the given text. If `execute_commands` is
not present or `false` commands will not be executed client-side, this affects
all user commands and all Twitch commands except `/me`.
Examples:
```lua
-- times out @Mm2PL
pajladas:send_message("/timeout mm2pl 1s test", true)
-- results in a "Unknown command" error from Twitch
pajladas:send_message("/timeout mm2pl 1s test")
-- Given a user command "hello":
-- this will execute it
pajladas:send_message("hello", true)
-- this will send "hello" literally, bypassing commands
pajladas:send_message("hello")
function cmd_shout(ctx)
table.remove(ctx.words, 1)
local output = table.concat(ctx.words, " ")
c2.send_msg(ctx.channel_name, string.upper(output))
ctx.channel:send_message(string.upper(output))
end
c2.register_command("/shout", cmdShout)
c2.register_command("/shout", cmd_shout)
```
Limitations/Known issues:
- It is possible to trigger your own Lua command with this causing a potentially infinite loop.
#### `system_msg(channel, text)`
##### `Channel:add_system_message(message)`
Creates a system message and adds it to the twitch channel specified by
`channel`. Returns `true` if everything went ok, `false` otherwise. It will
throw an error if the number of arguments received doesn't match what it
expects.
Shows a system message in the channel with the given text.
Example:
```lua
local ok = c2.system_msg("pajlada", "test")
if (not ok)
-- channel not found
end
pajladas:add_system_message("Hello, world!")
```
##### `Channel:is_twitch_channel()`
Returns `true` if the channel is a Twitch channel, that is its type name has
the `Twitch` prefix. This returns `true` for special channels like Mentions.
You might want `Channel:get_type() == "Twitch"` if you want to use
Twitch-specific functions.
##### `Channel:get_twitch_id()`
Returns the string form of the channel owner's Twitch user ID.
Example:
```lua
pajladas:get_twitch_id() -- "11148817"
```
##### `Channel:is_broadcaster()`
Returns `true` if the channel is owned by the current user.
##### `Channel:is_mod()`
Returns `true` if the channel can be moderated by the current user.
##### `Channel:is_vip()`
Returns `true` if the current user is a VIP in the channel.
### Changed globals
#### `load(chunk [, chunkname [, mode [, env]]])`

@ -1 +1 @@
Subproject commit 7923dbbf72da303ca1cca17efd24725668992f15
Subproject commit e288c5a91883793d14ed9e9d93464f6ee0b08915

@ -1 +1 @@
Subproject commit 53be0788a000960dbbc34350315e20ad1e194970
Subproject commit b7918804e37ebba092e5a26daedd72931b96ce05

View file

@ -186,6 +186,15 @@ public:
return nullptr;
}
#ifdef CHATTERINO_HAVE_PLUGINS
PluginController *getPlugins() override
{
assert(false && "EmptyApplication::getPlugins was called without "
"being initialized");
return nullptr;
}
#endif
Updates &getUpdates() override
{
return this->updates_;
@ -212,6 +221,13 @@ public:
return nullptr;
}
ILinkResolver *getLinkResolver() override
{
assert(false && "EmptyApplication::getLinkResolver was called without "
"being initialized");
return nullptr;
}
private:
Paths paths_;
Args args_;

View file

@ -24,8 +24,14 @@ public:
return this->watchingChannel;
}
QString getLastUserThatWhisperedMe() const override
{
return this->lastUserThatWhisperedMe;
}
ChannelPtr watchingChannelInner;
IndirectChannel watchingChannel;
QString lastUserThatWhisperedMe{"forsen"};
};
} // namespace chatterino::mock

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 456 KiB

View file

@ -8,6 +8,7 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<name>Chatterino</name>
<developer_name>Chatterino Developers</developer_name>
<summary>
Chat client for twitch.tv
</summary>

File diff suppressed because one or more lines are too long

View file

@ -50,6 +50,8 @@
"resizeHandleBackground": "#200094ff"
},
"tabs": {
"liveIndicator": "#ff0000",
"rerunIndicator": "#c7c715",
"dividerLine": "#555555",
"highlighted": {
"backgrounds": {

View file

@ -50,6 +50,8 @@
"resizeHandleBackground": "#200094ff"
},
"tabs": {
"liveIndicator": "#ff0000",
"rerunIndicator": "#c7c715",
"dividerLine": "#555555",
"highlighted": {
"backgrounds": {

View file

@ -50,6 +50,8 @@
"resizeHandleBackground": "#500094ff"
},
"tabs": {
"liveIndicator": "#ff0000",
"rerunIndicator": "#c7c715",
"dividerLine": "#b4d7ff",
"highlighted": {
"backgrounds": {

View file

@ -50,6 +50,8 @@
"resizeHandleBackground": "#500094ff"
},
"tabs": {
"liveIndicator": "#ff0000",
"rerunIndicator": "#c7c715",
"dividerLine": "#b4d7ff",
"highlighted": {
"backgrounds": {

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,8 @@ It assumes comments look like:
- Do not have any useful info on '/**' and '*/' lines.
- Class members are not allowed to have non-@command lines and commands different from @lua@field
When this scripts sees "@brief", any further lines of the comment will be ignored
Valid commands are:
1. @exposeenum [dotted.name.in_lua.last_part]
Define a table with keys of the enum. Values behind those keys aren't
@ -38,42 +40,54 @@ BOILERPLATE = """
-- Add the folder this file is in to "Lua.workspace.library".
c2 = {}
---@class IWeakResource
--- Returns true if the channel this object points to is valid.
--- If the object expired, returns false
--- If given a non-Channel object, it errors.
---@return boolean
function IWeakResource:is_valid() end
"""
repo_root = Path(__file__).parent.parent
lua_api_file = repo_root / "src" / "controllers" / "plugins" / "LuaAPI.hpp"
lua_meta = repo_root / "docs" / "plugin-meta.lua"
print("Reading from", lua_api_file.relative_to(repo_root))
print("Writing to", lua_meta.relative_to(repo_root))
with lua_api_file.open("r") as f:
lines = f.read().splitlines()
# Are we in a doc comment?
comment: bool = False
# Last `@lua@param`s seen - for @exposed generation
last_params_names: list[str] = []
# Are we in a `@lua@class` definition? - makes newlines around @lua@class and @lua@field prettier
is_class = False
def process_file(target, out):
print("Reading from", target.relative_to(repo_root))
with target.open("r") as f:
lines = f.read().splitlines()
# The name of the next enum in lua world
expose_next_enum_as: str | None = None
# Name of the current enum in c++ world, used to generate internal typenames for
current_enum_name: str | None = None
# Are we in a doc comment?
comment: bool = False
# This is set when @brief is encountered, making the rest of the comment be
# ignored
ignore_this_comment: bool = False
with lua_meta.open("w") as out:
out.write(BOILERPLATE[1:]) # skip the newline after triple quote
# Last `@lua@param`s seen - for @exposed generation
last_params_names: list[str] = []
# Are we in a `@lua@class` definition? - makes newlines around @lua@class and @lua@field prettier
is_class = False
for line in lines:
# The name of the next enum in lua world
expose_next_enum_as: str | None = None
# Name of the current enum in c++ world, used to generate internal typenames for
current_enum_name: str | None = None
for line_num, line in enumerate(lines):
line = line.strip()
loc = f'{target.relative_to(repo_root)}:{line_num}'
if line.startswith("enum class "):
line = line.removeprefix("enum class ")
temp = line.split(" ", 2)
current_enum_name = temp[0]
if not expose_next_enum_as:
print(
f"Skipping enum {current_enum_name}, there wasn't a @exposeenum command"
f"{loc} Skipping enum {current_enum_name}, there wasn't a @exposeenum command"
)
current_enum_name = None
continue
@ -94,7 +108,7 @@ with lua_meta.open("w") as out:
out.write(", ")
out.write(entry + ": " + current_enum_name)
out.write(" }\n" f"{expose_next_enum_as} = {{}}\n")
print(f"Wrote enum {expose_next_enum_as} => {current_enum_name}")
print(f"{loc} Wrote enum {expose_next_enum_as} => {current_enum_name}")
current_enum_name = None
expose_next_enum_as = None
continue
@ -104,28 +118,40 @@ with lua_meta.open("w") as out:
continue
elif "*/" in line:
comment = False
ignore_this_comment = False
if not is_class:
out.write("\n")
continue
if not comment:
continue
if ignore_this_comment:
continue
line = line.replace("*", "", 1).lstrip()
if line == "":
out.write("---\n")
elif line.startswith('@brief '):
# Doxygen comment, on a C++ only method
ignore_this_comment = True
elif line.startswith("@exposeenum "):
expose_next_enum_as = line.split(" ", 1)[1]
elif line.startswith("@exposed "):
exp = line.replace("@exposed ", "", 1)
params = ", ".join(last_params_names)
out.write(f"function {exp}({params}) end\n")
print(f"Wrote function {exp}(...)")
print(f"{loc} Wrote function {exp}(...)")
last_params_names = []
elif line.startswith("@includefile "):
filename = line.replace("@includefile ", "", 1)
output.write(f"-- Now including data from src/{filename}.\n")
process_file(repo_root / 'src' / filename, output)
output.write(f'-- Back to {target.relative_to(repo_root)}.\n')
elif line.startswith("@lua"):
command = line.replace("@lua", "", 1)
if command.startswith("@param"):
last_params_names.append(command.split(" ", 2)[1])
elif command.startswith("@class"):
print(f"Writing {command}")
print(f"{loc} Writing {command}")
if is_class:
out.write("\n")
is_class = True
@ -140,3 +166,8 @@ with lua_meta.open("w") as out:
# note the space difference from the branch above
out.write("--- " + line + "\n")
with lua_meta.open("w") as output:
output.write(BOILERPLATE[1:]) # skip the newline after triple quote
process_file(lua_api_file, output)

View file

@ -13,6 +13,7 @@
#include "controllers/sound/ISoundController.hpp"
#include "providers/bttv/BttvEmotes.hpp"
#include "providers/ffz/FfzEmotes.hpp"
#include "providers/links/LinkResolver.hpp"
#include "providers/seventv/SeventvAPI.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
#include "providers/twitch/TwitchBadges.hpp"
@ -142,6 +143,7 @@ Application::Application(Settings &_settings, const Paths &paths,
, ffzEmotes(new FfzEmotes)
, seventvEmotes(new SeventvEmotes)
, logging(new Logging(_settings))
, linkResolver(new LinkResolver)
#ifdef CHATTERINO_HAVE_PLUGINS
, plugins(&this->emplace(new PluginController(paths)))
#endif
@ -204,6 +206,10 @@ void Application::initialize(Settings &settings, const Paths &paths)
singleton->initialize(settings, paths);
}
// XXX: Loading Twitch badges after Helix has been initialized, which only happens after
// the AccountController initialize has been called
this->twitchBadges->loadTwitchBadges();
// Show crash message.
// On Windows, the crash message was already shown.
#ifndef Q_OS_WIN
@ -490,6 +496,13 @@ Logging *Application::getChatLogger()
return this->logging.get();
}
ILinkResolver *Application::getLinkResolver()
{
assertInGuiThread();
return this->linkResolver.get();
}
BttvEmotes *Application::getBttvEmotes()
{
assertInGuiThread();

View file

@ -54,6 +54,7 @@ class CrashHandler;
class BttvEmotes;
class FfzEmotes;
class SeventvEmotes;
class ILinkResolver;
class IApplication
{
@ -95,6 +96,7 @@ public:
virtual BttvEmotes *getBttvEmotes() = 0;
virtual FfzEmotes *getFfzEmotes() = 0;
virtual SeventvEmotes *getSeventvEmotes() = 0;
virtual ILinkResolver *getLinkResolver() = 0;
};
class Application : public IApplication
@ -162,6 +164,7 @@ private:
std::unique_ptr<FfzEmotes> ffzEmotes;
std::unique_ptr<SeventvEmotes> seventvEmotes;
const std::unique_ptr<Logging> logging;
std::unique_ptr<ILinkResolver> linkResolver;
#ifdef CHATTERINO_HAVE_PLUGINS
PluginController *const plugins{};
#endif
@ -212,6 +215,8 @@ public:
FfzEmotes *getFfzEmotes() override;
SeventvEmotes *getSeventvEmotes() override;
ILinkResolver *getLinkResolver() override;
pajlada::Signals::NoArgSignal streamerModeChanged;
private:

View file

@ -5,6 +5,7 @@ add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x050F00)
# registers the native messageing host
option(CHATTERINO_DEBUG_NATIVE_MESSAGES "Debug native messages" OFF)
option(CHATTERINO_STATIC_QT_BUILD "Static link Qt" OFF)
set(SOURCE_FILES
Application.cpp
@ -220,6 +221,8 @@ set(SOURCE_FILES
controllers/pings/MutedChannelModel.cpp
controllers/pings/MutedChannelModel.hpp
controllers/plugins/api/ChannelRef.cpp
controllers/plugins/api/ChannelRef.hpp
controllers/plugins/LuaAPI.cpp
controllers/plugins/LuaAPI.hpp
controllers/plugins/Plugin.cpp
@ -294,8 +297,6 @@ set(SOURCE_FILES
providers/IvrApi.cpp
providers/IvrApi.hpp
providers/LinkResolver.cpp
providers/LinkResolver.hpp
providers/NetworkConfigurationProvider.cpp
providers/NetworkConfigurationProvider.hpp
@ -342,6 +343,11 @@ set(SOURCE_FILES
providers/irc/IrcServer.cpp
providers/irc/IrcServer.hpp
providers/links/LinkInfo.cpp
providers/links/LinkInfo.hpp
providers/links/LinkResolver.cpp
providers/links/LinkResolver.hpp
providers/liveupdates/BasicPubSubClient.hpp
providers/liveupdates/BasicPubSubManager.hpp
providers/liveupdates/BasicPubSubWebsocket.hpp
@ -729,7 +735,7 @@ add_library(${LIBRARY_PROJECT} OBJECT ${SOURCE_FILES})
if(CHATTERINO_PLUGINS)
target_compile_definitions(${LIBRARY_PROJECT}
PRIVATE
PUBLIC
CHATTERINO_HAVE_PLUGINS
)
message(STATUS "Building Chatterino with lua plugin support enabled.")
@ -816,6 +822,13 @@ if (BUILD_APP)
message(WARNING "Sanitizers support is disabled")
endif()
if (CHATTERINO_STATIC_QT_BUILD)
qt_import_plugins(${EXECUTABLE_PROJECT} INCLUDE_BY_TYPE
platforms Qt::QXcbIntegrationPlugin
Qt::QMinimalIntegrationPlugin
)
endif ()
target_include_directories(${EXECUTABLE_PROJECT} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_BINARY_DIR}/autogen/)
target_link_libraries(${EXECUTABLE_PROJECT} PUBLIC ${LIBRARY_PROJECT})
@ -946,6 +959,7 @@ target_compile_definitions(${LIBRARY_PROJECT} PUBLIC
AB_CUSTOM_SETTINGS
IRC_STATIC
IRC_NAMESPACE=Communi
$<$<BOOL:${WIN32}>:_WIN32_WINNT=0x0A00> # Windows 10
)
if (USE_SYSTEM_QTKEYCHAIN)
@ -1059,10 +1073,6 @@ if (MSVC)
# Someone adds /W3 before we add /W4.
# This makes sure, only /W4 is specified.
string(REPLACE "/W3" "/W4" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
# 4505 - "unreferenced local version has been removed"
# Although this might give hints on dead code,
# there are some cases where it's distracting.
#
# 4100 - "unreferenced formal parameter"
# There are a lot of functions and methods where
# an argument was given a name but never used.
@ -1073,6 +1083,11 @@ if (MSVC)
# These are implicit conversions from size_t to int/qsizetype.
# We don't use size_t in a lot of cases, since
# Qt doesn't use it - it uses int (or qsizetype in Qt6).
#
# 4458 - "declaration of 'identifier' hides class member"
# We have a rule of exclusively using `this->`
# to access class members, thus it's fine to reclare a variable
# with the same name as a class member.
target_compile_options(${LIBRARY_PROJECT} PUBLIC
/W4
# 5038 - warnings about initialization order
@ -1080,9 +1095,9 @@ if (MSVC)
# 4855 - implicit capture of 'this' via '[=]' is deprecated
/w14855
# Disable the following warnings (see reasoning above)
/wd4505
/wd4100
/wd4267
/wd4458
# Enable updated '__cplusplus' macro - workaround for CMake#18837
/Zc:__cplusplus
)

View file

@ -78,6 +78,14 @@ namespace {
{
// set up the QApplication flags
QApplication::setAttribute(Qt::AA_Use96Dpi, true);
#ifdef Q_OS_WIN32
// Avoid promoting child widgets to child windows
// This causes bugs with frameless windows as not all child events
// get sent to the parent - effectively making the window immovable.
QApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings);
#endif
#if defined(Q_OS_WIN32) && QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
QApplication::setAttribute(Qt::AA_DisableHighDpiScaling, true);
#endif

View file

@ -82,7 +82,6 @@ LimitedQueueSnapshot<MessagePtr> Channel::getMessageSnapshot()
void Channel::addMessage(MessagePtr message,
std::optional<MessageFlags> overridingFlags)
{
auto *app = getApp();
MessagePtr deleted;
if (!overridingFlags || !overridingFlags->has(MessageFlag::DoNotLog))
@ -329,6 +328,11 @@ bool Channel::isLive() const
return false;
}
bool Channel::isRerun() const
{
return false;
}
bool Channel::shouldIgnoreHighlights() const
{
return this->type_ == Type::TwitchAutomod ||

View file

@ -30,6 +30,10 @@ enum class TimeoutStackStyle : int {
class Channel : public std::enable_shared_from_this<Channel>
{
public:
// This is for Lua. See scripts/make_luals_meta.py
/**
* @exposeenum ChannelType
*/
enum class Type {
None,
Direct,
@ -100,6 +104,7 @@ public:
virtual bool hasModRights() const;
virtual bool hasHighRateLimit() const;
virtual bool isLive() const;
virtual bool isRerun() const;
virtual bool shouldIgnoreHighlights() const;
virtual bool canReconnect() const;
virtual void reconnect();

View file

@ -1,6 +1,7 @@
#include "common/Credentials.hpp"
#include "Application.hpp"
#include "common/Modes.hpp"
#include "debug/AssertInGuiThread.hpp"
#include "singletons/Paths.hpp"
#include "singletons/Settings.hpp"
@ -41,7 +42,7 @@ bool useKeyring()
#ifdef NO_QTKEYCHAIN
return false;
#endif
if (getIApp()->getPaths().isPortable())
if (Modes::instance().isPortable)
{
return false;
}

View file

@ -24,6 +24,7 @@
#include "widgets/splits/SplitContainer.hpp"
#include "widgets/Window.hpp"
#include <QCommandLineParser>
#include <QDesktopServices>
#include <QString>
#include <QUrl>
@ -414,26 +415,97 @@ QString clearmessages(const CommandContext &ctx)
QString openURL(const CommandContext &ctx)
{
/**
* The /openurl command
* Takes a positional argument as the URL to open
*
* Accepts the option --private or --no-private (or --incognito or --no-incognito).
* These options will force the URL to be opened in private or non-private mode, regardless of the
* default incognito mode setting.
*
* Examples:
* - /openurl https://twitch.tv/forsen
* with the setting "Open links in incognito/private mode" enabled
* Opens https://twitch.tv/forsen in private mode
* - /openurl https://twitch.tv/forsen
* with the setting "Open links in incognito/private mode" disabled
* Opens https://twitch.tv/forsen in normal mode
* - /openurl https://twitch.tv/forsen --private
* with the setting "Open links in incognito/private mode" disabled
* Opens https://twitch.tv/forsen in private mode
* - /openurl https://twitch.tv/forsen --no-private
* with the setting "Open links in incognito/private mode" enabled
* Opens https://twitch.tv/forsen in normal mode
*/
if (ctx.channel == nullptr)
{
return "";
}
if (ctx.words.size() < 2)
QCommandLineParser parser;
parser.setOptionsAfterPositionalArgumentsMode(
QCommandLineParser::ParseAsPositionalArguments);
parser.addPositionalArgument("URL", "The URL to open");
QCommandLineOption privateModeOption(
{
"private",
"incognito",
},
"Force private mode. Cannot be used together with --no-private");
QCommandLineOption noPrivateModeOption(
{
"no-private",
"no-incognito",
},
"Force non-private mode. Cannot be used together with --private");
parser.addOptions({
privateModeOption,
noPrivateModeOption,
});
parser.parse(ctx.words);
const auto &positionalArguments = parser.positionalArguments();
if (positionalArguments.isEmpty())
{
ctx.channel->addMessage(makeSystemMessage("Usage: /openurl <URL>"));
ctx.channel->addMessage(makeSystemMessage(
"Usage: /openurl <URL> [--incognito/--no-incognito]"));
return "";
}
auto urlString = parser.positionalArguments().join(' ');
QUrl url = QUrl::fromUserInput(ctx.words.mid(1).join(" "));
QUrl url = QUrl::fromUserInput(urlString);
if (!url.isValid())
{
ctx.channel->addMessage(makeSystemMessage("Invalid URL specified."));
return "";
}
auto preferPrivateMode = getSettings()->openLinksIncognito.getValue();
auto forcePrivateMode = parser.isSet(privateModeOption);
auto forceNonPrivateMode = parser.isSet(noPrivateModeOption);
if (forcePrivateMode && forceNonPrivateMode)
{
ctx.channel->addMessage(makeSystemMessage(
"Error: /openurl may only be called with --incognito or "
"--no-incognito, not both at the same time."));
return "";
}
bool usePrivateMode = false;
if (forceNonPrivateMode)
{
usePrivateMode = false;
}
else if (supportsIncognitoLinks() &&
(forcePrivateMode || preferPrivateMode))
{
usePrivateMode = true;
}
bool res = false;
if (supportsIncognitoLinks() && getSettings()->openLinksIncognito)
if (usePrivateMode)
{
res = openLinkIncognito(url.toString(QUrl::FullyEncoded));
}

View file

@ -59,6 +59,26 @@ void UserSource::initializeFromChannel(const Channel *channel)
}
this->items_ = tc->accessChatters()->all();
if (getSettings()->alwaysIncludeBroadcasterInUserCompletions)
{
auto it = std::find_if(this->items_.begin(), this->items_.end(),
[tc](const UserItem &user) {
return user.first == tc->getName();
});
if (it != this->items_.end())
{
auto broadcaster = *it;
this->items_.erase(it);
this->items_.insert(this->items_.begin(), broadcaster);
}
else
{
this->items_.insert(this->items_.begin(),
{tc->getName(), tc->getDisplayName()});
}
}
}
const std::vector<UserItem> &UserSource::output() const

View file

@ -227,7 +227,8 @@ void NotificationController::removeFakeChannel(const QString channelName)
{
const auto &s = snapshot[i];
if (s->messageText == liveMessageSearchText)
if (QString::compare(s->messageText, liveMessageSearchText,
Qt::CaseInsensitive) == 0)
{
s->flags.set(MessageFlag::Disabled);
break;

View file

@ -126,97 +126,6 @@ int c2_register_callback(lua_State *L)
return 0;
}
int c2_send_msg(lua_State *L)
{
QString text;
QString channel;
if (lua_gettop(L) != 2)
{
luaL_error(L, "send_msg needs exactly 2 arguments (channel and text)");
lua::push(L, false);
return 1;
}
if (!lua::pop(L, &text))
{
luaL_error(
L, "cannot get text (2nd argument of send_msg, expected a string)");
lua::push(L, false);
return 1;
}
if (!lua::pop(L, &channel))
{
luaL_error(
L,
"cannot get channel (1st argument of send_msg, expected a string)");
lua::push(L, false);
return 1;
}
const auto chn = getApp()->twitch->getChannelOrEmpty(channel);
if (chn->isEmpty())
{
auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L);
qCWarning(chatterinoLua)
<< "Plugin" << pl->id
<< "tried to send a message (using send_msg) to channel" << channel
<< "which is not known";
lua::push(L, false);
return 1;
}
QString message = text;
message = message.replace('\n', ' ');
QString outText =
getIApp()->getCommands()->execCommand(message, chn, false);
chn->sendMessage(outText);
lua::push(L, true);
return 1;
}
int c2_system_msg(lua_State *L)
{
if (lua_gettop(L) != 2)
{
luaL_error(L,
"system_msg needs exactly 2 arguments (channel and text)");
lua::push(L, false);
return 1;
}
QString channel;
QString text;
if (!lua::pop(L, &text))
{
luaL_error(
L,
"cannot get text (2nd argument of system_msg, expected a string)");
lua::push(L, false);
return 1;
}
if (!lua::pop(L, &channel))
{
luaL_error(L, "cannot get channel (1st argument of system_msg, "
"expected a string)");
lua::push(L, false);
return 1;
}
const auto chn = getApp()->twitch->getChannelOrEmpty(channel);
if (chn->isEmpty())
{
auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L);
qCWarning(chatterinoLua)
<< "Plugin" << pl->id
<< "tried to show a system message (using system_msg) in channel"
<< channel << "which is not known";
lua::push(L, false);
return 1;
}
chn->addMessage(makeSystemMessage(text));
lua::push(L, true);
return 1;
}
int c2_log(lua_State *L)
{
auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L);
@ -238,6 +147,63 @@ int c2_log(lua_State *L)
return 0;
}
int c2_later(lua_State *L)
{
auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L);
if (pl == nullptr)
{
return luaL_error(L, "c2.later: internal error: no plugin?");
}
if (lua_gettop(L) != 2)
{
return luaL_error(
L, "c2.later expects two arguments (a callback that takes no "
"arguments and returns nothing and a number the time in "
"milliseconds to wait)\n");
}
int time{};
if (!lua::pop(L, &time))
{
return luaL_error(L, "cannot get time (2nd arg of c2.later, "
"expected a number)");
}
if (!lua_isfunction(L, lua_gettop(L)))
{
return luaL_error(L, "cannot get callback (1st arg of c2.later, "
"expected a function)");
}
auto *timer = new QTimer();
timer->setInterval(time);
auto id = pl->addTimeout(timer);
auto name = QString("timeout_%1").arg(id);
auto *coro = lua_newthread(L);
QObject::connect(timer, &QTimer::timeout, [pl, coro, name, timer]() {
timer->deleteLater();
pl->removeTimeout(timer);
int nres{};
lua_resume(coro, nullptr, 0, &nres);
lua_pushnil(coro);
lua_setfield(coro, LUA_REGISTRYINDEX, name.toStdString().c_str());
if (lua_gettop(coro) != 0)
{
stackDump(coro,
pl->id +
": timer returned a value, this shouldn't happen "
"and is probably a plugin bug");
}
});
stackDump(L, "before setfield");
lua_setfield(L, LUA_REGISTRYINDEX, name.toStdString().c_str());
lua_xmove(L, coro, 1); // move function to thread
timer->start();
return 0;
}
int g_load(lua_State *L)
{
# ifdef NDEBUG

View file

@ -1,8 +1,12 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include <lua.h>
# include <QString>
# include <cassert>
# include <memory>
# include <vector>
struct lua_State;
@ -31,7 +35,7 @@ enum class EventType {
/**
* @lua@class CommandContext
* @lua@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`.
* @lua@field channel_name string The name of the channel the command was executed in.
* @lua@field channel Channel The channel the command was executed in.
*/
/**
@ -49,6 +53,11 @@ struct CompletionList {
bool hideOthers{};
};
/**
* @includefile common/Channel.hpp
* @includefile controllers/plugins/api/ChannelRef.hpp
*/
/**
* Registers a new command called `name` which when executed will call `handler`.
*
@ -68,27 +77,6 @@ int c2_register_command(lua_State *L);
*/
int c2_register_callback(lua_State *L);
/**
* Sends a message to `channel` with the specified text. Also executes commands.
*
* **Warning**: It is possible to trigger your own Lua command with this causing a potentially infinite loop.
*
* @lua@param channel string The name of the Twitch channel
* @lua@param text string The text to be sent
* @lua@return boolean ok
* @exposed c2.send_msg
*/
int c2_send_msg(lua_State *L);
/**
* Creates a system message (gray message) and adds it to the Twitch channel specified by `channel`.
*
* @lua@param channel string
* @lua@param text string
* @lua@return boolean ok
* @exposed c2.system_msg
*/
int c2_system_msg(lua_State *L);
/**
* Writes a message to the Chatterino log.
*
@ -98,6 +86,15 @@ int c2_system_msg(lua_State *L);
*/
int c2_log(lua_State *L);
/**
* Calls callback around msec milliseconds later. Does not freeze Chatterino.
*
* @lua@param callback fun() The callback that will be called.
* @lua@param msec number How long to wait.
* @exposed c2.later
*/
int c2_later(lua_State *L);
// These ones are global
int g_load(lua_State *L);
int g_print(lua_State *L);
@ -107,6 +104,115 @@ int g_print(lua_State *L);
int searcherAbsolute(lua_State *L);
int searcherRelative(lua_State *L);
// This is a fat pointer that allows us to type check values given to functions needing a userdata.
// Ensure ALL userdata given to Lua are a subclass of this! Otherwise we garbage as a pointer!
struct UserData {
enum class Type { Channel };
Type type;
bool isWeak;
};
template <UserData::Type T, typename U>
struct WeakPtrUserData : public UserData {
std::weak_ptr<U> target;
WeakPtrUserData(std::weak_ptr<U> t)
: UserData()
, target(t)
{
this->type = T;
this->isWeak = true;
}
static WeakPtrUserData<T, U> *create(lua_State *L, std::weak_ptr<U> target)
{
void *ptr = lua_newuserdata(L, sizeof(WeakPtrUserData<T, U>));
return new (ptr) WeakPtrUserData<T, U>(target);
}
static WeakPtrUserData<T, U> *from(UserData *target)
{
if (!target->isWeak)
{
return nullptr;
}
if (target->type != T)
{
return nullptr;
}
return reinterpret_cast<WeakPtrUserData<T, U> *>(target);
}
static WeakPtrUserData<T, U> *from(void *target)
{
return from(reinterpret_cast<UserData *>(target));
}
static int destroy(lua_State *L)
{
auto self = WeakPtrUserData<T, U>::from(lua_touserdata(L, -1));
// Note it is safe to only check the weakness of the pointer, as
// std::weak_ptr seems to have identical representation regardless of
// what it points to
assert(self->isWeak);
self->target.reset();
lua_pop(L, 1); // Lua deallocates the memory for full user data
return 0;
}
};
template <UserData::Type T, typename U>
struct SharedPtrUserData : public UserData {
std::shared_ptr<U> target;
SharedPtrUserData(std::shared_ptr<U> t)
: UserData()
, target(t)
{
this->type = T;
this->isWeak = false;
}
static SharedPtrUserData<T, U> *create(lua_State *L,
std::shared_ptr<U> target)
{
void *ptr = lua_newuserdata(L, sizeof(SharedPtrUserData<T, U>));
return new (ptr) SharedPtrUserData<T, U>(target);
}
static SharedPtrUserData<T, U> *from(UserData *target)
{
if (target->isWeak)
{
return nullptr;
}
if (target->type != T)
{
return nullptr;
}
return reinterpret_cast<SharedPtrUserData<T, U> *>(target);
}
static SharedPtrUserData<T, U> *from(void *target)
{
return from(reinterpret_cast<UserData *>(target));
}
static int destroy(lua_State *L)
{
auto self = SharedPtrUserData<T, U>::from(lua_touserdata(L, -1));
// Note it is safe to only check the weakness of the pointer, as
// std::shared_ptr seems to have identical representation regardless of
// what it points to
assert(!self->isWeak);
self->target.reset();
lua_pop(L, 1); // Lua deallocates the memory for full user data
return 0;
}
};
} // namespace chatterino::lua::api
#endif

View file

@ -4,6 +4,7 @@
# include "common/Channel.hpp"
# include "common/QLogging.hpp"
# include "controllers/commands/CommandContext.hpp"
# include "controllers/plugins/api/ChannelRef.hpp"
# include "controllers/plugins/LuaAPI.hpp"
# include <lauxlib.h>
@ -120,8 +121,9 @@ StackIdx push(lua_State *L, const CommandContext &ctx)
push(L, ctx.words);
lua_setfield(L, outIdx, "words");
push(L, ctx.channel->getName());
lua_setfield(L, outIdx, "channel_name");
push(L, ctx.channel);
lua_setfield(L, outIdx, "channel");
return outIdx;
}
@ -138,6 +140,18 @@ StackIdx push(lua_State *L, const int &b)
return lua_gettop(L);
}
bool peek(lua_State *L, int *out, StackIdx idx)
{
StackGuard guard(L);
if (lua_isnumber(L, idx) == 0)
{
return false;
}
*out = lua_tointeger(L, idx);
return true;
}
bool peek(lua_State *L, bool *out, StackIdx idx)
{
StackGuard guard(L);

View file

@ -66,6 +66,7 @@ StackIdx push(lua_State *L, const bool &b);
StackIdx push(lua_State *L, const int &b);
// returns OK?
bool peek(lua_State *L, int *out, StackIdx idx = -1);
bool peek(lua_State *L, bool *out, StackIdx idx = -1);
bool peek(lua_State *L, double *out, StackIdx idx = -1);
bool peek(lua_State *L, QString *out, StackIdx idx = -1);
@ -137,6 +138,17 @@ public:
/// TEMPLATES
template <typename T>
StackIdx push(lua_State *L, std::optional<T> val)
{
if (val.has_value())
{
return lua::push(L, *val);
}
lua_pushnil(L);
return lua_gettop(L);
}
template <typename T>
bool peek(lua_State *L, std::optional<T> *out, StackIdx idx = -1)
{
@ -262,7 +274,7 @@ StackIdx push(lua_State *L, QList<T> vec)
*
* @return Stack index of newly created string.
*/
template <typename T, std::enable_if<std::is_enum_v<T>>>
template <typename T, typename std::enable_if_t<std::is_enum_v<T>, bool> = true>
StackIdx push(lua_State *L, T inp)
{
std::string_view name = magic_enum::enum_name<T>(inp);

View file

@ -1,6 +1,7 @@
#ifdef CHATTERINO_HAVE_PLUGINS
# include "controllers/plugins/Plugin.hpp"
# include "common/QLogging.hpp"
# include "controllers/commands/CommandController.hpp"
# include <lua.h>
@ -167,11 +168,38 @@ std::unordered_set<QString> Plugin::listRegisteredCommands()
Plugin::~Plugin()
{
for (auto *timer : this->activeTimeouts)
{
QObject::disconnect(timer, nullptr, nullptr, nullptr);
timer->deleteLater();
}
qCDebug(chatterinoLua) << "Destroyed" << this->activeTimeouts.size()
<< "timers for plugin" << this->id
<< "while destroying the object";
this->activeTimeouts.clear();
if (this->state_ != nullptr)
{
lua_close(this->state_);
}
}
int Plugin::addTimeout(QTimer *timer)
{
this->activeTimeouts.push_back(timer);
return ++this->lastTimerId;
}
void Plugin::removeTimeout(QTimer *timer)
{
for (auto it = this->activeTimeouts.begin();
it != this->activeTimeouts.end(); ++it)
{
if (*it == timer)
{
this->activeTimeouts.erase(it);
break;
}
}
}
} // namespace chatterino
#endif

View file

@ -14,6 +14,7 @@
# include <vector>
struct lua_State;
class QTimer;
namespace chatterino {
@ -126,6 +127,9 @@ public:
return this->error_;
}
int addTimeout(QTimer *timer);
void removeTimeout(QTimer *timer);
private:
QDir loadDirectory_;
lua_State *state_;
@ -134,6 +138,8 @@ private:
// maps command name -> function name
std::unordered_map<QString, QString> ownedCommands;
std::vector<QTimer *> activeTimeouts;
int lastTimerId = 0;
friend class PluginController;
};

View file

@ -6,6 +6,7 @@
# include "common/QLogging.hpp"
# include "controllers/commands/CommandContext.hpp"
# include "controllers/commands/CommandController.hpp"
# include "controllers/plugins/api/ChannelRef.hpp"
# include "controllers/plugins/LuaAPI.hpp"
# include "controllers/plugins/LuaUtilities.hpp"
# include "messages/MessageBuilder.hpp"
@ -117,8 +118,7 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta,
luaL_Reg{LUA_GNAME, luaopen_base},
// - load - don't allow in release mode
//luaL_Reg{LUA_COLIBNAME, luaopen_coroutine},
// - needs special support
luaL_Reg{LUA_COLIBNAME, luaopen_coroutine},
luaL_Reg{LUA_TABLIBNAME, luaopen_table},
// luaL_Reg{LUA_IOLIBNAME, luaopen_io},
// - explicit fs access, needs wrapper with permissions, no usage ideas yet
@ -143,11 +143,10 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta,
// NOLINTNEXTLINE(*-avoid-c-arrays)
static const luaL_Reg c2Lib[] = {
{"system_msg", lua::api::c2_system_msg},
{"register_command", lua::api::c2_register_command},
{"register_callback", lua::api::c2_register_callback},
{"send_msg", lua::api::c2_send_msg},
{"log", lua::api::c2_log},
{"later", lua::api::c2_later},
{nullptr, nullptr},
};
lua_pushglobaltable(L);
@ -164,6 +163,16 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta,
lua::pushEnumTable<lua::api::EventType>(L);
lua_setfield(L, c2libIdx, "EventType");
lua::pushEnumTable<lua::api::LPlatform>(L);
lua_setfield(L, c2libIdx, "Platform");
lua::pushEnumTable<Channel::Type>(L);
lua_setfield(L, c2libIdx, "ChannelType");
// Initialize metatables for objects
lua::api::ChannelRef::createMetatable(L);
lua_setfield(L, c2libIdx, "Channel");
lua_setfield(L, gtable, "c2");
// ban functions
@ -330,6 +339,11 @@ bool PluginController::isPluginEnabled(const QString &id)
Plugin *PluginController::getPluginByStatePtr(lua_State *L)
{
lua_geti(L, LUA_REGISTRYINDEX, LUA_RIDX_MAINTHREAD);
// Use the main thread for identification, not a coroutine instance
auto *mainL = lua_tothread(L, -1);
lua_pop(L, 1);
L = mainL;
for (auto &[name, plugin] : this->plugins_)
{
if (plugin->state_ == L)

View file

@ -0,0 +1,393 @@
#ifdef CHATTERINO_HAVE_PLUGINS
# include "controllers/plugins/api/ChannelRef.hpp"
# include "common/Channel.hpp"
# include "controllers/commands/CommandController.hpp"
# include "controllers/plugins/LuaAPI.hpp"
# include "controllers/plugins/LuaUtilities.hpp"
# include "messages/MessageBuilder.hpp"
# include "providers/twitch/TwitchChannel.hpp"
# include "providers/twitch/TwitchIrcServer.hpp"
# include <lauxlib.h>
# include <lua.h>
# include <cassert>
# include <memory>
# include <optional>
namespace chatterino::lua::api {
// NOLINTBEGIN(*vararg)
// NOLINTNEXTLINE(*-avoid-c-arrays)
static const luaL_Reg CHANNEL_REF_METHODS[] = {
{"is_valid", &ChannelRef::is_valid},
{"get_name", &ChannelRef::get_name},
{"get_type", &ChannelRef::get_type},
{"get_display_name", &ChannelRef::get_display_name},
{"send_message", &ChannelRef::send_message},
{"add_system_message", &ChannelRef::add_system_message},
{"is_twitch_channel", &ChannelRef::is_twitch_channel},
// Twitch
{"get_room_modes", &ChannelRef::get_room_modes},
{"get_stream_status", &ChannelRef::get_stream_status},
{"get_twitch_id", &ChannelRef::get_twitch_id},
{"is_broadcaster", &ChannelRef::is_broadcaster},
{"is_mod", &ChannelRef::is_mod},
{"is_vip", &ChannelRef::is_vip},
// misc
{"__tostring", &ChannelRef::to_string},
// static
{"by_name", &ChannelRef::get_by_name},
{"by_twitch_id", &ChannelRef::get_by_twitch_id},
{nullptr, nullptr},
};
void ChannelRef::createMetatable(lua_State *L)
{
lua::StackGuard guard(L, 1);
luaL_newmetatable(L, "c2.Channel");
lua_pushstring(L, "__index");
lua_pushvalue(L, -2); // clone metatable
lua_settable(L, -3); // metatable.__index = metatable
// Generic IWeakResource stuff
lua_pushstring(L, "__gc");
lua_pushcfunction(
L, (&WeakPtrUserData<UserData::Type::Channel, ChannelRef>::destroy));
lua_settable(L, -3); // metatable.__gc = WeakPtrUserData<...>::destroy
luaL_setfuncs(L, CHANNEL_REF_METHODS, 0);
}
ChannelPtr ChannelRef::getOrError(lua_State *L, bool expiredOk)
{
if (lua_gettop(L) < 1)
{
luaL_error(L, "Called c2.Channel method without a channel object");
return nullptr;
}
if (lua_isuserdata(L, lua_gettop(L)) == 0)
{
luaL_error(
L, "Called c2.Channel method with a non Channel 'self' argument.");
return nullptr;
}
auto *data = WeakPtrUserData<UserData::Type::Channel, Channel>::from(
lua_touserdata(L, lua_gettop(L)));
if (data == nullptr)
{
luaL_error(L,
"Called c2.Channel method with an invalid channel pointer");
return nullptr;
}
lua_pop(L, 1);
if (data->target.expired())
{
if (!expiredOk)
{
luaL_error(L,
"Usage of expired c2.Channel object. Underlying "
"resource was freed. Use Channel:is_valid() to check");
}
return nullptr;
}
return data->target.lock();
}
std::shared_ptr<TwitchChannel> ChannelRef::getTwitchOrError(lua_State *L)
{
auto ref = ChannelRef::getOrError(L);
auto ptr = dynamic_pointer_cast<TwitchChannel>(ref);
if (ptr == nullptr)
{
luaL_error(L,
"c2.Channel Twitch-only operation on non-Twitch channel.");
}
return ptr;
}
int ChannelRef::is_valid(lua_State *L)
{
ChannelPtr that = ChannelRef::getOrError(L, true);
lua::push(L, that != nullptr);
return 1;
}
int ChannelRef::get_name(lua_State *L)
{
ChannelPtr that = ChannelRef::getOrError(L);
lua::push(L, that->getName());
return 1;
}
int ChannelRef::get_type(lua_State *L)
{
ChannelPtr that = ChannelRef::getOrError(L);
lua::push(L, that->getType());
return 1;
}
int ChannelRef::get_display_name(lua_State *L)
{
ChannelPtr that = ChannelRef::getOrError(L);
lua::push(L, that->getDisplayName());
return 1;
}
int ChannelRef::send_message(lua_State *L)
{
if (lua_gettop(L) != 2 && lua_gettop(L) != 3)
{
luaL_error(L, "Channel:send_message needs 1 or 2 arguments (message "
"text and optionally execute_commands flag)");
return 0;
}
bool execcmds = false;
if (lua_gettop(L) == 3)
{
if (!lua::pop(L, &execcmds))
{
luaL_error(L, "cannot get execute_commands (2nd argument of "
"Channel:send_message)");
return 0;
}
}
QString text;
if (!lua::pop(L, &text))
{
luaL_error(L, "cannot get text (1st argument of Channel:send_message)");
return 0;
}
ChannelPtr that = ChannelRef::getOrError(L);
text = text.replace('\n', ' ');
if (execcmds)
{
text = getIApp()->getCommands()->execCommand(text, that, false);
}
that->sendMessage(text);
return 0;
}
int ChannelRef::add_system_message(lua_State *L)
{
// needs to account for the hidden self argument
if (lua_gettop(L) != 2)
{
luaL_error(
L, "Channel:add_system_message needs exactly 1 argument (message "
"text)");
return 0;
}
QString text;
if (!lua::pop(L, &text))
{
luaL_error(
L, "cannot get text (1st argument of Channel:add_system_message)");
return 0;
}
ChannelPtr that = ChannelRef::getOrError(L);
text = text.replace('\n', ' ');
that->addMessage(makeSystemMessage(text));
return 0;
}
int ChannelRef::is_twitch_channel(lua_State *L)
{
ChannelPtr that = ChannelRef::getOrError(L);
lua::push(L, that->isTwitchChannel());
return 1;
}
int ChannelRef::get_room_modes(lua_State *L)
{
auto tc = ChannelRef::getTwitchOrError(L);
const auto m = tc->accessRoomModes();
const auto modes = LuaRoomModes{
.unique_chat = m->r9k,
.subscriber_only = m->submode,
.emotes_only = m->emoteOnly,
.follower_only = (m->followerOnly == -1)
? std::nullopt
: std::optional(m->followerOnly),
.slow_mode =
(m->slowMode == 0) ? std::nullopt : std::optional(m->slowMode),
};
lua::push(L, modes);
return 1;
}
int ChannelRef::get_stream_status(lua_State *L)
{
auto tc = ChannelRef::getTwitchOrError(L);
const auto s = tc->accessStreamStatus();
const auto status = LuaStreamStatus{
.live = s->live,
.viewer_count = static_cast<int>(s->viewerCount),
.uptime = s->uptimeSeconds,
.title = s->title,
.game_name = s->game,
.game_id = s->gameId,
};
lua::push(L, status);
return 1;
}
int ChannelRef::get_twitch_id(lua_State *L)
{
auto tc = ChannelRef::getTwitchOrError(L);
lua::push(L, tc->roomId());
return 1;
}
int ChannelRef::is_broadcaster(lua_State *L)
{
auto tc = ChannelRef::getTwitchOrError(L);
lua::push(L, tc->isBroadcaster());
return 1;
}
int ChannelRef::is_mod(lua_State *L)
{
auto tc = ChannelRef::getTwitchOrError(L);
lua::push(L, tc->isMod());
return 1;
}
int ChannelRef::is_vip(lua_State *L)
{
auto tc = ChannelRef::getTwitchOrError(L);
lua::push(L, tc->isVip());
return 1;
}
int ChannelRef::get_by_name(lua_State *L)
{
if (lua_gettop(L) != 2)
{
luaL_error(L, "Channel.by_name needs exactly 2 arguments (channel "
"name and platform)");
lua_pushnil(L);
return 1;
}
LPlatform platform{};
if (!lua::pop(L, &platform))
{
luaL_error(L, "cannot get platform (2nd argument of Channel.by_name, "
"expected a string)");
lua_pushnil(L);
return 1;
}
QString name;
if (!lua::pop(L, &name))
{
luaL_error(L,
"cannot get channel name (1st argument of Channel.by_name, "
"expected a string)");
lua_pushnil(L);
return 1;
}
auto chn = getApp()->twitch->getChannelOrEmpty(name);
lua::push(L, chn);
return 1;
}
int ChannelRef::get_by_twitch_id(lua_State *L)
{
if (lua_gettop(L) != 1)
{
luaL_error(
L, "Channel.by_twitch_id needs exactly 1 arguments (channel owner "
"id)");
lua_pushnil(L);
return 1;
}
QString id;
if (!lua::pop(L, &id))
{
luaL_error(L,
"cannot get channel name (1st argument of Channel.by_name, "
"expected a string)");
lua_pushnil(L);
return 1;
}
auto chn = getApp()->twitch->getChannelOrEmptyByID(id);
lua::push(L, chn);
return 1;
}
int ChannelRef::to_string(lua_State *L)
{
ChannelPtr that = ChannelRef::getOrError(L, true);
if (that == nullptr)
{
lua_pushstring(L, "<c2.Channel expired>");
return 1;
}
QString formated = QString("<c2.Channel %1>").arg(that->getName());
lua::push(L, formated);
return 1;
}
} // namespace chatterino::lua::api
// NOLINTEND(*vararg)
//
namespace chatterino::lua {
StackIdx push(lua_State *L, const api::LuaRoomModes &modes)
{
auto out = lua::pushEmptyTable(L, 6);
# define PUSH(field) \
lua::push(L, modes.field); \
lua_setfield(L, out, #field)
PUSH(unique_chat);
PUSH(subscriber_only);
PUSH(emotes_only);
PUSH(follower_only);
PUSH(slow_mode);
# undef PUSH
return out;
}
StackIdx push(lua_State *L, const api::LuaStreamStatus &status)
{
auto out = lua::pushEmptyTable(L, 6);
# define PUSH(field) \
lua::push(L, status.field); \
lua_setfield(L, out, #field)
PUSH(live);
PUSH(viewer_count);
PUSH(uptime);
PUSH(title);
PUSH(game_name);
PUSH(game_id);
# undef PUSH
return out;
}
StackIdx push(lua_State *L, ChannelPtr chn)
{
using namespace chatterino::lua::api;
if (chn->isEmpty())
{
lua_pushnil(L);
return lua_gettop(L);
}
WeakPtrUserData<UserData::Type::Channel, Channel>::create(
L, chn->weak_from_this());
luaL_getmetatable(L, "c2.Channel");
lua_setmetatable(L, -2);
return lua_gettop(L);
}
} // namespace chatterino::lua
#endif

View file

@ -0,0 +1,276 @@
#pragma once
#include "providers/twitch/TwitchChannel.hpp"
#include <optional>
#ifdef CHATTERINO_HAVE_PLUGINS
# include "common/Channel.hpp"
# include "controllers/plugins/LuaUtilities.hpp"
# include "controllers/plugins/PluginController.hpp"
namespace chatterino::lua::api {
// NOLINTBEGIN(readability-identifier-naming)
/**
* This enum describes a platform for the purpose of searching for a channel.
* Currently only Twitch is supported because identifying IRC channels is tricky.
* @exposeenum Platform
*/
enum class LPlatform {
Twitch,
//IRC,
};
/**
* @lua@class Channel: IWeakResource
*/
struct ChannelRef {
static void createMetatable(lua_State *L);
friend class chatterino::PluginController;
/**
* @brief Get the content of the top object on Lua stack, usually first argument to function as a ChannelPtr.
* If the object given is not a userdatum or the pointer inside that
* userdatum doesn't point to a Channel, a lua error is thrown.
*
* @param expiredOk Should an expired return nullptr instead of erroring
*/
static ChannelPtr getOrError(lua_State *L, bool expiredOk = false);
/**
* @brief Casts the result of getOrError to std::shared_ptr<TwitchChannel>
* if that fails thows a lua error.
*/
static std::shared_ptr<TwitchChannel> getTwitchOrError(lua_State *L);
public:
/**
* Returns true if the channel this object points to is valid.
* If the object expired, returns false
* If given a non-Channel object, it errors.
*
* @lua@return boolean success
* @exposed Channel:is_valid
*/
static int is_valid(lua_State *L);
/**
* Gets the channel's name. This is the lowercase login name.
*
* @lua@return string name
* @exposed Channel:get_name
*/
static int get_name(lua_State *L);
/**
* Gets the channel's type
*
* @lua@return ChannelType
* @exposed Channel:get_type
*/
static int get_type(lua_State *L);
/**
* Get the channel owner's display name. This may contain non-lowercase ascii characters.
*
* @lua@return string name
* @exposed Channel:get_display_name
*/
static int get_display_name(lua_State *L);
/**
* Sends a message to the target channel.
* Note that this does not execute client-commands.
*
* @lua@param message string
* @lua@param execute_commands boolean Should commands be run on the text?
* @exposed Channel:send_message
*/
static int send_message(lua_State *L);
/**
* Adds a system message client-side
*
* @lua@param message string
* @exposed Channel:add_system_message
*/
static int add_system_message(lua_State *L);
/**
* Returns true for twitch channels.
* Compares the channel Type. Note that enum values aren't guaranteed, just
* that they are equal to the exposed enum.
*
* @lua@return bool
* @exposed Channel:is_twitch_channel
*/
static int is_twitch_channel(lua_State *L);
/**
* Twitch Channel specific functions
*/
/**
* Returns a copy of the channel mode settings (subscriber only, r9k etc.)
*
* @lua@return RoomModes
* @exposed Channel:get_room_modes
*/
static int get_room_modes(lua_State *L);
/**
* Returns a copy of the stream status.
*
* @lua@return StreamStatus
* @exposed Channel:get_stream_status
*/
static int get_stream_status(lua_State *L);
/**
* Returns the Twitch user ID of the owner of the channel.
*
* @lua@return string
* @exposed Channel:get_twitch_id
*/
static int get_twitch_id(lua_State *L);
/**
* Returns true if the channel is a Twitch channel and the user owns it
*
* @lua@return boolean
* @exposed Channel:is_broadcaster
*/
static int is_broadcaster(lua_State *L);
/**
* Returns true if the channel is a Twitch channel and the user is a moderator in the channel
* Returns false for broadcaster.
*
* @lua@return boolean
* @exposed Channel:is_mod
*/
static int is_mod(lua_State *L);
/**
* Returns true if the channel is a Twitch channel and the user is a VIP in the channel
* Returns false for broadcaster.
*
* @lua@return boolean
* @exposed Channel:is_vip
*/
static int is_vip(lua_State *L);
/**
* Misc
*/
/**
* @lua@return string
* @exposed Channel:__tostring
*/
static int to_string(lua_State *L);
/**
* Static functions
*/
/**
* Finds a channel by name.
*
* Misc channels are marked as Twitch:
* - /whispers
* - /mentions
* - /watching
* - /live
* - /automod
*
* @lua@param name string Which channel are you looking for?
* @lua@param platform Platform Where to search for the channel?
* @lua@return Channel?
* @exposed Channel.by_name
*/
static int get_by_name(lua_State *L);
/**
* Finds a channel by the Twitch user ID of its owner.
*
* @lua@param string id ID of the owner of the channel.
* @lua@return Channel?
* @exposed Channel.by_twitch_id
*/
static int get_by_twitch_id(lua_State *L);
};
// This is a copy of the TwitchChannel::RoomModes structure, except it uses nicer optionals
/**
* @lua@class RoomModes
*/
struct LuaRoomModes {
/**
* @lua@field unique_chat boolean You might know this as r9kbeta or robot9000.
*/
bool unique_chat = false;
/**
* @lua@field subscriber_only boolean
*/
bool subscriber_only = false;
/**
* @lua@field emotes_only boolean Whether or not text is allowed in messages.
* Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes
*/
bool emotes_only = false;
/**
* @lua@field unique_chat number? Time in minutes you need to follow to chat or nil.
*/
std::optional<int> follower_only;
/**
* @lua@field slow_mode number? Time in seconds you need to wait before sending messages or nil.
*/
std::optional<int> slow_mode;
};
/**
* @lua@class StreamStatus
*/
struct LuaStreamStatus {
/**
* @lua@field live boolean
*/
bool live = false;
/**
* @lua@field viewer_count number
*/
int viewer_count = 0;
/**
* @lua@field uptime number Seconds since the stream started.
*/
int uptime = 0;
/**
* @lua@field title string Stream title or last stream title
*/
QString title;
/**
* @lua@field game_name string
*/
QString game_name;
/**
* @lua@field game_id string
*/
QString game_id;
};
// NOLINTEND(readability-identifier-naming)
} // namespace chatterino::lua::api
namespace chatterino::lua {
StackIdx push(lua_State *L, const api::LuaRoomModes &modes);
StackIdx push(lua_State *L, const api::LuaStreamStatus &status);
StackIdx push(lua_State *L, ChannelPtr chn);
} // namespace chatterino::lua
#endif

View file

@ -7,12 +7,12 @@
namespace chatterino {
static bool isGuiThread()
inline bool isGuiThread()
{
return QCoreApplication::instance()->thread() == QThread::currentThread();
}
static void assertInGuiThread()
inline void assertInGuiThread()
{
#ifdef _DEBUG
assert(isGuiThread());

View file

@ -3,6 +3,7 @@
#include "common/FlagsEnum.hpp"
#include "util/QStringHash.hpp"
#include <magic_enum/magic_enum.hpp>
#include <QColor>
#include <QTime>
@ -57,6 +58,8 @@ enum class MessageFlag : int64_t {
RestrictedMessage = (1LL << 33),
/// The message is sent by a user marked as monitor with Twitch's "Low Trust"/"Suspicious User" feature
MonitoredMessage = (1LL << 34),
/// The message is an ACTION message (/me)
Action = (1LL << 35),
};
using MessageFlags = FlagsEnum<MessageFlag>;
@ -105,3 +108,8 @@ struct Message {
};
} // namespace chatterino
template <>
struct magic_enum::customize::enum_range<chatterino::MessageFlag> {
static constexpr bool is_flags = true;
};

View file

@ -8,7 +8,7 @@
#include "messages/Message.hpp"
#include "messages/MessageColor.hpp"
#include "messages/MessageElement.hpp"
#include "providers/LinkResolver.hpp"
#include "providers/links/LinkResolver.hpp"
#include "providers/twitch/PubSubActions.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "singletons/Emotes.hpp"
@ -528,12 +528,12 @@ MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/,
this->emplace<TimestampElement>();
using MEF = MessageElementFlag;
auto addText = [this](QString text, MessageElementFlags mefs = MEF::Text,
auto addText = [this](QString text,
MessageColor color =
MessageColor::System) -> TextElement * {
this->message().searchText += text;
this->message().messageText += text;
return this->emplace<TextElement>(text, mefs, color);
return this->emplace<TextElement>(text, MEF::Text, color);
};
addText("Your image has been uploaded to");
@ -541,16 +541,14 @@ MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/,
// ASSUMPTION: the user gave this uploader configuration to the program
// therefore they trust that the host is not wrong/malicious. This doesn't obey getSettings()->lowercaseDomains.
// This also ensures that the LinkResolver doesn't get these links.
addText(imageLink, {MEF::OriginalLink, MEF::LowercaseLink},
MessageColor::Link)
addText(imageLink, MessageColor::Link)
->setLink({Link::Url, imageLink})
->setTrailingSpace(false);
if (!deletionLink.isEmpty())
{
addText("(Deletion link:");
addText(deletionLink, {MEF::OriginalLink, MEF::LowercaseLink},
MessageColor::Link)
addText(deletionLink, MessageColor::Link)
->setLink({Link::Url, deletionLink})
->setTrailingSpace(false);
addText(")")->setTrailingSpace(false);
@ -634,46 +632,13 @@ void MessageBuilder::addLink(const ParsedLink &parsedLink)
lowercaseLinkString += parsedLink.host.toString().toLower();
lowercaseLinkString += parsedLink.rest;
auto linkElement = Link(Link::Url, matchedLink);
auto textColor = MessageColor(MessageColor::Link);
auto *linkMELowercase =
this->emplace<TextElement>(lowercaseLinkString,
MessageElementFlag::LowercaseLink, textColor)
->setLink(linkElement);
auto *linkMEOriginal =
this->emplace<TextElement>(origLink, MessageElementFlag::OriginalLink,
textColor)
->setLink(linkElement);
LinkResolver::getLinkInfo(
matchedLink, nullptr,
[weakMessage = this->weakOf(), linkMELowercase, linkMEOriginal,
matchedLink](QString tooltipText, Link originalLink,
ImagePtr thumbnail) {
auto shared = weakMessage.lock();
if (!shared)
{
return;
}
if (!tooltipText.isEmpty())
{
linkMELowercase->setTooltip(tooltipText);
linkMEOriginal->setTooltip(tooltipText);
}
if (originalLink.value != matchedLink &&
!originalLink.value.isEmpty())
{
linkMELowercase->setLink(originalLink)->updateLink();
linkMEOriginal->setLink(originalLink)->updateLink();
}
linkMELowercase->setThumbnail(thumbnail);
linkMELowercase->setThumbnailType(
MessageElement::ThumbnailType::Link_Thumbnail);
linkMEOriginal->setThumbnail(thumbnail);
linkMEOriginal->setThumbnailType(
MessageElement::ThumbnailType::Link_Thumbnail);
});
auto *el = this->emplace<LinkElement>(
LinkElement::Parsed{.lowercase = lowercaseLinkString,
.original = matchedLink},
MessageElementFlag::Text, textColor);
el->setLink({Link::Url, matchedLink});
getIApp()->getLinkResolver()->resolve(el->linkInfo());
}
void MessageBuilder::addIrcMessageText(const QString &text)

View file

@ -63,18 +63,6 @@ MessageElement *MessageElement::setTooltip(const QString &tooltip)
return this;
}
MessageElement *MessageElement::setThumbnail(const ImagePtr &thumbnail)
{
this->thumbnail_ = thumbnail;
return this;
}
MessageElement *MessageElement::setThumbnailType(const ThumbnailType type)
{
this->thumbnailType_ = type;
return this;
}
MessageElement *MessageElement::setTrailingSpace(bool value)
{
this->trailingSpace = value;
@ -86,17 +74,7 @@ const QString &MessageElement::getTooltip() const
return this->tooltip_;
}
const ImagePtr &MessageElement::getThumbnail() const
{
return this->thumbnail_;
}
const MessageElement::ThumbnailType &MessageElement::getThumbnailType() const
{
return this->thumbnailType_;
}
const Link &MessageElement::getLink() const
Link MessageElement::getLink() const
{
return this->link_;
}
@ -116,12 +94,6 @@ void MessageElement::addFlags(MessageElementFlags flags)
this->flags_.set(flags);
}
MessageElement *MessageElement::updateLink()
{
this->linkChanged.invoke();
return this;
}
// Empty
EmptyElement::EmptyElement()
: MessageElement(MessageElementFlag::None)
@ -155,8 +127,8 @@ void ImageElement::addToContainer(MessageLayoutContainer &container,
auto size = QSize(this->image_->width() * container.getScale(),
this->image_->height() * container.getScale());
container.addElement((new ImageLayoutElement(*this, this->image_, size))
->setLink(this->getLink()));
container.addElement(
(new ImageLayoutElement(*this, this->image_, size)));
}
}
@ -178,10 +150,8 @@ void CircularImageElement::addToContainer(MessageLayoutContainer &container,
auto imgSize = QSize(this->image_->width(), this->image_->height()) *
container.getScale();
container.addElement((new ImageWithCircleBackgroundLayoutElement(
*this, this->image_, imgSize,
this->background_, this->padding_))
->setLink(this->getLink()));
container.addElement(new ImageWithCircleBackgroundLayoutElement(
*this, this->image_, imgSize, this->background_, this->padding_));
}
}
@ -222,8 +192,7 @@ void EmoteElement::addToContainer(MessageLayoutContainer &container,
QSize(int(container.getScale() * image->width() * emoteScale),
int(container.getScale() * image->height() * emoteScale));
container.addElement(this->makeImageLayoutElement(image, size)
->setLink(this->getLink()));
container.addElement(this->makeImageLayoutElement(image, size));
}
else
{
@ -284,8 +253,7 @@ void LayeredEmoteElement::addToContainer(MessageLayoutContainer &container,
}
container.addElement(this->makeImageLayoutElement(
images, individualSizes, largestSize)
->setLink(this->getLink()));
images, individualSizes, largestSize));
}
else
{
@ -441,8 +409,7 @@ EmotePtr BadgeElement::getEmote() const
MessageLayoutElement *BadgeElement::makeImageLayoutElement(
const ImagePtr &image, const QSize &size)
{
auto *element =
(new ImageLayoutElement(*this, image, size))->setLink(this->getLink());
auto *element = new ImageLayoutElement(*this, image, size);
return element;
}
@ -459,9 +426,8 @@ MessageLayoutElement *ModBadgeElement::makeImageLayoutElement(
{
static const QColor modBadgeBackgroundColor("#34AE0A");
auto *element = (new ImageWithBackgroundLayoutElement(
*this, image, size, modBadgeBackgroundColor))
->setLink(this->getLink());
auto *element = new ImageWithBackgroundLayoutElement(
*this, image, size, modBadgeBackgroundColor);
return element;
}
@ -476,8 +442,7 @@ VipBadgeElement::VipBadgeElement(const EmotePtr &data,
MessageLayoutElement *VipBadgeElement::makeImageLayoutElement(
const ImagePtr &image, const QSize &size)
{
auto *element =
(new ImageLayoutElement(*this, image, size))->setLink(this->getLink());
auto *element = new ImageLayoutElement(*this, image, size);
return element;
}
@ -494,8 +459,7 @@ MessageLayoutElement *FfzBadgeElement::makeImageLayoutElement(
const ImagePtr &image, const QSize &size)
{
auto *element =
(new ImageWithBackgroundLayoutElement(*this, image, size, this->color))
->setLink(this->getLink());
new ImageWithBackgroundLayoutElement(*this, image, size, this->color);
return element;
}
@ -507,11 +471,8 @@ TextElement::TextElement(const QString &text, MessageElementFlags flags,
, color_(color)
, style_(style)
{
for (const auto &word : text.split(' '))
{
this->words_.push_back({word, -1});
// fourtf: add logic to store multiple spaces after message
}
this->words_ = text.split(' ');
// fourtf: add logic to store multiple spaces after message
}
void TextElement::addToContainer(MessageLayoutContainer &container,
@ -524,39 +485,29 @@ void TextElement::addToContainer(MessageLayoutContainer &container,
QFontMetrics metrics =
app->getFonts()->getFontMetrics(this->style_, container.getScale());
for (Word &word : this->words_)
for (const auto &word : this->words_)
{
auto getTextLayoutElement = [&](QString text, int width,
bool hasTrailingSpace) {
auto color = this->color_.getColor(*app->getThemes());
app->getThemes()->normalizeColor(color);
auto *e = (new TextLayoutElement(
*this, text, QSize(width, metrics.height()),
color, this->style_, container.getScale()))
->setLink(this->getLink());
auto *e = new TextLayoutElement(
*this, text, QSize(width, metrics.height()), color,
this->style_, container.getScale());
e->setTrailingSpace(hasTrailingSpace);
e->setText(text);
// If URL link was changed,
// Should update it in MessageLayoutElement too!
if (this->getLink().type == Link::Url)
{
static_cast<TextLayoutElement *>(e)->listenToLinkChanges();
}
return e;
};
// fourtf: add again
// if (word.width == -1) {
word.width = metrics.horizontalAdvance(word.text);
// }
auto width = metrics.horizontalAdvance(word);
// see if the text fits in the current line
if (container.fitsInLine(word.width))
if (container.fitsInLine(width))
{
container.addElementNoLineBreak(getTextLayoutElement(
word.text, word.width, this->hasTrailingSpace()));
word, width, this->hasTrailingSpace()));
continue;
}
@ -565,35 +516,34 @@ void TextElement::addToContainer(MessageLayoutContainer &container,
{
container.breakLine();
if (container.fitsInLine(word.width))
if (container.fitsInLine(width))
{
container.addElementNoLineBreak(getTextLayoutElement(
word.text, word.width, this->hasTrailingSpace()));
word, width, this->hasTrailingSpace()));
continue;
}
}
// we done goofed, we need to wrap the text
QString text = word.text;
int textLength = text.length();
auto textLength = word.length();
int wordStart = 0;
int width = 0;
width = 0;
// QChar::isHighSurrogate(text[0].unicode()) ? 2 : 1
for (int i = 0; i < textLength; i++)
{
auto isSurrogate = text.size() > i + 1 &&
QChar::isHighSurrogate(text[i].unicode());
auto isSurrogate = word.size() > i + 1 &&
QChar::isHighSurrogate(word[i].unicode());
auto charWidth = isSurrogate
? metrics.horizontalAdvance(text.mid(i, 2))
: metrics.horizontalAdvance(text[i]);
? metrics.horizontalAdvance(word.mid(i, 2))
: metrics.horizontalAdvance(word[i]);
if (!container.fitsInLine(width + charWidth))
{
container.addElementNoLineBreak(getTextLayoutElement(
text.mid(wordStart, i - wordStart), width, false));
word.mid(wordStart, i - wordStart), width, false));
container.breakLine();
wordStart = i;
@ -615,7 +565,7 @@ void TextElement::addToContainer(MessageLayoutContainer &container,
}
//add the final piece of wrapped text
container.addElementNoLineBreak(getTextLayoutElement(
text.mid(wordStart), width, this->hasTrailingSpace()));
word.mid(wordStart), width, this->hasTrailingSpace()));
}
}
}
@ -649,23 +599,16 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
auto color = this->color_.getColor(*app->getThemes());
app->getThemes()->normalizeColor(color);
auto *e = (new TextLayoutElement(
*this, text, QSize(width, metrics.height()), color,
this->style_, container.getScale()))
->setLink(this->getLink());
auto *e = new TextLayoutElement(
*this, text, QSize(width, metrics.height()), color,
this->style_, container.getScale());
e->setTrailingSpace(hasTrailingSpace);
e->setText(text);
// If URL link was changed,
// Should update it in MessageLayoutElement too!
if (this->getLink().type == Link::Url)
{
static_cast<TextLayoutElement *>(e)->listenToLinkChanges();
}
return e;
};
static const auto ellipsis = QStringLiteral("...");
static const auto ellipsis = QStringLiteral("");
// String to continuously append words onto until we place it in the container
// once we encounter an emote or reach the end of the message text. */
@ -685,6 +628,7 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
currentText += ' ';
}
bool done = false;
for (const auto &parsedWord :
app->getEmotes()->getEmojis()->parse(word.text))
{
@ -698,6 +642,7 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
container.remainingWidth());
if (currentText != prev)
{
done = true;
break;
}
}
@ -720,6 +665,7 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
emoteSize.width()))
{
currentText += ellipsis;
done = true;
break;
}
@ -735,6 +681,11 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
}
}
}
if (done)
{
break;
}
}
// Add the last of the pending message text to the container.
@ -749,6 +700,29 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
}
}
LinkElement::LinkElement(const Parsed &parsed, MessageElementFlags flags,
const MessageColor &color, FontStyle style)
: TextElement({}, flags, color, style)
, linkInfo_(parsed.original)
, lowercase_({parsed.lowercase})
, original_({parsed.original})
{
this->setTooltip(parsed.original);
}
void LinkElement::addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags)
{
this->words_ =
getSettings()->lowercaseDomains ? this->lowercase_ : this->original_;
TextElement::addToContainer(container, flags);
}
Link LinkElement::getLink() const
{
return {Link::Url, this->linkInfo_.url()};
}
// TIMESTAMP
TimestampElement::TimestampElement(QTime time)
: MessageElement(MessageElementFlag::Timestamp)
@ -853,8 +827,7 @@ void ScalingImageElement::addToContainer(MessageLayoutContainer &container,
auto size = QSize(image->width() * container.getScale(),
image->height() * container.getScale());
container.addElement((new ImageLayoutElement(*this, image, size))
->setLink(this->getLink()));
container.addElement(new ImageLayoutElement(*this, image, size));
}
}

View file

@ -4,6 +4,7 @@
#include "messages/ImageSet.hpp"
#include "messages/Link.hpp"
#include "messages/MessageColor.hpp"
#include "providers/links/LinkInfo.hpp"
#include "singletons/Fonts.hpp"
#include <pajlada/signals/signalholder.hpp>
@ -136,10 +137,9 @@ enum class MessageElementFlag : int64_t {
BoldUsername = (1LL << 27),
NonBoldUsername = (1LL << 28),
// for links
LowercaseLink = (1LL << 29),
OriginalLink = (1LL << 30),
// used to check if links should be lowercased
LowercaseLinks = (1LL << 29),
// Unused = (1LL << 30)
// Unused: (1LL << 31)
// for elements of the message reply
@ -166,9 +166,6 @@ public:
Update_Images = 4,
Update_All = Update_Text | Update_Emotes | Update_Images
};
enum ThumbnailType : char {
Link_Thumbnail = 1,
};
virtual ~MessageElement();
@ -181,25 +178,18 @@ public:
MessageElement *setLink(const Link &link);
MessageElement *setText(const QString &text);
MessageElement *setTooltip(const QString &tooltip);
MessageElement *setThumbnailType(const ThumbnailType type);
MessageElement *setThumbnail(const ImagePtr &thumbnail);
MessageElement *setTrailingSpace(bool value);
const QString &getTooltip() const;
const ImagePtr &getThumbnail() const;
const ThumbnailType &getThumbnailType() const;
const Link &getLink() const;
virtual Link getLink() const;
bool hasTrailingSpace() const;
MessageElementFlags getFlags() const;
void addFlags(MessageElementFlags flags);
MessageElement *updateLink();
virtual void addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags) = 0;
pajlada::Signals::NoArgSignal linkChanged;
protected:
MessageElement(MessageElementFlags flags);
bool trailingSpace = true;
@ -208,8 +198,6 @@ private:
QString text_;
Link link_;
QString tooltip_;
ImagePtr thumbnail_;
ThumbnailType thumbnailType_{};
MessageElementFlags flags_;
};
@ -269,15 +257,12 @@ public:
void addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags) override;
protected:
QStringList words_;
private:
MessageColor color_;
FontStyle style_;
struct Word {
QString text;
int width = -1;
};
std::vector<Word> words_;
};
// contains a text that will be truncated to one line
@ -303,6 +288,40 @@ private:
std::vector<Word> words_;
};
class LinkElement : public TextElement
{
public:
struct Parsed {
QString lowercase;
QString original;
};
LinkElement(const Parsed &parsed, MessageElementFlags flags,
const MessageColor &color = MessageColor::Text,
FontStyle style = FontStyle::ChatMedium);
~LinkElement() override = default;
LinkElement(const LinkElement &) = delete;
LinkElement(LinkElement &&) = delete;
LinkElement &operator=(const LinkElement &) = delete;
LinkElement &operator=(LinkElement &&) = delete;
void addToContainer(MessageLayoutContainer &container,
MessageElementFlags flags) override;
Link getLink() const override;
[[nodiscard]] LinkInfo *linkInfo()
{
return &this->linkInfo_;
}
private:
LinkInfo linkInfo_;
// these are implicitly shared
QStringList lowercase_;
QStringList original_;
};
// contains emote data and will pick the emote based on :
// a) are images for the emote type enabled
// b) which size it wants

View file

@ -77,6 +77,7 @@ void SharedMessageBuilder::parse()
if (this->action_)
{
this->textColor_ = this->usernameColor_;
this->message().flags.set(MessageFlag::Action);
}
this->parseUsername();

View file

@ -77,9 +77,9 @@ MessageLayoutElement *MessageLayoutElement::setTrailingSpace(bool value)
return this;
}
MessageLayoutElement *MessageLayoutElement::setLink(const Link &_link)
MessageLayoutElement *MessageLayoutElement::setLink(const Link &link)
{
this->link_ = _link;
this->link_ = link;
return this;
}
@ -89,9 +89,13 @@ MessageLayoutElement *MessageLayoutElement::setText(const QString &_text)
return this;
}
const Link &MessageLayoutElement::getLink() const
Link MessageLayoutElement::getLink() const
{
return this->link_;
if (this->link_)
{
return *this->link_;
}
return this->creator_.getLink();
}
const QString &MessageLayoutElement::getText() const
@ -406,14 +410,6 @@ TextLayoutElement::TextLayoutElement(MessageElement &_creator, QString &_text,
this->setText(_text);
}
void TextLayoutElement::listenToLinkChanges()
{
this->managedConnections_.managedConnect(
static_cast<TextElement &>(this->getCreator()).linkChanged, [this]() {
this->setLink(this->getCreator().getLink());
});
}
void TextLayoutElement::addCopyTextToString(QString &str, uint32_t from,
uint32_t to) const
{
@ -504,7 +500,7 @@ int TextLayoutElement::getXFromIndex(size_t index)
{
return this->getRect().left();
}
else if (index < this->getText().size())
else if (index < static_cast<size_t>(this->getText().size()))
{
int x = 0;
for (int i = 0; i < index; i++)

View file

@ -44,7 +44,12 @@ public:
void setLine(size_t line);
MessageLayoutElement *setTrailingSpace(bool value);
MessageLayoutElement *setLink(const Link &link_);
/// @brief Overwrites the link for this layout element
///
/// @sa #getLink()
MessageLayoutElement *setLink(const Link &link);
MessageLayoutElement *setText(const QString &text_);
virtual void addCopyTextToString(QString &str, uint32_t from = 0,
@ -57,7 +62,12 @@ public:
virtual int getMouseOverIndex(const QPoint &abs) const = 0;
virtual int getXFromIndex(size_t index) = 0;
const Link &getLink() const;
/// @brief Returns the link this layout element has
///
/// If there isn't any, an empty link is returned (type: None).
/// The link is sourced from the creator, but can be overwritten with
/// #setLink().
Link getLink() const;
const QString &getText() const;
FlagsEnum<MessageElementFlag> getFlags() const;
@ -67,7 +77,7 @@ protected:
private:
QString text_;
QRect rect_;
Link link_;
std::optional<Link> link_;
MessageElement &creator_;
/**
* The line of the container this element is laid out at

View file

@ -1,64 +0,0 @@
#include "providers/LinkResolver.hpp"
#include "common/Env.hpp"
#include "common/network/NetworkRequest.hpp"
#include "common/network/NetworkResult.hpp"
#include "messages/Image.hpp"
#include "messages/Link.hpp"
#include "singletons/Settings.hpp"
#include <QString>
namespace chatterino {
void LinkResolver::getLinkInfo(
const QString url, QObject *caller,
std::function<void(QString, Link, ImagePtr)> successCallback)
{
if (!getSettings()->linkInfoTooltip)
{
successCallback("No link info loaded", Link(Link::Url, url), nullptr);
return;
}
// Uncomment to test crashes
// QTimer::singleShot(3000, [=]() {
NetworkRequest(Env::get().linkResolverUrl.arg(QString::fromUtf8(
QUrl::toPercentEncoding(url, "", "/:"))))
.caller(caller)
.timeout(30000)
.onSuccess([successCallback, url](NetworkResult result) mutable {
auto root = result.parseJson();
auto statusCode = root.value("status").toInt();
QString response;
QString linkString = url;
ImagePtr thumbnail = nullptr;
if (statusCode == 200)
{
response = root.value("tooltip").toString();
if (root.contains("thumbnail"))
{
thumbnail =
Image::fromUrl({root.value("thumbnail").toString()});
}
if (getSettings()->unshortLinks)
{
linkString = root.value("link").toString();
}
}
else
{
response = root.value("message").toString();
}
successCallback(QUrl::fromPercentEncoding(response.toUtf8()),
Link(Link::Url, linkString), thumbnail);
})
.onError([successCallback, url](auto /*result*/) {
successCallback("No link info found", Link(Link::Url, url),
nullptr);
})
.execute();
// });
}
} // namespace chatterino

View file

@ -1,23 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <functional>
#include <memory>
namespace chatterino {
class Image;
struct Link;
using ImagePtr = std::shared_ptr<Image>;
class LinkResolver
{
public:
static void getLinkInfo(
const QString url, QObject *caller,
std::function<void(QString, Link, ImagePtr)> callback);
};
} // namespace chatterino

View file

@ -167,7 +167,7 @@ void Emojis::load()
void Emojis::loadEmojis()
{
// Current version: https://github.com/iamcal/emoji-data/blob/v14.0.0/emoji.json (Emoji version 14.0 (2022))
// Current version: https://github.com/iamcal/emoji-data/blob/v15.1.1/emoji.json (Emoji version 15.1 (2023))
QFile file(":/emoji.json");
file.open(QFile::ReadOnly);
QTextStream s1(&file);
@ -269,14 +269,15 @@ void Emojis::loadEmojiSet()
};
// clang-format on
// As of emoji-data v15.1.1, google is the only source missing no images.
if (!emoji->capabilities.contains(emojiSetToUse))
{
emojiSetToUse = "Twitter";
emojiSetToUse = "Google";
}
QString code = emoji->unifiedCode.toLower();
QString urlPrefix =
"https://pajbot.com/static/emoji-v2/img/twitter/64/";
"https://pajbot.com/static/emoji-v2/img/google/64/";
auto it = emojiSets.find(emojiSetToUse);
if (it != emojiSets.end())
{

View file

@ -42,8 +42,9 @@ std::vector<FfzBadges::Badge> FfzBadges::getUserBadges(const UserId &id)
return badges;
}
std::optional<FfzBadges::Badge> FfzBadges::getBadge(const int badgeID)
std::optional<FfzBadges::Badge> FfzBadges::getBadge(const int badgeID) const
{
this->tgBadges.guard();
auto it = this->badges.find(badgeID);
if (it != this->badges.end())
{
@ -62,6 +63,7 @@ void FfzBadges::load()
std::unique_lock lock(this->mutex_);
auto jsonRoot = result.parseJson();
this->tgBadges.guard();
for (const auto &jsonBadge_ : jsonRoot.value("badges").toArray())
{
auto jsonBadge = jsonBadge_.toObject();

View file

@ -3,6 +3,7 @@
#include "common/Aliases.hpp"
#include "common/Singleton.hpp"
#include "util/QStringHash.hpp"
#include "util/ThreadGuard.hpp"
#include <QColor>
@ -30,10 +31,9 @@ public:
};
std::vector<Badge> getUserBadges(const UserId &id);
std::optional<Badge> getBadge(int badgeID) const;
private:
std::optional<Badge> getBadge(int badgeID);
void load();
std::shared_mutex mutex_;
@ -43,6 +43,7 @@ private:
// badges points a badge ID to the information about the badge
std::unordered_map<int, Badge> badges;
ThreadGuard tgBadges;
};
} // namespace chatterino

View file

@ -169,6 +169,33 @@ EmoteMap ffz::detail::parseChannelEmotes(const QJsonObject &jsonRoot)
return emotes;
}
FfzChannelBadgeMap ffz::detail::parseChannelBadges(const QJsonObject &badgeRoot)
{
FfzChannelBadgeMap channelBadges;
for (auto it = badgeRoot.begin(); it != badgeRoot.end(); ++it)
{
const auto badgeID = it.key().toInt();
const auto &jsonUserIDs = it.value().toArray();
for (const auto &jsonUserID : jsonUserIDs)
{
// NOTE: The Twitch User IDs come through as ints right now, the code below
// tries to parse them as strings first since that's how we treat them anyway.
if (jsonUserID.isString())
{
channelBadges[jsonUserID.toString()].emplace_back(badgeID);
}
else
{
channelBadges[QString::number(jsonUserID.toInt())].emplace_back(
badgeID);
}
}
}
return channelBadges;
}
FfzEmotes::FfzEmotes()
: global_(std::make_shared<EmoteMap>())
{
@ -220,6 +247,7 @@ void FfzEmotes::loadChannel(
std::function<void(EmoteMap &&)> emoteCallback,
std::function<void(std::optional<EmotePtr>)> modBadgeCallback,
std::function<void(std::optional<EmotePtr>)> vipBadgeCallback,
std::function<void(FfzChannelBadgeMap &&)> channelBadgesCallback,
bool manualRefresh)
{
qCDebug(LOG) << "Reload FFZ Channel Emotes for channel" << channelID;
@ -229,8 +257,9 @@ void FfzEmotes::loadChannel(
.timeout(20000)
.onSuccess([emoteCallback = std::move(emoteCallback),
modBadgeCallback = std::move(modBadgeCallback),
vipBadgeCallback = std::move(vipBadgeCallback), channel,
manualRefresh](const auto &result) {
vipBadgeCallback = std::move(vipBadgeCallback),
channelBadgesCallback = std::move(channelBadgesCallback),
channel, manualRefresh](const auto &result) {
const auto json = result.parseJson();
auto emoteMap = parseChannelEmotes(json);
@ -238,12 +267,15 @@ void FfzEmotes::loadChannel(
json["room"]["mod_urls"].toObject(), "Moderator");
auto vipBadge = parseAuthorityBadge(
json["room"]["vip_badge"].toObject(), "VIP");
auto channelBadges =
parseChannelBadges(json["room"]["user_badge_ids"].toObject());
bool hasEmotes = !emoteMap.empty();
emoteCallback(std::move(emoteMap));
modBadgeCallback(std::move(modBadge));
vipBadgeCallback(std::move(vipBadge));
channelBadgesCallback(std::move(channelBadges));
if (auto shared = channel.lock(); manualRefresh)
{
if (hasEmotes)

View file

@ -2,7 +2,9 @@
#include "common/Aliases.hpp"
#include "common/Atomic.hpp"
#include "util/QStringHash.hpp"
#include <boost/unordered/unordered_flat_map.hpp>
#include <QJsonObject>
#include <memory>
@ -15,10 +17,19 @@ using EmotePtr = std::shared_ptr<const Emote>;
class EmoteMap;
class Channel;
/// Maps a Twitch User ID to a list of badge IDs
using FfzChannelBadgeMap =
boost::unordered::unordered_flat_map<QString, std::vector<int>>;
namespace ffz::detail {
EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot);
/**
* Parse the `user_badge_ids` into a map of User IDs -> Badge IDs
*/
FfzChannelBadgeMap parseChannelBadges(const QJsonObject &badgeRoot);
} // namespace ffz::detail
class FfzEmotes final
@ -35,6 +46,7 @@ public:
std::function<void(EmoteMap &&)> emoteCallback,
std::function<void(std::optional<EmotePtr>)> modBadgeCallback,
std::function<void(std::optional<EmotePtr>)> vipBadgeCallback,
std::function<void(FfzChannelBadgeMap &&)> channelBadgesCallback,
bool manualRefresh);
private:

View file

@ -0,0 +1,106 @@
#include "providers/links/LinkInfo.hpp"
#include "debug/AssertInGuiThread.hpp"
#include <QString>
namespace chatterino {
LinkInfo::LinkInfo(QString url)
: QObject(nullptr)
, originalUrl_(url)
, url_(std::move(url))
, tooltip_(this->url_)
{
}
LinkInfo::~LinkInfo() = default;
LinkInfo::State LinkInfo::state() const
{
return this->state_;
}
QString LinkInfo::url() const
{
return this->url_;
}
QString LinkInfo::originalUrl() const
{
return this->originalUrl_;
}
bool LinkInfo::isPending() const
{
return this->state_ == State::Created;
}
bool LinkInfo::isLoading() const
{
return this->state_ == State::Loading;
}
bool LinkInfo::isLoaded() const
{
return this->state_ > State::Loading;
}
bool LinkInfo::isResolved() const
{
return this->state_ == State::Resolved;
}
bool LinkInfo::hasError() const
{
return this->state_ == State::Errored;
}
bool LinkInfo::hasThumbnail() const
{
return this->thumbnail_ && !this->thumbnail_->url().string.isEmpty();
}
QString LinkInfo::tooltip() const
{
return this->tooltip_;
}
ImagePtr LinkInfo::thumbnail() const
{
return this->thumbnail_;
}
void LinkInfo::setState(State state)
{
assertInGuiThread();
assert(state >= this->state_);
if (this->state_ == state)
{
return;
}
this->state_ = state;
this->stateChanged(state);
}
void LinkInfo::setResolvedUrl(QString resolvedUrl)
{
assertInGuiThread();
this->url_ = std::move(resolvedUrl);
}
void LinkInfo::setTooltip(QString tooltip)
{
assertInGuiThread();
this->tooltip_ = std::move(tooltip);
}
void LinkInfo::setThumbnail(ImagePtr thumbnail)
{
assertInGuiThread();
this->thumbnail_ = std::move(thumbnail);
}
} // namespace chatterino

View file

@ -0,0 +1,135 @@
#pragma once
#include "messages/Image.hpp"
namespace chatterino {
/// @brief Rich info about a URL with tooltip and thumbnail
///
/// This is only a data class - it doesn't do the resolving.
/// It can only be used from the GUI thread.
class LinkInfo : public QObject
{
Q_OBJECT
public:
/// @brief the state of a link info
///
/// The state of a link can only increase. For example, it's not possible
/// for the link to change from "Resolved" to "Loading".
enum class State {
/// @brief The object was created, no info is resolved
///
/// This is the initial state
Created,
/// Info is currently loading
Loading,
/// Info has been resolved and the properties have been updated
Resolved,
/// There has been an error resolving the link info (e.g. timeout)
Errored,
};
/// @brief Constructs a new link info for a URL
///
/// This doesn't load any link info.
/// @see #ensureLoadingStarted()
[[nodiscard]] explicit LinkInfo(QString url);
~LinkInfo() override;
LinkInfo(const LinkInfo &) = delete;
LinkInfo(LinkInfo &&) = delete;
LinkInfo &operator=(const LinkInfo &) = delete;
LinkInfo &operator=(LinkInfo &&) = delete;
/// @brief The URL of this link
///
/// If the "unshortLinks" setting is enabled, this can change after the
/// link is resolved.
[[nodiscard]] QString url() const;
/// @brief The URL of this link as seen in the message
///
/// If the "unshortLinks" setting doesn't affect this URL.
[[nodiscard]] QString originalUrl() const;
/// Returns the current state
[[nodiscard]] State state() const;
/// Returns true if this link has not yet been resolved (it's "Created")
[[nodiscard]] bool isPending() const;
/// Returns true if the info is loading
[[nodiscard]] bool isLoading() const;
/// Returns true if the info is loaded (resolved or errored)
[[nodiscard]] bool isLoaded() const;
/// Returns true if this link has been resolved
[[nodiscard]] bool isResolved() const;
/// Returns true if the info failed to resolve
[[nodiscard]] bool hasError() const;
/// Returns true if this link has a thumbnail
[[nodiscard]] bool hasThumbnail() const;
/// @brief Returns the tooltip of this link
///
/// The tooltip contains the URL of the link and any info added by the
/// resolver. Resolvers must include the URL.
[[nodiscard]] QString tooltip() const;
/// @brief Returns the thumbnail of this link
///
/// The thumbnail is provided by the resolver and might not have been
/// loaded yet.
///
/// @pre The caller must check #hasThumbnail() before calling this method
[[nodiscard]] ImagePtr thumbnail() const;
/// @brief Updates the state and emits #stateChanged accordingly
///
/// @pre The caller must be in the GUI thread.
/// @pre @a state must be greater or equal to the current state.
/// @see #state(), #stateChanged
void setState(State state);
/// @brief Updates the resolved url of this link
///
/// @pre The caller must be in the GUI thread.
/// @see #url()
void setResolvedUrl(QString resolvedUrl);
/// @brief Updates the tooltip of this link
///
/// @pre The caller must be in the GUI thread.
/// @see #tooltip()
void setTooltip(QString tooltip);
/// @brief Updates the thumbnail of this link
///
/// The thumbnail is allowed to be empty or nullptr.
///
/// @pre The caller must be in the GUI thread.
/// @see #hasThumbnail(), #thumbnail()
void setThumbnail(ImagePtr thumbnail);
signals:
/// @brief Emitted when this link's state changes
///
/// @param state The new state
void stateChanged(State state);
private:
const QString originalUrl_;
QString url_;
QString tooltip_;
ImagePtr thumbnail_;
State state_ = State::Created;
};
} // namespace chatterino

View file

@ -0,0 +1,72 @@
#include "providers/links/LinkResolver.hpp"
#include "common/Env.hpp"
#include "common/network/NetworkRequest.hpp"
#include "common/network/NetworkResult.hpp"
#include "providers/links/LinkInfo.hpp"
#include "singletons/Settings.hpp"
#include <QStringBuilder>
namespace chatterino {
void LinkResolver::resolve(LinkInfo *info)
{
using State = LinkInfo::State;
assert(info);
if (info->state() != State::Created)
{
// The link is already resolved or is currently loading
return;
}
if (!getSettings()->linkInfoTooltip)
{
return;
}
info->setTooltip("Loading...");
info->setState(State::Loading);
NetworkRequest(Env::get().linkResolverUrl.arg(QString::fromUtf8(
QUrl::toPercentEncoding(info->originalUrl(), {}, "/:"))))
.caller(info)
.timeout(30000)
.onSuccess([info](const NetworkResult &result) {
const auto root = result.parseJson();
QString response;
QString url;
ImagePtr thumbnail = nullptr;
if (root["status"].toInt() == 200)
{
response = root["tooltip"].toString();
if (root.contains("thumbnail"))
{
info->setThumbnail(
Image::fromUrl({root["thumbnail"].toString()}));
}
if (getSettings()->unshortLinks && root.contains("link"))
{
info->setResolvedUrl(root["link"].toString());
}
}
else
{
response = root["message"].toString();
}
info->setTooltip(QUrl::fromPercentEncoding(response.toUtf8()));
info->setState(State::Resolved);
})
.onError([info](const auto &result) {
info->setTooltip(u"No link info found (" % result.formatError() %
u')');
info->setState(State::Errored);
})
.execute();
}
} // namespace chatterino

View file

@ -0,0 +1,36 @@
#pragma once
namespace chatterino {
class LinkInfo;
class ILinkResolver
{
public:
ILinkResolver() = default;
virtual ~ILinkResolver() = default;
ILinkResolver(const ILinkResolver &) = delete;
ILinkResolver(ILinkResolver &&) = delete;
ILinkResolver &operator=(const ILinkResolver &) = delete;
ILinkResolver &operator=(ILinkResolver &&) = delete;
virtual void resolve(LinkInfo *info) = 0;
};
class LinkResolver : public ILinkResolver
{
public:
LinkResolver() = default;
/// @brief Loads and updates the link info
///
/// Calling this with an already resolved or currently loading info is a
/// no-op. Loading can be blocked by disabling the "linkInfoTooltip"
/// setting. URLs will be unshortened if the "unshortLinks" setting is
/// enabled. The resolver is set through Env::linkResolverUrl.
///
/// @pre @a info must not be nullptr
void resolve(LinkInfo *info) override;
};
} // namespace chatterino

View file

@ -8,4 +8,16 @@ Message::Message(QJsonObject _json)
{
}
std::optional<Message> parseBaseMessage(const QString &blob)
{
QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8()));
if (jsonDoc.isNull())
{
return std::nullopt;
}
return Message(jsonDoc.object());
}
} // namespace chatterino::seventv::eventapi

View file

@ -28,16 +28,6 @@ std::optional<InnerClass> Message::toInner()
return InnerClass{this->data};
}
static std::optional<Message> parseBaseMessage(const QString &blob)
{
QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8()));
if (jsonDoc.isNull())
{
return std::nullopt;
}
return Message(jsonDoc.object());
}
std::optional<Message> parseBaseMessage(const QString &blob);
} // namespace chatterino::seventv::eventapi

View file

@ -20,21 +20,10 @@
namespace chatterino {
TwitchBadges::TwitchBadges()
{
this->loadTwitchBadges();
}
void TwitchBadges::loadTwitchBadges()
{
assert(this->loaded_ == false);
if (!getHelix())
{
// This is intended for tests and benchmarks.
return;
}
getHelix()->getGlobalBadges(
[this](auto globalBadges) {
auto badgeSets = this->badgeSets_.access();

View file

@ -32,8 +32,6 @@ class TwitchBadges
using BadgeIconCallback = std::function<void(QString, const QIconPtr)>;
public:
TwitchBadges();
// Get badge from name and version
std::optional<EmotePtr> badge(const QString &set,
const QString &version) const;
@ -45,8 +43,9 @@ public:
void getBadgeIcons(const QList<DisplayBadge> &badges,
BadgeIconCallback callback);
private:
void loadTwitchBadges();
private:
void parseTwitchBadges(QJsonObject root);
void loaded();
void loadEmoteImage(const QString &name, ImagePtr image,

View file

@ -18,6 +18,7 @@
#include "providers/bttv/BttvEmotes.hpp"
#include "providers/bttv/BttvLiveUpdates.hpp"
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
#include "providers/ffz/FfzBadges.hpp"
#include "providers/ffz/FfzEmotes.hpp"
#include "providers/recentmessages/Api.hpp"
#include "providers/seventv/eventapi/Dispatch.hpp"
@ -164,12 +165,14 @@ TwitchChannel::TwitchChannel(const QString &name)
MessageBuilder builder;
TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(),
&builder);
builder.message().id = this->roomId();
this->addMessage(builder.release());
// Message in /live channel
MessageBuilder builder2;
TwitchMessageBuilder::liveMessage(this->getDisplayName(),
&builder2);
builder2.message().id = this->roomId();
getApp()->twitch->liveChannel->addMessage(builder2.release());
// Notify on all channels with a ping sound
@ -197,14 +200,12 @@ TwitchChannel::TwitchChannel(const QString &name)
// 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)
if (s->id == this->roomId())
{
s->flags.set(MessageFlag::Disabled);
break;
@ -333,6 +334,14 @@ void TwitchChannel::refreshFFZChannelEmotes(bool manualRefresh)
std::forward<decltype(vipBadge)>(vipBadge));
}
},
[this, weak = weakOf<Channel>(this)](auto &&channelBadges) {
if (auto shared = weak.lock())
{
this->tgFfzChannelBadges_.guard();
this->ffzChannelBadges_ =
std::forward<decltype(channelBadges)>(channelBadges);
}
},
manualRefresh);
}
@ -467,9 +476,19 @@ void TwitchChannel::updateStreamStatus(
auto diff = since.secsTo(QDateTime::currentDateTime());
status->uptime = QString::number(diff / 3600) + "h " +
QString::number(diff % 3600 / 60) + "m";
status->uptimeSeconds = diff;
status->rerun = false;
status->streamType = stream.type;
for (const auto &tag : stream.tags)
{
if (QString::compare(tag, "Rerun", Qt::CaseInsensitive) == 0)
{
status->rerun = true;
status->streamType = "rerun";
break;
}
}
}
if (this->setLive(true))
{
@ -796,6 +815,11 @@ bool TwitchChannel::isLive() const
return this->streamStatus_.accessConst()->live;
}
bool TwitchChannel::isRerun() const
{
return this->streamStatus_.accessConst()->rerun;
}
SharedAccessGuard<const TwitchChannel::StreamStatus>
TwitchChannel::accessStreamStatus() const
{
@ -1692,6 +1716,33 @@ std::optional<EmotePtr> TwitchChannel::twitchBadge(const QString &set,
return std::nullopt;
}
std::vector<FfzBadges::Badge> TwitchChannel::ffzChannelBadges(
const QString &userID) const
{
this->tgFfzChannelBadges_.guard();
auto it = this->ffzChannelBadges_.find(userID);
if (it == this->ffzChannelBadges_.end())
{
return {};
}
std::vector<FfzBadges::Badge> badges;
const auto *ffzBadges = getIApp()->getFfzBadges();
for (const auto &badgeID : it->second)
{
auto badge = ffzBadges->getBadge(badgeID);
if (badge.has_value())
{
badges.emplace_back(*badge);
}
}
return badges;
}
std::optional<EmotePtr> TwitchChannel::ffzCustomModBadge() const
{
return this->ffzCustomModBadge_.get();

View file

@ -6,8 +6,11 @@
#include "common/ChannelChatters.hpp"
#include "common/Common.hpp"
#include "common/UniqueAccess.hpp"
#include "providers/ffz/FfzBadges.hpp"
#include "providers/ffz/FfzEmotes.hpp"
#include "providers/twitch/TwitchEmotes.hpp"
#include "util/QStringHash.hpp"
#include "util/ThreadGuard.hpp"
#include <boost/circular_buffer/space_optimized.hpp>
#include <boost/signals2.hpp>
@ -82,6 +85,7 @@ public:
QString game;
QString gameId;
QString uptime;
int uptimeSeconds = 0;
QString streamType;
};
@ -137,6 +141,7 @@ public:
const QString &popoutPlayerUrl();
int chatterCount() const;
bool isLive() const override;
bool isRerun() const override;
QString roomId() const;
SharedAccessGuard<const RoomModes> accessRoomModes() const;
SharedAccessGuard<const StreamStatus> accessStreamStatus() const;
@ -198,6 +203,10 @@ public:
std::optional<EmotePtr> ffzCustomVipBadge() const;
std::optional<EmotePtr> twitchBadge(const QString &set,
const QString &version) const;
/**
* Returns a list of channel-specific FrankerFaceZ badges for the given user
*/
std::vector<FfzBadges::Badge> ffzChannelBadges(const QString &userID) const;
// Cheers
std::optional<CheerEmote> cheerEmote(const QString &string) const;
@ -262,6 +271,12 @@ public:
void updateStreamStatus(const std::optional<HelixStream> &helixStream);
void updateStreamTitle(const QString &title);
/**
* 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;
void updateDisplayName(const QString &displayName);
private:
@ -321,13 +336,6 @@ 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
**/
@ -392,6 +400,9 @@ protected:
Atomic<std::optional<EmotePtr>> ffzCustomModBadge_;
Atomic<std::optional<EmotePtr>> ffzCustomVipBadge_;
FfzChannelBadgeMap ffzChannelBadges_;
ThreadGuard tgFfzChannelBadges_;
private:
// Badges
UniqueAccess<std::map<QString, std::map<QString, EmotePtr>>>

View file

@ -525,6 +525,11 @@ const IndirectChannel &TwitchIrcServer::getWatchingChannel() const
return this->watchingChannel;
}
QString TwitchIrcServer::getLastUserThatWhisperedMe() const
{
return this->lastUserThatWhisperedMe.get();
}
void TwitchIrcServer::reloadBTTVGlobalEmotes()
{
getIApp()->getBttvEmotes()->loadEmotes();

View file

@ -29,6 +29,8 @@ public:
virtual const IndirectChannel &getWatchingChannel() const = 0;
virtual QString getLastUserThatWhisperedMe() const = 0;
// Update this interface with TwitchIrcServer methods as needed
};
@ -81,6 +83,8 @@ public:
const IndirectChannel &getWatchingChannel() const override;
QString getLastUserThatWhisperedMe() const override;
protected:
void initializeConnection(IrcConnection *connection,
ConnectionType type) override;

View file

@ -269,6 +269,128 @@ namespace {
builder->message().badgeInfos = badgeInfos;
}
/**
* Computes (only) the replacement of @a match in @a source.
* The parts before and after the match in @a source are ignored.
*
* Occurrences of \b{\\1}, \b{\\2}, ..., in @a replacement are replaced
* with the string captured by the corresponding capturing group.
* This function should only be used if the regex contains capturing groups.
*
* Since Qt doesn't provide a way of replacing a single match with some replacement
* while supporting both capturing groups and lookahead/-behind in the regex,
* this is included here. It's essentially the implementation of
* QString::replace(const QRegularExpression &, const QString &).
* @see https://github.com/qt/qtbase/blob/97bb0ecfe628b5bb78e798563212adf02129c6f6/src/corelib/text/qstring.cpp#L4594-L4703
*/
QString makeRegexReplacement(QStringView source,
const QRegularExpression &regex,
const QRegularExpressionMatch &match,
const QString &replacement)
{
using SizeType = QString::size_type;
struct QStringCapture {
SizeType pos;
SizeType len;
int captureNumber;
};
qsizetype numCaptures = regex.captureCount();
// 1. build the backreferences list, holding where the backreferences
// are in the replacement string
QVarLengthArray<QStringCapture> backReferences;
SizeType replacementLength = replacement.size();
for (SizeType i = 0; i < replacementLength - 1; i++)
{
if (replacement[i] != u'\\')
{
continue;
}
int no = replacement[i + 1].digitValue();
if (no <= 0 || no > numCaptures)
{
continue;
}
QStringCapture backReference{.pos = i, .len = 2};
if (i < replacementLength - 2)
{
int secondDigit = replacement[i + 2].digitValue();
if (secondDigit != -1 &&
((no * 10) + secondDigit) <= numCaptures)
{
no = (no * 10) + secondDigit;
++backReference.len;
}
}
backReference.captureNumber = no;
backReferences.append(backReference);
}
// 2. iterate on the matches.
// For every match, copy the replacement string in chunks
// with the proper replacements for the backreferences
// length of the new string, with all the replacements
SizeType newLength = 0;
QVarLengthArray<QStringView> chunks;
QStringView replacementView{replacement};
// Initially: empty, as we only care about the replacement
SizeType len = 0;
SizeType lastEnd = 0;
for (const QStringCapture &backReference :
std::as_const(backReferences))
{
// part of "replacement" before the backreference
len = backReference.pos - lastEnd;
if (len > 0)
{
chunks << replacementView.mid(lastEnd, len);
newLength += len;
}
// backreference itself
len = match.capturedLength(backReference.captureNumber);
if (len > 0)
{
chunks << source.mid(
match.capturedStart(backReference.captureNumber), len);
newLength += len;
}
lastEnd = backReference.pos + backReference.len;
}
// add the last part of the replacement string
len = replacementView.size() - lastEnd;
if (len > 0)
{
chunks << replacementView.mid(lastEnd, len);
newLength += len;
}
// 3. assemble the chunks together
QString dst;
dst.reserve(newLength);
for (const QStringView &chunk : std::as_const(chunks))
{
#if QT_VERSION < QT_VERSION_CHECK(5, 15, 2)
static_assert(sizeof(QChar) == sizeof(decltype(*chunk.utf16())));
dst.append(reinterpret_cast<const QChar *>(chunk.utf16()),
chunk.length());
#else
dst += chunk;
#endif
}
return dst;
}
} // namespace
TwitchMessageBuilder::TwitchMessageBuilder(
@ -419,7 +541,9 @@ MessagePtr TwitchMessageBuilder::build()
this->tags, this->originalMessage_, this->messageOffset_);
// This runs through all ignored phrases and runs its replacements on this->originalMessage_
this->runIgnoreReplaces(twitchEmotes);
TwitchMessageBuilder::processIgnorePhrases(
*getSettings()->ignoredMessages.readOnly(), this->originalMessage_,
twitchEmotes);
std::sort(twitchEmotes.begin(), twitchEmotes.end(),
[](const auto &a, const auto &b) {
@ -440,9 +564,10 @@ MessagePtr TwitchMessageBuilder::build()
this->stylizeUsername(this->userName, this->message());
this->message().messageText = this->originalMessage_;
this->message().searchText = stylizedUsername + " " +
this->message().localizedName + " " +
this->userName + ": " + this->originalMessage_;
this->message().searchText =
stylizedUsername + " " + this->message().localizedName + " " +
this->userName + ": " + this->originalMessage_ + " " +
this->message().searchText;
// highlights
this->parseHighlights();
@ -772,11 +897,16 @@ void TwitchMessageBuilder::parseThread()
threadRoot->usernameColor, FontStyle::ChatMediumSmall)
->setLink({Link::UserInfo, threadRoot->displayName});
MessageColor color = MessageColor::Text;
if (threadRoot->flags.has(MessageFlag::Action))
{
color = threadRoot->usernameColor;
}
this->emplace<SingleLineTextElement>(
threadRoot->messageText,
MessageElementFlags({MessageElementFlag::RepliedMessage,
MessageElementFlag::Text}),
this->textColor_, FontStyle::ChatMediumSmall)
color, FontStyle::ChatMediumSmall)
->setLink({Link::ViewThread, this->thread_->rootId()});
}
else if (this->tags.find("reply-parent-msg-id") != this->tags.end())
@ -960,12 +1090,12 @@ void TwitchMessageBuilder::appendUsername()
}
}
void TwitchMessageBuilder::runIgnoreReplaces(
void TwitchMessageBuilder::processIgnorePhrases(
const std::vector<IgnorePhrase> &phrases, QString &originalMessage,
std::vector<TwitchEmoteOccurrence> &twitchEmotes)
{
using SizeType = QString::size_type;
auto phrases = getSettings()->ignoredMessages.readOnly();
auto removeEmotesInRange = [&twitchEmotes](SizeType pos, SizeType len) {
// all emotes outside the range come before `it`
// all emotes in the range start at `it`
@ -1034,20 +1164,20 @@ void TwitchMessageBuilder::runIgnoreReplaces(
auto replaceMessageAt = [&](const IgnorePhrase &phrase, SizeType from,
SizeType length, const QString &replacement) {
auto removedEmotes = removeEmotesInRange(from, length);
this->originalMessage_.replace(from, length, replacement);
originalMessage.replace(from, length, replacement);
auto wordStart = from;
while (wordStart > 0)
{
if (this->originalMessage_[wordStart - 1] == ' ')
if (originalMessage[wordStart - 1] == ' ')
{
break;
}
--wordStart;
}
auto wordEnd = from + replacement.length();
while (wordEnd < this->originalMessage_.length())
while (wordEnd < originalMessage.length())
{
if (this->originalMessage_[wordEnd] == ' ')
if (originalMessage[wordEnd] == ' ')
{
break;
}
@ -1058,11 +1188,11 @@ void TwitchMessageBuilder::runIgnoreReplaces(
static_cast<int>(replacement.length() - length));
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
auto midExtendedRef = QStringView{this->originalMessage_}.mid(
wordStart, wordEnd - wordStart);
auto midExtendedRef =
QStringView{originalMessage}.mid(wordStart, wordEnd - wordStart);
#else
auto midExtendedRef =
this->originalMessage_.midRef(wordStart, wordEnd - wordStart);
originalMessage.midRef(wordStart, wordEnd - wordStart);
#endif
for (auto &emote : removedEmotes)
@ -1088,7 +1218,7 @@ void TwitchMessageBuilder::runIgnoreReplaces(
addReplEmotes(phrase, midExtendedRef, wordStart);
};
for (const auto &phrase : *phrases)
for (const auto &phrase : phrases)
{
if (phrase.isBlock())
{
@ -1110,16 +1240,22 @@ void TwitchMessageBuilder::runIgnoreReplaces(
QRegularExpressionMatch match;
size_t iterations = 0;
SizeType from = 0;
while ((from = this->originalMessage_.indexOf(regex, from,
&match)) != -1)
while ((from = originalMessage.indexOf(regex, from, &match)) != -1)
{
auto replacement = phrase.getReplace();
if (regex.captureCount() > 0)
{
replacement = makeRegexReplacement(originalMessage, regex,
match, replacement);
}
replaceMessageAt(phrase, from, match.capturedLength(),
phrase.getReplace());
replacement);
from += phrase.getReplace().length();
iterations++;
if (iterations >= 128)
{
this->originalMessage_ =
originalMessage =
u"Too many replacements - check your ignores!"_s;
return;
}
@ -1129,8 +1265,8 @@ void TwitchMessageBuilder::runIgnoreReplaces(
}
SizeType from = 0;
while ((from = this->originalMessage_.indexOf(
pattern, from, phrase.caseSensitivity())) != -1)
while ((from = originalMessage.indexOf(pattern, from,
phrase.caseSensitivity())) != -1)
{
replaceMessageAt(phrase, from, pattern.length(),
phrase.getReplace());
@ -1311,6 +1447,18 @@ void TwitchMessageBuilder::appendFfzBadges()
this->emplace<FfzBadgeElement>(
badge.emote, MessageElementFlag::BadgeFfz, badge.color);
}
if (this->twitchChannel == nullptr)
{
return;
}
for (const auto &badge :
this->twitchChannel->ffzChannelBadges(this->userId_))
{
this->emplace<FfzBadgeElement>(
badge.emote, MessageElementFlag::BadgeFfz, badge.color);
}
}
void TwitchMessageBuilder::appendSeventvBadges()

View file

@ -20,6 +20,7 @@ using EmotePtr = std::shared_ptr<const Emote>;
class Channel;
class TwitchChannel;
class MessageThread;
class IgnorePhrase;
struct HelixVip;
using HelixModerator = HelixVip;
struct ChannelPointReward;
@ -108,6 +109,10 @@ public:
const QVariantMap &tags, const QString &originalMessage,
int messageOffset);
static void processIgnorePhrases(
const std::vector<IgnorePhrase> &phrases, QString &originalMessage,
std::vector<TwitchEmoteOccurrence> &twitchEmotes);
private:
void parseUsernameColor() override;
void parseUsername() override;
@ -118,8 +123,6 @@ private:
void parseThread();
void appendUsername();
void runIgnoreReplaces(std::vector<TwitchEmoteOccurrence> &twitchEmotes);
Outcome tryAppendEmote(const EmoteName &name) override;
void addWords(const QStringList &words,

View file

@ -2703,8 +2703,12 @@ void Helix::updateShieldMode(
Qt::CaseInsensitive))
{
failureCallback(Error::UserMissingScope, message);
break;
}
failureCallback(Error::Forwarded, message);
}
break;
case 401: {
failureCallback(Error::Forwarded, message);
}

View file

@ -69,6 +69,9 @@ struct HelixStream {
QString language;
QString thumbnailUrl;
// This is the names, the IDs are now always empty
std::vector<QString> tags;
HelixStream()
: id("")
, userId("")
@ -99,6 +102,11 @@ struct HelixStream {
, language(jsonObject.value("language").toString())
, thumbnailUrl(jsonObject.value("thumbnail_url").toString())
{
const auto jsonTags = jsonObject.value("tags").toArray();
for (const auto &tag : jsonTags)
{
this->tags.push_back(tag.toString());
}
}
};

View file

@ -16,4 +16,16 @@ PubSubMessage::PubSubMessage(QJsonObject _object)
}
}
std::optional<PubSubMessage> parsePubSubBaseMessage(const QString &blob)
{
QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8()));
if (jsonDoc.isNull())
{
return std::nullopt;
}
return PubSubMessage(jsonDoc.object());
}
} // namespace chatterino

View file

@ -45,17 +45,7 @@ std::optional<InnerClass> PubSubMessage::toInner()
return InnerClass{this->nonce, data};
}
static std::optional<PubSubMessage> parsePubSubBaseMessage(const QString &blob)
{
QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8()));
if (jsonDoc.isNull())
{
return std::nullopt;
}
return PubSubMessage(jsonDoc.object());
}
std::optional<PubSubMessage> parsePubSubBaseMessage(const QString &blob);
} // namespace chatterino

View file

@ -238,7 +238,7 @@ void ImageUploader::handleSuccessfulUpload(const NetworkResult &result,
}
else
{
QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit, this]() {
QTimer::singleShot(UPLOAD_DELAY, [channel, textEdit, this]() {
this->sendImageUploadRequest(this->uploadQueue_.front(), channel,
textEdit);
this->uploadQueue_.pop();
@ -259,8 +259,11 @@ void ImageUploader::upload(const QMimeData *source, ChannelPtr channel,
}
channel->addMessage(makeSystemMessage(QString("Started upload...")));
if (source->hasUrls())
{
auto tryUploadFromUrls = [&]() -> bool {
if (!source->hasUrls())
{
return false;
}
auto mimeDb = QMimeDatabase();
// This path gets chosen when files are copied from a file manager, like explorer.exe, caja.
// Each entry in source->urls() is a QUrl pointing to a file that was copied.
@ -277,8 +280,7 @@ void ImageUploader::upload(const QMimeData *source, ChannelPtr channel,
{
channel->addMessage(
makeSystemMessage(QString("Couldn't load image :(")));
this->uploadMutex_.unlock();
return;
return false;
}
auto imageData = convertToPng(img);
@ -293,8 +295,7 @@ void ImageUploader::upload(const QMimeData *source, ChannelPtr channel,
QString("Cannot upload file: %1. Couldn't convert "
"image to png.")
.arg(localPath)));
this->uploadMutex_.unlock();
return;
return false;
}
}
else if (mime.inherits("image/gif"))
@ -307,21 +308,12 @@ void ImageUploader::upload(const QMimeData *source, ChannelPtr channel,
{
channel->addMessage(
makeSystemMessage(QString("Failed to open file. :(")));
this->uploadMutex_.unlock();
return;
return false;
}
// file.readAll() => might be a bit big but it /should/ work
RawImageData data = {file.readAll(), "gif", localPath};
this->uploadQueue_.push(data);
file.close();
// file.readAll() => might be a bit big but it /should/ work
}
else
{
channel->addMessage(makeSystemMessage(
QString("Cannot upload file: %1. Not an image.")
.arg(localPath)));
this->uploadMutex_.unlock();
return;
}
}
if (!this->uploadQueue_.empty())
@ -329,40 +321,52 @@ void ImageUploader::upload(const QMimeData *source, ChannelPtr channel,
this->sendImageUploadRequest(this->uploadQueue_.front(), channel,
outputTextEdit);
this->uploadQueue_.pop();
return true;
}
}
else if (source->hasFormat("image/png"))
{
// the path to file is not present every time, thus the filePath is empty
this->sendImageUploadRequest({source->data("image/png"), "png", ""},
channel, outputTextEdit);
}
else if (source->hasFormat("image/jpeg"))
{
this->sendImageUploadRequest({source->data("image/jpeg"), "jpeg", ""},
channel, outputTextEdit);
}
else if (source->hasFormat("image/gif"))
{
this->sendImageUploadRequest({source->data("image/gif"), "gif", ""},
channel, outputTextEdit);
}
return false;
};
else
{ // not PNG, try loading it into QImage and save it to a PNG.
auto tryUploadDirectly = [&]() -> bool {
if (source->hasFormat("image/png"))
{
// the path to file is not present every time, thus the filePath is empty
this->sendImageUploadRequest({source->data("image/png"), "png", ""},
channel, outputTextEdit);
return true;
}
if (source->hasFormat("image/jpeg"))
{
this->sendImageUploadRequest(
{source->data("image/jpeg"), "jpeg", ""}, channel,
outputTextEdit);
return true;
}
if (source->hasFormat("image/gif"))
{
this->sendImageUploadRequest({source->data("image/gif"), "gif", ""},
channel, outputTextEdit);
return true;
}
// not PNG, try loading it into QImage and save it to a PNG.
auto image = qvariant_cast<QImage>(source->imageData());
auto imageData = convertToPng(image);
if (imageData)
{
sendImageUploadRequest({*imageData, "png", ""}, channel,
outputTextEdit);
return true;
}
else
{
channel->addMessage(makeSystemMessage(
QString("Cannot upload file, failed to convert to png.")));
this->uploadMutex_.unlock();
}
// No direct upload happenned
channel->addMessage(makeSystemMessage(
QString("Cannot upload file, failed to convert to png.")));
return false;
};
if (!tryUploadFromUrls() && !tryUploadDirectly())
{
channel->addMessage(
makeSystemMessage(QString("Cannot upload file from clipboard.")));
this->uploadMutex_.unlock();
}
}

View file

@ -2,6 +2,7 @@
#include "Application.hpp"
#include "common/Literals.hpp"
#include "common/Modes.hpp"
#include "common/QLogging.hpp"
#include "debug/AssertInGuiThread.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
@ -40,7 +41,7 @@ void registerNmManifest(const Paths &paths, const QString &manifestFilename,
void registerNmHost(const Paths &paths)
{
if (paths.isPortable())
if (Modes::instance().isPortable)
{
return;
}

View file

@ -86,7 +86,7 @@ void Paths::initRootDirectory()
this->rootAppDataDirectory = [&]() -> QString {
// portable
if (this->isPortable())
if (Modes::instance().isPortable)
{
return QCoreApplication::applicationDirPath();
}

View file

@ -218,6 +218,10 @@ public:
"/behaviour/autocompletion/emoteCompletionWithColon", true};
BoolSetting showUsernameCompletionMenu = {
"/behaviour/autocompletion/showUsernameCompletionMenu", true};
BoolSetting alwaysIncludeBroadcasterInUserCompletions = {
"/behaviour/autocompletion/alwaysIncludeBroadcasterInUserCompletions",
true,
};
BoolSetting useSmartEmoteCompletion = {
"/experiments/useSmartEmoteCompletion",
false,

View file

@ -6,6 +6,7 @@
#include "common/QLogging.hpp"
#include "singletons/Paths.hpp"
#include "singletons/Resources.hpp"
#include "singletons/WindowManager.hpp"
#include <QColor>
#include <QDir>
@ -13,6 +14,9 @@
#include <QFile>
#include <QJsonDocument>
#include <QSet>
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
# include <QStyleHints>
#endif
#include <cmath>
@ -21,67 +25,107 @@ namespace {
using namespace chatterino;
using namespace literals;
void parseInto(const QJsonObject &obj, QLatin1String key, QColor &color)
void parseInto(const QJsonObject &obj, const QJsonObject &fallbackObj,
QLatin1String key, QColor &color)
{
const auto &jsonValue = obj[key];
if (!jsonValue.isString()) [[unlikely]]
auto parseColorFrom = [](const auto &obj,
QLatin1String key) -> std::optional<QColor> {
auto jsonValue = obj[key];
if (!jsonValue.isString()) [[unlikely]]
{
return std::nullopt;
}
QColor parsed = {jsonValue.toString()};
if (!parsed.isValid()) [[unlikely]]
{
qCWarning(chatterinoTheme).nospace()
<< "While parsing " << key << ": '" << jsonValue.toString()
<< "' isn't a valid color.";
return std::nullopt;
}
return parsed;
};
auto firstColor = parseColorFrom(obj, key);
if (firstColor.has_value())
{
qCWarning(chatterinoTheme) << key
<< "was expected but not found in the "
"current theme - using previous value.";
color = firstColor.value();
return;
}
QColor parsed = {jsonValue.toString()};
if (!parsed.isValid()) [[unlikely]]
if (!fallbackObj.isEmpty())
{
qCWarning(chatterinoTheme).nospace()
<< "While parsing " << key << ": '" << jsonValue.toString()
<< "' isn't a valid color.";
return;
auto fallbackColor = parseColorFrom(fallbackObj, key);
if (fallbackColor.has_value())
{
color = fallbackColor.value();
return;
}
}
color = parsed;
qCWarning(chatterinoTheme) << key
<< "was expected but not found in the "
"current theme, and no fallback value found.";
}
// NOLINTBEGIN(cppcoreguidelines-macro-usage)
#define _c2StringLit(s, ty) s##ty
#define parseColor(to, from, key) \
parseInto(from, _c2StringLit(#key, _L1), (to).from.key)
parseInto(from, from##Fallback, _c2StringLit(#key, _L1), (to).from.key)
// NOLINTEND(cppcoreguidelines-macro-usage)
void parseWindow(const QJsonObject &window, chatterino::Theme &theme)
void parseWindow(const QJsonObject &window, const QJsonObject &windowFallback,
chatterino::Theme &theme)
{
parseColor(theme, window, background);
parseColor(theme, window, text);
}
void parseTabs(const QJsonObject &tabs, chatterino::Theme &theme)
void parseTabs(const QJsonObject &tabs, const QJsonObject &tabsFallback,
chatterino::Theme &theme)
{
const auto parseTabColors = [](const auto &json, auto &tab) {
parseInto(json, "text"_L1, tab.text);
const auto parseTabColors = [](const auto &json, const auto &jsonFallback,
auto &tab) {
parseInto(json, jsonFallback, "text"_L1, tab.text);
{
const auto backgrounds = json["backgrounds"_L1].toObject();
const auto backgroundsFallback =
jsonFallback["backgrounds"_L1].toObject();
parseColor(tab, backgrounds, regular);
parseColor(tab, backgrounds, hover);
parseColor(tab, backgrounds, unfocused);
}
{
const auto line = json["line"_L1].toObject();
const auto lineFallback = jsonFallback["line"_L1].toObject();
parseColor(tab, line, regular);
parseColor(tab, line, hover);
parseColor(tab, line, unfocused);
}
};
parseColor(theme, tabs, dividerLine);
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);
parseColor(theme, tabs, liveIndicator);
parseColor(theme, tabs, rerunIndicator);
parseTabColors(tabs["regular"_L1].toObject(),
tabsFallback["regular"_L1].toObject(), theme.tabs.regular);
parseTabColors(tabs["newMessage"_L1].toObject(),
tabsFallback["newMessage"_L1].toObject(),
theme.tabs.newMessage);
parseTabColors(tabs["highlighted"_L1].toObject(),
tabsFallback["highlighted"_L1].toObject(),
theme.tabs.highlighted);
parseTabColors(tabs["selected"_L1].toObject(),
tabsFallback["selected"_L1].toObject(), theme.tabs.selected);
}
void parseMessages(const QJsonObject &messages, chatterino::Theme &theme)
void parseMessages(const QJsonObject &messages,
const QJsonObject &messagesFallback,
chatterino::Theme &theme)
{
{
const auto textColors = messages["textColors"_L1].toObject();
const auto textColorsFallback =
messagesFallback["textColors"_L1].toObject();
parseColor(theme.messages, textColors, regular);
parseColor(theme.messages, textColors, caret);
parseColor(theme.messages, textColors, link);
@ -90,6 +134,8 @@ void parseMessages(const QJsonObject &messages, chatterino::Theme &theme)
}
{
const auto backgrounds = messages["backgrounds"_L1].toObject();
const auto backgroundsFallback =
messagesFallback["backgrounds"_L1].toObject();
parseColor(theme.messages, backgrounds, regular);
parseColor(theme.messages, backgrounds, alternate);
}
@ -99,14 +145,17 @@ void parseMessages(const QJsonObject &messages, chatterino::Theme &theme)
parseColor(theme, messages, highlightAnimationEnd);
}
void parseScrollbars(const QJsonObject &scrollbars, chatterino::Theme &theme)
void parseScrollbars(const QJsonObject &scrollbars,
const QJsonObject &scrollbarsFallback,
chatterino::Theme &theme)
{
parseColor(theme, scrollbars, background);
parseColor(theme, scrollbars, thumb);
parseColor(theme, scrollbars, thumbSelected);
}
void parseSplits(const QJsonObject &splits, chatterino::Theme &theme)
void parseSplits(const QJsonObject &splits, const QJsonObject &splitsFallback,
chatterino::Theme &theme)
{
parseColor(theme, splits, messageSeperator);
parseColor(theme, splits, background);
@ -119,6 +168,7 @@ void parseSplits(const QJsonObject &splits, chatterino::Theme &theme)
{
const auto header = splits["header"_L1].toObject();
const auto headerFallback = splitsFallback["header"_L1].toObject();
parseColor(theme.splits, header, border);
parseColor(theme.splits, header, focusedBorder);
parseColor(theme.splits, header, background);
@ -128,22 +178,30 @@ void parseSplits(const QJsonObject &splits, chatterino::Theme &theme)
}
{
const auto input = splits["input"_L1].toObject();
const auto inputFallback = splitsFallback["input"_L1].toObject();
parseColor(theme.splits, input, background);
parseColor(theme.splits, input, text);
}
}
void parseColors(const QJsonObject &root, chatterino::Theme &theme)
void parseColors(const QJsonObject &root, const QJsonObject &fallbackTheme,
chatterino::Theme &theme)
{
const auto colors = root["colors"_L1].toObject();
const auto fallbackColors = fallbackTheme["colors"_L1].toObject();
parseInto(colors, "accent"_L1, theme.accent);
parseInto(colors, fallbackColors, "accent"_L1, theme.accent);
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);
parseWindow(colors["window"_L1].toObject(),
fallbackColors["window"_L1].toObject(), theme);
parseTabs(colors["tabs"_L1].toObject(),
fallbackColors["tabs"_L1].toObject(), theme);
parseMessages(colors["messages"_L1].toObject(),
fallbackColors["messages"_L1].toObject(), theme);
parseScrollbars(colors["scrollbars"_L1].toObject(),
fallbackColors["scrollbars"_L1].toObject(), theme);
parseSplits(colors["splits"_L1].toObject(),
fallbackColors["splits"_L1].toObject(), theme);
}
#undef parseColor
#undef _c2StringLit
@ -219,6 +277,11 @@ bool Theme::isLightTheme() const
return this->isLight_;
}
bool Theme::isSystemTheme() const
{
return this->themeName == u"System"_s;
}
void Theme::initialize(Settings &settings, const Paths &paths)
{
this->themeName.connect(
@ -227,15 +290,51 @@ void Theme::initialize(Settings &settings, const Paths &paths)
this->update();
},
false);
auto updateIfSystem = [this](const auto &) {
if (this->isSystemTheme())
{
this->update();
}
};
this->darkSystemThemeName.connect(updateIfSystem, false);
this->lightSystemThemeName.connect(updateIfSystem, false);
this->loadAvailableThemes(paths);
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
QObject::connect(qApp->styleHints(), &QStyleHints::colorSchemeChanged,
&this->lifetime_, [this] {
if (this->isSystemTheme())
{
this->update();
getIApp()->getWindows()->forceLayoutChannelViews();
}
});
#endif
this->update();
}
void Theme::update()
{
auto oTheme = this->findThemeByKey(this->themeName);
auto currentTheme = [&]() -> QString {
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
if (this->isSystemTheme())
{
switch (qApp->styleHints()->colorScheme())
{
case Qt::ColorScheme::Light:
return this->lightSystemThemeName;
case Qt::ColorScheme::Unknown:
case Qt::ColorScheme::Dark:
return this->darkSystemThemeName;
}
}
#endif
return this->themeName;
};
auto oTheme = this->findThemeByKey(currentTheme());
constexpr const double nsToMs = 1.0 / 1000000.0;
QElapsedTimer timer;
@ -243,6 +342,7 @@ void Theme::update()
std::optional<QJsonObject> themeJSON;
QString themePath;
bool isCustomTheme = false;
if (!oTheme)
{
qCWarning(chatterinoTheme)
@ -269,6 +369,10 @@ void Theme::update()
themeJSON = loadTheme(fallbackTheme);
themePath = fallbackTheme.path;
}
else
{
isCustomTheme = theme.custom;
}
}
auto loadTs = double(timer.nsecsElapsed()) * nsToMs;
@ -284,7 +388,7 @@ void Theme::update()
return;
}
this->parseFrom(*themeJSON);
this->parseFrom(*themeJSON, isCustomTheme);
this->currentThemePath_ = themePath;
auto parseTs = double(timer.nsecsElapsed()) * nsToMs;
@ -375,13 +479,30 @@ std::optional<ThemeDescriptor> Theme::findThemeByKey(const QString &key)
return std::nullopt;
}
void Theme::parseFrom(const QJsonObject &root)
void Theme::parseFrom(const QJsonObject &root, bool isCustomTheme)
{
parseColors(root, *this);
this->isLight_ =
root["metadata"_L1]["iconTheme"_L1].toString() == u"dark"_s;
std::optional<QJsonObject> fallbackTheme;
if (isCustomTheme)
{
// Only attempt to load a fallback theme if the theme we're loading is a custom theme
auto fallbackThemeName =
root["metadata"_L1]["fallbackTheme"_L1].toString(
this->isLightTheme() ? "Light" : "Dark");
for (const auto &theme : Theme::builtInThemes)
{
if (fallbackThemeName.compare(theme.key, Qt::CaseInsensitive) == 0)
{
fallbackTheme = loadTheme(theme);
break;
}
}
}
parseColors(root, fallbackTheme.value_or(QJsonObject()), *this);
this->splits.input.styleSheet = uR"(
background: %1;
border: %2;

View file

@ -46,6 +46,7 @@ public:
void initialize(Settings &settings, const Paths &paths) final;
bool isLightTheme() const;
bool isSystemTheme() const;
struct TabColors {
QColor text;
@ -76,6 +77,9 @@ public:
TabColors highlighted;
TabColors selected;
QColor dividerLine;
QColor liveIndicator;
QColor rerunIndicator;
} tabs;
/// MESSAGES
@ -153,6 +157,9 @@ public:
pajlada::Signals::NoArgSignal updated;
QStringSetting themeName{"/appearance/theme/name", "Dark"};
QStringSetting lightSystemThemeName{"/appearance/theme/lightSystem",
"Light"};
QStringSetting darkSystemThemeName{"/appearance/theme/darkSystem", "Dark"};
private:
bool isLight_ = false;
@ -164,6 +171,8 @@ private:
// This will only be populated when auto-reloading themes
QJsonObject currentThemeJson_;
QObject lifetime_;
/**
* Figure out which themes are available in the Themes directory
*
@ -173,7 +182,7 @@ private:
std::optional<ThemeDescriptor> findThemeByKey(const QString &key);
void parseFrom(const QJsonObject &root);
void parseFrom(const QJsonObject &root, bool isCustomTheme);
pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_;

View file

@ -90,7 +90,7 @@ void Updates::installUpdates()
box->exec();
QDesktopServices::openUrl(this->updateGuideLink_);
#elif defined Q_OS_WIN
if (this->paths.isPortable())
if (Modes::instance().isPortable)
{
QMessageBox *box =
new QMessageBox(QMessageBox::Information, "Chatterino Update",

View file

@ -185,8 +185,7 @@ void WindowManager::updateWordTypeMask()
flags.set(MEF::Collapsed);
flags.set(settings->boldUsernames ? MEF::BoldUsername
: MEF::NonBoldUsername);
flags.set(settings->lowercaseDomains ? MEF::LowercaseLink
: MEF::OriginalLink);
flags.set(MEF::LowercaseLinks, settings->lowercaseDomains);
flags.set(MEF::ChannelPointReward);
// update flags

View file

@ -1,10 +1,23 @@
#pragma once
#include <boost/container_hash/hash_fwd.hpp>
#include <QHash>
#include <QString>
#include <functional>
namespace boost {
template <>
struct hash<QString> {
std::size_t operator()(QString const &s) const
{
return qHash(s);
}
};
} // namespace boost
namespace std {
#if QT_VERSION < QT_VERSION_CHECK(5, 14, 0)

View file

@ -5,7 +5,7 @@
namespace chatterino {
static auto defaultItemFlags(bool selectable)
inline auto defaultItemFlags(bool selectable)
{
return Qt::ItemIsEnabled |
(selectable ? Qt::ItemIsSelectable | Qt::ItemIsDragEnabled |
@ -13,7 +13,7 @@ static auto defaultItemFlags(bool selectable)
: Qt::ItemFlag());
}
static void setBoolItem(QStandardItem *item, bool value,
inline void setBoolItem(QStandardItem *item, bool value,
bool userCheckable = true, bool selectable = true)
{
item->setFlags(
@ -22,7 +22,7 @@ static void setBoolItem(QStandardItem *item, bool value,
item->setCheckState(value ? Qt::Checked : Qt::Unchecked);
}
static void setStringItem(QStandardItem *item, const QString &value,
inline void setStringItem(QStandardItem *item, const QString &value,
bool editable = true, bool selectable = true)
{
item->setData(value, Qt::EditRole);
@ -30,7 +30,7 @@ static void setStringItem(QStandardItem *item, const QString &value,
(editable ? (Qt::ItemIsEditable) : 0)));
}
static void setFilePathItem(QStandardItem *item, const QUrl &value,
inline void setFilePathItem(QStandardItem *item, const QUrl &value,
bool selectable = true)
{
item->setData(value, Qt::UserRole);
@ -40,7 +40,7 @@ static void setFilePathItem(QStandardItem *item, const QUrl &value,
(selectable ? Qt::ItemIsSelectable : Qt::NoItemFlags)));
}
static void setColorItem(QStandardItem *item, const QColor &value,
inline void setColorItem(QStandardItem *item, const QColor &value,
bool selectable = true)
{
item->setData(value, Qt::DecorationRole);
@ -49,7 +49,7 @@ static void setColorItem(QStandardItem *item, const QColor &value,
(selectable ? Qt::ItemIsSelectable : Qt::NoItemFlags)));
}
static QStandardItem *emptyItem()
inline QStandardItem *emptyItem()
{
auto *item = new QStandardItem();
item->setFlags(Qt::ItemFlags());

View file

@ -10,11 +10,11 @@ namespace chatterino {
// Debug-class which asserts if guard of the same object has been called from different threads
struct ThreadGuard {
#ifndef NDEBUG
std::mutex mutex;
std::optional<std::thread::id> threadID;
mutable std::mutex mutex;
mutable std::optional<std::thread::id> threadID;
#endif
inline void guard()
inline void guard() const
{
#ifndef NDEBUG
std::unique_lock lock(this->mutex);

View file

@ -1,6 +1,7 @@
#include "widgets/BaseWindow.hpp"
#include "Application.hpp"
#include "common/QLogging.hpp"
#include "singletons/Settings.hpp"
#include "singletons/Theme.hpp"
#include "singletons/WindowManager.hpp"
@ -200,44 +201,76 @@ void BaseWindow::init()
}
// DPI
// auto dpi = getWindowDpi(this->winId());
// auto dpi = getWindowDpi(this->safeHWND());
// if (dpi) {
// this->scale = dpi.value() / 96.f;
// }
#endif
#ifdef USEWINSDK
// fourtf: don't ask me why we need to delay this
if (!this->flags_.has(TopMost))
{
QTimer::singleShot(1, this, [this] {
getSettings()->windowTopMost.connect(
[this](bool topMost, auto) {
::SetWindowPos(HWND(this->winId()),
topMost ? HWND_TOPMOST : HWND_NOTOPMOST, 0,
0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
},
this->connections_);
});
}
#else
// TopMost flag overrides setting
if (!this->flags_.has(TopMost))
{
getSettings()->windowTopMost.connect(
[this](bool topMost, auto) {
auto isVisible = this->isVisible();
this->setWindowFlag(Qt::WindowStaysOnTopHint, topMost);
if (isVisible)
{
this->show();
}
[this](bool topMost) {
this->setTopMost(topMost);
},
this->connections_);
}
}
void BaseWindow::setTopMost(bool topMost)
{
if (this->flags_.has(TopMost))
{
qCWarning(chatterinoWidget)
<< "Called setTopMost on a window with the `TopMost` flag set.";
return;
}
if (this->isTopMost_ == topMost)
{
return;
}
this->isTopMost_ = topMost;
#ifdef USEWINSDK
if (!this->waitingForTopMost_)
{
this->tryApplyTopMost();
}
#else
auto isVisible = this->isVisible();
this->setWindowFlag(Qt::WindowStaysOnTopHint, topMost);
if (isVisible)
{
this->show();
}
#endif
this->topMostChanged(this->isTopMost_);
}
#ifdef USEWINSDK
void BaseWindow::tryApplyTopMost()
{
auto hwnd = this->safeHWND();
if (!hwnd)
{
this->waitingForTopMost_ = true;
QTimer::singleShot(50, this, &BaseWindow::tryApplyTopMost);
return;
}
this->waitingForTopMost_ = false;
::SetWindowPos(*hwnd, this->isTopMost_ ? HWND_TOPMOST : HWND_NOTOPMOST, 0,
0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}
#endif
bool BaseWindow::isTopMost() const
{
return this->isTopMost_ || this->flags_.has(TopMost);
}
void BaseWindow::setActionOnFocusLoss(ActionOnFocusLoss value)
@ -475,12 +508,15 @@ void BaseWindow::changeEvent(QEvent *)
if (this->isVisible() && this->hasCustomWindowFrame())
{
auto palette = this->palette();
palette.setColor(QPalette::Window,
GetForegroundWindow() == HWND(this->winId())
? QColor(90, 90, 90)
: QColor(50, 50, 50));
this->setPalette(palette);
auto hwnd = this->safeHWND();
if (hwnd)
{
auto palette = this->palette();
palette.setColor(QPalette::Window, GetForegroundWindow() == *hwnd
? QColor(90, 90, 90)
: QColor(50, 50, 50));
this->setPalette(palette);
}
}
#endif
@ -495,14 +531,29 @@ void BaseWindow::leaveEvent(QEvent *)
void BaseWindow::moveTo(QPoint point, widgets::BoundsChecking mode)
{
this->lastBoundsCheckPosition_ = point;
this->lastBoundsCheckMode_ = mode;
widgets::moveWindowTo(this, point, mode);
}
void BaseWindow::showAndMoveTo(QPoint point, widgets::BoundsChecking mode)
{
this->lastBoundsCheckPosition_ = point;
this->lastBoundsCheckMode_ = mode;
widgets::showAndMoveWindowTo(this, point, mode);
}
bool BaseWindow::applyLastBoundsCheck()
{
if (this->lastBoundsCheckMode_ == widgets::BoundsChecking::Off)
{
return false;
}
this->moveTo(this->lastBoundsCheckPosition_, this->lastBoundsCheckMode_);
return true;
}
void BaseWindow::resizeEvent(QResizeEvent *)
{
// Queue up save because: Window resized
@ -516,14 +567,18 @@ void BaseWindow::resizeEvent(QResizeEvent *)
{
this->isResizeFixing_ = true;
QTimer::singleShot(50, this, [this] {
auto hwnd = this->safeHWND();
if (!hwnd)
{
this->isResizeFixing_ = false;
return;
}
RECT rect;
::GetWindowRect((HWND)this->winId(), &rect);
::SetWindowPos((HWND)this->winId(), nullptr, 0, 0,
rect.right - rect.left + 1, rect.bottom - rect.top,
SWP_NOMOVE | SWP_NOZORDER);
::SetWindowPos((HWND)this->winId(), nullptr, 0, 0,
rect.right - rect.left, rect.bottom - rect.top,
SWP_NOMOVE | SWP_NOZORDER);
::GetWindowRect(*hwnd, &rect);
::SetWindowPos(*hwnd, nullptr, 0, 0, rect.right - rect.left + 1,
rect.bottom - rect.top, SWP_NOMOVE | SWP_NOZORDER);
::SetWindowPos(*hwnd, nullptr, 0, 0, rect.right - rect.left,
rect.bottom - rect.top, SWP_NOMOVE | SWP_NOZORDER);
QTimer::singleShot(10, this, [this] {
this->isResizeFixing_ = false;
});
@ -559,6 +614,16 @@ void BaseWindow::showEvent(QShowEvent *)
{
this->moveTo(this->pos(), widgets::BoundsChecking::CursorPosition);
}
if (!this->flags_.has(TopMost))
{
QTimer::singleShot(1, this, [this] {
if (!this->waitingForTopMost_)
{
this->tryApplyTopMost();
}
});
}
#endif
}
@ -628,7 +693,7 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message,
long y = GET_Y_LPARAM(msg->lParam);
RECT winrect;
GetWindowRect(HWND(winId()), &winrect);
GetWindowRect(msg->hwnd, &winrect);
QPoint globalPos(x, y);
this->ui_.titlebarButtons->hover(msg->wParam, globalPos);
this->lastEventWasNcMouseMove_ = true;
@ -679,7 +744,7 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message,
long y = GET_Y_LPARAM(msg->lParam);
RECT winrect;
GetWindowRect(HWND(winId()), &winrect);
GetWindowRect(msg->hwnd, &winrect);
QPoint globalPos(x, y);
if (msg->message == WM_NCLBUTTONDOWN)
{
@ -827,7 +892,7 @@ bool BaseWindow::handleSHOWWINDOW(MSG *msg)
{
// disable OS window border
const MARGINS margins = {-1};
DwmExtendFrameIntoClientArea(HWND(this->winId()), &margins);
DwmExtendFrameIntoClientArea(msg->hwnd, &margins);
}
if (!this->initalBounds_.isNull())
@ -890,8 +955,8 @@ bool BaseWindow::handleSIZE(MSG *msg)
{
if (msg->wParam == SIZE_MAXIMIZED)
{
auto offset = int(
getWindowDpi(HWND(this->winId())).value_or(96) * 8 / 96);
auto offset =
int(getWindowDpi(msg->hwnd).value_or(96) * 8 / 96);
this->ui_.windowLayout->setContentsMargins(offset, offset,
offset, offset);
@ -912,6 +977,13 @@ bool BaseWindow::handleSIZE(MSG *msg)
QPoint(rect.right - 1, rect.bottom - 1));
}
this->useNextBounds_.stop();
if (msg->wParam == SIZE_MINIMIZED && this->ui_.titlebarButtons)
{
// Windows doesn't send a WM_NCMOUSELEAVE event when clicking
// the minimize button, so we have to emulate it.
this->ui_.titlebarButtons->leave();
}
}
}
return false;
@ -945,7 +1017,7 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result)
#ifdef USEWINSDK
const LONG border_width = 8; // in pixels
RECT winrect;
GetWindowRect(HWND(winId()), &winrect);
GetWindowRect(msg->hwnd, &winrect);
long x = GET_X_LPARAM(msg->lParam);
long y = GET_Y_LPARAM(msg->lParam);
@ -1124,4 +1196,15 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result)
#endif
}
#ifdef USEWINSDK
std::optional<HWND> BaseWindow::safeHWND() const
{
if (!this->testAttribute(Qt::WA_WState_Created))
{
return std::nullopt;
}
return reinterpret_cast<HWND>(this->winId());
}
#endif
} // namespace chatterino

View file

@ -65,13 +65,32 @@ public:
**/
void showAndMoveTo(QPoint point, widgets::BoundsChecking mode);
/// @brief Applies the last moveTo operation if that one was bounds-checked
///
/// If there was a previous moveTo or showAndMoveTo operation with a mode
/// other than `Off`, a moveTo is repeated with the last supplied @a point
/// and @a mode. Note that in the case of showAndMoveTo, moveTo is run.
///
/// @returns true if there was a previous bounds-checked moveTo operation
bool applyLastBoundsCheck();
float scale() const override;
float qtFontScale() const;
/// @returns true if the window is the top-most window.
/// Either #setTopMost was called or the `TopMost` flag is set which overrides this
bool isTopMost() const;
/// Updates the window's top-most status
/// If the `TopMost` flag is set, this is a no-op
void setTopMost(bool topMost);
pajlada::Signals::NoArgSignal closing;
static bool supportsCustomWindowFrame();
signals:
void topMostChanged(bool topMost);
protected:
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
bool nativeEvent(const QByteArray &eventType, void *message,
@ -131,6 +150,7 @@ private:
FlagsEnum<Flags> flags_;
float nativeScale_ = 1;
bool isResizeFixing_ = false;
bool isTopMost_ = false;
struct {
QLayout *windowLayout = nullptr;
@ -141,7 +161,31 @@ private:
std::vector<Button *> buttons;
} ui_;
/// The last @a pos from moveTo and showAndMoveTo
QPoint lastBoundsCheckPosition_;
/// The last @a mode from moveTo and showAndMoveTo
widgets::BoundsChecking lastBoundsCheckMode_ = widgets::BoundsChecking::Off;
#ifdef USEWINSDK
/// @brief Returns the HWND of this window if it has one
///
/// A QWidget only has an HWND if it has been created. Before that,
/// accessing `winID()` will create the window which can lead to unintended
/// bugs.
std::optional<HWND> safeHWND() const;
/// @brief Tries to apply the `isTopMost_` setting
///
/// If the setting couldn't be applied (because the window wasn't created
/// yet), the operation is repeated after a short delay.
///
/// @pre When calling from outside this method, `waitingForTopMost_` must
/// be `false` to avoid too many pending calls.
/// @post If an operation was queued to be executed after some delay,
/// `waitingForTopMost_` will be set to `true`.
void tryApplyTopMost();
bool waitingForTopMost_ = false;
QRect initalBounds_;
QRect currentBounds_;
QRect nextBounds_;

View file

@ -59,6 +59,28 @@ Notebook::Notebook(QWidget *parent)
});
this->updateTabVisibilityMenuAction();
this->toggleTopMostAction_ = new QAction("Top most window", this);
this->toggleTopMostAction_->setCheckable(true);
auto *window = dynamic_cast<BaseWindow *>(this->window());
if (window)
{
auto updateTopMost = [this, window] {
this->toggleTopMostAction_->setChecked(window->isTopMost());
};
updateTopMost();
QObject::connect(this->toggleTopMostAction_, &QAction::triggered,
window, [window] {
window->setTopMost(!window->isTopMost());
});
QObject::connect(window, &BaseWindow::topMostChanged, this,
updateTopMost);
}
else
{
qCWarning(chatterinoApp)
<< "Notebook must be created within a BaseWindow";
}
this->addNotebookActionsToMenu(&this->menu_);
// Manually resize the add button so the initial paint uses the correct
@ -1181,6 +1203,8 @@ void Notebook::addNotebookActionsToMenu(QMenu *menu)
menu->addAction(this->showTabsAction_);
menu->addAction(this->lockNotebookLayoutAction_);
menu->addAction(this->toggleTopMostAction_);
}
NotebookButton *Notebook::getAddButton()

View file

@ -196,6 +196,7 @@ private:
NotebookTabLocation tabLocation_ = NotebookTabLocation::Top;
QAction *lockNotebookLayoutAction_;
QAction *showTabsAction_;
QAction *toggleTopMostAction_;
// This filter, if set, is used to figure out the visibility of
// the tabs in this notebook.
@ -224,7 +225,6 @@ private:
// Main window on Windows has basically a duplicate of this in Window
NotebookButton *streamerModeIcon_{};
void updateStreamerModeIcon();
};

View file

@ -90,6 +90,7 @@ TooltipWidget::TooltipWidget(BaseWidget *parent)
if (needSizeAdjustment)
{
this->adjustSize();
this->applyLastBoundsCheck();
}
});
}

View file

@ -19,7 +19,8 @@
#include "messages/MessageElement.hpp"
#include "messages/MessageThread.hpp"
#include "providers/colors/ColorProvider.hpp"
#include "providers/LinkResolver.hpp"
#include "providers/links/LinkInfo.hpp"
#include "providers/links/LinkResolver.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
@ -45,6 +46,7 @@
#include "widgets/TooltipWidget.hpp"
#include "widgets/Window.hpp"
#include <magic_enum/magic_enum_flags.hpp>
#include <QClipboard>
#include <QColor>
#include <QDate>
@ -52,6 +54,7 @@
#include <QDesktopServices>
#include <QEasingCurve>
#include <QGraphicsBlurEffect>
#include <QJsonDocument>
#include <QMessageBox>
#include <QPainter>
#include <QScreen>
@ -244,6 +247,30 @@ void addHiddenContextMenuItems(QMenu *menu,
crossPlatformCopy(messageID);
});
}
const auto *message = layout->getMessage();
if (message != nullptr)
{
QJsonDocument jsonDocument;
QJsonObject jsonObject;
jsonObject["id"] = message->id;
jsonObject["searchText"] = message->searchText;
jsonObject["messageText"] = message->messageText;
jsonObject["flags"] = QString::fromStdString(
magic_enum::enum_flags_name(message->flags.value()));
jsonDocument.setObject(jsonObject);
auto jsonString =
jsonDocument.toJson(QJsonDocument::JsonFormat::Indented);
menu->addAction("Copy message &JSON", [jsonString] {
crossPlatformCopy(jsonString);
});
}
}
// Current function: https://www.desmos.com/calculator/vdyamchjwh
@ -1911,55 +1938,16 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event)
}
else
{
if (element->getTooltip() == "No link info loaded")
{
std::weak_ptr<MessageLayout> weakLayout = layout;
LinkResolver::getLinkInfo(
element->getLink().value, nullptr,
[weakLayout, element](QString tooltipText,
Link originalLink,
ImagePtr thumbnail) {
auto shared = weakLayout.lock();
if (!shared)
{
return;
}
element->setTooltip(tooltipText);
element->setThumbnail(thumbnail);
});
}
auto thumbnailSize = getSettings()->thumbnailSize;
if (thumbnailSize == 0)
auto *linkElement = dynamic_cast<LinkElement *>(element);
if (linkElement)
{
// "Show thumbnails" is set to "Off", show text only
this->tooltipWidget_->setOne({nullptr, element->getTooltip()});
}
else
{
const auto shouldHideThumbnail =
isInStreamerMode() &&
getSettings()->streamerModeHideLinkThumbnails &&
element->getThumbnail() != nullptr &&
!element->getThumbnail()->url().string.isEmpty();
auto thumb =
shouldHideThumbnail
? Image::fromResourcePixmap(getResources().streamerMode)
: element->getThumbnail();
if (element->getThumbnailType() ==
MessageElement::ThumbnailType::Link_Thumbnail)
if (linkElement->linkInfo()->isPending())
{
this->tooltipWidget_->setOne({
std::move(thumb),
element->getTooltip(),
thumbnailSize,
thumbnailSize,
});
}
else
{
this->tooltipWidget_->setOne({std::move(thumb), ""});
getIApp()->getLinkResolver()->resolve(
linkElement->linkInfo());
}
this->setLinkInfoTooltip(linkElement->linkInfo());
}
}
@ -3077,4 +3065,63 @@ bool ChannelView::canReplyToMessages() const
return true;
}
void ChannelView::setLinkInfoTooltip(LinkInfo *info)
{
assert(info);
auto thumbnailSize = getSettings()->thumbnailSize;
ImagePtr thumbnail;
if (info->hasThumbnail() && thumbnailSize > 0)
{
if (isInStreamerMode() && getSettings()->streamerModeHideLinkThumbnails)
{
thumbnail = Image::fromResourcePixmap(getResources().streamerMode);
}
else
{
thumbnail = info->thumbnail();
}
}
this->tooltipWidget_->setOne({
.image = thumbnail,
.text = info->tooltip(),
.customWidth = thumbnailSize,
.customHeight = thumbnailSize,
});
if (info->isLoaded())
{
this->pendingLinkInfo_.clear();
return; // Either resolved or errored (can't change anymore)
}
// listen to changes
if (this->pendingLinkInfo_.data() == info)
{
return; // same info - already registered
}
if (this->pendingLinkInfo_)
{
QObject::disconnect(this->pendingLinkInfo_.data(),
&LinkInfo::stateChanged, this, nullptr);
}
QObject::connect(info, &LinkInfo::stateChanged, this,
&ChannelView::pendingLinkInfoStateChanged);
this->pendingLinkInfo_ = info;
}
void ChannelView::pendingLinkInfoStateChanged()
{
if (!this->pendingLinkInfo_)
{
return;
}
this->setLinkInfoTooltip(this->pendingLinkInfo_.data());
this->tooltipWidget_->applyLastBoundsCheck();
}
} // namespace chatterino

View file

@ -12,6 +12,7 @@
#include <pajlada/signals/signal.hpp>
#include <QMenu>
#include <QPaintEvent>
#include <QPointer>
#include <QScroller>
#include <QTimer>
#include <QVariantAnimation>
@ -47,6 +48,8 @@ class Split;
class FilterSet;
using FilterSetPtr = std::shared_ptr<FilterSet>;
class LinkInfo;
enum class PauseReason {
Mouse,
Selection,
@ -366,6 +369,17 @@ private:
void scrollUpdateRequested();
TooltipWidget *const tooltipWidget_{};
/// Pointer to a link info that hasn't loaded yet
QPointer<LinkInfo> pendingLinkInfo_;
/// @brief Sets the tooltip to contain the link info
///
/// If the info isn't loaded yet, it's tracked until it's resolved or errored.
void setLinkInfoTooltip(LinkInfo *info);
/// Slot for the LinkInfo::stateChanged signal.
void pendingLinkInfoStateChanged();
};
} // namespace chatterino

View file

@ -327,6 +327,18 @@ void NotebookTab::setTabLocation(NotebookTabLocation location)
}
}
bool NotebookTab::setRerun(bool isRerun)
{
if (this->isRerun_ != isRerun)
{
this->isRerun_ = isRerun;
this->update();
return true;
}
return false;
}
bool NotebookTab::setLive(bool isLive)
{
if (this->isLive_ != isLive)
@ -514,12 +526,22 @@ void NotebookTab::paintEvent(QPaintEvent *)
painter.fillRect(lineRect, lineColor);
// draw live indicator
if (this->isLive_ && getSettings()->showTabLive)
if ((this->isLive_ || this->isRerun_) && getSettings()->showTabLive)
{
painter.setPen(QColor(Qt::GlobalColor::red));
painter.setRenderHint(QPainter::Antialiasing);
// Live overrides rerun
QBrush b;
b.setColor(QColor(Qt::GlobalColor::red));
if (this->isLive_)
{
painter.setPen(this->theme->tabs.liveIndicator);
b.setColor(this->theme->tabs.liveIndicator);
}
else
{
painter.setPen(this->theme->tabs.rerunIndicator);
b.setColor(this->theme->tabs.rerunIndicator);
}
painter.setRenderHint(QPainter::Antialiasing);
b.setStyle(Qt::SolidPattern);
painter.setBrush(b);

Some files were not shown because too many files have changed in this diff Show more