diff --git a/CHANGELOG.md b/CHANGELOG.md
index 552a03adab6..9fe6aa1c791 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unversioned
+## 2.3.3
+
- Major: Added username autocompletion popup menu when typing usernames with an @ prefix. (#1979, #2866)
- Major: Added ability to toggle visibility of Channel Tabs - This can be done by right-clicking the tab area or pressing the keyboard shortcut (default: Ctrl+U). (#2600)
- Minor: The /live split now shows channels going offline. (#2880)
@@ -20,8 +22,9 @@
- Bugfix: Moderation buttons now show the correct time unit when using units other than seconds. (#1719, #2864)
- Bugfix: Fixed FFZ emote links for global emotes (#2807, #2808)
- Bugfix: Fixed pasting text with URLs included (#1688, #2855)
-- Bugfix: Fix reconnecting when IRC write connection is lost (#1831, #2356, #2850)
+- Bugfix: Fix reconnecting when IRC write connection is lost (#1831, #2356, #2850, #2892)
- Bugfix: Fixed bit and new subscriber emotes not (re)loading in some rare cases. (#2856, #2857)
+- Bugfix: Fixed subscription emotes showing up incorrectly in the emote menu. (#2905)
## 2.3.2
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ef5c2d31281..22fab10c3e3 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -7,7 +7,7 @@ list(APPEND CMAKE_MODULE_PATH
"${CMAKE_SOURCE_DIR}/cmake/sanitizers-cmake/cmake"
)
-project(chatterino VERSION 2.3.2)
+project(chatterino VERSION 2.3.3)
option(BUILD_APP "Build Chatterino" ON)
option(BUILD_TESTS "Build the tests for Chatterino" OFF)
diff --git a/resources/com.chatterino.chatterino.appdata.xml b/resources/com.chatterino.chatterino.appdata.xml
index 9300ab32b86..a0802b1633b 100644
--- a/resources/com.chatterino.chatterino.appdata.xml
+++ b/resources/com.chatterino.chatterino.appdata.xml
@@ -32,6 +32,6 @@
chatterino
-
+
diff --git a/src/common/Version.hpp b/src/common/Version.hpp
index 1ee40da0d1a..6fa909d3411 100644
--- a/src/common/Version.hpp
+++ b/src/common/Version.hpp
@@ -3,7 +3,7 @@
#include
#include
-#define CHATTERINO_VERSION "7.3.2"
+#define CHATTERINO_VERSION "7.3.3"
#if defined(Q_OS_WIN)
# define CHATTERINO_OS "win"
diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp
index b306a2a135c..9147bbea6bd 100644
--- a/src/messages/Image.cpp
+++ b/src/messages/Image.cpp
@@ -405,6 +405,16 @@ void Image::actuallyLoad()
QBuffer buffer(const_cast(&data));
buffer.open(QIODevice::ReadOnly);
QImageReader reader(&buffer);
+
+ if (reader.size().width() * reader.size().height() *
+ reader.imageCount() * 4 >
+ Image::maxBytesRam)
+ {
+ qCDebug(chatterinoImage) << "image too large in RAM";
+
+ return Failure;
+ }
+
auto parsed = detail::readFrames(reader, shared->url());
postToThread(makeConvertCallback(parsed, [weak](auto frames) {
diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp
index 193ba578709..a4ad674fe62 100644
--- a/src/messages/Image.hpp
+++ b/src/messages/Image.hpp
@@ -50,6 +50,9 @@ using ImagePtr = std::shared_ptr;
class Image : public std::enable_shared_from_this, boost::noncopyable
{
public:
+ // Maximum amount of RAM used by the image in bytes.
+ static constexpr int maxBytesRam = 20 * 1024 * 1024;
+
~Image();
static ImagePtr fromUrl(const Url &url, qreal scale = 1);
diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp
index 82c76f05235..9623d793759 100644
--- a/src/messages/MessageBuilder.hpp
+++ b/src/messages/MessageBuilder.hpp
@@ -51,6 +51,7 @@ class MessageBuilder
MessageBuilder(const BanAction &action, uint32_t count = 1);
MessageBuilder(const UnbanAction &action);
MessageBuilder(const AutomodUserAction &action);
+ virtual ~MessageBuilder() = default;
Message *operator->();
Message &message();
diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp
index e3faa664859..e3f1a180059 100644
--- a/src/messages/SharedMessageBuilder.cpp
+++ b/src/messages/SharedMessageBuilder.cpp
@@ -107,6 +107,11 @@ void SharedMessageBuilder::parse()
{
this->parseUsernameColor();
+ if (this->action_)
+ {
+ this->textColor_ = this->usernameColor_;
+ }
+
this->parseUsername();
this->message().flags.set(MessageFlag::Collapsed);
@@ -408,8 +413,7 @@ void SharedMessageBuilder::addTextOrEmoji(const QString &string_)
// Actually just text
auto linkString = this->matchLink(string);
auto link = Link();
- auto textColor = this->action_ ? MessageColor(this->usernameColor_)
- : MessageColor(MessageColor::Text);
+ auto &&textColor = this->textColor_;
if (linkString.isEmpty())
{
diff --git a/src/messages/SharedMessageBuilder.hpp b/src/messages/SharedMessageBuilder.hpp
index 49724b39633..2bee11fcdcd 100644
--- a/src/messages/SharedMessageBuilder.hpp
+++ b/src/messages/SharedMessageBuilder.hpp
@@ -2,6 +2,7 @@
#include "common/Aliases.hpp"
#include "common/Outcome.hpp"
+#include "messages/MessageColor.hpp"
#include
#include
@@ -59,7 +60,8 @@ class SharedMessageBuilder : public MessageBuilder
const bool action_{};
- QColor usernameColor_;
+ QColor usernameColor_ = {153, 153, 153};
+ MessageColor textColor_ = MessageColor::Text;
bool highlightAlert_ = false;
bool highlightSound_ = false;
diff --git a/src/providers/irc/IrcConnection2.cpp b/src/providers/irc/IrcConnection2.cpp
index 916cf544e19..ff52fa8479f 100644
--- a/src/providers/irc/IrcConnection2.cpp
+++ b/src/providers/irc/IrcConnection2.cpp
@@ -5,9 +5,6 @@
namespace chatterino {
-// The minimum interval between attempting to establish a new connection
-const int RECONNECT_MIN_INTERVAL = 15000;
-
namespace {
const auto payload = QString("chatterino/" CHATTERINO_VERSION);
@@ -48,18 +45,11 @@ IrcConnection::IrcConnection(QObject *parent)
return;
}
- auto delta =
- std::chrono::duration_cast(
- std::chrono::steady_clock::now() - this->lastConnected_)
- .count();
- delta = delta < RECONNECT_MIN_INTERVAL
- ? (RECONNECT_MIN_INTERVAL - delta)
- : 10;
- qCDebug(chatterinoIrc) << "Reconnecting in" << delta << "ms";
- this->reconnectTimer_.start(delta);
+ auto delay = this->reconnectBackoff_.next();
+ qCDebug(chatterinoIrc) << "Reconnecting in" << delay.count() << "ms";
+ this->reconnectTimer_.start(delay);
});
- this->reconnectTimer_.setInterval(RECONNECT_MIN_INTERVAL);
this->reconnectTimer_.setSingleShot(true);
QObject::connect(&this->reconnectTimer_, &QTimer::timeout, [this] {
if (this->isConnected())
@@ -120,13 +110,12 @@ IrcConnection::IrcConnection(QObject *parent)
[this](Communi::IrcMessage *message) {
// This connection is probably still alive
this->recentlyReceivedMessage_ = true;
+ this->reconnectBackoff_.reset();
});
}
void IrcConnection::open()
{
- // Accurately track the time a connection was opened
- this->lastConnected_ = std::chrono::steady_clock::now();
this->expectConnectionLoss_ = false;
this->waitingForPong_ = false;
this->recentlyReceivedMessage_ = false;
diff --git a/src/providers/irc/IrcConnection2.hpp b/src/providers/irc/IrcConnection2.hpp
index 930b5dc4a0b..d3426c02922 100644
--- a/src/providers/irc/IrcConnection2.hpp
+++ b/src/providers/irc/IrcConnection2.hpp
@@ -1,10 +1,11 @@
#pragma once
+#include "util/ExponentialBackoff.hpp"
+
#include
#include
#include
-#include
namespace chatterino {
@@ -28,7 +29,9 @@ class IrcConnection : public Communi::IrcConnection
QTimer pingTimer_;
QTimer reconnectTimer_;
std::atomic recentlyReceivedMessage_{true};
- std::chrono::steady_clock::time_point lastConnected_;
+
+ // Reconnect with a base delay of 1 second and max out at 1 second * (2^4) (i.e. 16 seconds)
+ ExponentialBackoff<4> reconnectBackoff_{std::chrono::milliseconds{1000}};
std::atomic expectConnectionLoss_{false};
diff --git a/src/providers/irc/IrcMessageBuilder.cpp b/src/providers/irc/IrcMessageBuilder.cpp
index c7529a5e5e9..aa93e7f63a2 100644
--- a/src/providers/irc/IrcMessageBuilder.cpp
+++ b/src/providers/irc/IrcMessageBuilder.cpp
@@ -21,7 +21,6 @@ IrcMessageBuilder::IrcMessageBuilder(
const MessageParseArgs &_args)
: SharedMessageBuilder(_channel, _ircMessage, _args)
{
- this->usernameColor_ = getApp()->themes->messages.textColors.system;
}
IrcMessageBuilder::IrcMessageBuilder(Channel *_channel,
@@ -31,7 +30,6 @@ IrcMessageBuilder::IrcMessageBuilder(Channel *_channel,
: SharedMessageBuilder(_channel, _ircMessage, _args, content, isAction)
{
assert(false);
- this->usernameColor_ = getApp()->themes->messages.textColors.system;
}
MessagePtr IrcMessageBuilder::build()
diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp
index 6f70386f3cc..580ee89ba52 100644
--- a/src/providers/twitch/TwitchAccount.cpp
+++ b/src/providers/twitch/TwitchAccount.cpp
@@ -358,7 +358,6 @@ void TwitchAccount::loadUserstateEmotes()
name[0] = name[0].toUpper();
newUserEmoteSet->text = name;
- newUserEmoteSet->type = QString();
newUserEmoteSet->channelName = ivrEmoteSet.login;
for (const auto &emote : ivrEmoteSet.emotes)
@@ -508,38 +507,48 @@ void TwitchAccount::loadEmoteSetData(std::shared_ptr emoteSet)
return;
}
- NetworkRequest(Env::get().twitchEmoteSetResolverUrl.arg(emoteSet->key))
- .cache()
- .onSuccess([emoteSet](NetworkResult result) -> Outcome {
- auto root = result.parseJson();
- if (root.isEmpty())
+ getHelix()->getEmoteSetData(
+ emoteSet->key,
+ [emoteSet](HelixEmoteSetData emoteSetData) {
+ if (emoteSetData.ownerId.isEmpty() ||
+ emoteSetData.setId != emoteSet->key)
{
- return Failure;
+ qCWarning(chatterinoTwitch)
+ << QString("Failed to fetch emoteSetData for %1, assuming "
+ "Twitch is the owner")
+ .arg(emoteSet->key);
+
+ // most (if not all) emotes that fail to load are time limited event emotes owned by Twitch
+ emoteSet->channelName = "twitch";
+ emoteSet->text = "Twitch";
+
+ return;
}
- TwitchEmoteSetResolverResponse response(root);
-
- auto name = response.channelName;
- name.detach();
- name[0] = name[0].toUpper();
-
- emoteSet->text = name;
- emoteSet->type = response.type;
- emoteSet->channelName = response.channelName;
-
- qCDebug(chatterinoTwitch)
- << QString("Loaded twitch emote set data for %1")
- .arg(emoteSet->key);
-
- return Success;
- })
- .onError([emoteSet](NetworkResult result) {
- qCWarning(chatterinoTwitch)
- << QString("Error code %1 while loading emote set data for %2")
- .arg(result.status())
- .arg(emoteSet->key);
- })
- .execute();
+ // emote set 0 = global emotes
+ if (emoteSetData.ownerId == "0")
+ {
+ // emoteSet->channelName = QString();
+ emoteSet->text = "Twitch Global";
+ return;
+ }
+
+ getHelix()->getUserById(
+ emoteSetData.ownerId,
+ [emoteSet](HelixUser user) {
+ emoteSet->channelName = user.login;
+ emoteSet->text = user.displayName;
+ },
+ [emoteSetData] {
+ qCWarning(chatterinoTwitch)
+ << "Failed to query user by id:" << emoteSetData.ownerId
+ << emoteSetData.setId;
+ });
+ },
+ [emoteSet] {
+ // fetching emoteset data failed
+ return;
+ });
}
} // namespace chatterino
diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp
index 761a583edb1..fc1918b1e9b 100644
--- a/src/providers/twitch/TwitchAccount.hpp
+++ b/src/providers/twitch/TwitchAccount.hpp
@@ -62,7 +62,6 @@ class TwitchAccount : public Account
QString key;
QString channelName;
QString text;
- QString type;
std::vector emotes;
};
diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp
index 0d9ff27fd5a..ee68bfc3c4c 100644
--- a/src/providers/twitch/TwitchMessageBuilder.cpp
+++ b/src/providers/twitch/TwitchMessageBuilder.cpp
@@ -118,7 +118,6 @@ TwitchMessageBuilder::TwitchMessageBuilder(
: SharedMessageBuilder(_channel, _ircMessage, _args)
, twitchChannel(dynamic_cast(_channel))
{
- this->usernameColor_ = getApp()->themes->messages.textColors.system;
}
TwitchMessageBuilder::TwitchMessageBuilder(
@@ -127,7 +126,6 @@ TwitchMessageBuilder::TwitchMessageBuilder(
: SharedMessageBuilder(_channel, _ircMessage, _args, content, isAction)
, twitchChannel(dynamic_cast(_channel))
{
- this->usernameColor_ = getApp()->themes->messages.textColors.system;
}
bool TwitchMessageBuilder::isIgnored() const
@@ -470,8 +468,7 @@ void TwitchMessageBuilder::addTextOrEmoji(const QString &string_)
// Actually just text
auto linkString = this->matchLink(string);
- auto textColor = this->action_ ? MessageColor(this->usernameColor_)
- : MessageColor(MessageColor::Text);
+ auto textColor = this->textColor_;
if (!linkString.isEmpty())
{
@@ -727,18 +724,15 @@ void TwitchMessageBuilder::appendUsername()
// Separator
this->emplace("->", MessageElementFlag::Username,
- app->themes->messages.textColors.system,
- FontStyle::ChatMedium);
+ MessageColor::System, FontStyle::ChatMedium);
QColor selfColor = currentUser->color();
- if (!selfColor.isValid())
- {
- selfColor = app->themes->messages.textColors.system;
- }
+ MessageColor selfMsgColor =
+ selfColor.isValid() ? selfColor : MessageColor::System;
// Your own username
this->emplace(currentUser->getUserName() + ":",
- MessageElementFlag::Username, selfColor,
+ MessageElementFlag::Username, selfMsgColor,
FontStyle::ChatMediumBold);
}
else
diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp
index a105914a31e..c430b59cfe1 100644
--- a/src/providers/twitch/api/Helix.cpp
+++ b/src/providers/twitch/api/Helix.cpp
@@ -761,6 +761,38 @@ void Helix::getCheermotes(
.execute();
}
+void Helix::getEmoteSetData(QString emoteSetId,
+ ResultCallback successCallback,
+ HelixFailureCallback failureCallback)
+{
+ QUrlQuery urlQuery;
+
+ urlQuery.addQueryItem("emote_set_id", emoteSetId);
+
+ this->makeRequest("chat/emotes/set", urlQuery)
+ .onSuccess([successCallback, failureCallback,
+ emoteSetId](auto result) -> Outcome {
+ QJsonObject root = result.parseJson();
+ auto data = root.value("data");
+
+ if (!data.isArray())
+ {
+ failureCallback();
+ return Failure;
+ }
+
+ HelixEmoteSetData emoteSetData(data.toArray()[0].toObject());
+
+ successCallback(emoteSetData);
+ return Success;
+ })
+ .onError([failureCallback](NetworkResult result) {
+ // TODO: make better xd
+ failureCallback();
+ })
+ .execute();
+}
+
NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery)
{
assert(!url.startsWith("/"));
diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp
index 6e02739180e..a4f745a7585 100644
--- a/src/providers/twitch/api/Helix.hpp
+++ b/src/providers/twitch/api/Helix.hpp
@@ -264,6 +264,17 @@ struct HelixCheermoteSet {
}
};
+struct HelixEmoteSetData {
+ QString setId;
+ QString ownerId;
+
+ explicit HelixEmoteSetData(QJsonObject jsonObject)
+ : setId(jsonObject.value("emote_set_id").toString())
+ , ownerId(jsonObject.value("owner_id").toString())
+ {
+ }
+};
+
enum class HelixClipError {
Unknown,
ClipsDisabled,
@@ -398,6 +409,11 @@ class Helix final : boost::noncopyable
ResultCallback> successCallback,
HelixFailureCallback failureCallback);
+ // https://dev.twitch.tv/docs/api/reference#get-emote-sets
+ void getEmoteSetData(QString emoteSetId,
+ ResultCallback successCallback,
+ HelixFailureCallback failureCallback);
+
void update(QString clientId, QString oauthToken);
static void initialize();
diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md
index 9b24e8f7e04..2a4a2dede90 100644
--- a/src/providers/twitch/api/README.md
+++ b/src/providers/twitch/api/README.md
@@ -157,6 +157,14 @@ URL: https://dev.twitch.tv/docs/api/reference/#get-cheermotes
Used in:
- `providers/twitch/TwitchChannel.cpp` to resolve a chats available cheer emotes. This helps us parse incoming messages like `pajaCheer1000`
+### Get Emote Sets
+
+URL: https://dev.twitch.tv/docs/api/reference#get-emote-sets
+
+- We implement this in `providers/twitch/api/Helix.cpp getEmoteSetData`
+ Used in:
+ - `providers/twitch/TwitchAccount.cpp` to set emoteset owner data upon loading subscriber emotes from Kraken
+
## TMI
The TMI api is undocumented.
diff --git a/src/util/ExponentialBackoff.hpp b/src/util/ExponentialBackoff.hpp
new file mode 100644
index 00000000000..44eca67e1bd
--- /dev/null
+++ b/src/util/ExponentialBackoff.hpp
@@ -0,0 +1,60 @@
+#pragma once
+
+#include
+#include
+
+namespace chatterino {
+
+// Yes, you can't specify the base 😎 deal with it
+template
+class ExponentialBackoff
+{
+public:
+ /**
+ * Creates an object helping you make exponentially (with base 2) backed off times.
+ *
+ * @param start The start time in milliseconds
+ * @param maxSteps The max number of progressions we will take before stopping
+ *
+ * For example, ExponentialBackoff(10ms, 3) would have the next() function return 10ms, 20ms, 40ms, 40ms, ..., 40ms
+ **/
+ ExponentialBackoff(const std::chrono::milliseconds &start)
+ : start_(start)
+ , step_{1}
+ {
+ static_assert(maxSteps > 1, "maxSteps must be higher than 1");
+ }
+
+ /**
+ * Return the current number in the progression and increment the step until the next one (assuming we're not at the cap)
+ *
+ * @returns current step in milliseconds
+ **/
+ [[nodiscard]] std::chrono::milliseconds next()
+ {
+ auto next = this->start_ * (1 << (this->step_ - 1));
+
+ this->step_ += 1;
+
+ if (this->step_ >= maxSteps)
+ {
+ this->step_ = maxSteps;
+ }
+
+ return next;
+ }
+
+ /**
+ * Reset the progression back to its initial state
+ **/
+ void reset()
+ {
+ this->step_ = 1;
+ }
+
+private:
+ const std::chrono::milliseconds start_;
+ unsigned step_;
+};
+
+} // namespace chatterino
diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp
index 0d551e14743..1d1bbe7854b 100644
--- a/src/widgets/Notebook.cpp
+++ b/src/widgets/Notebook.cpp
@@ -352,10 +352,10 @@ void Notebook::setShowTabs(bool value)
{
QMessageBox msgBox;
msgBox.window()->setWindowTitle("Chatterino - hidden tabs");
- msgBox.setText("You've just hidden your tabs");
+ msgBox.setText("You've just hidden your tabs.");
msgBox.setInformativeText(
- "You can toggle tabs by using the keyboard shortcut (Ctrl + U by "
- "default) or right-clicking on the tab area and selecting \"Toggle "
+ "You can toggle tabs by using the keyboard shortcut (Ctrl+U by "
+ "default) or right-clicking the tab area and selecting \"Toggle "
"visibility of tabs\".");
msgBox.addButton(QMessageBox::Ok);
auto *dsaButton =
diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp
index 2f2291aade5..fec62ba4e5f 100644
--- a/src/widgets/dialogs/EmotePopup.cpp
+++ b/src/widgets/dialogs/EmotePopup.cpp
@@ -71,8 +71,7 @@ namespace {
{
// TITLE
auto channelName = set->channelName;
- auto text =
- set->key == "0" || set->text.isEmpty() ? "Twitch" : set->text;
+ auto text = set->text.isEmpty() ? "Twitch" : set->text;
// EMOTES
MessageBuilder builder;
diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp
index b27e2e6980b..3c82d43f4f5 100644
--- a/src/widgets/dialogs/UserInfoPopup.cpp
+++ b/src/widgets/dialogs/UserInfoPopup.cpp
@@ -6,8 +6,8 @@
#include "controllers/accounts/AccountController.hpp"
#include "controllers/highlights/HighlightBlacklistUser.hpp"
#include "messages/Message.hpp"
+#include "messages/MessageBuilder.hpp"
#include "providers/IvrApi.hpp"
-#include "providers/irc/IrcMessageBuilder.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/api/Kraken.hpp"
diff --git a/src/widgets/settingspages/FiltersPage.cpp b/src/widgets/settingspages/FiltersPage.cpp
index 3fd8ef9543a..b5282db8286 100644
--- a/src/widgets/settingspages/FiltersPage.cpp
+++ b/src/widgets/settingspages/FiltersPage.cpp
@@ -12,7 +12,7 @@
#include
-#define FILTERS_DOCUMENTATION "https://wiki.chatterino.com/Filters/"
+#define FILTERS_DOCUMENTATION "https://wiki.chatterino.com/Filters"
namespace chatterino {
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 8a13c128d44..52e8560f7b1 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -8,6 +8,7 @@ set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/ChatterSet.cpp
${CMAKE_CURRENT_LIST_DIR}/src/HighlightPhrase.cpp
${CMAKE_CURRENT_LIST_DIR}/src/Emojis.cpp
+ ${CMAKE_CURRENT_LIST_DIR}/src/ExponentialBackoff.cpp
)
add_executable(${PROJECT_NAME} ${test_SOURCES})
diff --git a/tests/src/ExponentialBackoff.cpp b/tests/src/ExponentialBackoff.cpp
new file mode 100644
index 00000000000..2a4259744a1
--- /dev/null
+++ b/tests/src/ExponentialBackoff.cpp
@@ -0,0 +1,58 @@
+#include "util/ExponentialBackoff.hpp"
+
+#include
+
+using namespace chatterino;
+
+TEST(ExponentialBackoff, MaxSteps)
+{
+ using namespace std::literals::chrono_literals;
+
+ ExponentialBackoff<3> foo{10ms};
+
+ // First usage should be the start value
+ EXPECT_EQ(foo.next(), 10ms);
+ EXPECT_EQ(foo.next(), 20ms);
+ EXPECT_EQ(foo.next(), 40ms);
+ // We reached the max steps, so we should continue returning the max value without increasing
+ EXPECT_EQ(foo.next(), 40ms);
+ EXPECT_EQ(foo.next(), 40ms);
+ EXPECT_EQ(foo.next(), 40ms);
+}
+
+TEST(ExponentialBackoff, Reset)
+{
+ using namespace std::literals::chrono_literals;
+
+ ExponentialBackoff<3> foo{10ms};
+
+ // First usage should be the start value
+ EXPECT_EQ(foo.next(), 10ms);
+ EXPECT_EQ(foo.next(), 20ms);
+ EXPECT_EQ(foo.next(), 40ms);
+ // We reached the max steps, so we should continue returning the max value without increasing
+ EXPECT_EQ(foo.next(), 40ms);
+ EXPECT_EQ(foo.next(), 40ms);
+ EXPECT_EQ(foo.next(), 40ms);
+
+ foo.reset();
+
+ // After a reset, we should start at the beginning value again
+ EXPECT_EQ(foo.next(), 10ms);
+ EXPECT_EQ(foo.next(), 20ms);
+ EXPECT_EQ(foo.next(), 40ms);
+ // We reached the max steps, so we should continue returning the max value without increasing
+ EXPECT_EQ(foo.next(), 40ms);
+ EXPECT_EQ(foo.next(), 40ms);
+ EXPECT_EQ(foo.next(), 40ms);
+}
+
+TEST(ExponentialBackoff, BadMaxSteps)
+{
+ using namespace std::literals::chrono_literals;
+
+ // this will not compile
+ // ExponentialBackoff<1> foo{10ms};
+ // ExponentialBackoff<0> foo{10ms};
+ // ExponentialBackoff<-1> foo{10ms};
+}