Add subtitle to Hype Chats (#4715)

* feat: more hype chat

* Add `std::chrono::seconds` overload to formatTime

* Move & rename it to HypeChat + some other mini things

* Add changelog entry

* fix formattime test

---------

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
nerix 2023-07-30 18:54:42 +02:00 committed by GitHub
parent 71594ad0d8
commit 703847c9ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 207 additions and 8 deletions

View file

@ -10,6 +10,7 @@
- Minor: Improved editing hotkeys. (#4628) - Minor: Improved editing hotkeys. (#4628)
- Minor: The input completion and quick switcher are now styled to match your theme. (#4671) - 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 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 option to subscribe to and unsubscribe from reply threads. (#4680, #4739)
- Minor: Added a message for when Chatterino joins a channel (#4616) - Minor: Added a message for when Chatterino joins a channel (#4616)
- Minor: Add accelerators to the right click menu for messages (#4705) - Minor: Add accelerators to the right click menu for messages (#4705)

View file

@ -1,6 +1,7 @@
#include "IrcMessageHandler.hpp" #include "IrcMessageHandler.hpp"
#include "Application.hpp" #include "Application.hpp"
#include "common/Literals.hpp"
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
#include "controllers/accounts/AccountController.hpp" #include "controllers/accounts/AccountController.hpp"
#include "messages/LimitedQueue.hpp" #include "messages/LimitedQueue.hpp"
@ -26,6 +27,8 @@
#include "util/StreamerMode.hpp" #include "util/StreamerMode.hpp"
#include <IrcMessage> #include <IrcMessage>
#include <QLocale>
#include <QStringBuilder>
#include <memory> #include <memory>
#include <unordered_set> #include <unordered_set>
@ -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
namespace chatterino { namespace chatterino {
using namespace literals;
static float relativeSimilarity(const QString &str1, const QString &str2) static float relativeSimilarity(const QString &str1, const QString &str2)
{ {
// Longest Common Substring Problem // Longest Common Substring Problem
@ -314,6 +331,16 @@ std::vector<MessagePtr> IrcMessageHandler::parsePrivMessage(
builtMessages.emplace_back(builder.build()); builtMessages.emplace_back(builder.build());
builder.triggerHighlights(); 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; return builtMessages;
} }
@ -330,6 +357,21 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message,
message, message->target(), message, message->target(),
message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server,
false, message->isAction()); 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<MessagePtr> IrcMessageHandler::parseMessageWithReply( std::vector<MessagePtr> IrcMessageHandler::parseMessageWithReply(
@ -442,13 +484,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
TwitchIrcServer &server, bool isSub, TwitchIrcServer &server, bool isSub,
bool isAction) bool isAction)
{ {
QString channelName; auto chan = channelOrEmptyByTarget(target, server);
if (!trimChannelName(target, channelName))
{
return;
}
auto chan = server.getChannelOrEmpty(channelName);
if (chan->isEmpty()) if (chan->isEmpty())
{ {

View file

@ -2,6 +2,7 @@
#include "Application.hpp" #include "Application.hpp"
#include "common/LinkParser.hpp" #include "common/LinkParser.hpp"
#include "common/Literals.hpp"
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
#include "controllers/accounts/AccountController.hpp" #include "controllers/accounts/AccountController.hpp"
#include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnoreController.hpp"
@ -28,8 +29,10 @@
#include "singletons/Settings.hpp" #include "singletons/Settings.hpp"
#include "singletons/Theme.hpp" #include "singletons/Theme.hpp"
#include "singletons/WindowManager.hpp" #include "singletons/WindowManager.hpp"
#include "util/FormatTime.hpp"
#include "util/Helpers.hpp" #include "util/Helpers.hpp"
#include "util/IrcHelpers.hpp" #include "util/IrcHelpers.hpp"
#include "util/QStringHash.hpp"
#include "util/Qt.hpp" #include "util/Qt.hpp"
#include "widgets/Window.hpp" #include "widgets/Window.hpp"
@ -38,8 +41,15 @@
#include <QDebug> #include <QDebug>
#include <QStringRef> #include <QStringRef>
#include <chrono>
#include <unordered_set>
using namespace chatterino::literals;
namespace { namespace {
using namespace std::chrono_literals;
const QString regexHelpString("(\\w+)[.,!?;:]*?$"); const QString regexHelpString("(\\w+)[.,!?;:]*?$");
// matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username" // matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username"
@ -53,6 +63,19 @@ const QSet<QString> zeroWidthEmotes{
"ReinDeer", "CandyCane", "cvMask", "cvHazmat", "ReinDeer", "CandyCane", "cvMask", "cvHazmat",
}; };
struct HypeChatPaidLevel {
std::chrono::seconds duration;
uint8_t numeric;
};
const std::unordered_map<QString, HypeChatPaidLevel> 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
namespace chatterino { namespace chatterino {
@ -1739,6 +1762,45 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(
builder->message().searchText = text; 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<MessageThread> thread) void TwitchMessageBuilder::setThread(std::shared_ptr<MessageThread> thread)
{ {
this->thread_ = std::move(thread); this->thread_ = std::move(thread);

View file

@ -86,6 +86,8 @@ public:
QString prefix, const std::vector<HelixModerator> &users, QString prefix, const std::vector<HelixModerator> &users,
Channel *channel, MessageBuilder *builder); Channel *channel, MessageBuilder *builder);
static MessagePtr buildHypeChatMessage(Communi::IrcPrivateMessage *message);
// Shares some common logic from SharedMessageBuilder::parseBadgeTag // Shares some common logic from SharedMessageBuilder::parseBadgeTag
static std::unordered_map<QString, QString> parseBadgeInfoTag( static std::unordered_map<QString, QString> parseBadgeInfoTag(
const QVariantMap &tags); const QVariantMap &tags);

View file

@ -1,4 +1,7 @@
#include "FormatTime.hpp" #include "util/FormatTime.hpp"
#include <algorithm>
#include <limits>
namespace chatterino { namespace chatterino {
@ -57,4 +60,15 @@ QString formatTime(QString totalSecondsString)
return "n/a"; return "n/a";
} }
QString formatTime(std::chrono::seconds totalSeconds)
{
auto count = totalSeconds.count();
return formatTime(static_cast<int>(std::clamp(
count,
static_cast<std::chrono::seconds::rep>(std::numeric_limits<int>::min()),
static_cast<std::chrono::seconds::rep>(
std::numeric_limits<int>::max()))));
}
} // namespace chatterino } // namespace chatterino

View file

@ -2,10 +2,13 @@
#include <QString> #include <QString>
#include <chrono>
namespace chatterino { namespace chatterino {
// format: 1h 23m 42s // format: 1h 23m 42s
QString formatTime(int totalSeconds); QString formatTime(int totalSeconds);
QString formatTime(QString totalSecondsString); QString formatTime(QString totalSecondsString);
QString formatTime(std::chrono::seconds totalSeconds);
} // namespace chatterino } // namespace chatterino

View file

@ -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)", 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) // 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)", 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; return list;
} }

View file

@ -2,7 +2,10 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <chrono>
using namespace chatterino; using namespace chatterino;
using namespace std::chrono_literals;
TEST(FormatTime, Int) TEST(FormatTime, Int)
{ {
@ -131,3 +134,75 @@ TEST(FormatTime, QString)
<< ") did not match expected value " << qUtf8Printable(expected); << ") did not match expected value " << qUtf8Printable(expected);
} }
} }
TEST(FormatTime, chrono)
{
struct TestCase {
std::chrono::seconds input;
QString expectedOutput;
};
std::vector<TestCase> 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);
}
}