Merge remote-tracking branch 'origin/master' into fix/stop_windows_code_from_abort()ing_our_process

This commit is contained in:
Rasmus Karlsson 2024-01-07 14:02:55 +01:00
commit 60a913e4ae
103 changed files with 2665 additions and 1753 deletions

View file

@ -65,5 +65,11 @@ CheckOptions:
- key: readability-identifier-naming.LocalPointerIgnoredRegexp
value: ^L$
# Benchmarks
- key: readability-identifier-naming.FunctionIgnoredRegexp
value: ^BM_[^_]+$
- key: readability-identifier-naming.ClassIgnoredRegexp
value: ^BM_[^_]+$
- key: misc-const-correctness.AnalyzeValues
value: false

View file

@ -151,7 +151,7 @@ jobs:
# WINDOWS
- name: Enable Developer Command Prompt (Windows)
if: startsWith(matrix.os, 'windows')
uses: ilammy/msvc-dev-cmd@v1.12.1
uses: ilammy/msvc-dev-cmd@v1.13.0
- name: Setup conan variables (Windows)
if: startsWith(matrix.os, 'windows')

View file

@ -43,7 +43,7 @@ jobs:
run: echo "C:\Program Files (x86)\Inno Setup 6\" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Enable Developer Command Prompt
uses: ilammy/msvc-dev-cmd@v1.12.1
uses: ilammy/msvc-dev-cmd@v1.13.0
- name: Build installer
id: build-installer

View file

@ -63,7 +63,7 @@ jobs:
version: ${{ matrix.qt-version }}
- name: Enable Developer Command Prompt
uses: ilammy/msvc-dev-cmd@v1.12.1
uses: ilammy/msvc-dev-cmd@v1.13.0
- name: Setup conan variables
if: startsWith(matrix.os, 'windows')

View file

@ -1,5 +1,7 @@
# JSON resources should not be prettified...
resources/*.json
benchmarks/resources/*.json
tests/resources/*.json
# ...themes should be prettified for readability.
!resources/themes/*.json
@ -7,6 +9,7 @@ resources/*.json
lib/*/
conan-pkgs/*/
cmake/sanitizers-cmake/
tools/crash-handler
# Build folders
*build-*/

View file

@ -4,7 +4,7 @@
- Major: Allow use of Twitch follower emotes in other channels if subscribed. (#4922)
- Major: Add `/automod` split to track automod caught messages across all open channels the user moderates. (#4986, #5026)
- Major: Show restricted chat messages and suspicious treatment updates. (#5056)
- Major: Show restricted chat messages and suspicious treatment updates. (#5056, #5060)
- Minor: Migrate to the new Get Channel Followers Helix endpoint, fixing follower count not showing up in usercards. (#4809)
- Minor: The account switcher is now styled to match your theme. (#4817)
- Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795)
@ -20,6 +20,8 @@
- 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 missing periods at various moderator messages and commands. (#5061)
- Minor: Improved color selection and display. (#5057)
- 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)
@ -88,6 +90,7 @@
- Dev: Refactor `DebugCount` and add copy button to debug popup. (#4921)
- Dev: Refactor `common/Credentials`. (#4979)
- Dev: Refactor chat logger. (#5058)
- Dev: Refactor Twitch PubSub client. (#5059)
- Dev: Changed lifetime of context menus. (#4924)
- Dev: Renamed `tools` directory to `scripts`. (#5035)
- Dev: Refactor `ChannelView`, removing a bunch of clang-tidy warnings. (#4926)
@ -108,6 +111,9 @@
- 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: Removed duplicate scale in settings dialog. (#5069)
- Dev: Fix `NotebookTab` emitting updates for every message. (#5068)
- Dev: Added benchmark for parsing and building recent messages. (#5071)
## 2.4.6

View file

@ -1,13 +1,16 @@
project(chatterino-benchmark)
set(benchmark_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/main.cpp
${CMAKE_CURRENT_LIST_DIR}/src/Emojis.cpp
${CMAKE_CURRENT_LIST_DIR}/src/Highlights.cpp
${CMAKE_CURRENT_LIST_DIR}/src/FormatTime.cpp
${CMAKE_CURRENT_LIST_DIR}/src/Helpers.cpp
${CMAKE_CURRENT_LIST_DIR}/src/LimitedQueue.cpp
${CMAKE_CURRENT_LIST_DIR}/src/LinkParser.cpp
src/main.cpp
resources/bench.qrc
src/Emojis.cpp
src/Highlights.cpp
src/FormatTime.cpp
src/Helpers.cpp
src/LimitedQueue.cpp
src/LinkParser.cpp
src/RecentMessages.cpp
# Add your new file above this line!
)
@ -27,4 +30,5 @@ set_target_properties(${PROJECT_NAME}
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin"
AUTORCC ON
)

View file

@ -0,0 +1,6 @@
<RCC>
<qresource prefix="/bench">
<file>recentmessages-nymn.json</file>
<file>seventvemotes-nymn.json</file>
</qresource>
</RCC>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -145,16 +145,13 @@ static void BM_EmojiParsing(benchmark::State &state)
BENCHMARK(BM_EmojiParsing);
template <class... Args>
static void BM_EmojiParsing2(benchmark::State &state, Args &&...args)
static void BM_EmojiParsing2(benchmark::State &state, const QString &input,
int expectedNumEmojis)
{
Emojis emojis;
emojis.load();
auto argsTuple = std::make_tuple(std::move(args)...);
auto input = std::get<0>(argsTuple);
auto expectedNumEmojis = std::get<1>(argsTuple);
for (auto _ : state)
{
auto output = emojis.parse(input);

View file

@ -4,35 +4,41 @@
using namespace chatterino;
template <class... Args>
void BM_TimeFormatting(benchmark::State &state, Args &&...args)
void BM_TimeFormattingQString(benchmark::State &state, const QString &v)
{
auto args_tuple = std::make_tuple(std::move(args)...);
for (auto _ : state)
{
formatTime(std::get<0>(args_tuple));
formatTime(v);
}
}
BENCHMARK_CAPTURE(BM_TimeFormatting, 0, 0);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs0, "0");
BENCHMARK_CAPTURE(BM_TimeFormatting, 1337, 1337);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs1337, "1337");
BENCHMARK_CAPTURE(BM_TimeFormatting, 623452, 623452);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs623452, "623452");
BENCHMARK_CAPTURE(BM_TimeFormatting, 8345, 8345);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs8345, "8345");
BENCHMARK_CAPTURE(BM_TimeFormatting, 314034, 314034);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs314034, "314034");
BENCHMARK_CAPTURE(BM_TimeFormatting, 27, 27);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs27, "27");
BENCHMARK_CAPTURE(BM_TimeFormatting, 34589, 34589);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs34589, "34589");
BENCHMARK_CAPTURE(BM_TimeFormatting, 3659, 3659);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs3659, "3659");
BENCHMARK_CAPTURE(BM_TimeFormatting, 1045345, 1045345);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs1045345, "1045345");
BENCHMARK_CAPTURE(BM_TimeFormatting, 86432, 86432);
BENCHMARK_CAPTURE(BM_TimeFormatting, qs86432, "86432");
BENCHMARK_CAPTURE(BM_TimeFormatting, qsempty, "");
BENCHMARK_CAPTURE(BM_TimeFormatting, qsinvalid, "asd");
void BM_TimeFormattingInt(benchmark::State &state, int v)
{
for (auto _ : state)
{
formatTime(v);
}
}
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 0, 0);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 1045345, 1045345);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 1337, 1337);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 27, 27);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 314034, 314034);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 34589, 34589);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 3659, 3659);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 623452, 623452);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 8345, 8345);
BENCHMARK_CAPTURE(BM_TimeFormattingInt, 86432, 86432);
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs0, "0");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs1045345, "1045345");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs1337, "1337");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs27, "27");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs314034, "314034");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs34589, "34589");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs3659, "3659");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs623452, "623452");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs8345, "8345");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs86432, "86432");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qsempty, "");
BENCHMARK_CAPTURE(BM_TimeFormattingQString, qsinvalid, "asd");

View file

@ -0,0 +1,223 @@
#include "common/Literals.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "controllers/highlights/HighlightController.hpp"
#include "messages/Emote.hpp"
#include "mocks/EmptyApplication.hpp"
#include "mocks/TwitchIrcServer.hpp"
#include "mocks/UserData.hpp"
#include "providers/bttv/BttvEmotes.hpp"
#include "providers/chatterino/ChatterinoBadges.hpp"
#include "providers/ffz/FfzBadges.hpp"
#include "providers/ffz/FfzEmotes.hpp"
#include "providers/recentmessages/Impl.hpp"
#include "providers/seventv/SeventvBadges.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "singletons/Emotes.hpp"
#include "singletons/Resources.hpp"
#include <benchmark/benchmark.h>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QString>
#include <optional>
using namespace chatterino;
using namespace literals;
namespace {
class MockApplication : mock::EmptyApplication
{
public:
IEmotes *getEmotes() override
{
return &this->emotes;
}
IUserDataController *getUserData() override
{
return &this->userData;
}
AccountController *getAccounts() override
{
return &this->accounts;
}
ITwitchIrcServer *getTwitch() override
{
return &this->twitch;
}
ChatterinoBadges *getChatterinoBadges() override
{
return &this->chatterinoBadges;
}
FfzBadges *getFfzBadges() override
{
return &this->ffzBadges;
}
SeventvBadges *getSeventvBadges() override
{
return &this->seventvBadges;
}
HighlightController *getHighlights() override
{
return &this->highlights;
}
AccountController accounts;
Emotes emotes;
mock::UserDataController userData;
mock::MockTwitchIrcServer twitch;
ChatterinoBadges chatterinoBadges;
FfzBadges ffzBadges;
SeventvBadges seventvBadges;
HighlightController highlights;
};
std::optional<QJsonDocument> tryReadJsonFile(const QString &path)
{
QFile file(path);
if (!file.open(QFile::ReadOnly))
{
return std::nullopt;
}
QJsonParseError e;
auto doc = QJsonDocument::fromJson(file.readAll(), &e);
if (e.error != QJsonParseError::NoError)
{
return std::nullopt;
}
return doc;
}
QJsonDocument readJsonFile(const QString &path)
{
auto opt = tryReadJsonFile(path);
if (!opt)
{
_exit(1);
}
return *opt;
}
class RecentMessages
{
public:
explicit RecentMessages(const QString &name_)
: name(name_)
, chan(this->name)
{
const auto seventvEmotes =
tryReadJsonFile(u":/bench/seventvemotes-%1.json"_s.arg(this->name));
const auto bttvEmotes =
tryReadJsonFile(u":/bench/bttvemotes-%1.json"_s.arg(this->name));
const auto ffzEmotes =
tryReadJsonFile(u":/bench/ffzemotes-%1.json"_s.arg(this->name));
if (seventvEmotes)
{
this->chan.setSeventvEmotes(
std::make_shared<const EmoteMap>(seventv::detail::parseEmotes(
seventvEmotes->object()["emote_set"_L1]
.toObject()["emotes"_L1]
.toArray(),
false)));
}
if (bttvEmotes)
{
this->chan.setBttvEmotes(std::make_shared<const EmoteMap>(
bttv::detail::parseChannelEmotes(bttvEmotes->object(),
this->name)));
}
if (ffzEmotes)
{
this->chan.setFfzEmotes(std::make_shared<const EmoteMap>(
ffz::detail::parseChannelEmotes(ffzEmotes->object())));
}
this->messages =
readJsonFile(u":/bench/recentmessages-%1.json"_s.arg(this->name));
}
~RecentMessages()
{
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
}
virtual void run(benchmark::State &state) = 0;
protected:
QString name;
MockApplication app;
TwitchChannel chan;
QJsonDocument messages;
};
class ParseRecentMessages : public RecentMessages
{
public:
explicit ParseRecentMessages(const QString &name_)
: RecentMessages(name_)
{
}
void run(benchmark::State &state)
{
for (auto _ : state)
{
auto parsed = recentmessages::detail::parseRecentMessages(
this->messages.object());
benchmark::DoNotOptimize(parsed);
}
}
};
class BuildRecentMessages : public RecentMessages
{
public:
explicit BuildRecentMessages(const QString &name_)
: RecentMessages(name_)
{
}
void run(benchmark::State &state)
{
auto parsed = recentmessages::detail::parseRecentMessages(
this->messages.object());
for (auto _ : state)
{
auto built = recentmessages::detail::buildRecentMessages(
parsed, &this->chan);
benchmark::DoNotOptimize(built);
}
}
};
void BM_ParseRecentMessages(benchmark::State &state, const QString &name)
{
ParseRecentMessages bench(name);
bench.run(state);
}
void BM_BuildRecentMessages(benchmark::State &state, const QString &name)
{
BuildRecentMessages bench(name);
bench.run(state);
}
} // namespace
BENCHMARK_CAPTURE(BM_ParseRecentMessages, nymn, u"nymn"_s);
BENCHMARK_CAPTURE(BM_BuildRecentMessages, nymn, u"nymn"_s);

View file

@ -1,3 +1,4 @@
#include "singletons/Resources.hpp"
#include "singletons/Settings.hpp"
#include <benchmark/benchmark.h>
@ -11,6 +12,8 @@ int main(int argc, char **argv)
{
QApplication app(argc, argv);
initResources();
::benchmark::Initialize(&argc, argv);
// Ensure settings are initialized before any benchmarks are run

View file

@ -17,61 +17,96 @@ public:
Theme *getThemes() override
{
assert(
false &&
"EmptyApplication::getThemes was called without being initialized");
return nullptr;
}
Fonts *getFonts() override
{
assert(
false &&
"EmptyApplication::getFonts was called without being initialized");
return nullptr;
}
IEmotes *getEmotes() override
{
assert(
false &&
"EmptyApplication::getEmotes was called without being initialized");
return nullptr;
}
AccountController *getAccounts() override
{
assert(false && "EmptyApplication::getAccounts was called without "
"being initialized");
return nullptr;
}
HotkeyController *getHotkeys() override
{
assert(false && "EmptyApplication::getHotkeys was called without being "
"initialized");
return nullptr;
}
WindowManager *getWindows() override
{
assert(false && "EmptyApplication::getWindows was called without being "
"initialized");
return nullptr;
}
Toasts *getToasts() override
{
assert(
false &&
"EmptyApplication::getToasts was called without being initialized");
return nullptr;
}
CrashHandler *getCrashHandler() override
{
assert(false && "EmptyApplication::getCrashHandler was called without "
"being initialized");
return nullptr;
}
CommandController *getCommands() override
{
assert(false && "EmptyApplication::getCommands was called without "
"being initialized");
return nullptr;
}
NotificationController *getNotifications() override
{
assert(false && "EmptyApplication::getNotifications was called without "
"being initialized");
return nullptr;
}
HighlightController *getHighlights() override
{
assert(false && "EmptyApplication::getHighlights was called without "
"being initialized");
return nullptr;
}
ITwitchIrcServer *getTwitch() override
{
assert(
false &&
"EmptyApplication::getTwitch was called without being initialized");
return nullptr;
}
PubSub *getTwitchPubSub() override
{
assert(false && "getTwitchPubSub was called without being initialized");
return nullptr;
}
@ -83,11 +118,15 @@ public:
ChatterinoBadges *getChatterinoBadges() override
{
assert(false && "EmptyApplication::getChatterinoBadges was called "
"without being initialized");
return nullptr;
}
FfzBadges *getFfzBadges() override
{
assert(false && "EmptyApplication::getFfzBadges was called without "
"being initialized");
return nullptr;
}
@ -99,6 +138,8 @@ public:
IUserDataController *getUserData() override
{
assert(false && "EmptyApplication::getUserData was called without "
"being initialized");
return nullptr;
}
@ -110,11 +151,15 @@ public:
ITwitchLiveController *getTwitchLiveController() override
{
assert(false && "EmptyApplication::getTwitchLiveController was called "
"without being initialized");
return nullptr;
}
ImageUploader *getImageUploader() override
{
assert(false && "EmptyApplication::getImageUploader was called without "
"being initialized");
return nullptr;
}

BIN
resources/avatars/fraxx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

View file

@ -67,6 +67,7 @@ chrrs | https://github.com/chrrs | | Contributor
4rneee | https://github.com/4rneee | | Contributor
crazysmc | https://github.com/crazysmc | :/avatars/crazysmc.png | Contributor
SputNikPlop | https://github.com/SputNikPlop | | Contributor
fraxx | https://github.com/fraxxio | :/avatars/fraxx.png | Contributor
# If you are a contributor add yourself above this line

View file

@ -87,6 +87,8 @@ ISoundController *makeSoundController(Settings &settings)
}
}
const QString TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv";
} // namespace
namespace chatterino {
@ -128,6 +130,7 @@ Application::Application(Settings &_settings, Paths &_paths, const Args &_args)
, userData(&this->emplace<UserDataController>())
, sound(&this->emplace<ISoundController>(makeSoundController(_settings)))
, twitchLiveController(&this->emplace<TwitchLiveController>())
, twitchPubSub(new PubSub(TWITCH_PUBSUB_URL))
, logging(new Logging(_settings))
#ifdef CHATTERINO_HAVE_PLUGINS
, plugins(&this->emplace<PluginController>())
@ -144,6 +147,11 @@ Application::Application(Settings &_settings, Paths &_paths, const Args &_args)
Application::~Application() = default;
void Application::fakeDtor()
{
this->twitchPubSub.reset();
}
void Application::initialize(Settings &settings, Paths &paths)
{
assert(isAppInitialized == false);
@ -314,6 +322,11 @@ ITwitchIrcServer *Application::getTwitch()
return this->twitch;
}
PubSub *Application::getTwitchPubSub()
{
return this->twitchPubSub.get();
}
Logging *Application::getChatLogger()
{
return this->logging.get();
@ -343,7 +356,7 @@ void Application::initPubSub()
{
// We can safely ignore these signal connections since the twitch object will always
// be destroyed before the Application
std::ignore = this->twitch->pubsub->signals_.moderation.chatCleared.connect(
std::ignore = this->twitchPubSub->moderation.chatCleared.connect(
[this](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
@ -352,7 +365,7 @@ void Application::initPubSub()
}
QString text =
QString("%1 cleared the chat").arg(action.source.login);
QString("%1 cleared the chat.").arg(action.source.login);
auto msg = makeSystemMessage(text);
postToThread([chan, msg] {
@ -360,7 +373,7 @@ void Application::initPubSub()
});
});
std::ignore = this->twitch->pubsub->signals_.moderation.modeChanged.connect(
std::ignore = this->twitchPubSub->moderation.modeChanged.connect(
[this](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
@ -369,7 +382,7 @@ void Application::initPubSub()
}
QString text =
QString("%1 turned %2 %3 mode")
QString("%1 turned %2 %3 mode.")
.arg(action.source.login)
.arg(action.state == ModeChangedAction::State::On ? "on"
: "off")
@ -386,29 +399,28 @@ void Application::initPubSub()
});
});
std::ignore =
this->twitch->pubsub->signals_.moderation.moderationStateChanged
.connect([this](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
std::ignore = this->twitchPubSub->moderation.moderationStateChanged.connect(
[this](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
QString text;
QString text;
text = QString("%1 %2 %3")
.arg(action.source.login,
(action.modded ? "modded" : "unmodded"),
action.target.login);
text = QString("%1 %2 %3.")
.arg(action.source.login,
(action.modded ? "modded" : "unmodded"),
action.target.login);
auto msg = makeSystemMessage(text);
postToThread([chan, msg] {
chan->addMessage(msg);
});
auto msg = makeSystemMessage(text);
postToThread([chan, msg] {
chan->addMessage(msg);
});
});
std::ignore = this->twitch->pubsub->signals_.moderation.userBanned.connect(
std::ignore = this->twitchPubSub->moderation.userBanned.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
@ -423,67 +435,65 @@ void Application::initPubSub()
chan->addOrReplaceTimeout(msg.release());
});
});
std::ignore =
this->twitch->pubsub->signals_.moderation.messageDeleted.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
std::ignore = this->twitchPubSub->moderation.messageDeleted.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty() || getSettings()->hideDeletionActions)
if (chan->isEmpty() || getSettings()->hideDeletionActions)
{
return;
}
MessageBuilder msg;
TwitchMessageBuilder::deletionMessage(action, &msg);
msg->flags.set(MessageFlag::PubSub);
postToThread([chan, msg = msg.release()] {
auto replaced = false;
LimitedQueueSnapshot<MessagePtr> snapshot =
chan->getMessageSnapshot();
int snapshotLength = snapshot.size();
// without parens it doesn't build on windows
int end = (std::max)(0, snapshotLength - 200);
for (int i = snapshotLength - 1; i >= end; --i)
{
return;
auto &s = snapshot[i];
if (!s->flags.has(MessageFlag::PubSub) &&
s->timeoutUser == msg->timeoutUser)
{
chan->replaceMessage(s, msg);
replaced = true;
break;
}
}
MessageBuilder msg;
TwitchMessageBuilder::deletionMessage(action, &msg);
msg->flags.set(MessageFlag::PubSub);
postToThread([chan, msg = msg.release()] {
auto replaced = false;
LimitedQueueSnapshot<MessagePtr> snapshot =
chan->getMessageSnapshot();
int snapshotLength = snapshot.size();
// without parens it doesn't build on windows
int end = (std::max)(0, snapshotLength - 200);
for (int i = snapshotLength - 1; i >= end; --i)
{
auto &s = snapshot[i];
if (!s->flags.has(MessageFlag::PubSub) &&
s->timeoutUser == msg->timeoutUser)
{
chan->replaceMessage(s, msg);
replaced = true;
break;
}
}
if (!replaced)
{
chan->addMessage(msg);
}
});
});
std::ignore =
this->twitch->pubsub->signals_.moderation.userUnbanned.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
if (!replaced)
{
return;
}
auto msg = MessageBuilder(action).release();
postToThread([chan, msg] {
chan->addMessage(msg);
});
}
});
});
std::ignore = this->twitchPubSub->moderation.userUnbanned.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
auto msg = MessageBuilder(action).release();
postToThread([chan, msg] {
chan->addMessage(msg);
});
});
std::ignore =
this->twitch->pubsub->signals_.moderation.suspiciousMessageReceived
.connect([&](const auto &action) {
this->twitchPubSub->moderation.suspiciousMessageReceived.connect(
[&](const auto &action) {
if (action.treatment ==
PubSubLowTrustUsersMessage::Treatment::INVALID)
{
@ -515,18 +525,26 @@ void Application::initPubSub()
return;
}
postToThread([chan, action] {
auto twitchChannel =
std::dynamic_pointer_cast<TwitchChannel>(chan);
if (!twitchChannel)
{
return;
}
postToThread([twitchChannel, action] {
const auto p =
TwitchMessageBuilder::makeLowTrustUserMessage(
action, chan->getName());
chan->addMessage(p.first);
chan->addMessage(p.second);
action, twitchChannel->getName(),
twitchChannel.get());
twitchChannel->addMessage(p.first);
twitchChannel->addMessage(p.second);
});
});
std::ignore =
this->twitch->pubsub->signals_.moderation.suspiciousTreatmentUpdated
.connect([&](const auto &action) {
this->twitchPubSub->moderation.suspiciousTreatmentUpdated.connect(
[&](const auto &action) {
if (action.treatment ==
PubSubLowTrustUsersMessage::Treatment::INVALID)
{
@ -562,170 +580,162 @@ void Application::initPubSub()
});
});
std::ignore =
this->twitch->pubsub->signals_.moderation.autoModMessageCaught.connect(
[&](const auto &msg, const QString &channelID) {
auto chan = this->twitch->getChannelOrEmptyByID(channelID);
if (chan->isEmpty())
{
return;
}
std::ignore = this->twitchPubSub->moderation.autoModMessageCaught.connect(
[&](const auto &msg, const QString &channelID) {
auto chan = this->twitch->getChannelOrEmptyByID(channelID);
if (chan->isEmpty())
{
return;
}
switch (msg.type)
{
case PubSubAutoModQueueMessage::Type::
AutoModCaughtMessage: {
if (msg.status == "PENDING")
switch (msg.type)
{
case PubSubAutoModQueueMessage::Type::AutoModCaughtMessage: {
if (msg.status == "PENDING")
{
AutomodAction action(msg.data, channelID);
action.reason = QString("%1 level %2")
.arg(msg.contentCategory)
.arg(msg.contentLevel);
action.msgID = msg.messageID;
action.message = msg.messageText;
// this message also contains per-word automod data, which could be implemented
// extract sender data manually because Twitch loves not being consistent
QString senderDisplayName =
msg.senderUserDisplayName; // Might be transformed later
bool hasLocalizedName = false;
if (!msg.senderUserDisplayName.isEmpty())
{
AutomodAction action(msg.data, channelID);
action.reason = QString("%1 level %2")
.arg(msg.contentCategory)
.arg(msg.contentLevel);
action.msgID = msg.messageID;
action.message = msg.messageText;
// this message also contains per-word automod data, which could be implemented
// extract sender data manually because Twitch loves not being consistent
QString senderDisplayName =
msg.senderUserDisplayName; // Might be transformed later
bool hasLocalizedName = false;
if (!msg.senderUserDisplayName.isEmpty())
// check for non-ascii display names
if (QString::compare(msg.senderUserDisplayName,
msg.senderUserLogin,
Qt::CaseInsensitive) != 0)
{
// check for non-ascii display names
if (QString::compare(msg.senderUserDisplayName,
msg.senderUserLogin,
Qt::CaseInsensitive) != 0)
{
hasLocalizedName = true;
}
hasLocalizedName = true;
}
QColor senderColor = msg.senderUserChatColor;
QString senderColor_;
if (!senderColor.isValid() &&
getSettings()->colorizeNicknames)
{
// color may be not present if user is a grey-name
senderColor = getRandomColor(msg.senderUserID);
}
// handle username style based on prefered setting
switch (
getSettings()->usernameDisplayMode.getValue())
{
case UsernameDisplayMode::Username: {
if (hasLocalizedName)
{
senderDisplayName = msg.senderUserLogin;
}
break;
}
case UsernameDisplayMode::LocalizedName: {
break;
}
case UsernameDisplayMode::
UsernameAndLocalizedName: {
if (hasLocalizedName)
{
senderDisplayName =
QString("%1(%2)").arg(
msg.senderUserLogin,
msg.senderUserDisplayName);
}
break;
}
}
action.target = ActionUser{
msg.senderUserID, msg.senderUserLogin,
senderDisplayName, senderColor};
postToThread([chan, action] {
const auto p =
TwitchMessageBuilder::makeAutomodMessage(
action, chan->getName());
chan->addMessage(p.first);
chan->addMessage(p.second);
getApp()->twitch->automodChannel->addMessage(
p.first);
getApp()->twitch->automodChannel->addMessage(
p.second);
});
}
// "ALLOWED" and "DENIED" statuses remain unimplemented
// They are versions of automod_message_(denied|approved) but for mods.
QColor senderColor = msg.senderUserChatColor;
QString senderColor_;
if (!senderColor.isValid() &&
getSettings()->colorizeNicknames)
{
// color may be not present if user is a grey-name
senderColor = getRandomColor(msg.senderUserID);
}
// handle username style based on prefered setting
switch (getSettings()->usernameDisplayMode.getValue())
{
case UsernameDisplayMode::Username: {
if (hasLocalizedName)
{
senderDisplayName = msg.senderUserLogin;
}
break;
}
case UsernameDisplayMode::LocalizedName: {
break;
}
case UsernameDisplayMode::
UsernameAndLocalizedName: {
if (hasLocalizedName)
{
senderDisplayName = QString("%1(%2)").arg(
msg.senderUserLogin,
msg.senderUserDisplayName);
}
break;
}
}
action.target =
ActionUser{msg.senderUserID, msg.senderUserLogin,
senderDisplayName, senderColor};
postToThread([chan, action] {
const auto p =
TwitchMessageBuilder::makeAutomodMessage(
action, chan->getName());
chan->addMessage(p.first);
chan->addMessage(p.second);
getApp()->twitch->automodChannel->addMessage(
p.first);
getApp()->twitch->automodChannel->addMessage(
p.second);
});
}
break;
case PubSubAutoModQueueMessage::Type::INVALID:
default: {
}
break;
// "ALLOWED" and "DENIED" statuses remain unimplemented
// They are versions of automod_message_(denied|approved) but for mods.
}
break;
case PubSubAutoModQueueMessage::Type::INVALID:
default: {
}
break;
}
});
std::ignore = this->twitchPubSub->moderation.autoModMessageBlocked.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
postToThread([chan, action] {
const auto p = TwitchMessageBuilder::makeAutomodMessage(
action, chan->getName());
chan->addMessage(p.first);
chan->addMessage(p.second);
});
});
std::ignore = this->twitchPubSub->moderation.automodUserMessage.connect(
[&](const auto &action) {
// This condition has been set up to execute isInStreamerMode() as the last thing
// as it could end up being expensive.
if (getSettings()->streamerModeHideModActions && isInStreamerMode())
{
return;
}
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
auto msg = MessageBuilder(action).release();
postToThread([chan, msg] {
chan->addMessage(msg);
});
chan->deleteMessage(msg->id);
});
std::ignore = this->twitchPubSub->moderation.automodInfoMessage.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
postToThread([chan, action] {
const auto p =
TwitchMessageBuilder::makeAutomodInfoMessage(action);
chan->addMessage(p);
});
});
std::ignore =
this->twitch->pubsub->signals_.moderation.autoModMessageBlocked.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
postToThread([chan, action] {
const auto p = TwitchMessageBuilder::makeAutomodMessage(
action, chan->getName());
chan->addMessage(p.first);
chan->addMessage(p.second);
});
});
std::ignore =
this->twitch->pubsub->signals_.moderation.automodUserMessage.connect(
[&](const auto &action) {
// This condition has been set up to execute isInStreamerMode() as the last thing
// as it could end up being expensive.
if (getSettings()->streamerModeHideModActions &&
isInStreamerMode())
{
return;
}
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
auto msg = MessageBuilder(action).release();
postToThread([chan, msg] {
chan->addMessage(msg);
});
chan->deleteMessage(msg->id);
});
std::ignore =
this->twitch->pubsub->signals_.moderation.automodInfoMessage.connect(
[&](const auto &action) {
auto chan = this->twitch->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty())
{
return;
}
postToThread([chan, action] {
const auto p =
TwitchMessageBuilder::makeAutomodInfoMessage(action);
chan->addMessage(p);
});
});
std::ignore = this->twitch->pubsub->signals_.pointReward.redeemed.connect(
[&](auto &data) {
this->twitchPubSub->pointReward.redeemed.connect([&](auto &data) {
QString channelId = data.value("channel_id").toString();
if (channelId.isEmpty())
{
@ -746,29 +756,19 @@ void Application::initPubSub()
});
});
this->twitch->pubsub->start();
auto RequestModerationActions = [this]() {
this->twitch->pubsub->setAccount(
getApp()->accounts->twitch.getCurrent());
// TODO(pajlada): Unlisten to all authed topics instead of only
// moderation topics this->twitch->pubsub->UnlistenAllAuthedTopics();
this->twitch->pubsub->listenToWhispers();
};
this->twitchPubSub->start();
this->twitchPubSub->setAccount(this->accounts->twitch.getCurrent());
this->accounts->twitch.currentUserChanged.connect(
[this] {
this->twitch->pubsub->unlistenAllModerationActions();
this->twitch->pubsub->unlistenAutomod();
this->twitch->pubsub->unlistenLowTrustUsers();
this->twitch->pubsub->unlistenWhispers();
this->twitchPubSub->unlistenChannelModerationActions();
this->twitchPubSub->unlistenAutomod();
this->twitchPubSub->unlistenLowTrustUsers();
this->twitchPubSub->unlistenChannelPointRewards();
this->twitchPubSub->setAccount(this->accounts->twitch.getCurrent());
},
boost::signals2::at_front);
this->accounts->twitch.currentUserChanged.connect(RequestModerationActions);
RequestModerationActions();
}
void Application::initBttvLiveUpdates()

View file

@ -68,6 +68,7 @@ public:
virtual HighlightController *getHighlights() = 0;
virtual NotificationController *getNotifications() = 0;
virtual ITwitchIrcServer *getTwitch() = 0;
virtual PubSub *getTwitchPubSub() = 0;
virtual Logging *getChatLogger() = 0;
virtual ChatterinoBadges *getChatterinoBadges() = 0;
virtual FfzBadges *getFfzBadges() = 0;
@ -97,6 +98,12 @@ public:
Application &operator=(const Application &) = delete;
Application &operator=(Application &&) = delete;
/**
* In the interim, before we remove _exit(0); from RunGui.cpp,
* this will destroy things we know can be destroyed
*/
void fakeDtor();
void initialize(Settings &settings, Paths &paths);
void load();
void save();
@ -128,6 +135,7 @@ public:
private:
TwitchLiveController *const twitchLiveController{};
std::unique_ptr<PubSub> twitchPubSub;
const std::unique_ptr<Logging> logging;
public:
@ -181,6 +189,7 @@ public:
return this->highlights;
}
ITwitchIrcServer *getTwitch() override;
PubSub *getTwitchPubSub() override;
Logging *getChatLogger() override;
ChatterinoBadges *getChatterinoBadges() override
{

View file

@ -591,12 +591,25 @@ set(SOURCE_FILES
widgets/dialogs/switcher/SwitchSplitItem.cpp
widgets/dialogs/switcher/SwitchSplitItem.hpp
widgets/helper/color/AlphaSlider.cpp
widgets/helper/color/AlphaSlider.hpp
widgets/helper/color/Checkerboard.cpp
widgets/helper/color/Checkerboard.hpp
widgets/helper/color/ColorButton.cpp
widgets/helper/color/ColorButton.hpp
widgets/helper/color/ColorInput.cpp
widgets/helper/color/ColorInput.hpp
widgets/helper/color/ColorItemDelegate.cpp
widgets/helper/color/ColorItemDelegate.hpp
widgets/helper/color/HueSlider.cpp
widgets/helper/color/HueSlider.hpp
widgets/helper/color/SBCanvas.cpp
widgets/helper/color/SBCanvas.hpp
widgets/helper/Button.cpp
widgets/helper/Button.hpp
widgets/helper/ChannelView.cpp
widgets/helper/ChannelView.hpp
widgets/helper/ColorButton.cpp
widgets/helper/ColorButton.hpp
widgets/helper/ComboBoxItemDelegate.cpp
widgets/helper/ComboBoxItemDelegate.hpp
widgets/helper/DebugPopup.cpp
@ -611,8 +624,6 @@ set(SOURCE_FILES
widgets/helper/NotebookButton.hpp
widgets/helper/NotebookTab.cpp
widgets/helper/NotebookTab.hpp
widgets/helper/QColorPicker.cpp
widgets/helper/QColorPicker.hpp
widgets/helper/RegExpItemDelegate.cpp
widgets/helper/RegExpItemDelegate.hpp
widgets/helper/TrimRegExpValidator.cpp

View file

@ -294,5 +294,6 @@ void runGui(QApplication &a, Paths &paths, Settings &settings, const Args &args)
// flushing windows clipboard to keep copied messages
flushClipboard();
#endif
app.fakeDtor();
}
} // namespace chatterino

View file

@ -66,7 +66,7 @@ QString uptime(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /uptime command only works in Twitch Channels"));
"The /uptime command only works in Twitch Channels."));
return "";
}
@ -188,14 +188,14 @@ QString clip(const CommandContext &ctx)
type != Channel::Type::Twitch && type != Channel::Type::TwitchWatching)
{
ctx.channel->addMessage(makeSystemMessage(
"The /clip command only works in Twitch Channels"));
"The /clip command only works in Twitch Channels."));
return "";
}
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /clip command only works in Twitch Channels"));
"The /clip command only works in Twitch Channels."));
return "";
}
@ -214,7 +214,7 @@ QString marker(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /marker command only works in Twitch channels"));
"The /marker command only works in Twitch channels."));
return "";
}
@ -520,7 +520,7 @@ QString unstableSetUserClientSideColor(const CommandContext &ctx)
{
ctx.channel->addMessage(
makeSystemMessage("The /unstable-set-user-color command only "
"works in Twitch channels"));
"works in Twitch channels."));
return "";
}
if (ctx.words.size() < 2)

View file

@ -23,7 +23,7 @@ QString addModerator(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /mod command only works in Twitch channels"));
"The /mod command only works in Twitch channels."));
return "";
}
if (ctx.words.size() < 2)

View file

@ -23,7 +23,7 @@ QString addVIP(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /vip command only works in Twitch channels"));
"The /vip command only works in Twitch channels."));
return "";
}
if (ctx.words.size() < 2)

View file

@ -37,7 +37,7 @@ QString sendAnnouncement(const CommandContext &ctx)
if (user->isAnon())
{
ctx.channel->addMessage(makeSystemMessage(
"You must be logged in to use the /announce command"));
"You must be logged in to use the /announce command."));
return "";
}

View file

@ -133,7 +133,7 @@ QString sendBan(const CommandContext &ctx)
if (twitchChannel == nullptr)
{
channel->addMessage(makeSystemMessage(
QString("The /ban command only works in Twitch channels")));
QString("The /ban command only works in Twitch channels.")));
return "";
}
@ -196,7 +196,7 @@ QString sendBanById(const CommandContext &ctx)
if (twitchChannel == nullptr)
{
channel->addMessage(makeSystemMessage(
QString("The /banid command only works in Twitch channels")));
QString("The /banid command only works in Twitch channels.")));
return "";
}
@ -241,7 +241,7 @@ QString sendTimeout(const CommandContext &ctx)
if (twitchChannel == nullptr)
{
channel->addMessage(makeSystemMessage(
QString("The /timeout command only works in Twitch channels")));
QString("The /timeout command only works in Twitch channels.")));
return "";
}
const auto *usageStr =

View file

@ -27,7 +27,7 @@ QString blockUser(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /block command only works in Twitch channels"));
"The /block command only works in Twitch channels."));
return "";
}
@ -101,7 +101,7 @@ QString unblockUser(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /unblock command only works in Twitch channels"));
"The /unblock command only works in Twitch channels."));
return "";
}

View file

@ -111,7 +111,7 @@ QString emoteOnly(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /emoteonly command only works in Twitch channels"));
"The /emoteonly command only works in Twitch channels."));
return "";
}
@ -140,7 +140,7 @@ QString emoteOnlyOff(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /emoteonlyoff command only works in Twitch channels"));
"The /emoteonlyoff command only works in Twitch channels."));
return "";
}
@ -170,7 +170,7 @@ QString subscribers(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /subscribers command only works in Twitch channels"));
"The /subscribers command only works in Twitch channels."));
return "";
}
@ -200,7 +200,7 @@ QString subscribersOff(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /subscribersoff command only works in Twitch channels"));
"The /subscribersoff command only works in Twitch channels."));
return "";
}
@ -230,7 +230,7 @@ QString slow(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /slow command only works in Twitch channels"));
"The /slow command only works in Twitch channels."));
return "";
}
@ -277,7 +277,7 @@ QString slowOff(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /slowoff command only works in Twitch channels"));
"The /slowoff command only works in Twitch channels."));
return "";
}
@ -307,7 +307,7 @@ QString followers(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /followers command only works in Twitch channels"));
"The /followers command only works in Twitch channels."));
return "";
}
@ -355,7 +355,7 @@ QString followersOff(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /followersoff command only works in Twitch channels"));
"The /followersoff command only works in Twitch channels."));
return "";
}
@ -385,7 +385,7 @@ QString uniqueChat(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /uniquechat command only works in Twitch channels"));
"The /uniquechat command only works in Twitch channels."));
return "";
}
@ -415,7 +415,7 @@ QString uniqueChatOff(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /uniquechatoff command only works in Twitch channels"));
"The /uniquechatoff command only works in Twitch channels."));
return "";
}

View file

@ -70,7 +70,7 @@ QString chatters(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /chatters command only works in Twitch Channels"));
"The /chatters command only works in Twitch Channels."));
return "";
}
@ -80,7 +80,7 @@ QString chatters(const CommandContext &ctx)
getApp()->accounts->twitch.getCurrent()->getUserId(), 1,
[channel{ctx.channel}](auto result) {
channel->addMessage(
makeSystemMessage(QString("Chatter count: %1")
makeSystemMessage(QString("Chatter count: %1.")
.arg(localizeNumbers(result.total))));
},
[channel{ctx.channel}](auto error, auto message) {
@ -101,7 +101,7 @@ QString testChatters(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /test-chatters command only works in Twitch Channels"));
"The /test-chatters command only works in Twitch Channels."));
return "";
}

View file

@ -102,7 +102,7 @@ QString deleteAllMessages(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /clear command only works in Twitch channels"));
"The /clear command only works in Twitch channels."));
return "";
}
@ -121,7 +121,7 @@ QString deleteOneMessage(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /delete command only works in Twitch channels"));
"The /delete command only works in Twitch channels."));
return "";
}

View file

@ -61,7 +61,7 @@ QString getModerators(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /mods command only works in Twitch Channels"));
"The /mods command only works in Twitch Channels."));
return "";
}

View file

@ -78,7 +78,7 @@ QString getVIPs(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /vips command only works in Twitch channels"));
"The /vips command only works in Twitch channels."));
return "";
}

View file

@ -125,7 +125,7 @@ QString startRaid(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /raid command only works in Twitch channels"));
"The /raid command only works in Twitch channels."));
return "";
}
@ -183,7 +183,7 @@ QString cancelRaid(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /unraid command only works in Twitch channels"));
"The /unraid command only works in Twitch channels."));
return "";
}

View file

@ -23,7 +23,7 @@ QString removeModerator(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /unmod command only works in Twitch channels"));
"The /unmod command only works in Twitch channels."));
return "";
}
if (ctx.words.size() < 2)

View file

@ -23,7 +23,7 @@ QString removeVIP(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /unvip command only works in Twitch channels"));
"The /unvip command only works in Twitch channels."));
return "";
}
if (ctx.words.size() < 2)

View file

@ -20,7 +20,7 @@ QString sendReply(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /reply command only works in Twitch channels"));
"The /reply command only works in Twitch channels."));
return "";
}
@ -55,7 +55,7 @@ QString sendReply(const CommandContext &ctx)
}
ctx.channel->addMessage(
makeSystemMessage("A message from that user wasn't found"));
makeSystemMessage("A message from that user wasn't found."));
return "";
}

View file

@ -18,7 +18,7 @@ QString toggleShieldMode(const CommandContext &ctx, bool isActivating)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
QStringLiteral("The %1 command only works in Twitch channels")
QStringLiteral("The %1 command only works in Twitch channels.")
.arg(command)));
return {};
}
@ -29,7 +29,7 @@ QString toggleShieldMode(const CommandContext &ctx, bool isActivating)
if (user->isAnon())
{
ctx.channel->addMessage(makeSystemMessage(
QStringLiteral("You must be logged in to use the %1 command")
QStringLiteral("You must be logged in to use the %1 command.")
.arg(command)));
return {};
}

View file

@ -20,7 +20,7 @@ QString sendShoutout(const CommandContext &ctx)
if (twitchChannel == nullptr)
{
channel->addMessage(makeSystemMessage(
"The /shoutout command only works in Twitch channels"));
"The /shoutout command only works in Twitch channels."));
return "";
}
@ -28,7 +28,7 @@ QString sendShoutout(const CommandContext &ctx)
if (currentUser->isAnon())
{
channel->addMessage(
makeSystemMessage("You must be logged in to send shoutout"));
makeSystemMessage("You must be logged in to send shoutout."));
return "";
}

View file

@ -84,7 +84,7 @@ QString startCommercial(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
"The /commercial command only works in Twitch channels"));
"The /commercial command only works in Twitch channels."));
return "";
}
@ -106,7 +106,7 @@ QString startCommercial(const CommandContext &ctx)
if (user->isAnon())
{
ctx.channel->addMessage(makeSystemMessage(
"You must be logged in to use the /commercial command"));
"You must be logged in to use the /commercial command."));
return "";
}

View file

@ -94,7 +94,7 @@ QString unbanUser(const CommandContext &ctx)
if (ctx.twitchChannel == nullptr)
{
ctx.channel->addMessage(makeSystemMessage(
QString("The %1 command only works in Twitch channels")
QString("The %1 command only works in Twitch channels.")
.arg(commandName)));
return "";
}

View file

@ -22,7 +22,7 @@ QString updateUserColor(const CommandContext &ctx)
if (!ctx.channel->isTwitchChannel())
{
ctx.channel->addMessage(makeSystemMessage(
"The /color command only works in Twitch channels"));
"The /color command only works in Twitch channels."));
return "";
}
auto user = getApp()->accounts->twitch.getCurrent();
@ -31,7 +31,7 @@ QString updateUserColor(const CommandContext &ctx)
if (user->isAnon())
{
ctx.channel->addMessage(makeSystemMessage(
"You must be logged in to use the /color command"));
"You must be logged in to use the /color command."));
return "";
}

View file

@ -44,6 +44,8 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel)
* flags.whisper
* flags.reply
* flags.automod
* flags.restricted
* flags.monitored
*
* message.content
* message.length
@ -101,6 +103,8 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel)
{"flags.whisper", m->flags.has(MessageFlag::Whisper)},
{"flags.reply", m->flags.has(MessageFlag::ReplyMessage)},
{"flags.automod", m->flags.has(MessageFlag::AutoMod)},
{"flags.restricted", m->flags.has(MessageFlag::RestrictedMessage)},
{"flags.monitored", m->flags.has(MessageFlag::MonitoredMessage)},
{"message.content", m->messageText},
{"message.length", m->messageText.length()},

View file

@ -44,6 +44,8 @@ static const QMap<QString, Type> MESSAGE_TYPING_CONTEXT = {
{"flags.whisper", Type::Bool},
{"flags.reply", Type::Bool},
{"flags.automod", Type::Bool},
{"flags.restricted", Type::Bool},
{"flags.monitored", Type::Bool},
{"message.content", Type::String},
{"message.length", Type::Int},
};

View file

@ -32,6 +32,8 @@ static const QMap<QString, QString> validIdentifiersMap = {
{"flags.whisper", "whisper message?"},
{"flags.reply", "reply message?"},
{"flags.automod", "automod message?"},
{"flags.restricted", "restricted message?"},
{"flags.monitored", "monitored message?"},
{"message.content", "message text"},
{"message.length", "message length"}};

View file

@ -1,7 +1,6 @@
#include "UserHighlightModel.hpp"
#include "controllers/highlights/UserHighlightModel.hpp"
#include "Application.hpp"
#include "controllers/highlights/HighlightModel.hpp"
#include "controllers/highlights/HighlightPhrase.hpp"
#include "providers/colors/ColorProvider.hpp"
#include "singletons/Settings.hpp"
@ -10,8 +9,6 @@
namespace chatterino {
using Column = HighlightModel::Column;
// commandmodel
UserHighlightModel::UserHighlightModel(QObject *parent)
: SignalVectorModel<HighlightPhrase>(Column::COUNT, parent)

View file

@ -1,6 +1,7 @@
#pragma once
#include "common/SignalVectorModel.hpp"
#include "controllers/highlights/HighlightModel.hpp"
#include <QObject>
@ -12,6 +13,8 @@ class HighlightPhrase;
class UserHighlightModel : public SignalVectorModel<HighlightPhrase>
{
public:
using Column = HighlightModel::Column;
explicit UserHighlightModel(QObject *parent);
protected:

View file

@ -32,9 +32,11 @@ bool isIgnoredMessage(IgnoredMessageParameters &&params)
{
auto sourceUserID = params.twitchUserID;
bool isBlocked =
getApp()->accounts->twitch.getCurrent()->blockedUserIds().contains(
sourceUserID);
bool isBlocked = getIApp()
->getAccounts()
->twitch.getCurrent()
->blockedUserIds()
.contains(sourceUserID);
if (isBlocked)
{
switch (static_cast<ShowIgnoredUsersMessages>(

View file

@ -53,6 +53,10 @@ enum class MessageFlag : int64_t {
/// The message caught by AutoMod containing the user who sent the message & its contents
AutoModOffendingMessage = (1LL << 31),
LowTrustUsers = (1LL << 32),
/// The message is sent by a user marked as restricted with Twitch's "Low Trust"/"Suspicious User" feature
RestrictedMessage = (1LL << 33),
/// The message is sent by a user marked as monitor with Twitch's "Low Trust"/"Suspicious User" feature
MonitoredMessage = (1LL << 34),
};
using MessageFlags = FlagsEnum<MessageFlag>;

View file

@ -255,7 +255,8 @@ MessageBuilder::MessageBuilder(const BanAction &action, uint32_t count)
this->emplaceSystemTextAndUpdate("banned", text);
if (action.reason.isEmpty())
{
this->emplaceSystemTextAndUpdate(action.target.login, text)
this->emplaceSystemTextAndUpdate(action.target.login + ".",
text)
->setLink({Link::UserInfo, action.target.login});
}
else
@ -315,7 +316,7 @@ MessageBuilder::MessageBuilder(const UnbanAction &action)
->setLink({Link::UserInfo, action.source.login});
this->emplaceSystemTextAndUpdate(
action.wasBan() ? "unbanned" : "untimedout", text);
this->emplaceSystemTextAndUpdate(action.target.login, text)
this->emplaceSystemTextAndUpdate(action.target.login + ".", text)
->setLink({Link::UserInfo, action.target.login});
this->message().messageText = text;

View file

@ -52,6 +52,14 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags, bool negate)
{
this->flags_.set(MessageFlag::ReplyMessage);
}
else if (flag == "restricted")
{
this->flags_.set(MessageFlag::RestrictedMessage);
}
else if (flag == "monitored")
{
this->flags_.set(MessageFlag::MonitoredMessage);
}
}
}

View file

@ -142,31 +142,33 @@ namespace {
return anyModifications;
}
std::pair<Outcome, EmoteMap> parseChannelEmotes(
const QJsonObject &jsonRoot, const QString &channelDisplayName)
{
auto emotes = EmoteMap();
auto innerParse = [&jsonRoot, &emotes,
&channelDisplayName](const char *key) {
auto jsonEmotes = jsonRoot.value(key).toArray();
for (auto jsonEmote_ : jsonEmotes)
{
auto emote = createChannelEmote(channelDisplayName,
jsonEmote_.toObject());
emotes[emote.name] =
cachedOrMake(std::move(emote.emote), emote.id);
}
};
innerParse("channelEmotes");
innerParse("sharedEmotes");
return {Success, std::move(emotes)};
}
} // namespace
using namespace bttv::detail;
EmoteMap bttv::detail::parseChannelEmotes(const QJsonObject &jsonRoot,
const QString &channelDisplayName)
{
auto emotes = EmoteMap();
auto innerParse = [&jsonRoot, &emotes,
&channelDisplayName](const char *key) {
auto jsonEmotes = jsonRoot.value(key).toArray();
for (auto jsonEmote_ : jsonEmotes)
{
auto emote =
createChannelEmote(channelDisplayName, jsonEmote_.toObject());
emotes[emote.name] = cachedOrMake(std::move(emote.emote), emote.id);
}
};
innerParse("channelEmotes");
innerParse("sharedEmotes");
return emotes;
}
//
// BttvEmotes
//
@ -230,14 +232,11 @@ void BttvEmotes::loadChannel(std::weak_ptr<Channel> channel,
.timeout(20000)
.onSuccess([callback = std::move(callback), channel, channelDisplayName,
manualRefresh](auto result) {
auto pair =
auto emotes =
parseChannelEmotes(result.parseJson(), channelDisplayName);
bool hasEmotes = false;
if (pair.first)
{
hasEmotes = !pair.second.empty();
callback(std::move(pair.second));
}
bool hasEmotes = !emotes.empty();
callback(std::move(emotes));
if (auto shared = channel.lock(); manualRefresh)
{
if (hasEmotes)

View file

@ -3,6 +3,8 @@
#include "common/Aliases.hpp"
#include "common/Atomic.hpp"
#include <QJsonObject>
#include <memory>
#include <optional>
@ -15,6 +17,13 @@ class Channel;
struct BttvLiveUpdateEmoteUpdateAddMessage;
struct BttvLiveUpdateEmoteRemoveMessage;
namespace bttv::detail {
EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot,
const QString &channelDisplayName);
} // namespace bttv::detail
class BttvEmotes final
{
static constexpr const char *globalEmoteApiUrl =

View file

@ -149,19 +149,22 @@ namespace {
return authorityBadge;
}
EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot)
{
auto emotes = EmoteMap();
for (const auto emoteSetRef : jsonRoot["sets"].toObject())
{
parseEmoteSetInto(emoteSetRef.toObject(), "Channel", emotes);
}
return emotes;
}
} // namespace
using namespace ffz::detail;
EmoteMap ffz::detail::parseChannelEmotes(const QJsonObject &jsonRoot)
{
auto emotes = EmoteMap();
for (const auto emoteSetRef : jsonRoot["sets"].toObject())
{
parseEmoteSetInto(emoteSetRef.toObject(), "Channel", emotes);
}
return emotes;
}
FfzEmotes::FfzEmotes()
: global_(std::make_shared<EmoteMap>())
{

View file

@ -3,6 +3,8 @@
#include "common/Aliases.hpp"
#include "common/Atomic.hpp"
#include <QJsonObject>
#include <memory>
#include <optional>
@ -13,6 +15,12 @@ using EmotePtr = std::shared_ptr<const Emote>;
class EmoteMap;
class Channel;
namespace ffz::detail {
EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot);
} // namespace ffz::detail
class FfzEmotes final
{
public:

View file

@ -128,7 +128,34 @@ bool checkEmoteVisibility(const QJsonObject &emoteData)
return !flags.has(SeventvEmoteFlag::ContentTwitchDisallowed);
}
EmoteMap parseEmotes(const QJsonArray &emoteSetEmotes, bool isGlobal)
EmotePtr createUpdatedEmote(const EmotePtr &oldEmote,
const EmoteUpdateDispatch &dispatch)
{
bool toNonAliased = oldEmote->baseName.has_value() &&
dispatch.emoteName == oldEmote->baseName->string;
auto baseName = oldEmote->baseName.value_or(oldEmote->name);
auto emote = std::make_shared<const Emote>(Emote(
{EmoteName{dispatch.emoteName}, oldEmote->images,
toNonAliased
? createTooltip(dispatch.emoteName, oldEmote->author.string, false)
: createAliasedTooltip(dispatch.emoteName, baseName.string,
oldEmote->author.string, false),
oldEmote->homePage, oldEmote->zeroWidth, oldEmote->id,
oldEmote->author, makeConditionedOptional(!toNonAliased, baseName)}));
return emote;
}
} // namespace
namespace chatterino {
using namespace seventv::eventapi;
using namespace seventv::detail;
using namespace literals;
EmoteMap seventv::detail::parseEmotes(const QJsonArray &emoteSetEmotes,
bool isGlobal)
{
auto emotes = EmoteMap();
@ -158,31 +185,6 @@ EmoteMap parseEmotes(const QJsonArray &emoteSetEmotes, bool isGlobal)
return emotes;
}
EmotePtr createUpdatedEmote(const EmotePtr &oldEmote,
const EmoteUpdateDispatch &dispatch)
{
bool toNonAliased = oldEmote->baseName.has_value() &&
dispatch.emoteName == oldEmote->baseName->string;
auto baseName = oldEmote->baseName.value_or(oldEmote->name);
auto emote = std::make_shared<const Emote>(Emote(
{EmoteName{dispatch.emoteName}, oldEmote->images,
toNonAliased
? createTooltip(dispatch.emoteName, oldEmote->author.string, false)
: createAliasedTooltip(dispatch.emoteName, baseName.string,
oldEmote->author.string, false),
oldEmote->homePage, oldEmote->zeroWidth, oldEmote->id,
oldEmote->author, makeConditionedOptional(!toNonAliased, baseName)}));
return emote;
}
} // namespace
namespace chatterino {
using namespace seventv::eventapi;
using namespace literals;
SeventvEmotes::SeventvEmotes()
: global_(std::make_shared<EmoteMap>())
{

View file

@ -4,6 +4,7 @@
#include "common/Atomic.hpp"
#include "common/FlagsEnum.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <memory>
@ -77,6 +78,12 @@ enum class SeventvEmoteSetFlag : uint32_t {
};
using SeventvEmoteSetFlags = FlagsEnum<SeventvEmoteSetFlag>;
namespace seventv::detail {
EmoteMap parseEmotes(const QJsonArray &emoteSetEmotes, bool isGlobal);
} // namespace seventv::detail
class SeventvEmotes final
{
public:

View file

@ -128,7 +128,7 @@ void updateReplyParticipatedStatus(const QVariantMap &tags,
bool isNew)
{
const auto &currentLogin =
getApp()->accounts->twitch.getCurrent()->getUserName();
getIApp()->getAccounts()->twitch.getCurrent()->getUserName();
if (thread->subscribed())
{

View file

@ -22,6 +22,8 @@ PubSubClient::PubSubClient(WebsocketClient &websocketClient,
const PubSubClientOptions &clientOptions)
: websocketClient_(websocketClient)
, handle_(handle)
, heartbeatTimer_(std::make_shared<boost::asio::steady_timer>(
this->websocketClient_.get_io_service()))
, clientOptions_(clientOptions)
{
}
@ -40,6 +42,7 @@ void PubSubClient::stop()
assert(this->started_);
this->started_ = false;
this->heartbeatTimer_->cancel();
}
void PubSubClient::close(const std::string &reason,
@ -187,8 +190,8 @@ void PubSubClient::ping()
auto self = this->shared_from_this();
runAfter(this->websocketClient_.get_io_service(),
this->clientOptions_.pingInterval_, [self](auto timer) {
runAfter(this->heartbeatTimer_, this->clientOptions_.pingInterval_,
[self](auto timer) {
if (!self->started_)
{
return;

View file

@ -70,6 +70,7 @@ private:
std::atomic<bool> awaitingPong_{false};
std::atomic<bool> started_{false};
std::shared_ptr<boost::asio::steady_timer> heartbeatTimer_;
const PubSubClientOptions &clientOptions_;
};

View file

@ -17,12 +17,14 @@
#include <algorithm>
#include <exception>
#include <future>
#include <iostream>
#include <thread>
using websocketpp::lib::bind;
using websocketpp::lib::placeholders::_1;
using websocketpp::lib::placeholders::_2;
using namespace std::chrono_literals;
namespace chatterino {
@ -36,7 +38,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
const auto &roomID) {
ClearChatAction action(data, roomID);
this->signals_.moderation.chatCleared.invoke(action);
this->moderation.chatCleared.invoke(action);
};
this->moderationActionHandlers["slowoff"] = [this](const auto &data,
@ -46,7 +48,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.mode = ModeChangedAction::Mode::Slow;
action.state = ModeChangedAction::State::Off;
this->signals_.moderation.modeChanged.invoke(action);
this->moderation.modeChanged.invoke(action);
};
this->moderationActionHandlers["slow"] = [this](const auto &data,
@ -69,7 +71,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.duration = args.at(0).toString().toUInt(&ok, 10);
this->signals_.moderation.modeChanged.invoke(action);
this->moderation.modeChanged.invoke(action);
};
this->moderationActionHandlers["r9kbetaoff"] = [this](const auto &data,
@ -79,7 +81,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.mode = ModeChangedAction::Mode::R9K;
action.state = ModeChangedAction::State::Off;
this->signals_.moderation.modeChanged.invoke(action);
this->moderation.modeChanged.invoke(action);
};
this->moderationActionHandlers["r9kbeta"] = [this](const auto &data,
@ -89,7 +91,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.mode = ModeChangedAction::Mode::R9K;
action.state = ModeChangedAction::State::On;
this->signals_.moderation.modeChanged.invoke(action);
this->moderation.modeChanged.invoke(action);
};
this->moderationActionHandlers["subscribersoff"] =
@ -99,7 +101,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.mode = ModeChangedAction::Mode::SubscribersOnly;
action.state = ModeChangedAction::State::Off;
this->signals_.moderation.modeChanged.invoke(action);
this->moderation.modeChanged.invoke(action);
};
this->moderationActionHandlers["subscribers"] = [this](const auto &data,
@ -109,7 +111,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.mode = ModeChangedAction::Mode::SubscribersOnly;
action.state = ModeChangedAction::State::On;
this->signals_.moderation.modeChanged.invoke(action);
this->moderation.modeChanged.invoke(action);
};
this->moderationActionHandlers["emoteonlyoff"] =
@ -119,7 +121,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.mode = ModeChangedAction::Mode::EmoteOnly;
action.state = ModeChangedAction::State::Off;
this->signals_.moderation.modeChanged.invoke(action);
this->moderation.modeChanged.invoke(action);
};
this->moderationActionHandlers["emoteonly"] = [this](const auto &data,
@ -129,7 +131,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.mode = ModeChangedAction::Mode::EmoteOnly;
action.state = ModeChangedAction::State::On;
this->signals_.moderation.modeChanged.invoke(action);
this->moderation.modeChanged.invoke(action);
};
this->moderationActionHandlers["unmod"] = [this](const auto &data,
@ -149,7 +151,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.modded = false;
this->signals_.moderation.moderationStateChanged.invoke(action);
this->moderation.moderationStateChanged.invoke(action);
};
this->moderationActionHandlers["mod"] = [this](const auto &data,
@ -167,7 +169,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.target.id = data.value("target_user_id").toString();
action.target.login = data.value("target_user_login").toString();
this->signals_.moderation.moderationStateChanged.invoke(action);
this->moderation.moderationStateChanged.invoke(action);
};
this->moderationActionHandlers["timeout"] = [this](const auto &data,
@ -191,7 +193,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.duration = args[1].toString().toUInt(&ok, 10);
action.reason = args[2].toString(); // May be omitted
this->signals_.moderation.userBanned.invoke(action);
this->moderation.userBanned.invoke(action);
};
this->moderationActionHandlers["delete"] = [this](const auto &data,
@ -214,7 +216,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.messageText = args[1].toString();
action.messageId = args[2].toString();
this->signals_.moderation.messageDeleted.invoke(action);
this->moderation.messageDeleted.invoke(action);
};
this->moderationActionHandlers["ban"] = [this](const auto &data,
@ -236,7 +238,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.target.login = args[0].toString();
action.reason = args[1].toString(); // May be omitted
this->signals_.moderation.userBanned.invoke(action);
this->moderation.userBanned.invoke(action);
};
this->moderationActionHandlers["unban"] = [this](const auto &data,
@ -259,7 +261,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.target.login = args[0].toString();
this->signals_.moderation.userUnbanned.invoke(action);
this->moderation.userUnbanned.invoke(action);
};
this->moderationActionHandlers["untimeout"] = [this](const auto &data,
@ -282,7 +284,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.target.login = args[0].toString();
this->signals_.moderation.userUnbanned.invoke(action);
this->moderation.userUnbanned.invoke(action);
};
/*
@ -315,7 +317,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.message = args[1].toString(); // May be omitted
action.reason = args[2].toString(); // May be omitted
this->signals_.moderation.autoModMessageBlocked.invoke(action);
this->moderation.autoModMessageBlocked.invoke(action);
};
*/
@ -323,21 +325,21 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
[this](const auto &data, const auto &roomID) {
AutomodInfoAction action(data, roomID);
action.type = AutomodInfoAction::OnHold;
this->signals_.moderation.automodInfoMessage.invoke(action);
this->moderation.automodInfoMessage.invoke(action);
};
this->moderationActionHandlers["automod_message_denied"] =
[this](const auto &data, const auto &roomID) {
AutomodInfoAction action(data, roomID);
action.type = AutomodInfoAction::Denied;
this->signals_.moderation.automodInfoMessage.invoke(action);
this->moderation.automodInfoMessage.invoke(action);
};
this->moderationActionHandlers["automod_message_approved"] =
[this](const auto &data, const auto &roomID) {
AutomodInfoAction action(data, roomID);
action.type = AutomodInfoAction::Approved;
this->signals_.moderation.automodInfoMessage.invoke(action);
this->moderation.automodInfoMessage.invoke(action);
};
this->channelTermsActionHandlers["add_permitted_term"] =
@ -351,7 +353,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.message = data.value("text").toString();
action.source.login = data.value("requester_login").toString();
this->signals_.moderation.automodUserMessage.invoke(action);
this->moderation.automodUserMessage.invoke(action);
};
this->channelTermsActionHandlers["add_blocked_term"] =
@ -365,7 +367,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.message = data.value("text").toString();
action.source.login = data.value("requester_login").toString();
this->signals_.moderation.automodUserMessage.invoke(action);
this->moderation.automodUserMessage.invoke(action);
};
this->moderationActionHandlers["delete_permitted_term"] =
@ -385,7 +387,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.message = args[0].toString();
this->signals_.moderation.automodUserMessage.invoke(action);
this->moderation.automodUserMessage.invoke(action);
};
this->channelTermsActionHandlers["delete_permitted_term"] =
@ -399,7 +401,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.message = data.value("text").toString();
action.source.login = data.value("requester_login").toString();
this->signals_.moderation.automodUserMessage.invoke(action);
this->moderation.automodUserMessage.invoke(action);
};
this->moderationActionHandlers["delete_blocked_term"] =
@ -420,7 +422,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.message = args[0].toString();
this->signals_.moderation.automodUserMessage.invoke(action);
this->moderation.automodUserMessage.invoke(action);
};
this->channelTermsActionHandlers["delete_blocked_term"] =
[this](const auto &data, const auto &roomID) {
@ -434,21 +436,9 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
action.message = data.value("text").toString();
action.source.login = data.value("requester_login").toString();
this->signals_.moderation.automodUserMessage.invoke(action);
this->moderation.automodUserMessage.invoke(action);
};
// We don't get this one anymore or anything similiar
// We need some new topic so we can listen
//
//this->moderationActionHandlers["modified_automod_properties"] =
// [this](const auto &data, const auto &roomID) {
// // The automod settings got modified
// AutomodUserAction action(data, roomID);
// getCreatedByUser(data, action.source);
// action.type = AutomodUserAction::Properties;
// this->signals_.moderation.automodUserMessage.invoke(action);
// };
this->moderationActionHandlers["denied_automod_message"] =
[](const auto &data, const auto &roomID) {
// This message got denied by a moderator
@ -482,18 +472,17 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval)
bind(&PubSub::onConnectionFail, this, ::_1));
}
PubSub::~PubSub()
{
this->stop();
}
void PubSub::setAccount(std::shared_ptr<TwitchAccount> account)
{
this->token_ = account->getOAuthToken();
this->userID_ = account->getUserId();
}
void PubSub::setAccountData(QString token, QString userID)
{
this->token_ = token;
this->userID_ = userID;
}
void PubSub::addClient()
{
if (this->addingClient)
@ -525,104 +514,43 @@ void PubSub::start()
{
this->work = std::make_shared<boost::asio::io_service::work>(
this->websocketClient.get_io_service());
this->mainThread.reset(
new std::thread(std::bind(&PubSub::runThread, this)));
this->thread.reset(new std::thread(std::bind(&PubSub::runThread, this)));
}
void PubSub::stop()
{
this->stopping_ = true;
for (const auto &client : this->clients)
for (const auto &[hdl, client] : this->clients)
{
client.second->close("Shutting down");
(void)hdl;
client->close("Shutting down");
}
this->work.reset();
if (this->mainThread->joinable())
if (this->thread->joinable())
{
this->mainThread->join();
// NOTE: We spawn a new thread to join the websocket thread.
// There is a case where a new client was initiated but not added to the clients list.
// We just don't join the thread & let the operating system nuke the thread if joining fails
// within 1s.
// We could fix the underlying bug, but this is easier & we realistically won't use this exact code
// for super much longer.
auto joiner = std::async(std::launch::async, &std::thread::join,
this->thread.get());
if (joiner.wait_for(1s) == std::future_status::timeout)
{
qCWarning(chatterinoPubSub)
<< "Thread didn't join within 1 second, rip it out";
this->websocketClient.stop();
}
}
assert(this->clients.empty());
}
void PubSub::unlistenAllModerationActions()
{
for (const auto &p : this->clients)
{
const auto &client = p.second;
if (const auto &[topics, nonce] =
client->unlistenPrefix("chat_moderator_actions.");
!topics.empty())
{
this->registerNonce(nonce, {
client,
"UNLISTEN",
topics,
topics.size(),
});
}
}
}
void PubSub::unlistenAutomod()
{
for (const auto &p : this->clients)
{
const auto &client = p.second;
if (const auto &[topics, nonce] =
client->unlistenPrefix("automod-queue.");
!topics.empty())
{
this->registerNonce(nonce, {
client,
"UNLISTEN",
topics,
topics.size(),
});
}
}
}
void PubSub::unlistenLowTrustUsers()
{
for (const auto &p : this->clients)
{
const auto &client = p.second;
if (const auto &[topics, nonce] =
client->unlistenPrefix("low-trust-users.");
!topics.empty())
{
this->registerNonce(nonce, {
client,
"UNLISTEN",
topics,
topics.size(),
});
}
}
}
void PubSub::unlistenWhispers()
{
for (const auto &p : this->clients)
{
const auto &client = p.second;
if (const auto &[topics, nonce] = client->unlistenPrefix("whispers.");
!topics.empty())
{
this->registerNonce(nonce, {
client,
"UNLISTEN",
topics,
topics.size(),
});
}
}
}
bool PubSub::listenToWhispers()
{
if (this->userID_.isEmpty())
@ -642,6 +570,11 @@ bool PubSub::listenToWhispers()
return true;
}
void PubSub::unlistenWhispers()
{
this->unlistenPrefix("whispers.");
}
void PubSub::listenToChannelModerationActions(const QString &channelID)
{
if (this->userID_.isEmpty())
@ -666,6 +599,11 @@ void PubSub::listenToChannelModerationActions(const QString &channelID)
this->listenToTopic(topic);
}
void PubSub::unlistenChannelModerationActions()
{
this->unlistenPrefix("chat_moderator_actions.");
}
void PubSub::listenToAutomod(const QString &channelID)
{
if (this->userID_.isEmpty())
@ -690,6 +628,11 @@ void PubSub::listenToAutomod(const QString &channelID)
this->listenToTopic(topic);
}
void PubSub::unlistenAutomod()
{
this->unlistenPrefix("automod-queue.");
}
void PubSub::listenToLowTrustUsers(const QString &channelID)
{
if (this->userID_.isEmpty())
@ -714,6 +657,11 @@ void PubSub::listenToLowTrustUsers(const QString &channelID)
this->listenToTopic(topic);
}
void PubSub::unlistenLowTrustUsers()
{
this->unlistenPrefix("low-trust-users.");
}
void PubSub::listenToChannelPointRewards(const QString &channelID)
{
static const QString topicFormat("community-points-channel-v1.%1");
@ -730,6 +678,30 @@ void PubSub::listenToChannelPointRewards(const QString &channelID)
this->listenToTopic(topic);
}
void PubSub::unlistenChannelPointRewards()
{
this->unlistenPrefix("community-points-channel-v1.");
}
void PubSub::unlistenPrefix(const QString &prefix)
{
for (const auto &p : this->clients)
{
const auto &client = p.second;
if (const auto &[topics, nonce] = client->unlistenPrefix(prefix);
!topics.empty())
{
NonceInfo nonceInfo{
client,
"UNLISTEN",
topics,
topics.size(),
};
this->registerNonce(nonce, nonceInfo);
}
}
}
void PubSub::listen(PubSubListenMessage msg)
{
if (this->tryListen(msg))
@ -1083,11 +1055,11 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message)
switch (whisperMessage.type)
{
case PubSubWhisperMessage::Type::WhisperReceived: {
this->signals_.whisper.received.invoke(whisperMessage);
this->whisper.received.invoke(whisperMessage);
}
break;
case PubSubWhisperMessage::Type::WhisperSent: {
this->signals_.whisper.sent.invoke(whisperMessage);
this->whisper.sent.invoke(whisperMessage);
}
break;
case PubSubWhisperMessage::Type::Thread: {
@ -1182,7 +1154,7 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message)
case PubSubCommunityPointsChannelV1Message::Type::RewardRedeemed: {
auto redemption =
innerMessage.data.value("redemption").toObject();
this->signals_.pointReward.redeemed.invoke(redemption);
this->pointReward.redeemed.invoke(redemption);
}
break;
@ -1210,8 +1182,7 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message)
// Channel ID where the moderator actions are coming from
auto channelID = topicParts[2];
this->signals_.moderation.autoModMessageCaught.invoke(innerMessage,
channelID);
this->moderation.autoModMessageCaught.invoke(innerMessage, channelID);
}
else if (topic.startsWith("low-trust-users."))
{
@ -1226,13 +1197,12 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message)
switch (innerMessage.type)
{
case PubSubLowTrustUsersMessage::Type::UserMessage: {
this->signals_.moderation.suspiciousMessageReceived.invoke(
innerMessage);
this->moderation.suspiciousMessageReceived.invoke(innerMessage);
}
break;
case PubSubLowTrustUsersMessage::Type::TreatmentUpdate: {
this->signals_.moderation.suspiciousTreatmentUpdated.invoke(
this->moderation.suspiciousTreatmentUpdated.invoke(
innerMessage);
}
break;

View file

@ -41,6 +41,13 @@ struct PubSubListenMessage;
struct PubSubMessage;
struct PubSubMessageMessage;
/**
* This handles the Twitch PubSub connection
*
* Known issues:
* - Upon closing a channel, we don't unsubscribe to its pubsub connections
* - Stop is never called, meaning we never do a clean shutdown
*/
class PubSub
{
using WebsocketMessagePtr =
@ -60,75 +67,62 @@ class PubSub
};
WebsocketClient websocketClient;
std::unique_ptr<std::thread> mainThread;
std::unique_ptr<std::thread> thread;
// Account credentials
// Set from setAccount or setAccountData
// Set from setAccount
QString token_;
QString userID_;
public:
PubSub(const QString &host,
std::chrono::seconds pingInterval = std::chrono::seconds(15));
~PubSub();
PubSub(const PubSub &) = delete;
PubSub(PubSub &&) = delete;
PubSub &operator=(const PubSub &) = delete;
PubSub &operator=(PubSub &&) = delete;
void setAccount(std::shared_ptr<TwitchAccount> account);
void setAccountData(QString token, QString userID);
enum class State {
Connected,
Disconnected,
};
void start();
void stop();
bool isConnected() const
{
return this->state == State::Connected;
}
struct {
Signal<ClearChatAction> chatCleared;
Signal<DeleteAction> messageDeleted;
Signal<ModeChangedAction> modeChanged;
Signal<ModerationStateAction> moderationStateChanged;
Signal<BanAction> userBanned;
Signal<UnbanAction> userUnbanned;
Signal<PubSubLowTrustUsersMessage> suspiciousMessageReceived;
Signal<PubSubLowTrustUsersMessage> suspiciousTreatmentUpdated;
// Message caught by automod
// channelID
pajlada::Signals::Signal<PubSubAutoModQueueMessage, QString>
autoModMessageCaught;
// Message blocked by moderator
Signal<AutomodAction> autoModMessageBlocked;
Signal<AutomodUserAction> automodUserMessage;
Signal<AutomodInfoAction> automodInfoMessage;
} moderation;
struct {
struct {
Signal<ClearChatAction> chatCleared;
Signal<DeleteAction> messageDeleted;
Signal<ModeChangedAction> modeChanged;
Signal<ModerationStateAction> moderationStateChanged;
// Parsing should be done in PubSubManager as well,
// but for now we just send the raw data
Signal<const PubSubWhisperMessage &> received;
Signal<const PubSubWhisperMessage &> sent;
} whisper;
Signal<BanAction> userBanned;
Signal<UnbanAction> userUnbanned;
Signal<PubSubLowTrustUsersMessage> suspiciousMessageReceived;
Signal<PubSubLowTrustUsersMessage> suspiciousTreatmentUpdated;
// Message caught by automod
// channelID
pajlada::Signals::Signal<PubSubAutoModQueueMessage, QString>
autoModMessageCaught;
// Message blocked by moderator
Signal<AutomodAction> autoModMessageBlocked;
Signal<AutomodUserAction> automodUserMessage;
Signal<AutomodInfoAction> automodInfoMessage;
} moderation;
struct {
// Parsing should be done in PubSubManager as well,
// but for now we just send the raw data
Signal<const PubSubWhisperMessage &> received;
Signal<const PubSubWhisperMessage &> sent;
} whisper;
struct {
Signal<const QJsonObject &> redeemed;
} pointReward;
} signals_;
void unlistenAllModerationActions();
void unlistenAutomod();
void unlistenLowTrustUsers();
void unlistenWhispers();
struct {
Signal<const QJsonObject &> redeemed;
} pointReward;
/**
* Listen to incoming whispers for the currently logged in user.
@ -137,6 +131,7 @@ public:
* PubSub topic: whispers.{currentUserID}
*/
bool listenToWhispers();
void unlistenWhispers();
/**
* Listen to moderation actions in the given channel.
@ -150,6 +145,7 @@ public:
* PubSub topic: chat_moderator_actions.{currentUserID}.{channelID}
*/
void listenToChannelModerationActions(const QString &channelID);
void unlistenChannelModerationActions();
/**
* Listen to Automod events in the given channel.
@ -160,6 +156,7 @@ public:
* PubSub topic: automod-queue.{currentUserID}.{channelID}
*/
void listenToAutomod(const QString &channelID);
void unlistenAutomod();
/**
* Listen to Low Trust events in the given channel.
@ -170,6 +167,7 @@ public:
* PubSub topic: low-trust-users.{currentUserID}.{channelID}
*/
void listenToLowTrustUsers(const QString &channelID);
void unlistenLowTrustUsers();
/**
* Listen to incoming channel point redemptions in the given channel.
@ -178,8 +176,7 @@ public:
* PubSub topic: community-points-channel-v1.{channelID}
*/
void listenToChannelPointRewards(const QString &channelID);
std::vector<QString> requests;
void unlistenChannelPointRewards();
struct {
std::atomic<uint32_t> connectionsClosed{0};
@ -192,20 +189,26 @@ public:
std::atomic<uint32_t> unlistenResponses{0};
} diag;
private:
/**
* Unlistens to all topics matching the prefix in all clients
*/
void unlistenPrefix(const QString &prefix);
void listenToTopic(const QString &topic);
private:
void listen(PubSubListenMessage msg);
bool tryListen(PubSubListenMessage msg);
bool isListeningToTopic(const QString &topic);
void addClient();
std::vector<QString> requests;
std::atomic<bool> addingClient{false};
ExponentialBackoff<5> connectBackoff{std::chrono::milliseconds(1000)};
State state = State::Connected;
std::map<WebsocketHandle, std::shared_ptr<PubSubClient>,
std::owner_less<WebsocketHandle>>
clients;

View file

@ -29,6 +29,12 @@ 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

@ -87,6 +87,13 @@ TwitchChannel::TwitchChannel(const QString &name)
{
qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened";
if (!getApp())
{
// This is intended for tests and benchmarks.
// Irc, Pubsub, live-updates, and live-notifications aren't mocked there.
return;
}
this->bSignals_.emplace_back(
getApp()->accounts->twitch.currentUserChanged.connect([this] {
this->setMod(false);
@ -219,6 +226,13 @@ TwitchChannel::TwitchChannel(const QString &name)
TwitchChannel::~TwitchChannel()
{
if (!getApp())
{
// This is for tests and benchmarks, where live-updates aren't mocked
// see comment in constructor.
return;
}
getApp()->twitch->dropSeventvChannel(this->seventvUserID_,
this->seventvEmoteSetID_);
@ -282,8 +296,9 @@ void TwitchChannel::refreshBTTVChannelEmotes(bool manualRefresh)
weakOf<Channel>(this), this->roomId(), this->getLocalizedName(),
[this, weak = weakOf<Channel>(this)](auto &&emoteMap) {
if (auto shared = weak.lock())
this->bttvEmotes_.set(
std::make_shared<EmoteMap>(std::move(emoteMap)));
{
this->setBttvEmotes(std::make_shared<const EmoteMap>(emoteMap));
}
},
manualRefresh);
}
@ -300,8 +315,9 @@ void TwitchChannel::refreshFFZChannelEmotes(bool manualRefresh)
weakOf<Channel>(this), this->roomId(),
[this, weak = weakOf<Channel>(this)](auto &&emoteMap) {
if (auto shared = weak.lock())
this->ffzEmotes_.set(
std::make_shared<EmoteMap>(std::move(emoteMap)));
{
this->setFfzEmotes(std::make_shared<const EmoteMap>(emoteMap));
}
},
[this, weak = weakOf<Channel>(this)](auto &&modBadge) {
if (auto shared = weak.lock())
@ -332,8 +348,8 @@ void TwitchChannel::refreshSevenTVChannelEmotes(bool manualRefresh)
auto channelInfo) {
if (auto shared = weak.lock())
{
this->seventvEmotes_.set(std::make_shared<EmoteMap>(
std::forward<decltype(emoteMap)>(emoteMap)));
this->setSeventvEmotes(
std::make_shared<const EmoteMap>(emoteMap));
this->updateSeventvData(channelInfo.userID,
channelInfo.emoteSetID);
this->seventvUserTwitchConnectionIndex_ =
@ -343,6 +359,21 @@ void TwitchChannel::refreshSevenTVChannelEmotes(bool manualRefresh)
manualRefresh);
}
void TwitchChannel::setBttvEmotes(std::shared_ptr<const EmoteMap> &&map)
{
this->bttvEmotes_.set(std::move(map));
}
void TwitchChannel::setFfzEmotes(std::shared_ptr<const EmoteMap> &&map)
{
this->ffzEmotes_.set(std::move(map));
}
void TwitchChannel::setSeventvEmotes(std::shared_ptr<const EmoteMap> &&map)
{
this->seventvEmotes_.set(std::move(map));
}
void TwitchChannel::addQueuedRedemption(const QString &rewardId,
const QString &originalContent,
Communi::IrcMessage *message)
@ -700,9 +731,10 @@ void TwitchChannel::setStaff(bool value)
bool TwitchChannel::isBroadcaster() const
{
auto app = getApp();
auto *app = getIApp();
return this->getName() == app->accounts->twitch.getCurrent()->getUserName();
return this->getName() ==
app->getAccounts()->twitch.getCurrent()->getUserName();
}
bool TwitchChannel::hasHighRateLimit() const
@ -730,8 +762,12 @@ void TwitchChannel::setRoomId(const QString &id)
if (*this->roomID_.accessConst() != id)
{
*this->roomID_.access() = id;
this->roomIdChanged();
this->loadRecentMessages();
// This is intended for tests and benchmarks. See comment in constructor.
if (getApp())
{
this->roomIdChanged();
this->loadRecentMessages();
}
this->disconnected_ = false;
this->lastConnectedAt_ = std::chrono::system_clock::now();
}
@ -1250,15 +1286,15 @@ void TwitchChannel::refreshPubSub()
auto currentAccount = getApp()->accounts->twitch.getCurrent();
getApp()->twitch->pubsub->setAccount(currentAccount);
getIApp()->getTwitchPubSub()->setAccount(currentAccount);
getApp()->twitch->pubsub->listenToChannelModerationActions(roomId);
getIApp()->getTwitchPubSub()->listenToChannelModerationActions(roomId);
if (this->hasModRights())
{
getApp()->twitch->pubsub->listenToAutomod(roomId);
getApp()->twitch->pubsub->listenToLowTrustUsers(roomId);
getIApp()->getTwitchPubSub()->listenToAutomod(roomId);
getIApp()->getTwitchPubSub()->listenToLowTrustUsers(roomId);
}
getApp()->twitch->pubsub->listenToChannelPointRewards(roomId);
getIApp()->getTwitchPubSub()->listenToChannelPointRewards(roomId);
}
void TwitchChannel::refreshChatters()

View file

@ -158,6 +158,10 @@ public:
void refreshFFZChannelEmotes(bool manualRefresh);
void refreshSevenTVChannelEmotes(bool manualRefresh);
void setBttvEmotes(std::shared_ptr<const EmoteMap> &&map);
void setFfzEmotes(std::shared_ptr<const EmoteMap> &&map);
void setSeventvEmotes(std::shared_ptr<const EmoteMap> &&map);
const QString &seventvUserID() const;
const QString &seventvEmoteSetID() const;

View file

@ -1,4 +1,4 @@
#include "TwitchIrcServer.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "Application.hpp"
#include "common/Channel.hpp"
@ -13,7 +13,6 @@
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/ChannelPointReward.hpp"
#include "providers/twitch/IrcMessageHandler.hpp"
#include "providers/twitch/PubSubManager.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "singletons/Settings.hpp"
@ -29,7 +28,6 @@ using namespace std::chrono_literals;
namespace {
const QString TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv";
const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws";
const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3";
@ -43,7 +41,6 @@ TwitchIrcServer::TwitchIrcServer()
, liveChannel(new Channel("/live", Channel::Type::TwitchLive))
, automodChannel(new Channel("/automod", Channel::Type::TwitchAutomod))
, watchingChannel(Channel::getEmpty(), Channel::Type::TwitchWatching)
, pubsub(new PubSub(TWITCH_PUBSUB_URL))
{
this->initializeIrc();
@ -72,7 +69,6 @@ void TwitchIrcServer::initialize(Settings &settings, Paths &paths)
getApp()->accounts->twitch.currentUserChanged.connect([this]() {
postToThread([this] {
this->connect();
this->pubsub->setAccount(getApp()->accounts->twitch.getCurrent());
});
});

View file

@ -18,7 +18,6 @@ namespace chatterino {
class Settings;
class Paths;
class PubSub;
class TwitchChannel;
class BttvLiveUpdates;
class SeventvEventAPI;
@ -80,8 +79,6 @@ public:
const ChannelPtr automodChannel;
IndirectChannel watchingChannel;
// NOTE: We currently leak this
PubSub *pubsub;
std::unique_ptr<BttvLiveUpdates> bttvLiveUpdates;
std::unique_ptr<SeventvEventAPI> seventvEventAPI;

View file

@ -153,6 +153,119 @@ namespace {
}
}
std::optional<EmotePtr> getTwitchBadge(const Badge &badge,
const TwitchChannel *twitchChannel)
{
if (auto channelBadge =
twitchChannel->twitchBadge(badge.key_, badge.value_))
{
return channelBadge;
}
if (auto globalBadge =
TwitchBadges::instance()->badge(badge.key_, badge.value_))
{
return globalBadge;
}
return std::nullopt;
}
void appendBadges(MessageBuilder *builder, const std::vector<Badge> &badges,
const std::unordered_map<QString, QString> &badgeInfos,
const TwitchChannel *twitchChannel)
{
if (twitchChannel == nullptr)
{
return;
}
for (const auto &badge : badges)
{
auto badgeEmote = getTwitchBadge(badge, twitchChannel);
if (!badgeEmote)
{
continue;
}
auto tooltip = (*badgeEmote)->tooltip.string;
if (badge.key_ == "bits")
{
const auto &cheerAmount = badge.value_;
tooltip = QString("Twitch cheer %0").arg(cheerAmount);
}
else if (badge.key_ == "moderator" &&
getSettings()->useCustomFfzModeratorBadges)
{
if (auto customModBadge = twitchChannel->ffzCustomModBadge())
{
builder
->emplace<ModBadgeElement>(
*customModBadge,
MessageElementFlag::BadgeChannelAuthority)
->setTooltip((*customModBadge)->tooltip.string);
// early out, since we have to add a custom badge element here
continue;
}
}
else if (badge.key_ == "vip" &&
getSettings()->useCustomFfzVipBadges)
{
if (auto customVipBadge = twitchChannel->ffzCustomVipBadge())
{
builder
->emplace<VipBadgeElement>(
*customVipBadge,
MessageElementFlag::BadgeChannelAuthority)
->setTooltip((*customVipBadge)->tooltip.string);
// early out, since we have to add a custom badge element here
continue;
}
}
else if (badge.flag_ == MessageElementFlag::BadgeSubscription)
{
auto badgeInfoIt = badgeInfos.find(badge.key_);
if (badgeInfoIt != badgeInfos.end())
{
// badge.value_ is 4 chars long if user is subbed on higher tier
// (tier + amount of months with leading zero if less than 100)
// e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub
const auto &subTier =
badge.value_.length() > 3 ? badge.value_.at(0) : '1';
const auto &subMonths = badgeInfoIt->second;
tooltip += QString(" (%1%2 months)")
.arg(subTier != '1'
? QString("Tier %1, ").arg(subTier)
: "")
.arg(subMonths);
}
}
else if (badge.flag_ == MessageElementFlag::BadgePredictions)
{
auto badgeInfoIt = badgeInfos.find(badge.key_);
if (badgeInfoIt != badgeInfos.end())
{
auto infoValue = badgeInfoIt->second;
auto predictionText =
infoValue
.replace(R"(\s)", " ") // standard IRC escapes
.replace(R"(\:)", ";")
.replace(R"(\\)", R"(\)")
.replace("", ","); // twitch's comma escape
// Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma
tooltip = QString("Predicted %1").arg(predictionText);
}
}
builder->emplace<BadgeElement>(*badgeEmote, badge.flag_)
->setTooltip(tooltip);
}
builder->message().badges = badges;
builder->message().badgeInfos = badgeInfos;
}
} // namespace
TwitchMessageBuilder::TwitchMessageBuilder(
@ -1113,24 +1226,6 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name)
return Failure;
}
std::optional<EmotePtr> TwitchMessageBuilder::getTwitchBadge(
const Badge &badge) const
{
if (auto channelBadge =
this->twitchChannel->twitchBadge(badge.key_, badge.value_))
{
return channelBadge;
}
if (auto globalBadge =
TwitchBadges::instance()->badge(badge.key_, badge.value_))
{
return globalBadge;
}
return std::nullopt;
}
std::unordered_map<QString, QString> TwitchMessageBuilder::parseBadgeInfoTag(
const QVariantMap &tags)
{
@ -1189,88 +1284,8 @@ void TwitchMessageBuilder::appendTwitchBadges()
}
auto badgeInfos = TwitchMessageBuilder::parseBadgeInfoTag(this->tags);
auto badges = this->parseBadgeTag(this->tags);
for (const auto &badge : badges)
{
auto badgeEmote = this->getTwitchBadge(badge);
if (!badgeEmote)
{
continue;
}
auto tooltip = (*badgeEmote)->tooltip.string;
if (badge.key_ == "bits")
{
const auto &cheerAmount = badge.value_;
tooltip = QString("Twitch cheer %0").arg(cheerAmount);
}
else if (badge.key_ == "moderator" &&
getSettings()->useCustomFfzModeratorBadges)
{
if (auto customModBadge = this->twitchChannel->ffzCustomModBadge())
{
this->emplace<ModBadgeElement>(
*customModBadge,
MessageElementFlag::BadgeChannelAuthority)
->setTooltip((*customModBadge)->tooltip.string);
// early out, since we have to add a custom badge element here
continue;
}
}
else if (badge.key_ == "vip" && getSettings()->useCustomFfzVipBadges)
{
if (auto customVipBadge = this->twitchChannel->ffzCustomVipBadge())
{
this->emplace<VipBadgeElement>(
*customVipBadge,
MessageElementFlag::BadgeChannelAuthority)
->setTooltip((*customVipBadge)->tooltip.string);
// early out, since we have to add a custom badge element here
continue;
}
}
else if (badge.flag_ == MessageElementFlag::BadgeSubscription)
{
auto badgeInfoIt = badgeInfos.find(badge.key_);
if (badgeInfoIt != badgeInfos.end())
{
// badge.value_ is 4 chars long if user is subbed on higher tier
// (tier + amount of months with leading zero if less than 100)
// e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub
const auto &subTier =
badge.value_.length() > 3 ? badge.value_.at(0) : '1';
const auto &subMonths = badgeInfoIt->second;
tooltip +=
QString(" (%1%2 months)")
.arg(subTier != '1' ? QString("Tier %1, ").arg(subTier)
: "")
.arg(subMonths);
}
}
else if (badge.flag_ == MessageElementFlag::BadgePredictions)
{
auto badgeInfoIt = badgeInfos.find(badge.key_);
if (badgeInfoIt != badgeInfos.end())
{
auto predictionText =
badgeInfoIt->second
.replace(R"(\s)", " ") // standard IRC escapes
.replace(R"(\:)", ";")
.replace(R"(\\)", R"(\)")
.replace("", ","); // twitch's comma escape
// Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma
tooltip = QString("Predicted %1").arg(predictionText);
}
}
this->emplace<BadgeElement>(*badgeEmote, badge.flag_)
->setTooltip(tooltip);
}
this->message().badges = badges;
this->message().badgeInfos = badgeInfos;
auto badges = TwitchMessageBuilder::parseBadgeTag(this->tags);
appendBadges(this, badges, badgeInfos, this->twitchChannel);
}
void TwitchMessageBuilder::appendChatterinoBadges()
@ -1936,6 +1951,12 @@ std::pair<MessagePtr, MessagePtr> TwitchMessageBuilder::makeAutomodMessage(
MessagePtr TwitchMessageBuilder::makeLowTrustUpdateMessage(
const PubSubLowTrustUsersMessage &action)
{
/**
* Known issues:
* - Non-Twitch badges are not shown
* - Non-Twitch emotes are not shown
*/
MessageBuilder builder;
builder.emplace<TimestampElement>();
builder.message().flags.set(MessageFlag::System);
@ -2006,7 +2027,8 @@ MessagePtr TwitchMessageBuilder::makeLowTrustUpdateMessage(
}
std::pair<MessagePtr, MessagePtr> TwitchMessageBuilder::makeLowTrustUserMessage(
const PubSubLowTrustUsersMessage &action, const QString &channelName)
const PubSubLowTrustUsersMessage &action, const QString &channelName,
const TwitchChannel *twitchChannel)
{
MessageBuilder builder, builder2;
@ -2029,10 +2051,12 @@ std::pair<MessagePtr, MessagePtr> TwitchMessageBuilder::makeLowTrustUserMessage(
if (action.treatment == PubSubLowTrustUsersMessage::Treatment::Restricted)
{
headerMessage = "Restricted";
builder2.message().flags.set(MessageFlag::RestrictedMessage);
}
else
{
headerMessage = "Monitored";
builder2.message().flags.set(MessageFlag::MonitoredMessage);
}
if (action.restrictionTypes.has(
@ -2089,6 +2113,9 @@ std::pair<MessagePtr, MessagePtr> TwitchMessageBuilder::makeLowTrustUserMessage(
builder2.message().flags.set(MessageFlag::PubSub);
builder2.message().flags.set(MessageFlag::LowTrustUsers);
// sender badges
appendBadges(&builder2, action.senderBadges, {}, twitchChannel);
// sender username
builder2
.emplace<TextElement>(action.suspiciousUserDisplayName + ":",
@ -2103,8 +2130,23 @@ std::pair<MessagePtr, MessagePtr> TwitchMessageBuilder::makeLowTrustUserMessage(
->setLink({Link::UserInfo, action.suspiciousUserLogin});
// sender's message caught by AutoMod
builder2.emplace<TextElement>(action.text, MessageElementFlag::Text,
MessageColor::Text);
for (const auto &fragment : action.fragments)
{
if (fragment.emoteID.isEmpty())
{
builder2.emplace<TextElement>(
fragment.text, MessageElementFlag::Text, MessageColor::Text);
}
else
{
const auto emotePtr =
getIApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote(
EmoteId{fragment.emoteID}, EmoteName{fragment.text});
builder2.emplace<EmoteElement>(
emotePtr, MessageElementFlag::TwitchEmote, MessageColor::Text);
}
}
auto text =
QString("%1: %2").arg(action.suspiciousUserDisplayName, action.text);
builder2.message().messageText = text;

View file

@ -95,7 +95,8 @@ public:
static MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action);
static std::pair<MessagePtr, MessagePtr> makeLowTrustUserMessage(
const PubSubLowTrustUsersMessage &action, const QString &channelName);
const PubSubLowTrustUsersMessage &action, const QString &channelName,
const TwitchChannel *twitchChannel);
static MessagePtr makeLowTrustUpdateMessage(
const PubSubLowTrustUsersMessage &action);
@ -119,7 +120,6 @@ private:
void runIgnoreReplaces(std::vector<TwitchEmoteOccurrence> &twitchEmotes);
std::optional<EmotePtr> getTwitchBadge(const Badge &badge) const;
Outcome tryAppendEmote(const EmoteName &name) override;
void addWords(const QStringList &words,

View file

@ -21,8 +21,12 @@ PubSubLowTrustUsersMessage::PubSubLowTrustUsersMessage(const QJsonObject &root)
{
this->msgID = data.value("message_id").toString();
this->sentAt = data.value("sent_at").toString();
this->text =
data.value("message_content").toObject().value("text").toString();
const auto content = data.value("message_content").toObject();
this->text = content.value("text").toString();
for (const auto &part : content.value("fragments").toArray())
{
this->fragments.emplace_back(part.toObject());
}
// the rest of the data is within a nested object
data = data.value("low_trust_user").toObject();
@ -35,23 +39,22 @@ PubSubLowTrustUsersMessage::PubSubLowTrustUsersMessage(const QJsonObject &root)
this->suspiciousUserColor =
QColor(sender.value("chat_color").toString());
std::vector<LowTrustUserChatBadge> badges;
for (const auto &badge : sender.value("badges").toArray())
{
badges.emplace_back(badge.toObject());
const auto badgeObj = badge.toObject();
const auto badgeID = badgeObj.value("id").toString();
const auto badgeVersion = badgeObj.value("version").toString();
this->senderBadges.emplace_back(Badge{badgeID, badgeVersion});
}
this->senderBadges = badges;
const auto sharedValue = data.value("shared_ban_channel_ids");
std::vector<QString> sharedIDs;
if (!sharedValue.isNull())
{
for (const auto &id : sharedValue.toArray())
{
sharedIDs.emplace_back(id.toString());
this->sharedBanChannelIDs.emplace_back(id.toString());
}
}
this->sharedBanChannelIDs = sharedIDs;
}
else
{
@ -88,17 +91,15 @@ PubSubLowTrustUsersMessage::PubSubLowTrustUsersMessage(const QJsonObject &root)
this->evasionEvaluation = oEvaluation.value();
}
FlagsEnum<RestrictionType> restrictions;
for (const auto &rType : data.value("types").toArray())
{
if (const auto oRestriction = magic_enum::enum_cast<RestrictionType>(
rType.toString().toStdString());
oRestriction.has_value())
{
restrictions.set(oRestriction.value());
this->restrictionTypes.set(oRestriction.value());
}
}
this->restrictionTypes = restrictions;
}
} // namespace chatterino

View file

@ -1,5 +1,7 @@
#pragma once
#include "providers/twitch/TwitchBadge.hpp"
#include <common/FlagsEnum.hpp>
#include <magic_enum/magic_enum.hpp>
#include <QColor>
@ -8,18 +10,21 @@
namespace chatterino {
struct LowTrustUserChatBadge {
QString id;
QString version;
explicit LowTrustUserChatBadge(const QJsonObject &obj)
: id(obj.value("id").toString())
, version(obj.value("version").toString())
{
}
};
struct PubSubLowTrustUsersMessage {
struct Fragment {
QString text;
QString emoteID;
explicit Fragment(const QJsonObject &obj)
: text(obj.value("text").toString())
, emoteID(obj.value("emoticon")
.toObject()
.value("emoticonID")
.toString())
{
}
};
/**
* The type of low trust message update
*/
@ -102,6 +107,12 @@ struct PubSubLowTrustUsersMessage {
*/
QString text;
/**
* Pre-parsed components of the message.
* Only used for the UserMessage type.
*/
std::vector<Fragment> fragments;
/**
* ID of the message.
* Only used for the UserMessage type.
@ -130,7 +141,7 @@ struct PubSubLowTrustUsersMessage {
* A list of badges of the user who sent the message.
* Only used for the UserMessage type.
*/
std::vector<LowTrustUserChatBadge> senderBadges;
std::vector<Badge> senderBadges;
/**
* Stores the string value of `type`

View file

@ -476,7 +476,7 @@ void Theme::normalizeColor(QColor &color) const
Theme *getTheme()
{
return getApp()->themes;
return getIApp()->getThemes();
}
} // namespace chatterino

View file

@ -1,5 +1,6 @@
#include "widgets/BaseWidget.hpp"
#include "Application.hpp"
#include "common/QLogging.hpp"
#include "controllers/hotkeys/HotkeyController.hpp"
#include "singletons/Theme.hpp"
@ -17,10 +18,8 @@ namespace chatterino {
BaseWidget::BaseWidget(QWidget *parent, Qt::WindowFlags f)
: QWidget(parent, f)
, theme(getIApp()->getThemes())
{
// REMOVED
this->theme = getTheme();
this->signalHolder_.managedConnect(this->theme->updated, [this]() {
this->themeChangedEvent();

View file

@ -588,11 +588,13 @@ void Notebook::updateTabVisibility()
void Notebook::updateTabVisibilityMenuAction()
{
auto toggleSeq = getApp()->hotkeys->getDisplaySequence(
const auto *hotkeys = getIApp()->getHotkeys();
auto toggleSeq = hotkeys->getDisplaySequence(
HotkeyCategory::Window, "setTabVisibility", {std::vector<QString>()});
if (toggleSeq.isEmpty())
{
toggleSeq = getApp()->hotkeys->getDisplaySequence(
toggleSeq = hotkeys->getDisplaySequence(
HotkeyCategory::Window, "setTabVisibility", {{"toggle"}});
}
@ -601,12 +603,12 @@ void Notebook::updateTabVisibilityMenuAction()
// show contextual shortcuts
if (this->getShowTabs())
{
toggleSeq = getApp()->hotkeys->getDisplaySequence(
toggleSeq = hotkeys->getDisplaySequence(
HotkeyCategory::Window, "setTabVisibility", {{"off"}});
}
else if (!this->getShowTabs())
{
toggleSeq = getApp()->hotkeys->getDisplaySequence(
toggleSeq = hotkeys->getDisplaySequence(
HotkeyCategory::Window, "setTabVisibility", {{"on"}});
}
}

View file

@ -281,7 +281,7 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions)
->toInner<PubSubCommunityPointsChannelV1Message>();
app->twitch->addFakeMessage(getSampleChannelRewardIRCMessage());
app->twitch->pubsub->signals_.pointReward.redeemed.invoke(
getIApp()->getTwitchPubSub()->pointReward.redeemed.invoke(
oInnerMessage->data.value("redemption").toObject());
alt = !alt;
}
@ -292,7 +292,7 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions)
auto oInnerMessage =
oMessage->toInner<PubSubMessageMessage>()
->toInner<PubSubCommunityPointsChannelV1Message>();
app->twitch->pubsub->signals_.pointReward.redeemed.invoke(
getIApp()->getTwitchPubSub()->pointReward.redeemed.invoke(
oInnerMessage->data.value("redemption").toObject());
alt = !alt;
}

View file

@ -1,18 +1,66 @@
#include "widgets/dialogs/ColorPickerDialog.hpp"
#include "common/Literals.hpp"
#include "providers/colors/ColorProvider.hpp"
#include "singletons/Theme.hpp"
#include "util/LayoutCreator.hpp"
#include "widgets/helper/ColorButton.hpp"
#include "widgets/helper/QColorPicker.hpp"
#include "widgets/helper/color/AlphaSlider.hpp"
#include "widgets/helper/color/ColorButton.hpp"
#include "widgets/helper/color/ColorInput.hpp"
#include "widgets/helper/color/HueSlider.hpp"
#include "widgets/helper/color/SBCanvas.hpp"
#include <QDialogButtonBox>
#include <QLineEdit>
#include <QSet>
namespace {
using namespace chatterino;
constexpr size_t COLORS_PER_ROW = 5;
constexpr size_t MAX_RECENT_COLORS = 15;
constexpr size_t MAX_DEFAULT_COLORS = 15;
QGridLayout *makeColorGrid(const auto &items, auto *self,
std::size_t maxButtons)
{
auto *layout = new QGridLayout;
// TODO(nerix): use std::ranges::views::enumerate (C++ 23)
for (std::size_t i = 0; auto color : items)
{
auto *button = new ColorButton(color);
button->setMinimumWidth(40);
QObject::connect(button, &ColorButton::clicked, self, [self, color]() {
self->setColor(color);
});
layout->addWidget(button, static_cast<int>(i / COLORS_PER_ROW),
static_cast<int>(i % COLORS_PER_ROW));
i++;
if (i >= maxButtons)
{
break;
}
}
return layout;
}
/// All color inputs have the same two signals and slots:
/// `colorChanged` and `setColor`.
/// `colorChanged` is emitted when the user changed the color (not after calling `setColor`).
template <typename D, typename W>
void connectSignals(D *dialog, W *widget)
{
QObject::connect(widget, &W::colorChanged, dialog, &D::setColor);
QObject::connect(dialog, &D::colorChanged, widget, &W::setColor);
}
} // namespace
namespace chatterino {
ColorPickerDialog::ColorPickerDialog(const QColor &initial, QWidget *parent)
using namespace literals;
ColorPickerDialog::ColorPickerDialog(QColor color, QWidget *parent)
: BasePopup(
{
BaseWindow::EnableCustomFrame,
@ -20,371 +68,95 @@ ColorPickerDialog::ColorPickerDialog(const QColor &initial, QWidget *parent)
BaseWindow::BoundsCheckOnShow,
},
parent)
, color_()
, dialogConfirmed_(false)
, color_(color)
{
// This hosts the "business logic" and the dialog button box
LayoutCreator<QWidget> layoutWidget(this->getLayoutContainer());
auto layout = layoutWidget.setLayoutType<QVBoxLayout>().withoutMargin();
this->setWindowTitle(u"Chatterino - Color picker"_s);
this->setAttribute(Qt::WA_DeleteOnClose);
// This hosts the business logic: color picker and predefined colors
LayoutCreator<QWidget> contentCreator(new QWidget());
auto contents = contentCreator.setLayoutType<QHBoxLayout>();
// This hosts the predefined colors (and also the currently selected color)
LayoutCreator<QWidget> predefCreator(new QWidget());
auto predef = predefCreator.setLayoutType<QVBoxLayout>();
// Recently used colors
auto *dialogContents = new QHBoxLayout;
dialogContents->setContentsMargins(10, 10, 10, 10);
{
LayoutCreator<QWidget> gridCreator(new QWidget());
this->initRecentColors(gridCreator);
auto *buttons = new QVBoxLayout;
buttons->addWidget(new QLabel(u"Recently used"_s));
buttons->addLayout(makeColorGrid(
ColorProvider::instance().recentColors(), this, MAX_RECENT_COLORS));
predef.append(gridCreator.getElement());
buttons->addSpacing(10);
buttons->addWidget(new QLabel(u"Default colors"_s));
buttons->addLayout(
makeColorGrid(ColorProvider::instance().defaultColors(), this,
MAX_DEFAULT_COLORS));
buttons->addStretch(1);
buttons->addWidget(new QLabel(u"Selected"_s));
auto *display = new ColorButton(this->color());
QObject::connect(this, &ColorPickerDialog::colorChanged, display,
&ColorButton::setColor);
buttons->addWidget(display);
dialogContents->addLayout(buttons);
dialogContents->addSpacing(10);
}
// Default colors
{
LayoutCreator<QWidget> gridCreator(new QWidget());
this->initDefaultColors(gridCreator);
auto *controls = new QVBoxLayout;
predef.append(gridCreator.getElement());
}
// Currently selected color
{
LayoutCreator<QWidget> curColorCreator(new QWidget());
auto curColor = curColorCreator.setLayoutType<QHBoxLayout>();
curColor.emplace<QLabel>("Selected:").assign(&this->ui_.selected.label);
curColor.emplace<ColorButton>(initial).assign(
&this->ui_.selected.color);
predef.append(curColor.getElement());
}
contents.append(predef.getElement());
// Color picker
{
LayoutCreator<QWidget> obj(new QWidget());
auto vbox = obj.setLayoutType<QVBoxLayout>();
// The actual color picker
{
LayoutCreator<QWidget> cpCreator(new QWidget());
this->initColorPicker(cpCreator);
auto *select = new QVBoxLayout;
vbox.append(cpCreator.getElement());
auto *sbCanvas = new SBCanvas(this->color());
auto *hueSlider = new HueSlider(this->color());
auto *alphaSlider = new AlphaSlider(this->color());
connectSignals(this, sbCanvas);
connectSignals(this, hueSlider);
connectSignals(this, alphaSlider);
select->addWidget(sbCanvas, 0, Qt::AlignHCenter);
select->addWidget(hueSlider);
select->addWidget(alphaSlider);
controls->addLayout(select);
}
{
auto *input = new ColorInput(this->color());
connectSignals(this, input);
controls->addWidget(input);
}
// Spin boxes
{
LayoutCreator<QWidget> sbCreator(new QWidget());
this->initSpinBoxes(sbCreator);
vbox.append(sbCreator.getElement());
}
// HTML color
{
LayoutCreator<QWidget> htmlCreator(new QWidget());
this->initHtmlColor(htmlCreator);
vbox.append(htmlCreator.getElement());
}
contents.append(obj.getElement());
dialogContents->addLayout(controls);
}
layout.append(contents.getElement());
auto *dialogLayout = new QVBoxLayout(this->getLayoutContainer());
dialogLayout->addLayout(dialogContents, 1);
dialogLayout->addStretch(1);
// Dialog buttons
auto buttons =
layout.emplace<QHBoxLayout>().emplace<QDialogButtonBox>(this);
{
auto *button_ok = buttons->addButton(QDialogButtonBox::Ok);
QObject::connect(button_ok, &QPushButton::clicked, [this](bool) {
this->ok();
});
auto *button_cancel = buttons->addButton(QDialogButtonBox::Cancel);
QObject::connect(button_cancel, &QAbstractButton::clicked,
[this](bool) {
this->close();
});
}
auto *buttonBox =
new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
this->themeChangedEvent();
this->selectColor(initial, false);
QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, [this] {
emit this->colorConfirmed(this->color());
this->close();
});
QObject::connect(buttonBox, &QDialogButtonBox::rejected, this,
&ColorPickerDialog::close);
dialogLayout->addWidget(buttonBox, 0, Qt::AlignRight);
}
void ColorPickerDialog::addShortcuts()
QColor ColorPickerDialog::color() const
{
}
ColorPickerDialog::~ColorPickerDialog()
{
if (this->htmlColorValidator_)
{
this->htmlColorValidator_->deleteLater();
this->htmlColorValidator_ = nullptr;
}
}
QColor ColorPickerDialog::selectedColor() const
{
if (!this->dialogConfirmed_)
{
// If the Cancel button was clicked, return the invalid color
return QColor();
}
return this->color_;
}
void ColorPickerDialog::closeEvent(QCloseEvent *)
{
this->closed.invoke(this->selectedColor());
}
void ColorPickerDialog::themeChangedEvent()
{
BaseWindow::themeChangedEvent();
QString textCol = this->theme->splits.input.text.name(QColor::HexRgb);
QString bgCol = this->theme->splits.input.background.name(QColor::HexRgb);
// Labels
QString labelStyle = QString("color: %1;").arg(textCol);
this->ui_.recent.label->setStyleSheet(labelStyle);
this->ui_.def.label->setStyleSheet(labelStyle);
this->ui_.selected.label->setStyleSheet(labelStyle);
this->ui_.picker.htmlLabel->setStyleSheet(labelStyle);
for (auto spinBoxLabel : this->ui_.picker.spinBoxLabels)
{
spinBoxLabel->setStyleSheet(labelStyle);
}
this->ui_.picker.htmlEdit->setStyleSheet(
this->theme->splits.input.styleSheet);
// Styling spin boxes is too much effort
}
void ColorPickerDialog::selectColor(const QColor &color, bool fromColorPicker)
void ColorPickerDialog::setColor(const QColor &color)
{
if (color == this->color_)
{
return;
}
this->color_ = color;
// Update UI elements
this->ui_.selected.color->setColor(this->color_);
/*
* Somewhat "ugly" hack to prevent feedback loop between widgets. Since
* this method is private, I'm okay with this being ugly.
*/
if (!fromColorPicker)
{
this->ui_.picker.colorPicker->setCol(this->color_.hslHue(),
this->color_.hslSaturation());
this->ui_.picker.luminancePicker->setCol(this->color_.hsvHue(),
this->color_.hsvSaturation(),
this->color_.value());
}
this->ui_.picker.spinBoxes[SpinBox::RED]->setValue(this->color_.red());
this->ui_.picker.spinBoxes[SpinBox::GREEN]->setValue(this->color_.green());
this->ui_.picker.spinBoxes[SpinBox::BLUE]->setValue(this->color_.blue());
this->ui_.picker.spinBoxes[SpinBox::ALPHA]->setValue(this->color_.alpha());
/*
* Here, we are intentionally using HexRgb instead of HexArgb. Most online
* sites (or other applications) will likely not include the alpha channel
* in their output.
*/
this->ui_.picker.htmlEdit->setText(this->color_.name(QColor::HexRgb));
}
void ColorPickerDialog::ok()
{
this->dialogConfirmed_ = true;
this->close();
}
void ColorPickerDialog::initRecentColors(LayoutCreator<QWidget> &creator)
{
auto grid = creator.setLayoutType<QGridLayout>();
auto label = this->ui_.recent.label = new QLabel("Recently used:");
grid->addWidget(label, 0, 0, 1, -1);
const auto recentColors = ColorProvider::instance().recentColors();
auto it = recentColors.begin();
size_t ind = 0;
while (it != recentColors.end() && ind < MAX_RECENT_COLORS)
{
this->ui_.recent.colors.push_back(new ColorButton(*it, this));
auto *button = this->ui_.recent.colors[ind];
static_assert(RECENT_COLORS_PER_ROW != 0);
const int rowInd = (ind / RECENT_COLORS_PER_ROW) + 1;
const int columnInd = ind % RECENT_COLORS_PER_ROW;
grid->addWidget(button, rowInd, columnInd);
QObject::connect(button, &QPushButton::clicked, [=, this] {
this->selectColor(button->color(), false);
});
++it;
++ind;
}
auto spacer =
new QSpacerItem(40, 20, QSizePolicy::Minimum, QSizePolicy::Expanding);
grid->addItem(spacer, (ind / RECENT_COLORS_PER_ROW) + 2, 0, 1, 1,
Qt::AlignTop);
}
void ColorPickerDialog::initDefaultColors(LayoutCreator<QWidget> &creator)
{
auto grid = creator.setLayoutType<QGridLayout>();
auto label = this->ui_.def.label = new QLabel("Default colors:");
grid->addWidget(label, 0, 0, 1, -1);
const auto defaultColors = ColorProvider::instance().defaultColors();
auto it = defaultColors.begin();
size_t ind = 0;
while (it != defaultColors.end())
{
this->ui_.def.colors.push_back(new ColorButton(*it, this));
auto *button = this->ui_.def.colors[ind];
const int rowInd = (ind / DEFAULT_COLORS_PER_ROW) + 1;
const int columnInd = ind % DEFAULT_COLORS_PER_ROW;
grid->addWidget(button, rowInd, columnInd);
QObject::connect(button, &QPushButton::clicked, [=, this] {
this->selectColor(button->color(), false);
});
++it;
++ind;
}
auto spacer =
new QSpacerItem(40, 20, QSizePolicy::Minimum, QSizePolicy::Expanding);
grid->addItem(spacer, (ind / DEFAULT_COLORS_PER_ROW) + 2, 0, 1, 1,
Qt::AlignTop);
}
void ColorPickerDialog::initColorPicker(LayoutCreator<QWidget> &creator)
{
this->setWindowTitle("Chatterino - color picker");
auto cpPanel = creator.setLayoutType<QHBoxLayout>();
/*
* For some reason, LayoutCreator::emplace didn't work for these.
* (Or maybe I was too dense to make it work.)
* After trying to debug for 4 hours or so, I gave up and settled
* for this solution.
*/
auto *colorPicker = new QColorPicker(this);
this->ui_.picker.colorPicker = colorPicker;
auto *luminancePicker = new QColorLuminancePicker(this);
this->ui_.picker.luminancePicker = luminancePicker;
cpPanel.append(colorPicker);
cpPanel.append(luminancePicker);
QObject::connect(colorPicker, SIGNAL(newCol(int, int)), luminancePicker,
SLOT(setCol(int, int)));
QObject::connect(
luminancePicker, &QColorLuminancePicker::newHsv,
[this](int h, int s, int v) {
int alpha = this->ui_.picker.spinBoxes[SpinBox::ALPHA]->value();
this->selectColor(QColor::fromHsv(h, s, v, alpha), true);
});
}
void ColorPickerDialog::initSpinBoxes(LayoutCreator<QWidget> &creator)
{
auto spinBoxes = creator.setLayoutType<QGridLayout>();
auto *red = this->ui_.picker.spinBoxes[SpinBox::RED] =
new QColSpinBox(this);
auto *green = this->ui_.picker.spinBoxes[SpinBox::GREEN] =
new QColSpinBox(this);
auto *blue = this->ui_.picker.spinBoxes[SpinBox::BLUE] =
new QColSpinBox(this);
auto *alpha = this->ui_.picker.spinBoxes[SpinBox::ALPHA] =
new QColSpinBox(this);
// We need pointers to these for theme changes
auto *redLbl = this->ui_.picker.spinBoxLabels[SpinBox::RED] =
new QLabel("Red:");
auto *greenLbl = this->ui_.picker.spinBoxLabels[SpinBox::GREEN] =
new QLabel("Green:");
auto *blueLbl = this->ui_.picker.spinBoxLabels[SpinBox::BLUE] =
new QLabel("Blue:");
auto *alphaLbl = this->ui_.picker.spinBoxLabels[SpinBox::ALPHA] =
new QLabel("Alpha:");
spinBoxes->addWidget(redLbl, 0, 0);
spinBoxes->addWidget(red, 0, 1);
spinBoxes->addWidget(greenLbl, 1, 0);
spinBoxes->addWidget(green, 1, 1);
spinBoxes->addWidget(blueLbl, 2, 0);
spinBoxes->addWidget(blue, 2, 1);
spinBoxes->addWidget(alphaLbl, 3, 0);
spinBoxes->addWidget(alpha, 3, 1);
for (size_t i = 0; i < SpinBox::END; ++i)
{
QObject::connect(
this->ui_.picker.spinBoxes[i],
QOverload<int>::of(&QSpinBox::valueChanged), [=, this](int value) {
this->selectColor(QColor(red->value(), green->value(),
blue->value(), alpha->value()),
false);
});
}
}
void ColorPickerDialog::initHtmlColor(LayoutCreator<QWidget> &creator)
{
auto html = creator.setLayoutType<QGridLayout>();
// Copied from Qt source for QColorShower
static QRegularExpression regExp(
QStringLiteral("#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})"));
auto *validator = this->htmlColorValidator_ =
new QRegularExpressionValidator(regExp, this);
auto *htmlLabel = this->ui_.picker.htmlLabel = new QLabel("HTML:");
auto *htmlEdit = this->ui_.picker.htmlEdit = new QLineEdit(this);
htmlEdit->setValidator(validator);
html->addWidget(htmlLabel, 0, 0);
html->addWidget(htmlEdit, 0, 1);
QObject::connect(htmlEdit, &QLineEdit::editingFinished, [this] {
const QColor col(this->ui_.picker.htmlEdit->text());
if (col.isValid())
this->selectColor(col, false);
});
emit this->colorChanged(color);
}
} // namespace chatterino

View file

@ -2,119 +2,26 @@
#include "widgets/BasePopup.hpp"
#include <pajlada/signals/signal.hpp>
#include <QLabel>
#include <QLineEdit>
#include <QRegularExpressionValidator>
#include <array>
namespace chatterino {
class ColorButton;
class QColorLuminancePicker;
class QColorPicker;
class QColSpinBox;
template <class T>
class LayoutCreator;
/**
* @brief A custom color picker dialog.
*
* This class exists because QColorPickerDialog did not suit our use case.
* This dialog provides buttons for recently used and default colors, as well
* as a color picker widget identical to the one used in QColorPickerDialog.
*/
class ColorPickerDialog : public BasePopup
{
Q_OBJECT
public:
/**
* @brief Create a new color picker dialog that selects the initial color.
*
* You can connect to the ::closed signal of this instance to get notified
* when the dialog is closed.
*/
ColorPickerDialog(const QColor &initial, QWidget *parent);
ColorPickerDialog(QColor color, QWidget *parent);
~ColorPickerDialog() override;
QColor color() const;
/**
* @brief Return the final color selected by the user.
*
* Note that this method will always return the invalid color if the dialog
* is still open, or if the dialog has not been confirmed.
*
* @return The color selected by the user, if the dialog was confirmed.
* The invalid color, if the dialog has not been confirmed.
*/
QColor selectedColor() const;
signals:
void colorChanged(QColor color);
void colorConfirmed(QColor color);
pajlada::Signals::Signal<QColor> closed;
protected:
void closeEvent(QCloseEvent *) override;
void themeChangedEvent() override;
public slots:
void setColor(const QColor &color);
private:
struct {
struct {
QLabel *label;
std::vector<ColorButton *> colors;
} recent;
struct {
QLabel *label;
std::vector<ColorButton *> colors;
} def;
struct {
QLabel *label;
ColorButton *color;
} selected{};
struct {
QColorPicker *colorPicker;
QColorLuminancePicker *luminancePicker;
std::array<QLabel *, 4> spinBoxLabels;
std::array<QColSpinBox *, 4> spinBoxes;
QLabel *htmlLabel;
QLineEdit *htmlEdit;
} picker{};
} ui_;
enum SpinBox : size_t { RED = 0, GREEN = 1, BLUE = 2, ALPHA = 3, END };
static const size_t MAX_RECENT_COLORS = 10;
static const size_t RECENT_COLORS_PER_ROW = 5;
static const size_t DEFAULT_COLORS_PER_ROW = 5;
QColor color_;
bool dialogConfirmed_;
QRegularExpressionValidator *htmlColorValidator_{};
/**
* @brief Update the currently selected color.
*
* @param color Color to update to.
* @param fromColorPicker Whether the color update has been triggered by
* one of the color picker widgets. This is needed
* to prevent weird widget behavior.
*/
void selectColor(const QColor &color, bool fromColorPicker);
/// Called when the dialog is confirmed.
void ok();
// Helper methods for initializing UI elements
void initRecentColors(LayoutCreator<QWidget> &creator);
void initDefaultColors(LayoutCreator<QWidget> &creator);
void initColorPicker(LayoutCreator<QWidget> &creator);
void initSpinBoxes(LayoutCreator<QWidget> &creator);
void initHtmlColor(LayoutCreator<QWidget> &creator);
void addShortcuts() override;
};
} // namespace chatterino

View file

@ -52,7 +52,6 @@ SettingsDialog::SettingsDialog(QWidget *parent)
this->initUi();
this->addTabs();
this->overrideBackgroundColor_ = QColor("#111111");
this->scaleChangedEvent(this->scale()); // execute twice to width of item
this->addShortcuts();
this->signalHolder_.managedConnect(getApp()->hotkeys->onItemsUpdated,
@ -128,6 +127,8 @@ void SettingsDialog::initUi()
.setLayoutType<QVBoxLayout>()
.withoutMargin()
.assign(&this->ui_.tabContainer);
this->ui_.tabContainerContainer->setFixedWidth(
static_cast<int>(150 * this->dpi_));
// right side (pages)
centerBox.emplace<QStackedLayout>()
@ -257,6 +258,7 @@ void SettingsDialog::addTab(std::function<SettingsPage *()> page,
SettingsTabId id, Qt::Alignment alignment)
{
auto tab = new SettingsDialogTab(this, std::move(page), name, iconPath, id);
tab->setFixedHeight(static_cast<int>(30 * this->dpi_));
this->ui_.tabContainer->addWidget(tab, 0, alignment);
this->tabs_.push_back(tab);
@ -390,7 +392,11 @@ void SettingsDialog::scaleChangedEvent(float newDpi)
this->setStyleSheet(styleSheet);
if (this->ui_.tabContainerContainer)
{
this->ui_.tabContainerContainer->setFixedWidth(int(150 * newDpi));
}
this->dpi_ = newDpi;
}
void SettingsDialog::themeChangedEvent()

View file

@ -77,6 +77,7 @@ private:
std::vector<SettingsDialogTab *> tabs_;
SettingsDialogTab *selectedTab_{};
SettingsDialogTab *lastSelectedByUser_{};
float dpi_ = 1.0F;
friend class SettingsDialogTab;
};

View file

@ -1,23 +0,0 @@
#include "widgets/helper/ColorButton.hpp"
namespace chatterino {
ColorButton::ColorButton(const QColor &color, QWidget *parent)
: QPushButton(parent)
, color_(color)
{
this->setColor(color_);
}
const QColor &ColorButton::color() const
{
return this->color_;
}
void ColorButton::setColor(QColor color)
{
this->color_ = color;
this->setStyleSheet("background-color: " + color.name(QColor::HexArgb));
}
} // namespace chatterino

View file

@ -1,20 +0,0 @@
#pragma once
#include <QPushButton>
namespace chatterino {
class ColorButton : public QPushButton
{
public:
ColorButton(const QColor &color, QWidget *parent = nullptr);
const QColor &color() const;
void setColor(QColor color);
private:
QColor color_;
};
} // namespace chatterino

View file

@ -93,8 +93,8 @@ NotebookTab::NotebookTab(Notebook *notebook)
[this]() {
this->notebook_->removePage(this->page);
},
getApp()->hotkeys->getDisplaySequence(HotkeyCategory::Window,
"removeTab"));
getIApp()->getHotkeys()->getDisplaySequence(HotkeyCategory::Window,
"removeTab"));
this->menu_.addAction(
"Popup Tab",
@ -104,8 +104,8 @@ NotebookTab::NotebookTab(Notebook *notebook)
container->popup();
}
},
getApp()->hotkeys->getDisplaySequence(HotkeyCategory::Window, "popup",
{{"window"}}));
getIApp()->getHotkeys()->getDisplaySequence(HotkeyCategory::Window,
"popup", {{"window"}}));
highlightNewMessagesAction_ =
new QAction("Mark Tab as Unread on New Messages", &this->menu_);
@ -196,7 +196,7 @@ int NotebookTab::normalTabWidth()
float scale = this->scale();
int width;
QFontMetrics metrics = getApp()->fonts->getFontMetrics(
auto metrics = getIApp()->getFonts()->getFontMetrics(
FontStyle::UiTabs, float(qreal(this->scale()) * deviceDpi(this)));
if (this->hasXButton())
@ -346,17 +346,30 @@ bool NotebookTab::isLive() const
void NotebookTab::setHighlightState(HighlightState newHighlightStyle)
{
if (this->isSelected() || (!this->highlightEnabled_ &&
newHighlightStyle == HighlightState::NewMessage))
if (this->isSelected())
{
return;
}
if (this->highlightState_ != HighlightState::Highlighted)
{
this->highlightState_ = newHighlightStyle;
this->update();
if (!this->highlightEnabled_ &&
newHighlightStyle == HighlightState::NewMessage)
{
return;
}
if (this->highlightState_ == newHighlightStyle ||
this->highlightState_ == HighlightState::Highlighted)
{
return;
}
this->highlightState_ = newHighlightStyle;
this->update();
}
HighlightState NotebookTab::highlightState() const
{
return this->highlightState_;
}
void NotebookTab::setHighlightsEnabled(const bool &newVal)
@ -783,6 +796,11 @@ void NotebookTab::wheelEvent(QWheelEvent *event)
}
}
void NotebookTab::update()
{
Button::update();
}
QRect NotebookTab::getXRect()
{
QRect rect = this->rect();

View file

@ -53,6 +53,8 @@ public:
bool isLive() const;
void setHighlightState(HighlightState style);
HighlightState highlightState() const;
void setHighlightsEnabled(const bool &newVal);
bool hasHighlightsEnabled() const;
@ -84,6 +86,10 @@ protected:
void mouseMoveEvent(QMouseEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
/// This exists as an alias to its base classes update, and is virtual
/// to allow for mocking
virtual void update();
private:
void showRenameDialog();

View file

@ -1,292 +0,0 @@
/****************************************************************************
**
** Copyright (C) 2016 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of the QtWidgets module of the Qt Toolkit.
**
** $QT_BEGIN_LICENSE:LGPL$
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU Lesser General Public License Usage
** Alternatively, this file may be used under the terms of the GNU Lesser
** General Public License version 3 as published by the Free Software
** Foundation and appearing in the file LICENSE.LGPL3 included in the
** packaging of this file. Please review the following information to
** ensure the GNU Lesser General Public License version 3 requirements
** will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 2.0 or (at your option) the GNU General
** Public license version 3 or any later version approved by the KDE Free
** Qt Foundation. The licenses are as published by the Free Software
** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-2.0.html and
** https://www.gnu.org/licenses/gpl-3.0.html.
**
** $QT_END_LICENSE$
**
****************************************************************************/
#include "widgets/helper/QColorPicker.hpp"
#include <qdrawutil.h>
#include <QMouseEvent>
#include <QPainter>
/*
* These classes are literally copied from the Qt source.
* Unfortunately, they are private to the QColorDialog class so we cannot use
* them directly.
* If they become public at any point in the future, it should be possible to
* replace every include of this header with the respective includes for the
* QColorPicker, QColorLuminancePicker, and QColSpinBox classes.
*/
namespace chatterino {
int QColorLuminancePicker::y2val(int y)
{
int d = height() - 2 * coff - 1;
return 255 - (y - coff) * 255 / d;
}
int QColorLuminancePicker::val2y(int v)
{
int d = height() - 2 * coff - 1;
return coff + (255 - v) * d / 255;
}
QColorLuminancePicker::QColorLuminancePicker(QWidget *parent)
: QWidget(parent)
{
hue = 100;
val = 100;
sat = 100;
pix = 0;
setSizePolicy(QSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed));
}
QColorLuminancePicker::~QColorLuminancePicker()
{
delete pix;
}
void QColorLuminancePicker::mouseMoveEvent(QMouseEvent *m)
{
setVal(y2val(m->y()));
}
void QColorLuminancePicker::mousePressEvent(QMouseEvent *m)
{
setVal(y2val(m->y()));
}
void QColorLuminancePicker::setVal(int v)
{
if (val == v)
return;
val = qMax(0, qMin(v, 255));
delete pix;
pix = 0;
repaint();
emit newHsv(hue, sat, val);
}
//receives from a hue,sat chooser and relays.
void QColorLuminancePicker::setCol(int h, int s)
{
setCol(h, s, val);
emit newHsv(h, s, val);
}
QSize QColorLuminancePicker::sizeHint() const
{
return QSize(LUMINANCE_PICKER_WIDTH, LUMINANCE_PICKER_HEIGHT);
}
void QColorLuminancePicker::paintEvent(QPaintEvent *)
{
int w = width() - 5;
QRect r(0, foff, w, height() - 2 * foff);
int wi = r.width() - 2;
int hi = r.height() - 2;
if (!pix || pix->height() != hi || pix->width() != wi)
{
delete pix;
QImage img(wi, hi, QImage::Format_RGB32);
int y;
uint *pixel = (uint *)img.scanLine(0);
for (y = 0; y < hi; y++)
{
uint *end = pixel + wi;
std::fill(pixel, end,
QColor::fromHsv(hue, sat, y2val(y + coff)).rgb());
pixel = end;
}
pix = new QPixmap(QPixmap::fromImage(img));
}
QPainter p(this);
p.drawPixmap(1, coff, *pix);
const QPalette &g = palette();
qDrawShadePanel(&p, r, g, true);
p.setPen(g.windowText().color());
p.setBrush(g.windowText());
QPolygon a;
int y = val2y(val);
a.setPoints(3, w, y, w + 5, y + 5, w + 5, y - 5);
p.eraseRect(w, 0, 5, height());
p.drawPolygon(a);
}
void QColorLuminancePicker::setCol(int h, int s, int v)
{
val = v;
hue = h;
sat = s;
delete pix;
pix = 0;
repaint();
}
QPoint QColorPicker::colPt()
{
QRect r = contentsRect();
return QPoint((360 - hue) * (r.width() - 1) / 360,
(255 - sat) * (r.height() - 1) / 255);
}
int QColorPicker::huePt(const QPoint &pt)
{
QRect r = contentsRect();
return 360 - pt.x() * 360 / (r.width() - 1);
}
int QColorPicker::satPt(const QPoint &pt)
{
QRect r = contentsRect();
return 255 - pt.y() * 255 / (r.height() - 1);
}
void QColorPicker::setCol(const QPoint &pt)
{
setCol(huePt(pt), satPt(pt));
}
QColorPicker::QColorPicker(QWidget *parent)
: QFrame(parent)
, crossVisible(true)
{
hue = 0;
sat = 0;
setCol(150, 255);
setAttribute(Qt::WA_NoSystemBackground);
setSizePolicy(QSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed));
}
QColorPicker::~QColorPicker()
{
}
void QColorPicker::setCrossVisible(bool visible)
{
if (crossVisible != visible)
{
crossVisible = visible;
update();
}
}
QSize QColorPicker::sizeHint() const
{
return QSize(COLOR_PICKER_WIDTH, COLOR_PICKER_HEIGHT);
}
void QColorPicker::setCol(int h, int s)
{
int nhue = qMin(qMax(0, h), 359);
int nsat = qMin(qMax(0, s), 255);
if (nhue == hue && nsat == sat)
return;
QRect r(colPt(), QSize(20, 20));
hue = nhue;
sat = nsat;
r = r.united(QRect(colPt(), QSize(20, 20)));
r.translate(contentsRect().x() - 9, contentsRect().y() - 9);
repaint(r);
}
void QColorPicker::mouseMoveEvent(QMouseEvent *m)
{
QPoint p = m->pos() - contentsRect().topLeft();
setCol(p);
emit newCol(hue, sat);
}
void QColorPicker::mousePressEvent(QMouseEvent *m)
{
QPoint p = m->pos() - contentsRect().topLeft();
setCol(p);
emit newCol(hue, sat);
}
void QColorPicker::paintEvent(QPaintEvent *)
{
QPainter p(this);
drawFrame(&p);
QRect r = contentsRect();
p.drawPixmap(r.topLeft(), pix);
if (crossVisible)
{
QPoint pt = colPt() + r.topLeft();
p.setPen(Qt::black);
p.fillRect(pt.x() - 9, pt.y(), 20, 2, Qt::black);
p.fillRect(pt.x(), pt.y() - 9, 2, 20, Qt::black);
}
}
void QColorPicker::resizeEvent(QResizeEvent *ev)
{
QFrame::resizeEvent(ev);
int w = width() - frameWidth() * 2;
int h = height() - frameWidth() * 2;
QImage img(w, h, QImage::Format_RGB32);
int x, y;
uint *pixel = (uint *)img.scanLine(0);
for (y = 0; y < h; y++)
{
const uint *end = pixel + w;
x = 0;
while (pixel < end)
{
QPoint p(x, y);
QColor c;
c.setHsv(huePt(p), satPt(p), 200);
*pixel = c.rgb();
++pixel;
++x;
}
}
pix = QPixmap::fromImage(img);
}
QColSpinBox::QColSpinBox(QWidget *parent)
: QSpinBox(parent)
{
this->setRange(0, 255);
}
void QColSpinBox::setValue(int i)
{
const QSignalBlocker blocker(this);
QSpinBox::setValue(i);
}
} // namespace chatterino

View file

@ -1,131 +0,0 @@
/****************************************************************************
**
** Copyright (C) 2016 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of the QtWidgets module of the Qt Toolkit.
**
** $QT_BEGIN_LICENSE:LGPL$
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU Lesser General Public License Usage
** Alternatively, this file may be used under the terms of the GNU Lesser
** General Public License version 3 as published by the Free Software
** Foundation and appearing in the file LICENSE.LGPL3 included in the
** packaging of this file. Please review the following information to
** ensure the GNU Lesser General Public License version 3 requirements
** will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 2.0 or (at your option) the GNU General
** Public license version 3 or any later version approved by the KDE Free
** Qt Foundation. The licenses are as published by the Free Software
** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-2.0.html and
** https://www.gnu.org/licenses/gpl-3.0.html.
**
** $QT_END_LICENSE$
**
****************************************************************************/
#pragma once
#include <QFrame>
#include <QSpinBox>
namespace chatterino {
/*
* These classes are literally copied from the Qt source.
* Unfortunately, they are private to the QColorDialog class so we cannot use
* them directly.
* If they become public at any point in the future, it should be possible to
* replace every include of this header with the respective includes for the
* QColorPicker, QColorLuminancePicker, and QColSpinBox classes.
*/
class QColorPicker : public QFrame
{
Q_OBJECT
public:
QColorPicker(QWidget *parent);
~QColorPicker() override;
void setCrossVisible(bool visible);
public slots:
void setCol(int h, int s);
signals:
void newCol(int h, int s);
protected:
QSize sizeHint() const override;
void paintEvent(QPaintEvent *) override;
void mouseMoveEvent(QMouseEvent *) override;
void mousePressEvent(QMouseEvent *) override;
void resizeEvent(QResizeEvent *) override;
private:
int hue;
int sat;
QPoint colPt();
int huePt(const QPoint &pt);
int satPt(const QPoint &pt);
void setCol(const QPoint &pt);
QPixmap pix;
bool crossVisible;
};
static const int COLOR_PICKER_WIDTH = 220;
static const int COLOR_PICKER_HEIGHT = 200;
class QColorLuminancePicker : public QWidget
{
Q_OBJECT
public:
QColorLuminancePicker(QWidget *parent = 0);
~QColorLuminancePicker() override;
public slots:
void setCol(int h, int s, int v);
void setCol(int h, int s);
signals:
void newHsv(int h, int s, int v);
protected:
QSize sizeHint() const override;
void paintEvent(QPaintEvent *) override;
void mouseMoveEvent(QMouseEvent *) override;
void mousePressEvent(QMouseEvent *) override;
private:
enum { foff = 3, coff = 4 }; //frame and contents offset
int val;
int hue;
int sat;
int y2val(int y);
int val2y(int val);
void setVal(int v);
QPixmap *pix;
};
static const int LUMINANCE_PICKER_WIDTH = 25;
static const int LUMINANCE_PICKER_HEIGHT = COLOR_PICKER_HEIGHT;
class QColSpinBox : public QSpinBox
{
public:
QColSpinBox(QWidget *parent);
void setValue(int i);
};
} // namespace chatterino

View file

@ -0,0 +1,163 @@
#include "widgets/helper/color/AlphaSlider.hpp"
#include "widgets/helper/color/Checkerboard.hpp"
#include <QMouseEvent>
#include <QPainterPath>
namespace {
constexpr int SLIDER_WIDTH = 256;
constexpr int SLIDER_HEIGHT = 12;
} // namespace
namespace chatterino {
AlphaSlider::AlphaSlider(QColor color, QWidget *parent)
: QWidget(parent)
, alpha_(color.alpha())
, color_(color)
{
this->setSizePolicy({QSizePolicy::Expanding, QSizePolicy::Fixed});
}
void AlphaSlider::setColor(QColor color)
{
if (this->color_ == color)
{
return;
}
this->alpha_ = color.alpha();
this->color_ = color;
this->cachedPixmap_ = {};
this->update();
}
int AlphaSlider::alpha() const
{
return this->alpha_;
}
QSize AlphaSlider::sizeHint() const
{
return {SLIDER_WIDTH, SLIDER_HEIGHT};
}
void AlphaSlider::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
this->updatePixmap();
}
int AlphaSlider::xPosToAlpha(int xPos) const
{
return (xPos * 255) / (this->width() - this->height());
}
void AlphaSlider::mousePressEvent(QMouseEvent *event)
{
if (event->buttons().testFlag(Qt::MouseButton::LeftButton))
{
this->trackingMouseEvents_ = true;
this->updateFromEvent(event);
this->setFocus(Qt::FocusReason::MouseFocusReason);
}
}
void AlphaSlider::mouseMoveEvent(QMouseEvent *event)
{
if (this->trackingMouseEvents_)
{
this->updateFromEvent(event);
event->accept();
}
}
void AlphaSlider::mouseReleaseEvent(QMouseEvent *event)
{
if (this->trackingMouseEvents_ &&
event->buttons().testFlag(Qt::MouseButton::LeftButton))
{
this->updateFromEvent(event);
this->trackingMouseEvents_ = false;
event->accept();
}
}
void AlphaSlider::updateFromEvent(QMouseEvent *event)
{
int cornerRadius = this->height() / 2;
auto clampedX = std::clamp(event->pos().x(), cornerRadius,
this->width() - cornerRadius);
this->setAlpha(this->xPosToAlpha(clampedX - cornerRadius));
}
void AlphaSlider::updatePixmap()
{
this->cachedPixmap_ = QPixmap(this->size());
this->cachedPixmap_.fill(Qt::transparent);
QPainter painter(&this->cachedPixmap_);
painter.setRenderHint(QPainter::Antialiasing);
qreal cornerRadius = (qreal)this->height() / 2.0;
QPainterPath mask;
mask.addRoundedRect(QRect({0, 0}, this->size()), cornerRadius,
cornerRadius);
painter.setClipPath(mask);
drawCheckerboard(painter, this->size(), this->height() / 2);
QLinearGradient gradient(cornerRadius, 0.0,
(qreal)this->width() - cornerRadius, 0.0);
QColor start = this->color_;
QColor end = this->color_;
start.setAlpha(0);
end.setAlpha(255);
gradient.setColorAt(0.0, start);
gradient.setColorAt(1.0, end);
painter.setPen({Qt::transparent, 0});
painter.setBrush(gradient);
painter.drawRect(QRect({0, 0}, this->size()));
}
void AlphaSlider::paintEvent(QPaintEvent * /*event*/)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (this->cachedPixmap_.isNull())
{
this->updatePixmap();
}
painter.drawPixmap(this->rect().topLeft(), this->cachedPixmap_);
int cornerRadius = this->height() / 2;
QPoint circ = {
cornerRadius +
(this->alpha() * (this->width() - 2 * cornerRadius)) / 255,
cornerRadius};
auto circleColor = 0;
painter.setPen({QColor(circleColor, circleColor, circleColor), 2});
auto opaqueBase = this->color_;
opaqueBase.setAlpha(255);
painter.setBrush(opaqueBase);
painter.drawEllipse(circ, cornerRadius - 1, cornerRadius - 1);
}
void AlphaSlider::setAlpha(int alpha)
{
if (this->alpha_ == alpha)
{
return;
}
this->alpha_ = alpha;
this->color_.setAlpha(alpha);
emit this->colorChanged(this->color_);
this->update();
}
} // namespace chatterino

View file

@ -0,0 +1,48 @@
#pragma once
#include <QWidget>
namespace chatterino {
class AlphaSlider : public QWidget
{
Q_OBJECT
public:
AlphaSlider(QColor color, QWidget *parent = nullptr);
QSize sizeHint() const override;
int alpha() const;
signals:
void colorChanged(QColor color) const;
public slots:
void setColor(QColor color);
protected:
void resizeEvent(QResizeEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
private:
int alpha_ = 255;
QColor color_;
QPixmap cachedPixmap_;
bool trackingMouseEvents_ = false;
void updatePixmap();
int xPosToAlpha(int xPos) const;
void updateFromEvent(QMouseEvent *event);
void setAlpha(int alpha);
};
} // namespace chatterino

View file

@ -0,0 +1,29 @@
#include "widgets/helper/color/Checkerboard.hpp"
namespace chatterino {
void drawCheckerboard(QPainter &painter, QRect rect, int tileSize)
{
painter.fillRect(rect, QColor(255, 255, 255));
if (tileSize <= 0)
{
tileSize = 1;
}
int overflowY = rect.height() % tileSize == 0 ? 0 : 1;
int overflowX = rect.width() % tileSize == 0 ? 0 : 1;
for (int row = 0; row < rect.height() / tileSize + overflowY; row++)
{
int offsetX = row % 2 == 0 ? 0 : 1;
for (int col = offsetX; col < rect.width() / tileSize + overflowX;
col += 2)
{
painter.fillRect(rect.x() + col * tileSize,
rect.y() + row * tileSize, tileSize, tileSize,
QColor(204, 204, 204));
}
}
}
} // namespace chatterino

View file

@ -0,0 +1,13 @@
#pragma once
#include <QPainter>
namespace chatterino {
void drawCheckerboard(QPainter &painter, QRect rect, int tileSize = 4);
inline void drawCheckerboard(QPainter &painter, QSize size, int tileSize = 4)
{
drawCheckerboard(painter, {{0, 0}, size}, tileSize);
}
} // namespace chatterino

View file

@ -0,0 +1,81 @@
#include "widgets/helper/color/ColorButton.hpp"
#include "widgets/helper/color/Checkerboard.hpp"
#include <QPainterPath>
namespace chatterino {
ColorButton::ColorButton(QColor color, QWidget *parent)
: QAbstractButton(parent)
, currentColor_(color)
{
this->setSizePolicy({QSizePolicy::Expanding, QSizePolicy::Expanding});
this->setMinimumSize({30, 30});
}
QSize ColorButton::sizeHint() const
{
return {50, 30};
}
void ColorButton::setColor(const QColor &color)
{
if (this->currentColor_ == color)
{
return;
}
this->currentColor_ = color;
this->update();
}
QColor ColorButton::color() const
{
return this->currentColor_;
}
void ColorButton::resizeEvent(QResizeEvent * /*event*/)
{
this->checkerboardCacheValid_ = false;
this->repaint();
}
void ColorButton::paintEvent(QPaintEvent * /*event*/)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
auto rect = this->rect();
if (this->currentColor_.alpha() != 255)
{
if (!this->checkerboardCacheValid_)
{
QPixmap cache(this->size());
cache.fill(Qt::transparent);
QPainter cachePainter(&cache);
cachePainter.setRenderHint(QPainter::Antialiasing);
QPainterPath path;
path.addRoundedRect(QRect(1, 1, this->size().width() - 2,
this->size().height() - 2),
5, 5);
cachePainter.setClipPath(path);
drawCheckerboard(cachePainter, this->size(),
std::min(this->height() / 2, 10));
cachePainter.end();
this->checkerboardCache_ = std::move(cache);
this->checkerboardCacheValid_ = true;
}
painter.drawPixmap(rect.topLeft(), this->checkerboardCache_);
}
painter.setBrush(this->currentColor_);
painter.setPen({QColor(255, 255, 255, 127), 1});
painter.drawRoundedRect(rect.x() + 1, rect.y() + 1, rect.width() - 2,
rect.height() - 2, 5, 5);
}
} // namespace chatterino

View file

@ -0,0 +1,33 @@
#pragma once
#include <QAbstractButton>
namespace chatterino {
class ColorButton : public QAbstractButton
{
Q_OBJECT
public:
ColorButton(QColor color, QWidget *parent = nullptr);
QSize sizeHint() const override;
QColor color() const;
// NOLINTNEXTLINE(readability-redundant-access-specifiers)
public slots:
void setColor(const QColor &color);
protected:
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private:
QColor currentColor_;
QPixmap checkerboardCache_;
bool checkerboardCacheValid_ = false;
};
} // namespace chatterino

View file

@ -0,0 +1,168 @@
#include "widgets/helper/color/ColorInput.hpp"
namespace {
// from qtools_p.h
int fromHex(char c) noexcept
{
if (c >= '0' && c <= '9')
{
return int(c - '0');
}
if (c >= 'A' && c <= 'F')
{
return int(c - 'A' + 10);
}
if (c >= 'a' && c <= 'f')
{
return int(c - 'a' + 10);
}
return -1;
}
QColor parseHexColor(const QString &text)
{
if (text.length() == 5) // #rgba
{
auto alphaHex = fromHex(text[4].toLatin1());
QStringView v(text);
v.chop(1);
QColor col(v);
col.setAlpha(alphaHex);
return col;
}
QColor col(text);
if (col.isValid() && text.length() == 9) // #rrggbbaa
{
auto rgba = col.rgba();
auto alpha = rgba & 0xff;
QColor actual(rgba >> 8);
actual.setAlpha((int)alpha);
return actual;
}
return col;
}
} // namespace
namespace chatterino {
ColorInput::ColorInput(QColor color, QWidget *parent)
: QWidget(parent)
, currentColor_(color)
, hexValidator_(QRegularExpression(
R"(^#([A-Fa-f\d]{3,4}|[A-Fa-f\d]{6}|[A-Fa-f\d]{8})$)"))
, layout_(this)
{
int row = 0;
const auto initComponent = [&](Component &component, auto label,
auto applyToColor) {
component.lbl.setText(label);
component.box.setRange(0, 255);
QObject::connect(&component.box,
qOverload<int>(&QSpinBox::valueChanged), this,
[this, &component, applyToColor](int value) {
if (component.value == value)
{
return;
}
applyToColor(this->currentColor_, value);
this->emitUpdate();
});
this->layout_.addWidget(&component.lbl, row, 0);
this->layout_.addWidget(&component.box, row, 1);
row++;
};
initComponent(this->red_, "Red:", [](auto &color, int value) {
color.setRed(value);
});
initComponent(this->green_, "Green:", [](auto &color, int value) {
color.setGreen(value);
});
initComponent(this->blue_, "Red:", [](auto &color, int value) {
color.setBlue(value);
});
initComponent(this->alpha_, "Alpha:", [](auto &color, int value) {
color.setAlpha(value);
});
this->hexLabel_.setText("Hex:");
this->hexInput_.setValidator(&this->hexValidator_);
QObject::connect(&this->hexInput_, &QLineEdit::editingFinished, [this]() {
auto css = parseHexColor(this->hexInput_.text());
if (!css.isValid() || this->currentColor_ == css)
{
return;
}
this->currentColor_ = css;
this->emitUpdate();
});
this->layout_.addWidget(&this->hexLabel_, row, 0);
this->layout_.addWidget(&this->hexInput_, row, 1);
this->updateComponents();
}
void ColorInput::updateComponents()
{
auto color = this->currentColor_.toRgb();
const auto updateComponent = [](Component &component, auto getValue) {
int value = getValue();
if (component.value != value)
{
component.value = value;
component.box.setValue(value);
}
};
updateComponent(this->red_, [&]() {
return color.red();
});
updateComponent(this->green_, [&]() {
return color.green();
});
updateComponent(this->blue_, [&]() {
return color.blue();
});
updateComponent(this->alpha_, [&]() {
return color.alpha();
});
this->updateHex();
}
void ColorInput::updateHex()
{
auto rgb = this->currentColor_.rgb();
rgb <<= 8;
rgb |= this->currentColor_.alpha();
// we always need to update the CSS color
this->hexInput_.setText(QStringLiteral("#%1").arg(rgb, 8, 16, QChar(u'0')));
}
QColor ColorInput::color() const
{
return this->currentColor_;
}
void ColorInput::setColor(QColor color)
{
if (this->currentColor_ == color)
{
return;
}
this->currentColor_ = color;
this->updateComponents();
// no emit, as we just got the updated color
}
void ColorInput::emitUpdate()
{
this->updateComponents();
// our components triggered this update, emit the new color
emit this->colorChanged(this->currentColor_);
}
} // namespace chatterino

View file

@ -0,0 +1,52 @@
#pragma once
#include <QGridLayout>
#include <QLabel>
#include <QLineEdit>
#include <QSpinBox>
#include <QWidget>
namespace chatterino {
class ColorInput : public QWidget
{
Q_OBJECT
public:
ColorInput(QColor color, QWidget *parent = nullptr);
QColor color() const;
signals:
void colorChanged(QColor color);
public slots:
void setColor(QColor color);
private:
QColor currentColor_;
struct Component {
QLabel lbl;
QSpinBox box;
int value = -1;
};
Component red_;
Component green_;
Component blue_;
Component alpha_;
QLabel hexLabel_;
QLineEdit hexInput_;
QRegularExpressionValidator hexValidator_;
QGridLayout layout_;
void updateComponents();
void updateHex();
void emitUpdate();
};
} // namespace chatterino

View file

@ -0,0 +1,35 @@
#include "widgets/helper/color/ColorItemDelegate.hpp"
#include "widgets/helper/color/Checkerboard.hpp"
namespace chatterino {
ColorItemDelegate::ColorItemDelegate(QObject *parent)
: QStyledItemDelegate(parent)
{
}
void ColorItemDelegate::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
auto data = index.data(Qt::DecorationRole);
if (data.type() != QVariant::Color)
{
return QStyledItemDelegate::paint(painter, option, index);
}
auto color = data.value<QColor>();
painter->save();
if (color.alpha() != 255)
{
drawCheckerboard(*painter, option.rect,
std::min(option.rect.height() / 2, 10));
}
painter->setBrush(color);
painter->drawRect(option.rect);
painter->restore();
}
} // namespace chatterino

View file

@ -0,0 +1,16 @@
#pragma once
#include <QStyledItemDelegate>
namespace chatterino {
class ColorItemDelegate : public QStyledItemDelegate
{
public:
explicit ColorItemDelegate(QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
};
} // namespace chatterino

View file

@ -0,0 +1,165 @@
#include "widgets/helper/color/HueSlider.hpp"
#include <QMouseEvent>
#include <QPainter>
namespace {
constexpr int SLIDER_WIDTH = 256;
constexpr int SLIDER_HEIGHT = 12;
} // namespace
namespace chatterino {
HueSlider::HueSlider(QColor color, QWidget *parent)
: QWidget(parent)
{
this->setColor(color);
this->setSizePolicy({QSizePolicy::Expanding, QSizePolicy::Fixed});
}
void HueSlider::setColor(QColor color)
{
if (this->color_ == color)
{
return;
}
this->color_ = color.toHsv();
auto hue = std::max(this->color_.hue(), 0);
if (this->hue_ == hue)
{
return;
}
this->hue_ = hue;
this->update();
}
int HueSlider::hue() const
{
return this->hue_;
}
QSize HueSlider::sizeHint() const
{
return {SLIDER_WIDTH, SLIDER_HEIGHT};
}
void HueSlider::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
this->updatePixmap();
}
int HueSlider::xPosToHue(int xPos) const
{
return (xPos * 359) / (this->width() - this->height());
}
void HueSlider::mousePressEvent(QMouseEvent *event)
{
if (event->buttons().testFlag(Qt::MouseButton::LeftButton))
{
this->trackingMouseEvents_ = true;
this->updateFromEvent(event);
event->accept();
this->setFocus(Qt::FocusReason::MouseFocusReason);
}
}
void HueSlider::mouseMoveEvent(QMouseEvent *event)
{
if (this->trackingMouseEvents_)
{
this->updateFromEvent(event);
event->accept();
}
}
void HueSlider::mouseReleaseEvent(QMouseEvent *event)
{
if (this->trackingMouseEvents_ &&
event->buttons().testFlag(Qt::MouseButton::LeftButton))
{
this->updateFromEvent(event);
this->trackingMouseEvents_ = false;
event->accept();
}
}
void HueSlider::updateFromEvent(QMouseEvent *event)
{
int cornerRadius = this->height() / 2;
auto clampedX = std::clamp(event->pos().x(), cornerRadius,
this->width() - cornerRadius);
this->setHue(this->xPosToHue(clampedX - cornerRadius));
}
void HueSlider::updatePixmap()
{
constexpr int nStops = 10;
constexpr auto nStopsF = (qreal)nStops;
this->gradientPixmap_ = QPixmap(this->size());
this->gradientPixmap_.fill(Qt::transparent);
QPainter painter(&this->gradientPixmap_);
painter.setRenderHint(QPainter::Antialiasing);
qreal cornerRadius = (qreal)this->height() / 2.0;
QLinearGradient gradient(cornerRadius, 0.0,
(qreal)this->width() - cornerRadius, 0.0);
for (int i = 0; i <= nStops; i++)
{
gradient.setColorAt(
(qreal)i / nStopsF,
QColor::fromHsv(std::min((i * 360) / nStops, 359), 255, 255));
}
painter.setPen({Qt::transparent, 0});
painter.setBrush(gradient);
painter.drawRoundedRect(QRect({0, 0}, this->size()), cornerRadius,
cornerRadius);
}
void HueSlider::paintEvent(QPaintEvent * /*event*/)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (this->gradientPixmap_.isNull())
{
this->updatePixmap();
}
painter.drawPixmap(this->rect().topLeft(), this->gradientPixmap_);
int cornerRadius = this->height() / 2;
QPoint circ = {
cornerRadius + (this->hue() * (this->width() - 2 * cornerRadius)) / 360,
cornerRadius};
auto circleColor = 0;
painter.setPen({QColor(circleColor, circleColor, circleColor), 2});
painter.setBrush(QColor::fromHsv(this->hue(), 255, 255));
painter.drawEllipse(circ, cornerRadius - 1, cornerRadius - 1);
}
void HueSlider::setHue(int hue)
{
if (this->hue_ == hue)
{
return;
}
this->hue_ = hue;
// ugh
int h{};
int s{};
int v{};
int a{};
this->color_.getHsv(&h, &s, &v, &a);
this->color_.setHsv(this->hue_, s, v, a);
emit this->colorChanged(this->color_);
this->update();
}
} // namespace chatterino

View file

@ -0,0 +1,48 @@
#pragma once
#include <QWidget>
namespace chatterino {
class HueSlider : public QWidget
{
Q_OBJECT
public:
HueSlider(QColor color, QWidget *parent = nullptr);
QSize sizeHint() const override;
int hue() const;
signals:
void colorChanged(QColor color) const;
public slots:
void setColor(QColor color);
protected:
void resizeEvent(QResizeEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
private:
int hue_ = 0;
QColor color_;
QPixmap gradientPixmap_;
bool trackingMouseEvents_ = false;
void updatePixmap();
int xPosToHue(int xPos) const;
void updateFromEvent(QMouseEvent *event);
void setHue(int hue);
};
} // namespace chatterino

View file

@ -0,0 +1,192 @@
#include "widgets/helper/color/SBCanvas.hpp"
#include <QMouseEvent>
#include <QPainter>
namespace {
constexpr int PICKER_WIDTH = 256;
constexpr int PICKER_HEIGHT = 256;
} // namespace
namespace chatterino {
SBCanvas::SBCanvas(QColor color, QWidget *parent)
: QWidget(parent)
{
this->setColor(color);
this->setSizePolicy({QSizePolicy::Fixed, QSizePolicy::Fixed});
}
void SBCanvas::setColor(QColor color)
{
color = color.toHsv();
if (this->color_ == color)
{
return;
}
this->color_ = color;
int h{};
int s{};
int v{};
color.getHsv(&h, &s, &v);
h = std::max(h, 0);
if (this->hue_ == h && this->saturation_ == s && this->brightness_ == v)
{
return; // alpha changed
}
this->hue_ = h;
this->saturation_ = s;
this->brightness_ = v;
this->gradientPixmap_ = {};
this->update();
}
int SBCanvas::saturation() const
{
return this->saturation_;
}
int SBCanvas::brightness() const
{
return this->brightness_;
}
QSize SBCanvas::sizeHint() const
{
return {PICKER_WIDTH, PICKER_HEIGHT};
}
void SBCanvas::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
this->updatePixmap();
}
int SBCanvas::xPosToSaturation(int xPos) const
{
return (xPos * 255) / this->width();
}
int SBCanvas::yPosToBrightness(int yPos) const
{
return 255 - (yPos * 255) / this->height();
}
void SBCanvas::mousePressEvent(QMouseEvent *event)
{
if (event->buttons().testFlag(Qt::MouseButton::LeftButton))
{
this->trackingMouseEvents_ = true;
this->updateFromEvent(event);
event->accept();
this->setFocus(Qt::FocusReason::MouseFocusReason);
}
}
void SBCanvas::mouseMoveEvent(QMouseEvent *event)
{
if (this->trackingMouseEvents_)
{
this->updateFromEvent(event);
event->accept();
}
}
void SBCanvas::mouseReleaseEvent(QMouseEvent *event)
{
if (this->trackingMouseEvents_ &&
event->buttons().testFlag(Qt::MouseButton::LeftButton))
{
this->updateFromEvent(event);
this->trackingMouseEvents_ = false;
event->accept();
}
}
void SBCanvas::updateFromEvent(QMouseEvent *event)
{
auto clampedX = std::clamp(event->pos().x(), 0, this->width());
auto clampedY = std::clamp(event->pos().y(), 0, this->height());
bool updated = this->setSaturation(this->xPosToSaturation(clampedX));
updated |= this->setBrightness(this->yPosToBrightness(clampedY));
if (updated)
{
this->emitUpdatedColor();
this->update();
}
}
void SBCanvas::updatePixmap()
{
int w = this->width();
int h = this->height();
QImage img(w, h, QImage::Format_RGB32);
uint *pixel = (uint *)img.scanLine(0);
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
QColor c = QColor::fromHsv(this->hue_, this->xPosToSaturation(x),
this->yPosToBrightness(y));
*pixel = c.rgb();
pixel++;
}
}
this->gradientPixmap_ = QPixmap::fromImage(img);
}
void SBCanvas::paintEvent(QPaintEvent * /*event*/)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (this->gradientPixmap_.isNull())
{
this->updatePixmap();
}
painter.drawPixmap(this->rect().topLeft(), this->gradientPixmap_);
QPoint circ = {(this->saturation() * this->width()) / 256,
((255 - this->brightness()) * this->height()) / 256};
auto circleColor = this->brightness() >= 128 ? 50 : 200;
painter.setPen({QColor(circleColor, circleColor, circleColor), 2});
painter.setBrush(
QColor::fromHsv(this->hue_, this->saturation_, this->brightness_));
painter.drawEllipse(circ, 5, 5);
}
bool SBCanvas::setSaturation(int saturation)
{
if (this->saturation_ == saturation)
{
return false;
}
this->saturation_ = saturation;
return true;
}
bool SBCanvas::setBrightness(int brightness)
{
if (this->brightness_ == brightness)
{
return false;
}
this->brightness_ = brightness;
return true;
}
void SBCanvas::emitUpdatedColor()
{
this->color_.setHsv(this->hue_, this->saturation_, this->brightness_,
this->color_.alpha());
emit this->colorChanged(this->color_);
}
} // namespace chatterino

View file

@ -0,0 +1,56 @@
#pragma once
#include <QWidget>
namespace chatterino {
/// 2D canvas for saturation (x-axis) and brightness (y-axis)
class SBCanvas : public QWidget
{
Q_OBJECT
public:
SBCanvas(QColor color, QWidget *parent = nullptr);
QSize sizeHint() const override;
int saturation() const;
int brightness() const;
signals:
void colorChanged(QColor color) const;
public slots:
void setColor(QColor color);
protected:
void resizeEvent(QResizeEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
private:
int hue_ = 0;
int saturation_ = 0;
int brightness_ = 0;
QColor color_;
QPixmap gradientPixmap_;
bool trackingMouseEvents_ = false;
void updatePixmap();
int xPosToSaturation(int xPos) const;
int yPosToBrightness(int yPos) const;
void updateFromEvent(QMouseEvent *event);
[[nodiscard]] bool setSaturation(int saturation);
[[nodiscard]] bool setBrightness(int brightness);
void emitUpdatedColor();
};
} // namespace chatterino

View file

@ -4,7 +4,7 @@
#include "util/LayoutHelper.hpp"
#include "util/RapidJsonSerializeQString.hpp"
#include "widgets/dialogs/ColorPickerDialog.hpp"
#include "widgets/helper/ColorButton.hpp"
#include "widgets/helper/color/ColorButton.hpp"
#include "widgets/helper/Line.hpp"
#include <QRegularExpression>
@ -214,20 +214,18 @@ ColorButton *GeneralPageView::addColorButton(
QObject::connect(
colorButton, &ColorButton::clicked, [this, &setting, colorButton]() {
auto dialog = new ColorPickerDialog(QColor(setting), this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->show();
// We can safely ignore this signal connection, for now, since the
auto *dialog = new ColorPickerDialog(QColor(setting), this);
// colorButton & setting are never deleted and the signal is deleted
// once the dialog is closed
std::ignore = dialog->closed.connect(
[&setting, colorButton](QColor selected) {
if (selected.isValid())
{
setting = selected.name(QColor::HexArgb);
colorButton->setColor(selected);
}
});
QObject::connect(dialog, &ColorPickerDialog::colorConfirmed, this,
[&setting, colorButton](auto selected) {
if (selected.isValid())
{
setting = selected.name(QColor::HexArgb);
colorButton->setColor(selected);
}
});
dialog->show();
});
this->groups_.back().widgets.push_back({label, {text}});

View file

@ -15,6 +15,7 @@
#include "util/LayoutCreator.hpp"
#include "widgets/dialogs/BadgePickerDialog.hpp"
#include "widgets/dialogs/ColorPickerDialog.hpp"
#include "widgets/helper/color/ColorItemDelegate.hpp"
#include "widgets/helper/EditableModelView.hpp"
#include <QFileDialog>
@ -82,6 +83,8 @@ HighlightingPage::HighlightingPage()
QHeaderView::Fixed);
view->getTableView()->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::Stretch);
view->getTableView()->setItemDelegateForColumn(
HighlightModel::Column::Color, new ColorItemDelegate(view));
// fourtf: make class extrend BaseWidget and add this to
// dpiChanged
@ -134,6 +137,9 @@ HighlightingPage::HighlightingPage()
QHeaderView::Fixed);
view->getTableView()->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::Stretch);
view->getTableView()->setItemDelegateForColumn(
UserHighlightModel::Column::Color,
new ColorItemDelegate(view));
// fourtf: make class extrend BaseWidget and add this to
// dpiChanged
@ -176,6 +182,9 @@ HighlightingPage::HighlightingPage()
QHeaderView::Fixed);
view->getTableView()->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::Stretch);
view->getTableView()->setItemDelegateForColumn(
BadgeHighlightModel::Column::Color,
new ColorItemDelegate(view));
// fourtf: make class extrend BaseWidget and add this to
// dpiChanged
@ -330,18 +339,18 @@ void HighlightingPage::openColorDialog(const QModelIndex &clicked,
auto initial =
view->getModel()->data(clicked, Qt::DecorationRole).value<QColor>();
auto dialog = new ColorPickerDialog(initial, this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->show();
// We can safely ignore this signal connection since the view and tab are never deleted
auto *dialog = new ColorPickerDialog(initial, this);
// TODO: The QModelIndex clicked is technically not safe to persist here since the model
// can be changed between the color dialog being created & the color dialog being closed
std::ignore = dialog->closed.connect([=](auto selected) {
if (selected.isValid())
{
view->getModel()->setData(clicked, selected, Qt::DecorationRole);
}
});
QObject::connect(dialog, &ColorPickerDialog::colorConfirmed, this,
[=](auto selected) {
if (selected.isValid())
{
view->getModel()->setData(clicked, selected,
Qt::DecorationRole);
}
});
dialog->show();
}
void HighlightingPage::tableCellClicked(const QModelIndex &clicked,

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