Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add subtitle to Hype Chats #4715

Merged
merged 6 commits into from
Jul 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 43 additions & 7 deletions src/providers/twitch/IrcMessageHandler.cpp
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -26,6 +27,8 @@
#include "util/StreamerMode.hpp"

#include <IrcMessage>
#include <QLocale>
#include <QStringBuilder>

#include <memory>
#include <unordered_set>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -314,6 +331,16 @@ std::vector<MessagePtr> 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;
}

Expand All @@ -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<MessagePtr> IrcMessageHandler::parseMessageWithReply(
Expand Down Expand Up @@ -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())
{
Expand Down
62 changes: 62 additions & 0 deletions src/providers/twitch/TwitchMessageBuilder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand All @@ -38,8 +41,15 @@
#include <QDebug>
#include <QStringRef>

#include <chrono>
#include <unordered_set>

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"
Expand All @@ -53,6 +63,19 @@ const QSet<QString> zeroWidthEmotes{
"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 chatterino {
Expand Down Expand Up @@ -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<MessageThread> thread)
{
this->thread_ = std::move(thread);
Expand Down
2 changes: 2 additions & 0 deletions src/providers/twitch/TwitchMessageBuilder.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ class TwitchMessageBuilder : public SharedMessageBuilder
QString prefix, const std::vector<HelixModerator> &users,
Channel *channel, MessageBuilder *builder);

static MessagePtr buildHypeChatMessage(Communi::IrcPrivateMessage *message);

// Shares some common logic from SharedMessageBuilder::parseBadgeTag
static std::unordered_map<QString, QString> parseBadgeInfoTag(
const QVariantMap &tags);
Expand Down
16 changes: 15 additions & 1 deletion src/util/FormatTime.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
#include "FormatTime.hpp"
#include "util/FormatTime.hpp"

#include <algorithm>
#include <limits>

namespace chatterino {

Expand Down Expand Up @@ -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<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
3 changes: 3 additions & 0 deletions src/util/FormatTime.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

#include <QString>

#include <chrono>

namespace chatterino {

// format: 1h 23m 42s
QString formatTime(int totalSeconds);
QString formatTime(QString totalSecondsString);
QString formatTime(std::chrono::seconds totalSeconds);

} // namespace chatterino
6 changes: 6 additions & 0 deletions src/util/SampleData.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
75 changes: 75 additions & 0 deletions tests/src/FormatTime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

#include <gtest/gtest.h>

#include <chrono>

using namespace chatterino;
using namespace std::chrono_literals;

TEST(FormatTime, Int)
{
Expand Down Expand Up @@ -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<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);
}
}