diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f7f4ee9..713f1f63f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Minor: Improved editing hotkeys. (#4628) - Minor: The input completion and quick switcher are now styled to match your theme. (#4671) - Minor: Added setting to only show tabs with live channels (default toggle hotkey: Ctrl+Shift+L). (#4358) +- Minor: Added better support for Twitch's Hype Chat feature. (#4715) - Minor: Added option to subscribe to and unsubscribe from reply threads. (#4680, #4739) - Minor: Added a message for when Chatterino joins a channel (#4616) - Minor: Add accelerators to the right click menu for messages (#4705) diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 280d8a95f..a5e81c01c 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -1,6 +1,7 @@ #include "IrcMessageHandler.hpp" #include "Application.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "messages/LimitedQueue.hpp" @@ -26,6 +27,8 @@ #include "util/StreamerMode.hpp" #include +#include +#include #include #include @@ -156,9 +159,23 @@ void updateReplyParticipatedStatus(const QVariantMap &tags, } } +ChannelPtr channelOrEmptyByTarget(const QString &target, + TwitchIrcServer &server) +{ + QString channelName; + if (!trimChannelName(target, channelName)) + { + return Channel::getEmpty(); + } + + return server.getChannelOrEmpty(channelName); +} + } // namespace namespace chatterino { +using namespace literals; + static float relativeSimilarity(const QString &str1, const QString &str2) { // Longest Common Substring Problem @@ -314,6 +331,16 @@ std::vector IrcMessageHandler::parsePrivMessage( builtMessages.emplace_back(builder.build()); builder.triggerHighlights(); } + + if (message->tags().contains(u"pinned-chat-paid-amount"_s)) + { + auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + if (ptr) + { + builtMessages.emplace_back(std::move(ptr)); + } + } + return builtMessages; } @@ -330,6 +357,21 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, message, message->target(), message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, false, message->isAction()); + + auto chan = channelOrEmptyByTarget(message->target(), server); + if (chan->isEmpty()) + { + return; + } + + if (message->tags().contains(u"pinned-chat-paid-amount"_s)) + { + auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + if (ptr) + { + chan->addMessage(ptr); + } + } } std::vector IrcMessageHandler::parseMessageWithReply( @@ -442,13 +484,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, TwitchIrcServer &server, bool isSub, bool isAction) { - QString channelName; - if (!trimChannelName(target, channelName)) - { - return; - } - - auto chan = server.getChannelOrEmpty(channelName); + auto chan = channelOrEmptyByTarget(target, server); if (chan->isEmpty()) { diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 3cd93303c..9d8dce891 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "common/LinkParser.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/ignores/IgnoreController.hpp" @@ -28,8 +29,10 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" +#include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" +#include "util/QStringHash.hpp" #include "util/Qt.hpp" #include "widgets/Window.hpp" @@ -38,8 +41,15 @@ #include #include +#include +#include + +using namespace chatterino::literals; + namespace { +using namespace std::chrono_literals; + const QString regexHelpString("(\\w+)[.,!?;:]*?$"); // matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username" @@ -53,6 +63,19 @@ const QSet zeroWidthEmotes{ "ReinDeer", "CandyCane", "cvMask", "cvHazmat", }; +struct HypeChatPaidLevel { + std::chrono::seconds duration; + uint8_t numeric; +}; + +const std::unordered_map HYPE_CHAT_PAID_LEVEL{ + {u"ONE"_s, {30s, 1}}, {u"TWO"_s, {2min + 30s, 2}}, + {u"THREE"_s, {5min, 3}}, {u"FOUR"_s, {10min, 4}}, + {u"FIVE"_s, {30min, 5}}, {u"SIX"_s, {1h, 6}}, + {u"SEVEN"_s, {2h, 7}}, {u"EIGHT"_s, {3h, 8}}, + {u"NINE"_s, {4h, 9}}, {u"TEN"_s, {5h, 10}}, +}; + } // namespace namespace chatterino { @@ -1739,6 +1762,45 @@ void TwitchMessageBuilder::listOfUsersSystemMessage( builder->message().searchText = text; } +MessagePtr TwitchMessageBuilder::buildHypeChatMessage( + Communi::IrcPrivateMessage *message) +{ + auto level = message->tag(u"pinned-chat-paid-level"_s).toString(); + auto currency = message->tag(u"pinned-chat-paid-currency"_s).toString(); + bool okAmount = false; + auto amount = message->tag(u"pinned-chat-paid-amount"_s).toInt(&okAmount); + bool okExponent = false; + auto exponent = + message->tag(u"pinned-chat-paid-exponent"_s).toInt(&okExponent); + if (!okAmount || !okExponent || currency.isEmpty()) + { + return {}; + } + // additionally, there's `pinned-chat-paid-is-system-message` which isn't used by Chatterino. + + QString subtitle; + auto levelIt = HYPE_CHAT_PAID_LEVEL.find(level); + if (levelIt != HYPE_CHAT_PAID_LEVEL.end()) + { + const auto &level = levelIt->second; + subtitle = u"Level %1 Hype Chat (%2) "_s.arg(level.numeric) + .arg(formatTime(level.duration)); + } + else + { + subtitle = u"Hype Chat "_s; + } + + // actualAmount = amount * 10^(-exponent) + double actualAmount = std::pow(10.0, double(-exponent)) * double(amount); + subtitle += QLocale::system().toCurrencyString(actualAmount, currency); + + MessageBuilder builder(systemMessage, parseTagString(subtitle), + calculateMessageTime(message).time()); + builder->flags.set(MessageFlag::ElevatedMessage); + return builder.release(); +} + void TwitchMessageBuilder::setThread(std::shared_ptr thread) { this->thread_ = std::move(thread); diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 480e18688..0d1f34726 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -86,6 +86,8 @@ public: QString prefix, const std::vector &users, Channel *channel, MessageBuilder *builder); + static MessagePtr buildHypeChatMessage(Communi::IrcPrivateMessage *message); + // Shares some common logic from SharedMessageBuilder::parseBadgeTag static std::unordered_map parseBadgeInfoTag( const QVariantMap &tags); diff --git a/src/util/FormatTime.cpp b/src/util/FormatTime.cpp index 6d6f2e525..1bc0e07b6 100644 --- a/src/util/FormatTime.cpp +++ b/src/util/FormatTime.cpp @@ -1,4 +1,7 @@ -#include "FormatTime.hpp" +#include "util/FormatTime.hpp" + +#include +#include namespace chatterino { @@ -57,4 +60,15 @@ QString formatTime(QString totalSecondsString) return "n/a"; } +QString formatTime(std::chrono::seconds totalSeconds) +{ + auto count = totalSeconds.count(); + + return formatTime(static_cast(std::clamp( + count, + static_cast(std::numeric_limits::min()), + static_cast( + std::numeric_limits::max())))); +} + } // namespace chatterino diff --git a/src/util/FormatTime.hpp b/src/util/FormatTime.hpp index 0e4eb2725..c9bb12cae 100644 --- a/src/util/FormatTime.hpp +++ b/src/util/FormatTime.hpp @@ -2,10 +2,13 @@ #include +#include + namespace chatterino { // format: 1h 23m 42s QString formatTime(int totalSeconds); QString formatTime(QString totalSecondsString); +QString formatTime(std::chrono::seconds totalSeconds); } // namespace chatterino diff --git a/src/util/SampleData.cpp b/src/util/SampleData.cpp index f68305a1b..74c0b1850 100644 --- a/src/util/SampleData.cpp +++ b/src/util/SampleData.cpp @@ -108,7 +108,13 @@ const QStringList &getSampleMiscMessages() R"(@badge-info=subscriber/47;badges=broadcaster/1,subscriber/3012,twitchconAmsterdam2020/1;color=#FF0000;display-name=Supinic;emotes=;flags=;id=8c26e1ab-b50c-4d9d-bc11-3fd57a941d90;login=supinic;mod=0;msg-id=announcement;msg-param-color=PRIMARY;room-id=31400525;subscriber=1;system-msg=;tmi-sent-ts=1648762219962;user-id=31400525;user-type= :tmi.twitch.tv USERNOTICE #supinic :mm test lol)", // Elevated Message (Paid option for keeping a message in chat longer) + // no level R"(@badge-info=subscriber/3;badges=subscriber/0,bits-charity/1;color=#0000FF;display-name=SnoopyTheBot;emotes=;first-msg=0;flags=;id=8779a9e5-cf1b-47b3-b9fe-67a5b1b605f6;mod=0;pinned-chat-paid-amount=500;pinned-chat-paid-canonical-amount=5;pinned-chat-paid-currency=USD;pinned-chat-paid-exponent=2;returning-chatter=0;room-id=36340781;subscriber=1;tmi-sent-ts=1664505974154;turbo=0;user-id=136881249;user-type= :snoopythebot!snoopythebot@snoopythebot.tmi.twitch.tv PRIVMSG #pajlada :-$5)", + // level 1 + R"(@pinned-chat-paid-level=ONE;mod=0;flags=;pinned-chat-paid-amount=1400;pinned-chat-paid-exponent=2;tmi-sent-ts=1687970631828;subscriber=1;user-type=;color=#9DA364;emotes=;badges=predictions/blue-1,subscriber/60,twitchconAmsterdam2020/1;pinned-chat-paid-canonical-amount=1400;turbo=0;user-id=26753388;id=e6681ba0-cdc6-4482-93a3-515b74361e8b;room-id=36340781;first-msg=0;returning-chatter=0;pinned-chat-paid-currency=NOK;pinned-chat-paid-is-system-message=0;badge-info=predictions/Day\s53/53\sforsenSmug,subscriber/67;display-name=matrHS :matrhs!matrhs@matrhs.tmi.twitch.tv PRIVMSG #pajlada :Title: Beating the record. but who is recordingLOL)", + R"(@flags=;pinned-chat-paid-amount=8761;turbo=0;user-id=35669184;pinned-chat-paid-level=ONE;user-type=;pinned-chat-paid-canonical-amount=8761;badge-info=subscriber/2;badges=subscriber/2,sub-gifter/1;emotes=;pinned-chat-paid-exponent=2;subscriber=1;mod=0;room-id=36340781;returning-chatter=0;id=289b614d-1837-4cff-ac22-ce33a9735323;first-msg=0;tmi-sent-ts=1687631719188;color=#00FF7F;pinned-chat-paid-currency=RUB;display-name=Danis;pinned-chat-paid-is-system-message=0 :danis!danis@danis.tmi.twitch.tv PRIVMSG #pajlada :-1 lulw)", + // level 2 + R"(@room-id=36340781;tmi-sent-ts=1687970634371;flags=;id=39a80a3d-c16e-420f-9bbb-faba4976a3bb;badges=subscriber/6,premium/1;emotes=;display-name=rickharrisoncoc;pinned-chat-paid-level=TWO;turbo=0;pinned-chat-paid-amount=500;pinned-chat-paid-is-system-message=0;color=#FF69B4;subscriber=1;user-type=;first-msg=0;pinned-chat-paid-currency=USD;pinned-chat-paid-canonical-amount=500;user-id=518404689;badge-info=subscriber/10;pinned-chat-paid-exponent=2;returning-chatter=0;mod=0 :rickharrisoncoc!rickharrisoncoc@rickharrisoncoc.tmi.twitch.tv PRIVMSG #pajlada :forsen please read my super chat. Please.)", }; return list; } diff --git a/tests/src/FormatTime.cpp b/tests/src/FormatTime.cpp index 3c4b5e8c9..bc15f44ef 100644 --- a/tests/src/FormatTime.cpp +++ b/tests/src/FormatTime.cpp @@ -2,7 +2,10 @@ #include +#include + using namespace chatterino; +using namespace std::chrono_literals; TEST(FormatTime, Int) { @@ -131,3 +134,75 @@ TEST(FormatTime, QString) << ") did not match expected value " << qUtf8Printable(expected); } } + +TEST(FormatTime, chrono) +{ + struct TestCase { + std::chrono::seconds input; + QString expectedOutput; + }; + + std::vector tests{ + { + 0s, + "", + }, + { + 1337s, + "22m 17s", + }, + { + {22min + 17s}, + "22m 17s", + }, + { + 623452s, + "7d 5h 10m 52s", + }, + { + 8345s, + "2h 19m 5s", + }, + { + 314034s, + "3d 15h 13m 54s", + }, + { + 27s, + "27s", + }, + { + 34589s, + "9h 36m 29s", + }, + { + 9h + 36min + 29s, + "9h 36m 29s", + }, + { + 3659s, + "1h 59s", + }, + { + 1h + 59s, + "1h 59s", + }, + { + 1045345s, + "12d 2h 22m 25s", + }, + { + 86432s, + "1d 32s", + }, + }; + + for (const auto &[input, expected] : tests) + { + const auto actual = formatTime(input); + + EXPECT_EQ(actual, expected) + << qUtf8Printable(actual) << " did not match expected value " + << qUtf8Printable(expected); + } +}