mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-21 22:24:07 +01:00
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:
parent
71594ad0d8
commit
703847c9ba
8 changed files with 207 additions and 8 deletions
|
@ -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)
|
||||||
|
|
|
@ -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())
|
||||||
{
|
{
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue