From f61cd1068cd638a5e27edf703badf28e468f0bd7 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Sun, 1 Feb 2026 19:54:24 +0800 Subject: [PATCH] feat: add countdown for slow mode and timeouts (#6782) --- CHANGELOG.md | 1 + src/providers/twitch/IrcMessageHandler.cpp | 73 +++++++++++++++++++++- src/providers/twitch/IrcMessageHandler.hpp | 1 + src/providers/twitch/TwitchChannel.cpp | 57 +++++++++++++++++ src/providers/twitch/TwitchChannel.hpp | 21 +++++++ src/singletons/Settings.hpp | 2 + src/util/FormatTime.cpp | 32 ++++++---- src/util/FormatTime.hpp | 7 ++- src/widgets/settingspages/GeneralPage.cpp | 8 +++ src/widgets/splits/Split.cpp | 5 ++ src/widgets/splits/SplitInput.cpp | 31 ++++++++- src/widgets/splits/SplitInput.hpp | 8 +++ 12 files changed, 228 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8e10d8..417546b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Minor: Added a countdown timer to the input box to show when you can chat again during slow mode or after a timeout. This feature is disabled by default, and can be enabled with the "Show countdown on slow mode or when timed out" setting. (#6782) - Minor: Add search engine selection for context menu search action. (#6743, #6770) - Minor: Add a separate highlight option for watchstreak notifications. (#6571, #6581) - Minor: Badges now link to their home page like emotes in the context menu. (#6437) diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index f1f905b5..2a2eccd9 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -222,7 +222,9 @@ std::optional parseClearChatMessage( calculateMessageTime(message)) .release(); - return ClearChatMessage{.message = timeoutMsg, .disableAllMessages = false}; + return ClearChatMessage{.message = timeoutMsg, + .disableAllMessages = false, + .username = username}; } /** @@ -403,6 +405,22 @@ void IrcMessageHandler::parsePrivMessageInto( channel->setVIP(parsedBadges.contains("vip")); channel->setStaff(parsedBadges.contains("staff")); } + + if (!channel->isLoadingRecentMessages()) + { + // Clear the send wait timer when we are able to send a message + channel->setSendWait(0); + + // Update send wait timer with slow mode timeout if this user is not a mod or vip. + if (!channel->hasHighRateLimit()) + { + auto roomModes = *channel->accessRoomModes(); + if (roomModes.slowMode > 0) + { + channel->setSendWait(roomModes.slowMode); + } + } + } } IrcMessageHandler::addMessage( @@ -510,6 +528,25 @@ void IrcMessageHandler::handleClearChatMessage(Communi::IrcMessage *message) } else { + // Set send wait timer when the user is timed out + const auto currentUsername = + getApp()->getAccounts()->twitch.getCurrent()->getUserName(); + if (currentUsername == clearChat.username) + { + bool ok = false; + int remainingTime = + message->tags().value("ban-duration").toInt(&ok); + if (ok) + { + auto *tc = dynamic_cast(chan.get()); + assert(tc != nullptr); + if (tc != nullptr) + { + tc->setSendWait(remainingTime); + } + } + } + chan->addOrReplaceTimeout(std::move(clearChat.message), time); } @@ -617,6 +654,13 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) tc->setMod(modTag == "1"); } } + + // When a user is updated to mod or vip status, any previous + // slow mode or timeout timers are no longer in effect. + if (tc->hasHighRateLimit()) + { + tc->setSendWait(0); + } } } @@ -979,6 +1023,33 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) { channel->addMessage(msg, MessageContext::Original); } + + auto handleSendWait = [&channel](const QString &remaining) { + bool ok = false; + int seconds = remaining.toInt(&ok); + if (ok) + { + auto *tc = dynamic_cast(channel.get()); + assert(tc != nullptr); + if (tc != nullptr) + { + tc->setSendWait(seconds); + } + } + }; + + if (tags == "msg_slowmode") + { + // Notice received when the user sends a message too quickly during slow mode. + // @msg-id=msg_slowmode :tmi.twitch.tv NOTICE #channel :This room is in slow mode and you are sending messages too quickly. You will be able to talk again in 10 seconds. + handleSendWait(message->content().split(u' ').value(21)); + } + else if (tags == "msg_timedout") + { + // Notice received when the user sends a message while timed out. + // @msg-id=msg_timedout :tmi.twitch.tv NOTICE #twitch :You are timed out for 3600 more seconds. + handleSendWait(message->content().split(u' ').value(5)); + } } void IrcMessageHandler::handleJoinMessage(Communi::IrcMessage *message) diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp index 912d4c1d..b26cdb48 100644 --- a/src/providers/twitch/IrcMessageHandler.hpp +++ b/src/providers/twitch/IrcMessageHandler.hpp @@ -23,6 +23,7 @@ class MessageSink; struct ClearChatMessage { MessagePtr message; bool disableAllMessages; + std::optional username; }; class IrcMessageHandler diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 3ee7e4ed..bbd7e777 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -46,6 +46,7 @@ #include "singletons/StreamerMode.hpp" #include "singletons/Toasts.hpp" #include "singletons/WindowManager.hpp" +#include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/PostToThread.hpp" #include "util/QStringHash.hpp" @@ -65,6 +66,7 @@ namespace chatterino { using namespace literals; +using namespace std::chrono_literals; namespace detail { @@ -212,6 +214,11 @@ TwitchChannel::TwitchChannel(const QString &name) } }); + QObject::connect(&this->sendWaitTimer_, &QTimer::timeout, + &this->lifetimeGuard_, [this] { + this->syncSendWaitTimer(); + }); + // debugging #if 0 for (int i = 0; i < 1000; i++) { @@ -997,6 +1004,12 @@ void TwitchChannel::setRoomModes(const RoomModes &newRoomModes) { this->roomModes = newRoomModes; + // Clear send wait timer when slow mode is disabled + if (newRoomModes.slowMode == 0) + { + this->setSendWait(newRoomModes.slowMode); + } + this->roomModesChanged.invoke(); } @@ -2342,4 +2355,48 @@ void TwitchChannel::listenSevenTVCosmetics() const } } +void TwitchChannel::syncSendWaitTimer() +{ + auto now = std::chrono::steady_clock::now(); + const auto remaining = + this->sendWaitEnd_.has_value() + ? std::chrono::duration_cast( + this->sendWaitEnd_.value() - now) + : 0s; + if (remaining <= 0s) + { + this->sendWaitTimer_.stop(); + this->sendWaitUpdate.invoke(""); + } + else + { + this->sendWaitUpdate.invoke(formatTime(remaining, 2)); + } +} + +void TwitchChannel::setSendWait(int seconds) +{ + if (seconds <= 0) + { + if (this->sendWaitEnd_.has_value()) + { + this->sendWaitEnd_ = std::nullopt; + this->syncSendWaitTimer(); + } + return; + } + this->sendWaitEnd_ = + std::chrono::steady_clock::now() + std::chrono::seconds(seconds); + if (!this->sendWaitTimer_.isActive()) + { + this->sendWaitTimer_.start(1s); + this->syncSendWaitTimer(); + } +} + +bool TwitchChannel::isLoadingRecentMessages() const +{ + return this->loadingRecentMessages_.test(); +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 326e1e8d..e3e9a1db 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -338,6 +338,8 @@ public: pajlada::Signals::NoArgSignal destroyed; + pajlada::Signals::Signal sendWaitUpdate; + // Channel point rewards void addQueuedRedemption(const QString &rewardId, const QString &originalContent, @@ -368,6 +370,21 @@ public: const QString &getDisplayName() const override; void updateDisplayName(const QString &displayName); + /** + * Sync the text of the send wait timer to the actual time remaining. + */ + void syncSendWaitTimer(); + /** + * Set the send wait timer to given number of seconds + * + * When a channel is in slow mode or the user is timed out, calling + * this function sets the timer to show how long the user will need + * to wait before they can send again. + */ + void setSendWait(int seconds); + + bool isLoadingRecentMessages() const; + private: struct NameOptions { // displayName is the non-CJK-display name for this user @@ -572,6 +589,10 @@ private: friend class Commands_E2E_Test; friend class ::TestIrcMessageHandlerP; friend class ::TestEventSubMessagesP; + + QTimer sendWaitTimer_; + // Timepoint at which the user can send messages again + std::optional sendWaitEnd_; }; } // namespace chatterino diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 214d39cd..721c2e02 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -170,6 +170,8 @@ public: BoolSetting showEmptyInput = {"/appearance/showEmptyInputBox", true}; BoolSetting showMessageLength = {"/appearance/messages/showMessageLength", false}; + BoolSetting showSendWaitTimer = {"/appearance/messages/showSendWaitTimer", + false}; EnumSetting messageOverflow = { "/appearance/messages/messageOverflow", MessageOverflow::Highlight}; BoolSetting separateMessages = {"/appearance/messages/separateMessages", diff --git a/src/util/FormatTime.cpp b/src/util/FormatTime.cpp index b4563085..0eefdda6 100644 --- a/src/util/FormatTime.cpp +++ b/src/util/FormatTime.cpp @@ -125,7 +125,7 @@ BalancedDuration durationBetween(const QDateTime &a, const QDateTime &b) } // namespace -QString formatTime(int totalSeconds) +QString formatTime(int totalSeconds, int components) { QString res; @@ -135,46 +135,52 @@ QString formatTime(int totalSeconds) int timeoutHours = timeoutMinutes / 60; int hours = timeoutHours % 24; int days = timeoutHours / 24; - if (days > 0) + if (days > 0 && components > 0) { appendShortDuration(days, 'd', res); + components--; } - if (hours > 0) + if (hours > 0 && components > 0) { appendShortDuration(hours, 'h', res); + components--; } - if (minutes > 0) + if (minutes > 0 && components > 0) { appendShortDuration(minutes, 'm', res); + components--; } - if (seconds > 0) + if (seconds > 0 && components > 0) { appendShortDuration(seconds, 's', res); + components--; } return res; } -QString formatTime(const QString &totalSecondsString) +QString formatTime(const QString &totalSecondsString, int components) { bool ok = true; int totalSeconds(totalSecondsString.toInt(&ok)); if (ok) { - return formatTime(totalSeconds); + return formatTime(totalSeconds, components); } return "n/a"; } -QString formatTime(std::chrono::seconds totalSeconds) +QString formatTime(std::chrono::seconds totalSeconds, int components) { auto count = totalSeconds.count(); - return formatTime(static_cast(std::clamp( - count, - static_cast(std::numeric_limits::min()), - static_cast( - std::numeric_limits::max())))); + return formatTime( + static_cast(std::clamp(count, + static_cast( + std::numeric_limits::min()), + static_cast( + std::numeric_limits::max()))), + components); } QString formatLongFriendlyDuration(const QDateTime &from, const QDateTime &to) diff --git a/src/util/FormatTime.hpp b/src/util/FormatTime.hpp index edd323e5..f0a867c0 100644 --- a/src/util/FormatTime.hpp +++ b/src/util/FormatTime.hpp @@ -12,9 +12,10 @@ namespace chatterino { // format: 1h 23m 42s -QString formatTime(int totalSeconds); -QString formatTime(const QString &totalSecondsString); -QString formatTime(std::chrono::seconds totalSeconds); +// 'components' controls how many most significant components the formatted time will have +QString formatTime(int totalSeconds, int components = 4); +QString formatTime(const QString &totalSecondsString, int components = 4); +QString formatTime(std::chrono::seconds totalSeconds, int components = 4); /// Formats a duration that's expected to be long (sevreal months or years) like /// "4 years, 5 days and 8 hours". diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 93766907..61623415 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -381,6 +381,14 @@ void GeneralPage::initLayout(GeneralPageView &layout) "limit, or a lower limit enforced by a moderation bot") ->addTo(layout); + SettingWidget::checkbox("Show countdown on slow mode or when timed out", + s.showSendWaitTimer) + ->setTooltip("Show how long you may need to wait before being able to " + "send in a Twitch channel again if the channel is in slow " + "mode or if you have been timed out") + ->addKeywords({"slowmode", "timeout"}) + ->addTo(layout); + SettingWidget::checkbox("Allow sending duplicate messages", s.allowDuplicateMessages) ->setTooltip( diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index c135e268..094cbf5e 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -847,6 +847,11 @@ void Split::setChannel(IndirectChannel newChannel) this->roomModeChangedConnection_ = tc->roomModesChanged.connect([this] { this->header_->updateRoomModes(); }); + + this->channelSignalHolder_.managedConnect( + tc->sendWaitUpdate, [this](const QString &text) { + this->getInput().setSendWaitStatus(text); + }); } this->indirectChannelChangedConnection_ = diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index 745f0062..e6300561 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -211,10 +211,16 @@ void SplitInput::initLayout() auto box = hboxLayout.emplace().withoutMargin(); box->setSpacing(0); { + auto hbox = box.emplace().withoutMargin(); this->ui_.textEditLength = new QLabel(); // Right-align the labels contents this->ui_.textEditLength->setAlignment(Qt::AlignRight); - box->addWidget(this->ui_.textEditLength); + hbox->addWidget(this->ui_.textEditLength); + + this->ui_.sendWaitStatus = new QLabel(); + this->ui_.sendWaitStatus->setAlignment(Qt::AlignRight); + this->ui_.sendWaitStatus->setHidden(true); + hbox->addWidget(this->ui_.sendWaitStatus); this->ui_.emoteButton = new SvgButton( { @@ -266,6 +272,16 @@ void SplitInput::initLayout() this->editTextChanged(); }, this->managedConnections_); + + // sendWaitStatus visibility + getSettings()->showSendWaitTimer.connect( + [this](bool value, const auto &) { + if (!this->ui_.sendWaitStatus->text().isEmpty()) + { + this->ui_.sendWaitStatus->setHidden(!value); + } + }, + this->managedConnections_); } void SplitInput::triggerSelfMessageReceived() @@ -1427,4 +1443,17 @@ void SplitInput::updateFonts() app->getFonts()->getFont(FontStyle::ChatMediumBold, this->scale())); } +void SplitInput::setSendWaitStatus(const QString &text) const +{ + this->ui_.sendWaitStatus->setText(text); + if (text.isEmpty()) + { + this->ui_.sendWaitStatus->setHidden(true); + } + else + { + this->ui_.sendWaitStatus->setHidden(!getSettings()->showSendWaitTimer); + } +} + } // namespace chatterino diff --git a/src/widgets/splits/SplitInput.hpp b/src/widgets/splits/SplitInput.hpp index dc8e2c8a..bb47d3e7 100644 --- a/src/widgets/splits/SplitInput.hpp +++ b/src/widgets/splits/SplitInput.hpp @@ -82,6 +82,13 @@ public: */ void setInputText(const QString &newInputText); + /** + * @brief Sets a formatted time to sendWaitStatus + * + * This method is used to update the text of the timeout and slow mode timer + */ + void setSendWaitStatus(const QString &text) const; + void triggerSelfMessageReceived(); std::optional checkSpellingOverride() const; @@ -161,6 +168,7 @@ protected: ResizingTextEdit *textEdit; QLabel *textEditLength; LabelButton *sendButton; + QLabel *sendWaitStatus; SvgButton *emoteButton; } ui_;