diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..8ac1ca177 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Suggestions or feature request + url: https://github.com/chatterino/chatterino2/discussions/categories/ideas + about: Got something you think should change or be added? Search for or start a new discussion! + - name: Help + url: https://github.com/chatterino/chatterino2/discussions/categories/q-a + about: Chatterino2 not working as you'd expect? Not sure it's a bug? Check the Q&A section! diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f669847..1c62bf8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,34 @@ ## Unversioned +- Minor: Remove TwitchEmotes.com attribution and the open/copy options when right-clicking a Twitch Emote. (#2214, #3136) +- Minor: Strip leading @ and trailing , from username in /user and /usercard commands. (#3143) +- Minor: Display a system message when reloading subscription emotes to match BTTV/FFZ behavior (#3135) +- Bugfix: Moderation mode and active filters are now preserved when opening a split as a popup. (#3113, #3130) +- Bugfix: Fixed a bug that caused all badge highlights to use the same color. (#3132, #3134) +- Dev: Renamed CMake's build option `USE_SYSTEM_QT5KEYCHAIN` to `USE_SYSTEM_QTKEYCHAIN`. (#3103) +- Dev: Add benchmarks that can be compiled with the `BUILD_BENCHMARKS` CMake flag. Off by default. (#3038) + +## 2.3.4 + - Major: Newly uploaded Twitch emotes are once again present in emote picker and can be autocompleted with Tab as well. (#2992) +- Major: Deprecated `/(un)follow` commands and (un)following in the usercards as Twitch has removed this feature for 3rd party applications. (#3076, #3078) - Major: Added the ability to add nicknames for users. (#137, #2981) +- Major: Fixed constant disconnections with more than 20 channels by rate-limiting outgoing JOIN messages. (#3112, #3115) - Minor: Added autocompletion in /whispers for Twitch emotes, Global Bttv/Ffz emotes and emojis. (#2999, #3033) - Minor: Received Twitch messages now use the exact same timestamp (obtained from Twitch's server) for every Chatterino user instead of assuming message timestamp on client's side. (#3021) - Minor: Received IRC messages use `time` message tag for timestamp if it's available. (#3021) - Minor: Added informative messages for recent-messages API's errors. (#3029) - Minor: Added section with helpful Chatterino-related links to the About page. (#3068) +- Minor: Now uses spaces instead of magic Unicode character for sending duplicate messages (#3081) +- Minor: Added `channel.live` filter variable (#3092, #3110) - Bugfix: Fixed "smiley" emotes being unable to be "Tabbed" with autocompletion, introduced in v2.3.3. (#3010) - Bugfix: Fixed PubSub not properly trying to resolve pending listens when the pending listens list was larger than 50. (#3037) - Bugfix: Copy buttons in usercard now show properly in light mode (#3057) - Bugfix: Fixed comma appended to username completion when not at the beginning of the message. (#3060) - Bugfix: Fixed bug misplacing chat when zooming on Chrome with Chatterino Native Host extension (#1936) +- Bugfix: Channel point redemptions from ignored users are now properly blocked. (#3102) +- Dev: Allow building against Qt 5.11 (#3105) - Dev: Ubuntu packages are now available (#2936) - Dev: Disabled update checker on Flatpak. (#3051) - Dev: Add logging for HTTP requests (#2991) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4684868b7..6b5e7a470 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,17 +7,25 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/sanitizers-cmake/cmake" ) -project(chatterino VERSION 2.3.3) +project(chatterino VERSION 2.3.4) option(BUILD_APP "Build Chatterino" ON) option(BUILD_TESTS "Build the tests for Chatterino" OFF) +option(BUILD_BENCHMARKS "Build the benchmarks for Chatterino" OFF) option(USE_SYSTEM_PAJLADA_SETTINGS "Use system pajlada settings library" OFF) option(USE_SYSTEM_LIBCOMMUNI "Use system communi library" OFF) -option(USE_SYSTEM_QT6KEYCHAIN "Use system Qt6Keychain library" OFF) +option(USE_SYSTEM_QTKEYCHAIN "Use system QtKeychain library" OFF) option(USE_PRECOMPILED_HEADERS "Use precompiled headers" ON) +option(BUILD_WITH_QT6 "Use Qt6 instead of default Qt5" OFF) option(USE_CONAN "Use conan" OFF) +if (BUILD_WITH_QT6) + set(MAJOR_QT_VERSION "6") +else() + set(MAJOR_QT_VERSION "5") +endif() + if (USE_CONAN OR CONAN_EXPORTED) include(${CMAKE_CURRENT_BINARY_DIR}/conanbuildinfo.cmake) conan_basic_setup(TARGETS NO_OUTPUT_DIRS) @@ -31,7 +39,7 @@ endif () include(${CMAKE_CURRENT_LIST_DIR}/cmake/GIT.cmake) -find_package(Qt6 REQUIRED +find_package(Qt${MAJOR_QT_VERSION} REQUIRED COMPONENTS Core Widgets @@ -72,17 +80,17 @@ endif() # Link QtKeychain statically option(QTKEYCHAIN_STATIC "" ON) -if (USE_SYSTEM_QT6KEYCHAIN) - find_package(Qt6Keychain REQUIRED) +if (USE_SYSTEM_QTKEYCHAIN) + find_package(Qt${MAJOR_QT_VERSION}Keychain REQUIRED) else() - set(QT6KEYCHAIN_ROOT_LIB_FOLDER "${CMAKE_SOURCE_DIR}/lib/qtkeychain") - if (NOT EXISTS "${QT6KEYCHAIN_ROOT_LIB_FOLDER}/CMakeLists.txt") + set(QTKEYCHAIN_ROOT_LIB_FOLDER "${CMAKE_SOURCE_DIR}/lib/qtkeychain") + if (NOT EXISTS "${QTKEYCHAIN_ROOT_LIB_FOLDER}/CMakeLists.txt") message(FATAL_ERROR "Submodules probably not loaded, unable to find lib/qtkeychain/CMakeLists.txt") endif() - add_subdirectory("${QT6KEYCHAIN_ROOT_LIB_FOLDER}" EXCLUDE_FROM_ALL) - if (NOT TARGET qt6keychain) - message(FATAL_ERROR "qt6keychain target was not created :@") + add_subdirectory("${QTKEYCHAIN_ROOT_LIB_FOLDER}" EXCLUDE_FROM_ALL) + if (NOT TARGET qt${MAJOR_QT_VERSION}keychain) + message(FATAL_ERROR "qt${MAJOR_QT_VERSION}keychain target was not created :@") endif() endif() @@ -94,6 +102,11 @@ if (BUILD_TESTS) find_package(GTest REQUIRED) endif () +if (BUILD_BENCHMARKS) + # Include system benchmark (Google Benchmark) + find_package(benchmark REQUIRED) +endif () + find_package(PajladaSerialize REQUIRED) find_package(PajladaSignals REQUIRED) find_package(LRUCache REQUIRED) @@ -111,7 +124,7 @@ endif() set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -if (BUILD_TESTS) +if (BUILD_TESTS OR BUILD_BENCHMARKS) add_definitions(-DCHATTERINO_TEST) endif () @@ -122,4 +135,8 @@ if (BUILD_TESTS) add_subdirectory(tests) endif () +if (BUILD_BENCHMARKS) + add_subdirectory(benchmarks) +endif () + feature_summary(WHAT ALL) diff --git a/benchmarks/.clang-format b/benchmarks/.clang-format new file mode 100644 index 000000000..f34c1465b --- /dev/null +++ b/benchmarks/.clang-format @@ -0,0 +1,35 @@ +Language: Cpp + +AccessModifierOffset: -4 +AlignEscapedNewlinesLeft: true +AllowShortFunctionsOnASingleLine: false +AllowShortIfStatementsOnASingleLine: false +AllowShortLambdasOnASingleLine: Empty +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: false +AlwaysBreakBeforeMultilineStrings: false +BasedOnStyle: Google +BraceWrapping: { + AfterClass: 'true' + AfterControlStatement: 'true' + AfterFunction: 'true' + AfterNamespace: 'false' + BeforeCatch: 'true' + BeforeElse: 'true' +} +BreakBeforeBraces: Custom +BreakConstructorInitializersBeforeComma: true +ColumnLimit: 80 +ConstructorInitializerAllOnOneLineOrOnePerLine: false +DerivePointerBinding: false +FixNamespaceComments: true +IndentCaseLabels: true +IndentWidth: 4 +IndentWrappedFunctionNames: true +IndentPPDirectives: AfterHash +IncludeBlocks: Preserve +NamespaceIndentation: Inner +PointerBindsToType: false +SpacesBeforeTrailingComments: 2 +Standard: Auto +ReflowComments: false diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt new file mode 100644 index 000000000..6435f1398 --- /dev/null +++ b/benchmarks/CMakeLists.txt @@ -0,0 +1,28 @@ +project(chatterino-benchmark) + +set(benchmark_SOURCES + ${CMAKE_CURRENT_LIST_DIR}/src/main.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Emojis.cpp + # Add your new file above this line! + ) + +add_executable(${PROJECT_NAME} ${benchmark_SOURCES}) +add_sanitizers(${PROJECT_NAME}) + +target_link_libraries(${PROJECT_NAME} PRIVATE chatterino-lib) + +target_link_libraries(${PROJECT_NAME} PRIVATE benchmark::benchmark) + +target_compile_definitions(${PROJECT_NAME} PRIVATE + CHATTERINO_TEST + ) + +set_target_properties(${PROJECT_NAME} + PROPERTIES + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin" + ) diff --git a/benchmarks/src/Emojis.cpp b/benchmarks/src/Emojis.cpp new file mode 100644 index 000000000..7eb5106e3 --- /dev/null +++ b/benchmarks/src/Emojis.cpp @@ -0,0 +1,57 @@ +#include "providers/emoji/Emojis.hpp" + +#include +#include +#include + +using namespace chatterino; + +static void BM_ShortcodeParsing(benchmark::State &state) +{ + Emojis emojis; + + emojis.load(); + + struct TestCase { + QString input; + QString expectedOutput; + }; + + std::vector tests{ + { + // input + "foo :penguin: bar", + // expected output + "foo 🐧 bar", + }, + { + // input + "foo :nonexistantcode: bar", + // expected output + "foo :nonexistantcode: bar", + }, + { + // input + ":male-doctor:", + // expected output + "👨‍⚕️", + }, + }; + + for (auto _ : state) + { + for (const auto &test : tests) + { + auto output = emojis.replaceShortCodes(test.input); + + auto matches = output == test.expectedOutput; + if (!matches && !output.endsWith(QChar(0xFE0F))) + { + // Try to append 0xFE0F if needed + output = output.append(QChar(0xFE0F)); + } + } + } +} + +BENCHMARK(BM_ShortcodeParsing); diff --git a/benchmarks/src/main.cpp b/benchmarks/src/main.cpp new file mode 100644 index 000000000..501b3aa51 --- /dev/null +++ b/benchmarks/src/main.cpp @@ -0,0 +1,18 @@ +#include +#include +#include + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + + ::benchmark::Initialize(&argc, argv); + + QtConcurrent::run([&app] { + ::benchmark::RunSpecifiedBenchmarks(); + + app.exit(0); + }); + + return app.exec(); +} diff --git a/chatterino.pro b/chatterino.pro index b858cec90..2586ac790 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -14,7 +14,7 @@ CCACHE_BIN = $$system(which ccache) CONFIG+=ccache } -MINIMUM_REQUIRED_QT_VERSION = 5.12.0 +MINIMUM_REQUIRED_QT_VERSION = 5.11.0 !versionAtLeast(QT_VERSION, $$MINIMUM_REQUIRED_QT_VERSION) { error("You're trying to compile with Qt $$QT_VERSION, but minimum required Qt version is $$MINIMUM_REQUIRED_QT_VERSION") @@ -159,6 +159,7 @@ SOURCES += \ src/controllers/highlights/HighlightModel.cpp \ src/controllers/highlights/HighlightPhrase.cpp \ src/controllers/highlights/UserHighlightModel.cpp \ + src/controllers/ignores/IgnoreController.cpp \ src/controllers/ignores/IgnoreModel.cpp \ src/controllers/moderationactions/ModerationAction.cpp \ src/controllers/moderationactions/ModerationActionModel.cpp \ @@ -251,6 +252,7 @@ SOURCES += \ src/util/LayoutHelper.cpp \ src/util/NuulsUploader.cpp \ src/util/RapidjsonHelpers.cpp \ + src/util/RatelimitBucket.cpp \ src/util/SplitCommand.cpp \ src/util/StreamerMode.cpp \ src/util/StreamLink.cpp \ @@ -508,6 +510,7 @@ HEADERS += \ src/util/rangealgorithm.hpp \ src/util/RapidjsonHelpers.hpp \ src/util/RapidJsonSerializeQString.hpp \ + src/util/RatelimitBucket.hpp \ src/util/RemoveScrollAreaBackground.hpp \ src/util/SampleCheerMessages.hpp \ src/util/SampleLinks.hpp \ @@ -582,6 +585,7 @@ HEADERS += \ src/widgets/settingspages/IgnoresPage.hpp \ src/widgets/settingspages/KeyboardSettingsPage.hpp \ src/widgets/settingspages/ModerationPage.hpp \ + src/widgets/settingspages/NicknamesPage.hpp \ src/widgets/settingspages/NotificationPage.hpp \ src/widgets/settingspages/SettingsPage.hpp \ src/widgets/splits/ClosedSplits.hpp \ diff --git a/docs/test-and-benchmark.md b/docs/test-and-benchmark.md new file mode 100644 index 000000000..e881db6be --- /dev/null +++ b/docs/test-and-benchmark.md @@ -0,0 +1,88 @@ +# Test and Benchmark + +Chatterino includes a set of unit tests and benchmarks. These can be built using cmake by adding the `-DBUILD_TESTS=On` and `-DBUILD_BENCHMARKS=On` flags respectively. + +## Adding your own test + +1. Create a new file for the file you're adding tests for. If you're creating tests for `src/providers/emoji/Emojis.cpp`, create `tests/src/Emojis.cpp`. +2. Add the newly created file to `tests/CMakeLists.txt` in the `test_SOURCES` variable (see the comment near it) + +See `tests/src/Emojis.cpp` for simple tests you can base your tests off of. + +Read up on http://google.github.io/googletest/primer.html to figure out how GoogleTest works. + +## Building and running tests + +```sh +mkdir build-tests +cd build-tests +cmake -DBUILD_TESTS=On .. +make +./bin/chatterino-test +``` + +### Example output + +``` +[==========] Running 26 tests from 8 test suites. +[----------] Global test environment set-up. +[----------] 2 tests from AccessGuardLocker +[ RUN ] AccessGuardLocker.NonConcurrentUsage +[ OK ] AccessGuardLocker.NonConcurrentUsage (0 ms) +[ RUN ] AccessGuardLocker.ConcurrentUsage +[ OK ] AccessGuardLocker.ConcurrentUsage (686 ms) +[----------] 2 tests from AccessGuardLocker (686 ms total) + +[----------] 4 tests from NetworkCommon +[ RUN ] NetworkCommon.parseHeaderList1 +[ OK ] NetworkCommon.parseHeaderList1 (0 ms) +[ RUN ] NetworkCommon.parseHeaderListTrimmed +[ OK ] NetworkCommon.parseHeaderListTrimmed (0 ms) +[ RUN ] NetworkCommon.parseHeaderListColonInValue +... +[ RUN ] TwitchAccount.NotEnoughForMoreThanOneBatch +[ OK ] TwitchAccount.NotEnoughForMoreThanOneBatch (0 ms) +[ RUN ] TwitchAccount.BatchThreeParts +[ OK ] TwitchAccount.BatchThreeParts (0 ms) +[----------] 3 tests from TwitchAccount (2 ms total) + +[----------] Global test environment tear-down +[==========] 26 tests from 8 test suites ran. (10297 ms total) +[ PASSED ] 26 tests. +``` + +## Adding your own benchmark + +1. Create a new file for the file you're adding benchmark for. If you're creating benchmarks for `src/providers/emoji/Emojis.cpp`, create `benchmarks/src/Emojis.cpp`. +2. Add the newly created file to `benchmarks/CMakeLists.txt` in the `benchmark_SOURCES` variable (see the comment near it) + +See `benchmarks/src/Emojis.cpp` for simple benchmark you can base your benchmarks off of. + +## Building and running benchmarks + +```sh +mkdir build-benchmarks +cd build-benchmarks +cmake -DBUILD_BENCHMARKS=On .. +make +./bin/chatterino-benchmark +``` + +### Example output + +``` +2021-07-18T13:12:11+02:00 +Running ./bin/chatterino-benchmark +Run on (12 X 4000 MHz CPU s) +CPU Caches: + L1 Data 32 KiB (x6) + L1 Instruction 32 KiB (x6) + L2 Unified 256 KiB (x6) + L3 Unified 15360 KiB (x1) +Load Average: 2.86, 3.08, 3.51 +***WARNING*** CPU scaling is enabled, the benchmark real time measurements may be noisy and will incur extra overhead. +-------------------------------------------------------------- +Benchmark Time CPU Iterations +-------------------------------------------------------------- +BM_ShortcodeParsing 2394 ns 2389 ns 278933 +``` diff --git a/lib/libcommuni b/lib/libcommuni index c613600e6..95f05478d 160000 --- a/lib/libcommuni +++ b/lib/libcommuni @@ -1 +1 @@ -Subproject commit c613600e6a52e6d3166247a05205cf1c755d4868 +Subproject commit 95f05478de1623767282d8019ea8f3a4b1178b35 diff --git a/resources/com.chatterino.chatterino.appdata.xml b/resources/com.chatterino.chatterino.appdata.xml index a0802b163..5c8314f08 100644 --- a/resources/com.chatterino.chatterino.appdata.xml +++ b/resources/com.chatterino.chatterino.appdata.xml @@ -32,6 +32,6 @@ chatterino - + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 306824b22..78015123f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -88,6 +88,8 @@ set(SOURCE_FILES controllers/highlights/UserHighlightModel.cpp controllers/highlights/UserHighlightModel.hpp + controllers/ignores/IgnoreController.cpp + controllers/ignores/IgnoreController.hpp controllers/ignores/IgnoreModel.cpp controllers/ignores/IgnoreModel.hpp @@ -290,6 +292,8 @@ set(SOURCE_FILES util/NuulsUploader.hpp util/RapidjsonHelpers.cpp util/RapidjsonHelpers.hpp + util/RatelimitBucket.cpp + util/RatelimitBucket.hpp util/SplitCommand.cpp util/SplitCommand.hpp util/StreamLink.cpp @@ -482,16 +486,16 @@ add_library(${LIBRARY_PROJECT} OBJECT ${SOURCE_FILES}) target_link_libraries(${LIBRARY_PROJECT} PUBLIC - Qt6::Core - Qt6::Widgets - Qt6::Gui - Qt6::Network - Qt6::Multimedia - Qt6::Svg - Qt6::Concurrent + Qt${MAJOR_QT_VERSION}::Core + Qt${MAJOR_QT_VERSION}::Widgets + Qt${MAJOR_QT_VERSION}::Gui + Qt${MAJOR_QT_VERSION}::Network + Qt${MAJOR_QT_VERSION}::Multimedia + Qt${MAJOR_QT_VERSION}::Svg + Qt${MAJOR_QT_VERSION}::Concurrent LibCommuni::LibCommuni - qt6keychain + qt${MAJOR_QT_VERSION}keychain Pajlada::Serialize Pajlada::Settings Pajlada::Signals @@ -520,8 +524,8 @@ if (BUILD_APP) ) if (MSVC) - get_target_property(Qt6_Core_Location Qt6::Core LOCATION) - get_filename_component(QT_BIN_DIR ${Qt6_Core_Location} DIRECTORY) + get_target_property(Qt_Core_Location Qt${MAJOR_QT_VERSION}::Core LOCATION) + get_filename_component(QT_BIN_DIR ${Qt_Core_Location} DIRECTORY) set(WINDEPLOYQT_COMMAND "${QT_BIN_DIR}/windeployqt.exe" $ --release --no-compiler-runtime --no-translations --no-opengl-sw) install(TARGETS ${EXECUTABLE_PROJECT} @@ -578,7 +582,7 @@ target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO_GIT_RELEASE=\"${GIT_RELEASE}\" CHATTERINO_GIT_COMMIT=\"${GIT_COMMIT}\" ) -if (USE_SYSTEM_QT6KEYCHAIN) +if (USE_SYSTEM_QTKEYCHAIN) target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CMAKE_BUILD ) diff --git a/src/common/Version.hpp b/src/common/Version.hpp index 29c88e21a..1a858c0ea 100644 --- a/src/common/Version.hpp +++ b/src/common/Version.hpp @@ -3,7 +3,7 @@ #include #include -#define CHATTERINO_VERSION "2.3.3" +#define CHATTERINO_VERSION "2.3.4" #if defined(Q_OS_WIN) # define CHATTERINO_OS "win" diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 10f3117b6..716d9f088 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -70,6 +70,32 @@ static const QStringList twitchDefaultCommands{ static const QStringList whisperCommands{"/w", ".w"}; +// stripUserName removes any @ prefix or , suffix to make it more suitable for command use +void stripUserName(QString &userName) +{ + if (userName.startsWith('@')) + { + userName.remove(0, 1); + } + if (userName.endsWith(',')) + { + userName.chop(1); + } +} + +// stripChannelName removes any @ prefix or , suffix to make it more suitable for command use +void stripChannelName(QString &channelName) +{ + if (channelName.startsWith('@') || channelName.startsWith('#')) + { + channelName.remove(0, 1); + } + if (channelName.endsWith(',')) + { + channelName.chop(1); + } +} + void sendWhisperMessage(const QString &text) { // (hemirt) pajlada: "we should not be sending whispers through jtv, but @@ -373,6 +399,22 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + this->registerCommand("/follow", [](const auto &words, auto channel) { + channel->addMessage(makeSystemMessage( + "Twitch has removed the ability to follow users through " + "third-party applications. For more information, see " + "https://github.com/Chatterino/chatterino2/issues/3076")); + return ""; + }); + + this->registerCommand("/unfollow", [](const auto &words, auto channel) { + channel->addMessage(makeSystemMessage( + "Twitch has removed the ability to unfollow users through " + "third-party applications. For more information, see " + "https://github.com/Chatterino/chatterino2/issues/3076")); + return ""; + }); + /// Supported commands this->registerCommand( @@ -407,90 +449,6 @@ void CommandController::initialize(Settings &, Paths &paths) this->registerCommand("/unblock", unblockLambda); - this->registerCommand("/follow", [](const auto &words, auto channel) { - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage("Usage: /follow [user]")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to follow someone!")); - return ""; - } - - auto target = words.at(1); - - getHelix()->getUserByName( - target, - [currentUser, channel, target](const auto &targetUser) { - getHelix()->followUser( - currentUser->getUserId(), targetUser.id, - [channel, target]() { - channel->addMessage(makeSystemMessage( - "You successfully followed " + target)); - }, - [channel, target]() { - channel->addMessage(makeSystemMessage( - QString("User %1 could not be followed, an unknown " - "error occurred!") - .arg(target))); - }); - }, - [channel, target] { - channel->addMessage( - makeSystemMessage(QString("User %1 could not be followed, " - "no user with that name found!") - .arg(target))); - }); - - return ""; - }); - - this->registerCommand("/unfollow", [](const auto &words, auto channel) { - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage("Usage: /unfollow [user]")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to unfollow someone!")); - return ""; - } - - auto target = words.at(1); - - getHelix()->getUserByName( - target, - [currentUser, channel, target](const auto &targetUser) { - getHelix()->unfollowUser( - currentUser->getUserId(), targetUser.id, - [channel, target]() { - channel->addMessage(makeSystemMessage( - "You successfully unfollowed " + target)); - }, - [channel, target]() { - channel->addMessage(makeSystemMessage( - "An error occurred while unfollowing " + target)); - }); - }, - [channel, target] { - channel->addMessage(makeSystemMessage( - QString("User %1 could not be followed!").arg(target))); - }); - - return ""; - }); - this->registerCommand("/user", [](const auto &words, auto channel) { if (words.size() < 2) { @@ -498,16 +456,17 @@ void CommandController::initialize(Settings &, Paths &paths) makeSystemMessage("Usage /user [user] (channel)")); return ""; } + QString userName = words[1]; + stripUserName(userName); + QString channelName = channel->getName(); + if (words.size() > 2) { channelName = words[2]; - if (channelName[0] == '#') - { - channelName.remove(0, 1); - } + stripChannelName(channelName); } - openTwitchUsercard(channelName, words[1]); + openTwitchUsercard(channelName, userName); return ""; }); @@ -519,10 +478,12 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; } + QString userName = words[1]; + stripUserName(userName); auto *userPopup = new UserInfoPopup( getSettings()->autoCloseUserPopup, static_cast(&(getApp()->windows->getMainWindow()))); - userPopup->setData(words[1], channel); + userPopup->setData(userName, channel); userPopup->move(QCursor::pos()); userPopup->show(); return ""; diff --git a/src/controllers/filters/FilterRecord.hpp b/src/controllers/filters/FilterRecord.hpp index 8d699f828..5f0eb750c 100644 --- a/src/controllers/filters/FilterRecord.hpp +++ b/src/controllers/filters/FilterRecord.hpp @@ -60,11 +60,6 @@ public: return this->parser_->valid(); } - bool filter(const MessagePtr &message) const - { - return this->parser_->execute(message); - } - bool filter(const filterparser::ContextMap &context) const { return this->parser_->execute(context); diff --git a/src/controllers/filters/FilterSet.hpp b/src/controllers/filters/FilterSet.hpp index 52a953e54..687f79964 100644 --- a/src/controllers/filters/FilterSet.hpp +++ b/src/controllers/filters/FilterSet.hpp @@ -36,12 +36,13 @@ public: this->listener_.disconnect(); } - bool filter(const MessagePtr &m) const + bool filter(const MessagePtr &m, ChannelPtr channel) const { if (this->filters_.size() == 0) return true; - filterparser::ContextMap context = filterparser::buildContextMap(m); + filterparser::ContextMap context = + filterparser::buildContextMap(m, channel.get()); for (const auto &f : this->filters_.values()) { if (!f->valid() || !f->filter(context)) diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp index d7cd18822..c4dca050a 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/parser/FilterParser.cpp @@ -1,12 +1,13 @@ #include "FilterParser.hpp" #include "Application.hpp" +#include "common/Channel.hpp" #include "controllers/filters/parser/Types.hpp" #include "providers/twitch/TwitchIrcServer.hpp" namespace filterparser { -ContextMap buildContextMap(const MessagePtr &m) +ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) { auto watchingChannel = chatterino::getApp()->twitch.server->watchingChannel.get(); @@ -61,8 +62,7 @@ ContextMap buildContextMap(const MessagePtr &m) subLength = m->badgeInfos.at(subBadge).toInt(); } } - - return { + ContextMap vars = { {"author.badges", std::move(badges)}, {"author.color", m->usernameColor}, {"author.name", m->displayName}, @@ -82,6 +82,19 @@ ContextMap buildContextMap(const MessagePtr &m) {"message.content", m->messageText}, {"message.length", m->messageText.length()}, }; + { + using namespace chatterino; + auto *tc = dynamic_cast(channel); + if (channel && !channel->isEmpty() && tc) + { + vars["channel.live"] = tc->isLive(); + } + else + { + vars["channel.live"] = false; + } + } + return vars; } FilterParser::FilterParser(const QString &text) @@ -91,12 +104,6 @@ FilterParser::FilterParser(const QString &text) { } -bool FilterParser::execute(const MessagePtr &message) const -{ - auto context = buildContextMap(message); - return this->execute(context); -} - bool FilterParser::execute(const ContextMap &context) const { return this->builtExpression_->execute(context).toBool(); diff --git a/src/controllers/filters/parser/FilterParser.hpp b/src/controllers/filters/parser/FilterParser.hpp index 0c144fae5..70037993e 100644 --- a/src/controllers/filters/parser/FilterParser.hpp +++ b/src/controllers/filters/parser/FilterParser.hpp @@ -3,15 +3,20 @@ #include "controllers/filters/parser/Tokenizer.hpp" #include "controllers/filters/parser/Types.hpp" +namespace chatterino { + +class Channel; + +} // namespace chatterino + namespace filterparser { -ContextMap buildContextMap(const MessagePtr &m); +ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); class FilterParser { public: FilterParser(const QString &text); - bool execute(const MessagePtr &message) const; bool execute(const ContextMap &context) const; bool valid() const; diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/parser/Tokenizer.hpp index 78ff27064..8f9b5824b 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/parser/Tokenizer.hpp @@ -17,6 +17,7 @@ static const QMap validIdentifiersMap = { {"author.sub_length", "author sub length"}, {"channel.name", "channel name"}, {"channel.watching", "/watching channel?"}, + {"channel.live", "Channel live?"}, {"flags.highlighted", "highlighted?"}, {"flags.points_redeemed", "redeemed points?"}, {"flags.sub_message", "sub/resub message?"}, diff --git a/src/controllers/ignores/IgnoreController.cpp b/src/controllers/ignores/IgnoreController.cpp new file mode 100644 index 000000000..e36feead0 --- /dev/null +++ b/src/controllers/ignores/IgnoreController.cpp @@ -0,0 +1,63 @@ +#include "controllers/ignores/IgnoreController.hpp" + +#include "common/QLogging.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" +#include "singletons/Settings.hpp" + +namespace chatterino { + +bool isIgnoredMessage(IgnoredMessageParameters &¶ms) +{ + if (!params.message.isEmpty()) + { + // TODO(pajlada): Do we need to check if the phrase is valid first? + auto phrases = getCSettings().ignoredMessages.readOnly(); + for (const auto &phrase : *phrases) + { + if (phrase.isBlock() && phrase.isMatch(params.message)) + { + qCDebug(chatterinoMessage) + << "Blocking message because it contains ignored phrase" + << phrase.getPattern(); + return true; + } + } + } + + if (!params.twitchUserID.isEmpty() && + getSettings()->enableTwitchBlockedUsers) + { + auto sourceUserID = params.twitchUserID; + + auto blocks = + getApp()->accounts->twitch.getCurrent()->accessBlockedUserIds(); + + if (auto it = blocks->find(sourceUserID); it != blocks->end()) + { + switch (static_cast( + getSettings()->showBlockedUsersMessages.getValue())) + { + case ShowIgnoredUsersMessages::IfModerator: + if (params.isMod || params.isBroadcaster) + { + return false; + } + break; + case ShowIgnoredUsersMessages::IfBroadcaster: + if (params.isBroadcaster) + { + return false; + } + break; + case ShowIgnoredUsersMessages::Never: + break; + } + + return true; + } + } + + return false; +} + +} // namespace chatterino diff --git a/src/controllers/ignores/IgnoreController.hpp b/src/controllers/ignores/IgnoreController.hpp index fed12f12c..4c2048621 100644 --- a/src/controllers/ignores/IgnoreController.hpp +++ b/src/controllers/ignores/IgnoreController.hpp @@ -1,7 +1,19 @@ #pragma once +#include + namespace chatterino { enum class ShowIgnoredUsersMessages { Never, IfModerator, IfBroadcaster }; +struct IgnoredMessageParameters { + QString message; + + QString twitchUserID; + bool isMod; + bool isBroadcaster; +}; + +bool isIgnoredMessage(IgnoredMessageParameters &¶ms); + } // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 066652c40..ceced523c 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "common/QLogging.hpp" +#include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" @@ -103,20 +104,9 @@ void SharedMessageBuilder::parse() bool SharedMessageBuilder::isIgnored() const { - // TODO(pajlada): Do we need to check if the phrase is valid first? - auto phrases = getCSettings().ignoredMessages.readOnly(); - for (const auto &phrase : *phrases) - { - if (phrase.isBlock() && phrase.isMatch(this->originalMessage_)) - { - qCDebug(chatterinoMessage) - << "Blocking message because it contains ignored phrase" - << phrase.getPattern(); - return true; - } - } - - return false; + return isIgnoredMessage({ + /*.message = */ this->originalMessage_, + }); } void SharedMessageBuilder::parseUsernameColor() diff --git a/src/providers/irc/AbstractIrcServer.cpp b/src/providers/irc/AbstractIrcServer.cpp index be9fc4f88..0ccebdec9 100644 --- a/src/providers/irc/AbstractIrcServer.cpp +++ b/src/providers/irc/AbstractIrcServer.cpp @@ -15,6 +15,10 @@ const int RECONNECT_BASE_INTERVAL = 2000; // 60 falloff counter means it will try to reconnect at most every 60*2 seconds const int MAX_FALLOFF_COUNTER = 60; +// Ratelimits for joinBucket_ +const int JOIN_RATELIMIT_BUDGET = 18; +const int JOIN_RATELIMIT_COOLDOWN = 10500; + AbstractIrcServer::AbstractIrcServer() { // Initialize the connections @@ -23,6 +27,17 @@ AbstractIrcServer::AbstractIrcServer() this->writeConnection_->moveToThread( QCoreApplication::instance()->thread()); + // Apply a leaky bucket rate limiting to JOIN messages + auto actuallyJoin = [&](QString message) { + if (!this->channels.contains(message)) + { + return; + } + this->readConnection_->sendRaw("JOIN #" + message); + }; + this->joinBucket_.reset(new RatelimitBucket( + JOIN_RATELIMIT_BUDGET, JOIN_RATELIMIT_COOLDOWN, actuallyJoin, this)); + QObject::connect(this->writeConnection_.get(), &Communi::IrcConnection::messageReceived, this, [this](auto msg) { @@ -214,11 +229,6 @@ ChannelPtr AbstractIrcServer::getOrAddChannel(const QString &dirtyChannelName) { this->readConnection_->sendRaw("PART #" + channelName); } - - if (this->writeConnection_ && this->hasSeparateWriteConnection()) - { - this->writeConnection_->sendRaw("PART #" + channelName); - } })); // join irc channel @@ -229,15 +239,7 @@ ChannelPtr AbstractIrcServer::getOrAddChannel(const QString &dirtyChannelName) { if (this->readConnection_->isConnected()) { - this->readConnection_->sendRaw("JOIN #" + channelName); - } - } - - if (this->writeConnection_ && this->hasSeparateWriteConnection()) - { - if (this->readConnection_->isConnected()) - { - this->writeConnection_->sendRaw("JOIN #" + channelName); + this->joinBucket_->send(channelName); } } } @@ -297,7 +299,7 @@ void AbstractIrcServer::onReadConnected(IrcConnection *connection) { if (auto channel = weak.lock()) { - connection->sendRaw("JOIN #" + channel->getName()); + this->joinBucket_->send(channel->getName()); } } diff --git a/src/providers/irc/AbstractIrcServer.hpp b/src/providers/irc/AbstractIrcServer.hpp index 7cfc38f5e..40da3ba20 100644 --- a/src/providers/irc/AbstractIrcServer.hpp +++ b/src/providers/irc/AbstractIrcServer.hpp @@ -8,6 +8,7 @@ #include "common/Common.hpp" #include "providers/irc/IrcConnection2.hpp" +#include "util/RatelimitBucket.hpp" namespace chatterino { @@ -88,6 +89,10 @@ private: QObjectPtr writeConnection_ = nullptr; QObjectPtr readConnection_ = nullptr; + // Our rate limiting bucket for the Twitch join rate limits + // https://dev.twitch.tv/docs/irc/guide#rate-limits + QObjectPtr joinBucket_; + QTimer reconnectTimer_; int falloffCounter_ = 1; diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 10eac03bb..74fc43d6e 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -187,23 +187,6 @@ void TwitchAccount::unblockUser(QString userId, std::function onSuccess, std::move(onFailure)); } -void TwitchAccount::checkFollow(const QString targetUserID, - std::function onFinished) -{ - const auto onResponse = [onFinished](bool following, const auto &record) { - if (!following) - { - onFinished(FollowResult_NotFollowing); - return; - } - - onFinished(FollowResult_Following); - }; - - getHelix()->getUserFollow(this->getUserId(), targetUserID, onResponse, - [] {}); -} - SharedAccessGuard> TwitchAccount::accessBlocks() const { @@ -216,7 +199,7 @@ SharedAccessGuard> TwitchAccount::accessBlockedUserIds() return this->ignoresUserIds_.accessConst(); } -void TwitchAccount::loadEmotes() +void TwitchAccount::loadEmotes(std::weak_ptr weakChannel) { qCDebug(chatterinoTwitch) << "Loading Twitch emotes for user" << this->getUserName(); @@ -238,9 +221,14 @@ void TwitchAccount::loadEmotes() // TODO(zneix): Once Helix adds Get User Emotes we could remove this hacky solution // For now, this is necessary as Kraken's equivalent doesn't return all emotes // See: https://twitch.uservoice.com/forums/310213-developers/suggestions/43599900 - this->loadUserstateEmotes([=] { + this->loadUserstateEmotes([this, weakChannel] { // Fill up emoteData with emote sets that were returned in a Kraken call, but aren't present in emoteData. this->loadKrakenEmotes(); + if (auto channel = weakChannel.lock(); channel != nullptr) + { + channel->addMessage( + makeSystemMessage("Twitch subscriber emotes reloaded.")); + } }); } diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index af56afaf1..034912a24 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -108,13 +108,10 @@ public: void unblockUser(QString userId, std::function onSuccess, std::function onFailure); - void checkFollow(const QString targetUserID, - std::function onFinished); - SharedAccessGuard> accessBlockedUserIds() const; SharedAccessGuard> accessBlocks() const; - void loadEmotes(); + void loadEmotes(std::weak_ptr weakChannel = {}); // loadUserstateEmotes loads emote sets that are part of the USERSTATE emote-sets key // this function makes sure not to load emote sets that have already been loaded void loadUserstateEmotes(std::function callback); diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 4571cca22..94c458a55 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -286,7 +286,8 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) if (!reward.isUserInputRequired) { MessageBuilder builder; - TwitchMessageBuilder::appendChannelPointRewardMessage(reward, &builder); + TwitchMessageBuilder::appendChannelPointRewardMessage( + reward, &builder, this->isMod(), this->isBroadcaster()); this->addMessage(builder.release()); return; } @@ -375,7 +376,17 @@ void TwitchChannel::sendMessage(const QString &message) { if (parsedMessage == this->lastSentMessage_) { - parsedMessage.append(MAGIC_MESSAGE_SUFFIX); + auto spaceIndex = parsedMessage.indexOf(' '); + if (spaceIndex == -1) + { + // no spaces found, fall back to old magic character + parsedMessage.append(MAGIC_MESSAGE_SUFFIX); + } + else + { + // replace the space we found in spaceIndex with two spaces + parsedMessage.replace(spaceIndex, 1, " "); + } } } } diff --git a/src/providers/twitch/TwitchEmotes.cpp b/src/providers/twitch/TwitchEmotes.cpp index 38593a03a..9a84bcabf 100644 --- a/src/providers/twitch/TwitchEmotes.cpp +++ b/src/providers/twitch/TwitchEmotes.cpp @@ -57,7 +57,7 @@ EmotePtr TwitchEmotes::getOrCreateEmote(const EmoteId &id, Image::fromUrl(getEmoteLink(id, "3.0"), 0.25), }, Tooltip{name.toHtmlEscaped() + "
Twitch Emote"}, - Url{QString("https://twitchemotes.com/emotes/%1").arg(id.string)}}); + }); } return shared; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index b12ae6f98..d2f6f884c 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -197,34 +197,13 @@ void TwitchIrcServer::writeConnectionMessageReceived( // Below commands enabled through the twitch.tv/commands CAP REQ if (command == "USERSTATE") { - // Received USERSTATE upon PRIVMSGing + // Received USERSTATE upon sending PRIVMSG messages handler.handleUserStateMessage(message); } else if (command == "NOTICE") { - static std::unordered_set readConnectionOnlyIDs{ - "host_on", - "host_off", - "host_target_went_offline", - "emote_only_on", - "emote_only_off", - "slow_on", - "slow_off", - "subs_on", - "subs_off", - "r9k_on", - "r9k_off", - - // Display for user who times someone out. This implies you're a - // moderator, at which point you will be connected to PubSub and receive - // a better message from there. - "timeout_success", - "ban_success", - - // Channel suspended notices - "msg_channel_suspended", - }; - + // List of expected NOTICE messages on write connection + // https://git.kotmisia.pl/Mm2PL/docs/src/branch/master/irc_msg_ids.md#command-results handler.handleNoticeMessage( static_cast(message)); } diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 5f3c0a20c..cb2d67e5a 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -113,44 +113,12 @@ TwitchMessageBuilder::TwitchMessageBuilder( bool TwitchMessageBuilder::isIgnored() const { - if (SharedMessageBuilder::isIgnored()) - { - return true; - } - - auto app = getApp(); - - if (getSettings()->enableTwitchBlockedUsers && - this->tags.contains("user-id")) - { - auto sourceUserID = this->tags.value("user-id").toString(); - - auto blocks = - app->accounts->twitch.getCurrent()->accessBlockedUserIds(); - - if (auto it = blocks->find(sourceUserID); it != blocks->end()) - { - switch (static_cast( - getSettings()->showBlockedUsersMessages.getValue())) - { - case ShowIgnoredUsersMessages::IfModerator: - if (this->channel->isMod() || - this->channel->isBroadcaster()) - return false; - break; - case ShowIgnoredUsersMessages::IfBroadcaster: - if (this->channel->isBroadcaster()) - return false; - break; - case ShowIgnoredUsersMessages::Never: - break; - } - - return true; - } - } - - return false; + return isIgnoredMessage({ + /*.message = */ this->originalMessage_, + /*.twitchUserID = */ this->tags.value("user-id").toString(), + /*.isMod = */ this->channel->isMod(), + /*.isBroadcaster = */ this->channel->isBroadcaster(), + }); } void TwitchMessageBuilder::triggerHighlights() @@ -189,7 +157,9 @@ MessagePtr TwitchMessageBuilder::build() this->args.channelPointRewardId); if (reward) { - this->appendChannelPointRewardMessage(reward.get(), this); + this->appendChannelPointRewardMessage( + reward.get(), this, this->channel->isMod(), + this->channel->isBroadcaster()); } } @@ -1261,8 +1231,19 @@ Outcome TwitchMessageBuilder::tryParseCheermote(const QString &string) } void TwitchMessageBuilder::appendChannelPointRewardMessage( - const ChannelPointReward &reward, MessageBuilder *builder) + const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, + bool isBroadcaster) { + if (isIgnoredMessage({ + /*.message = */ "", + /*.twitchUserID = */ reward.user.id, + /*.isMod = */ isMod, + /*.isBroadcaster = */ isBroadcaster, + })) + { + return; + } + builder->emplace(); QString redeemed = "Redeemed"; QStringList textList; diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 3e14412b7..7cf68494a 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -46,7 +46,8 @@ public: MessagePtr build() override; static void appendChannelPointRewardMessage( - const ChannelPointReward &reward, MessageBuilder *builder); + const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, + bool isBroadcaster); // Message in the /live chat for channel going live static void liveMessage(const QString &channelName, diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 2fe734a53..7895995d2 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -142,25 +142,6 @@ void Helix::getUserFollowers( std::move(failureCallback)); } -void Helix::getUserFollow( - QString userId, QString targetId, - ResultCallback successCallback, - HelixFailureCallback failureCallback) -{ - this->fetchUsersFollows( - std::move(userId), std::move(targetId), - [successCallback](const auto &response) { - if (response.data.empty()) - { - successCallback(false, HelixUsersFollowsRecord()); - return; - } - - successCallback(true, response.data[0]); - }, - std::move(failureCallback)); -} - void Helix::fetchStreams( QStringList userIds, QStringList userLogins, ResultCallback> successCallback, @@ -354,50 +335,6 @@ void Helix::getGameById(QString gameId, failureCallback); } -void Helix::followUser(QString userId, QString targetId, - std::function successCallback, - HelixFailureCallback failureCallback) -{ - QUrlQuery urlQuery; - - urlQuery.addQueryItem("from_id", userId); - urlQuery.addQueryItem("to_id", targetId); - - this->makeRequest("users/follows", urlQuery) - .type(NetworkRequestType::Post) - .onSuccess([successCallback](auto /*result*/) -> Outcome { - successCallback(); - return Success; - }) - .onError([failureCallback](auto /*result*/) { - // TODO: make better xd - failureCallback(); - }) - .execute(); -} - -void Helix::unfollowUser(QString userId, QString targetId, - std::function successCallback, - HelixFailureCallback failureCallback) -{ - QUrlQuery urlQuery; - - urlQuery.addQueryItem("from_id", userId); - urlQuery.addQueryItem("to_id", targetId); - - this->makeRequest("users/follows", urlQuery) - .type(NetworkRequestType::Delete) - .onSuccess([successCallback](auto /*result*/) -> Outcome { - successCallback(); - return Success; - }) - .onError([failureCallback](auto /*result*/) { - // TODO: make better xd - failureCallback(); - }) - .execute(); -} - void Helix::createClip(QString channelId, ResultCallback successCallback, std::function failureCallback, @@ -775,7 +712,7 @@ void Helix::getEmoteSetData(QString emoteSetId, QJsonObject root = result.parseJson(); auto data = root.value("data"); - if (!data.isArray()) + if (!data.isArray() || data.toArray().isEmpty()) { failureCallback(); return Failure; diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 0b437765c..7648243a2 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -341,11 +341,6 @@ public: ResultCallback successCallback, HelixFailureCallback failureCallback); - void getUserFollow( - QString userId, QString targetId, - ResultCallback successCallback, - HelixFailureCallback failureCallback); - // https://dev.twitch.tv/docs/api/reference#get-streams void fetchStreams(QStringList userIds, QStringList userLogins, ResultCallback> successCallback, @@ -372,16 +367,6 @@ public: void getGameById(QString gameId, ResultCallback successCallback, HelixFailureCallback failureCallback); - // https://dev.twitch.tv/docs/api/reference#create-user-follows - void followUser(QString userId, QString targetId, - std::function successCallback, - HelixFailureCallback failureCallback); - - // https://dev.twitch.tv/docs/api/reference#delete-user-follows - void unfollowUser(QString userId, QString targetlId, - std::function successCallback, - HelixFailureCallback failureCallback); - // https://dev.twitch.tv/docs/api/reference#create-clip void createClip(QString channelId, ResultCallback successCallback, diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index 15c52cca9..6f1b46a03 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -47,26 +47,6 @@ URL: https://dev.twitch.tv/docs/api/reference#get-streams - `TwitchChannel` to get live status, game, title, and viewer count of a channel - `NotificationController` to provide notifications for channels you might not have open in Chatterino, but are still interested in getting notifications for -### Follow User - -URL: https://dev.twitch.tv/docs/api/reference#create-user-follows -Requires `user:edit:follows` scope - -- We implement this in `providers/twitch/api/Helix.cpp followUser` - Used in: - - `widgets/dialogs/UserInfoPopup.cpp` to follow a user by ticking follow checkbox in usercard - - `controllers/commands/CommandController.cpp` in /follow command - -### Unfollow User - -URL: https://dev.twitch.tv/docs/api/reference#delete-user-follows -Requires `user:edit:follows` scope - -- We implement this in `providers/twitch/api/Helix.cpp unfollowUser` - Used in: - - `widgets/dialogs/UserInfoPopup.cpp` to unfollow a user by unticking follow checkbox in usercard - - `controllers/commands/CommandController.cpp` in /unfollow command - ### Create Clip URL: https://dev.twitch.tv/docs/api/reference#create-clip diff --git a/src/util/RatelimitBucket.cpp b/src/util/RatelimitBucket.cpp new file mode 100644 index 000000000..c33f3a30a --- /dev/null +++ b/src/util/RatelimitBucket.cpp @@ -0,0 +1,45 @@ +#include "RatelimitBucket.hpp" + +#include + +namespace chatterino { + +RatelimitBucket::RatelimitBucket(int budget, int cooldown, + std::function callback, + QObject *parent) + : QObject(parent) + , budget_(budget) + , cooldown_(cooldown) + , callback_(callback) +{ +} + +void RatelimitBucket::send(QString channel) +{ + this->queue_.append(channel); + + if (this->budget_ > 0) + { + this->handleOne(); + } +} + +void RatelimitBucket::handleOne() +{ + if (queue_.isEmpty()) + { + return; + } + + auto item = queue_.takeFirst(); + + this->budget_--; + callback_(item); + + QTimer::singleShot(cooldown_, this, [this] { + this->budget_++; + this->handleOne(); + }); +} + +} // namespace chatterino diff --git a/src/util/RatelimitBucket.hpp b/src/util/RatelimitBucket.hpp new file mode 100644 index 000000000..89ecdc570 --- /dev/null +++ b/src/util/RatelimitBucket.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +namespace chatterino { + +class RatelimitBucket : public QObject +{ +public: + RatelimitBucket(int budget, int cooldown, + std::function callback, QObject *parent); + + void send(QString channel); + +private: + /** + * @brief budget_ denotes the amount of calls that can be handled before we need to wait for the cooldown + **/ + int budget_; + + /** + * @brief This is the amount of time in milliseconds it takes for one used up budget to be put back into the bucket for use elsewhere + **/ + const int cooldown_; + + std::function callback_; + QList queue_; + + /** + * @brief Run the callback on one entry in the queue. + * + * This will start a timer that runs after cooldown_ milliseconds that + * gives back one "token" to the bucket and calls handleOne again. + **/ + void handleOne(); +}; + +} // namespace chatterino diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 1f5c62afe..706ca839d 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -233,7 +233,6 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent) { user->addStretch(1); - user.emplace("Follow").assign(&this->ui_.follow); user.emplace("Block").assign(&this->ui_.block); user.emplace("Ignore highlights") .assign(&this->ui_.ignoreHighlights); @@ -403,56 +402,6 @@ void UserInfoPopup::scaleChangedEvent(float /*scale*/) void UserInfoPopup::installEvents() { - std::weak_ptr hack = this->hack_; - - // follow - QObject::connect( - this->ui_.follow, &QCheckBox::stateChanged, - [this](int newState) mutable { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - - const auto reenableFollowCheckbox = [this] { - this->ui_.follow->setEnabled(true); - }; - - if (!this->ui_.follow->isEnabled()) - { - // We received a state update while the checkbox was disabled - // This can only happen from the "check current follow state" call - // The state has been updated to properly reflect the users current follow state - reenableFollowCheckbox(); - return; - } - - switch (newState) - { - case Qt::CheckState::Unchecked: { - this->ui_.follow->setEnabled(false); - getHelix()->unfollowUser(currentUser->getUserId(), - this->userId_, - reenableFollowCheckbox, [] { - // - }); - } - break; - - case Qt::CheckState::PartiallyChecked: { - // We deliberately ignore this state - } - break; - - case Qt::CheckState::Checked: { - this->ui_.follow->setEnabled(false); - getHelix()->followUser(currentUser->getUserId(), - this->userId_, - reenableFollowCheckbox, [] { - // - }); - } - break; - } - }); - std::shared_ptr ignoreNext = std::make_shared(false); // block @@ -616,8 +565,6 @@ void UserInfoPopup::updateLatestMessages() void UserInfoPopup::updateUserData() { - this->ui_.follow->setEnabled(false); - std::weak_ptr hack = this->hack_; auto currentUser = getApp()->accounts->twitch.getCurrent(); @@ -683,19 +630,6 @@ void UserInfoPopup::updateUserData() // on failure }); - // get follow state - currentUser->checkFollow(user.id, [this, hack](auto result) { - if (!hack.lock()) - { - return; - } - if (result != FollowResult_Failed) - { - this->ui_.follow->setChecked(result == FollowResult_Following); - this->ui_.follow->setEnabled(true); - } - }); - // get ignore state bool isIgnoring = false; @@ -772,7 +706,6 @@ void UserInfoPopup::updateUserData() getHelix()->getUserByName(this->userName_, onUserFetched, onUserFetchFailed); - this->ui_.follow->setEnabled(false); this->ui_.block->setEnabled(false); this->ui_.ignoreHighlights->setEnabled(false); } diff --git a/src/widgets/dialogs/UserInfoPopup.hpp b/src/widgets/dialogs/UserInfoPopup.hpp index c0b868b9d..ed195d033 100644 --- a/src/widgets/dialogs/UserInfoPopup.hpp +++ b/src/widgets/dialogs/UserInfoPopup.hpp @@ -59,7 +59,6 @@ private: Label *followageLabel = nullptr; Label *subageLabel = nullptr; - QCheckBox *follow = nullptr; QCheckBox *block = nullptr; QCheckBox *ignoreHighlights = nullptr; diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index fd84620d1..406dcb023 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -108,11 +108,7 @@ namespace { }); }; - if (creatorFlags.has(MessageElementFlag::TwitchEmote)) - { - addPageLink("TwitchEmotes"); - } - else if (creatorFlags.has(MessageElementFlag::BttvEmote)) + if (creatorFlags.has(MessageElementFlag::BttvEmote)) { addPageLink("BTTV"); } @@ -754,7 +750,7 @@ bool ChannelView::shouldIncludeMessage(const MessagePtr &m) const m->loginName, Qt::CaseInsensitive) == 0) return true; - return this->channelFilters_->filter(m); + return this->channelFilters_->filter(m, this->channel_); } return true; diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index 41f700f25..208567cb4 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -44,7 +44,7 @@ ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName, } if (accept && filterSet) - accept = filterSet->filter(message); + accept = filterSet->filter(message, channel); // If all predicates match, add the message to the channel if (accept) diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 68f4d3e97..74ce32425 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -162,7 +162,6 @@ AboutPage::AboutPage() l.emplace("Google emojis provided by Google")->setOpenExternalLinks(true); l.emplace("Emoji datasource provided by Cal Henderson" "(show license)")->setOpenExternalLinks(true); - l.emplace("Twitch emote data provided by twitchemotes.com through the Chatterino API")->setOpenExternalLinks(true); // clang-format on } diff --git a/src/widgets/settingspages/HighlightingPage.cpp b/src/widgets/settingspages/HighlightingPage.cpp index d8aa170ca..29c627dba 100644 --- a/src/widgets/settingspages/HighlightingPage.cpp +++ b/src/widgets/settingspages/HighlightingPage.cpp @@ -195,7 +195,7 @@ HighlightingPage::HighlightingPage() } getSettings()->highlightedBadges.append(HighlightBadge{ s->badgeName(), s->displayName(), false, false, "", - ColorProvider::instance().color( + *ColorProvider::instance().color( ColorType::SelfHighlight)}); } }); diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 78f5cd971..63293b248 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -624,8 +624,10 @@ void Split::popup() window.getNotebook().getOrAddSelectedPage())); split->setChannel(this->getIndirectChannel()); - window.getNotebook().getOrAddSelectedPage()->appendSplit(split); + split->setModerationMode(this->getModerationMode()); + split->setFilters(this->getFilters()); + window.getNotebook().getOrAddSelectedPage()->appendSplit(split); window.show(); } @@ -868,8 +870,8 @@ void Split::showSearch() void Split::reloadChannelAndSubscriberEmotes() { - getApp()->accounts->twitch.getCurrent()->loadEmotes(); auto channel = this->getChannel(); + getApp()->accounts->twitch.getCurrent()->loadEmotes(channel); if (auto twitchChannel = dynamic_cast(channel.get())) { diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index fe564314c..e39127ea5 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -915,10 +915,6 @@ void SplitHeader::themeChangedEvent() } } -void SplitHeader::moveSplit() -{ -} - void SplitHeader::reloadChannelEmotes() { auto channel = this->split_->getChannel(); @@ -932,7 +928,8 @@ void SplitHeader::reloadChannelEmotes() void SplitHeader::reloadSubscriberEmotes() { - getApp()->accounts->twitch.getCurrent()->loadEmotes(); + auto channel = this->split_->getChannel(); + getApp()->accounts->twitch.getCurrent()->loadEmotes(channel); } void SplitHeader::reconnect() diff --git a/src/widgets/splits/SplitHeader.hpp b/src/widgets/splits/SplitHeader.hpp index 47960f54e..5b5b9171e 100644 --- a/src/widgets/splits/SplitHeader.hpp +++ b/src/widgets/splits/SplitHeader.hpp @@ -85,7 +85,6 @@ private: std::vector channelConnections_; public slots: - void moveSplit(); void reloadChannelEmotes(); void reloadSubscriberEmotes(); void reconnect(); diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index 841d46ac2..e8a79620d 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -134,14 +134,18 @@ void SplitInput::themeChangedEvent() QPalette palette, placeholderPalette; palette.setColor(QPalette::WindowText, this->theme->splits.input.text); +#if (QT_VERSION >= QT_VERSION_CHECK(5, 12, 0)) placeholderPalette.setColor( QPalette::PlaceholderText, this->theme->messages.textColors.chatPlaceholder); +#endif this->updateEmoteButton(); this->ui_.textEditLength->setPalette(palette); +#if (QT_VERSION >= QT_VERSION_CHECK(5, 12, 0)) this->ui_.textEdit->setPalette(placeholderPalette); +#endif this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet); int scale = (this->theme->isLightTheme() ? 4 : 2) * this->scale(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e7e2c96b0..8b1fafe99 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,8 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/ExponentialBackoff.cpp ${CMAKE_CURRENT_LIST_DIR}/src/TwitchAccount.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Helpers.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/RatelimitBucket.cpp + # Add your new file above this line! ) add_executable(${PROJECT_NAME} ${test_SOURCES}) diff --git a/tests/src/RatelimitBucket.cpp b/tests/src/RatelimitBucket.cpp new file mode 100644 index 000000000..c92a42234 --- /dev/null +++ b/tests/src/RatelimitBucket.cpp @@ -0,0 +1,46 @@ +#include "util/RatelimitBucket.hpp" + +#include +#include +#include +#include + +#include +#include + +using namespace chatterino; + +TEST(RatelimitBucket, BatchTwoParts) +{ + const int cooldown = 100; + int n = 0; + auto cb = [&n](QString msg) { + qDebug() << msg; + ++n; + }; + auto bucket = std::make_unique(5, cooldown, cb, nullptr); + bucket->send("1"); + EXPECT_EQ(n, 1); + + bucket->send("2"); + EXPECT_EQ(n, 2); + + bucket->send("3"); + EXPECT_EQ(n, 3); + + bucket->send("4"); + EXPECT_EQ(n, 4); + + bucket->send("5"); + EXPECT_EQ(n, 5); + + bucket->send("6"); + // Rate limit reached, n will not have changed yet. If we wait for the cooldown to run, n should have changed + EXPECT_EQ(n, 5); + + QCoreApplication::processEvents(); + std::this_thread::sleep_for(std::chrono::milliseconds{cooldown}); + QCoreApplication::processEvents(); + + EXPECT_EQ(n, 6); +}