diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e233395a44..9cfc088c938 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,6 +49,8 @@
- Bugfix: Fixed account switch not being saved if no other settings were changed. (#5558)
- Bugfix: Fixed some tooltips not being readable. (#5578)
- Bugfix: Fixed log files being locked longer than needed. (#5592)
+- Bugfix: Fixed global badges not showing in anonymous mode. (#5599)
+- Bugfix: Fixed grammar in the user highlight page. (#5602)
- Dev: Update Windows build from Qt 6.5.0 to Qt 6.7.1. (#5420)
- Dev: Update vcpkg build Qt from 6.5.0 to 6.7.0, boost from 1.83.0 to 1.85.0, openssl from 3.1.3 to 3.3.0. (#5422)
- Dev: Unsingletonize `ISoundController`. (#5462)
@@ -88,6 +90,8 @@
- Dev: The timer for `StreamerMode` is now destroyed on the correct thread. (#5571)
- Dev: Cleanup some parts of the `magic_enum` adaptation for Qt. (#5587)
- Dev: Refactored `static`s in headers to only be present once in the final app. (#5588)
+- Dev: Refactored legacy Unicode zero-width-joiner replacement. (#5594)
+- Dev: The JSON output when copying a message (SHIFT + right-click) is now more extensive. (#5600)
- Dev: Added more tests for message building. (#5598)
## 2.5.1
diff --git a/src/providers/recentmessages/Impl.cpp b/src/providers/recentmessages/Impl.cpp
index 6971b55473b..410a34aac75 100644
--- a/src/providers/recentmessages/Impl.cpp
+++ b/src/providers/recentmessages/Impl.cpp
@@ -1,22 +1,13 @@
#include "providers/recentmessages/Impl.hpp"
#include "common/Env.hpp"
-#include "common/QLogging.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/twitch/IrcMessageHandler.hpp"
-#include "providers/twitch/TwitchChannel.hpp"
-#include "util/FormatTime.hpp"
+#include "util/Helpers.hpp"
#include
#include
-namespace {
-
-// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
-const auto &LOG = chatterinoRecentMessages;
-
-} // namespace
-
namespace chatterino::recentmessages::detail {
// Parse the IRC messages returned in JSON form into Communi messages
@@ -33,11 +24,7 @@ std::vector parseRecentMessages(
for (const auto &jsonMessage : jsonMessages)
{
- auto content = jsonMessage.toString();
-
- // For explanation of why this exists, see src/providers/twitch/TwitchChannel.hpp,
- // where these constants are defined
- content.replace(COMBINED_FIXER, ZERO_WIDTH_JOINER);
+ auto content = unescapeZeroWidthJoiner(jsonMessage.toString());
auto *message =
Communi::IrcMessage::fromData(content.toUtf8(), nullptr);
diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp
index 6a7c3f26c07..dc280939290 100644
--- a/src/providers/twitch/IrcMessageHandler.cpp
+++ b/src/providers/twitch/IrcMessageHandler.cpp
@@ -707,15 +707,8 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message,
}
}
- // This is for compatibility with older Chatterino versions. Twitch didn't use
- // to allow ZERO WIDTH JOINER unicode character, so Chatterino used ESCAPE_TAG
- // instead.
- // See https://github.com/Chatterino/chatterino2/issues/3384 and
- // https://mm2pl.github.io/emoji_rfc.pdf for more details
- this->addMessage(
- message, chan,
- message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER),
- twitchServer, false, message->isAction());
+ this->addMessage(message, chan, unescapeZeroWidthJoiner(message->content()),
+ twitchServer, false, message->isAction());
if (message->tags().contains(u"pinned-chat-paid-amount"_s))
{
@@ -920,10 +913,9 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage)
auto *c = getApp()->getTwitch()->getWhispersChannel().get();
- MessageBuilder builder(
- c, ircMessage, args,
- ircMessage->parameter(1).replace(COMBINED_FIXER, ZERO_WIDTH_JOINER),
- false);
+ MessageBuilder builder(c, ircMessage, args,
+ unescapeZeroWidthJoiner(ircMessage->parameter(1)),
+ false);
if (builder.isIgnored())
{
diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp
index 40cddf6bcc7..95e42af68f0 100644
--- a/src/providers/twitch/TwitchBadges.cpp
+++ b/src/providers/twitch/TwitchBadges.cpp
@@ -98,41 +98,40 @@ void TwitchBadges::loadLocalBadges()
auto bytes = file.readAll();
auto doc = QJsonDocument::fromJson(bytes);
- this->parseTwitchBadges(doc.object());
-
- this->loaded();
-}
-
-void TwitchBadges::parseTwitchBadges(const QJsonObject &root)
-{
- auto badgeSets = this->badgeSets_.access();
-
- for (auto setIt = root.begin(); setIt != root.end(); setIt++)
{
- auto key = setIt.key();
+ const auto &root = doc.object();
+ auto badgeSets = this->badgeSets_.access();
- for (auto versionValue : setIt.value().toArray())
+ for (auto setIt = root.begin(); setIt != root.end(); setIt++)
{
- const auto versionObj = versionValue.toObject();
- auto id = versionObj["id"].toString();
- auto baseImage = versionObj["image"].toString();
- auto emote = Emote{
- .name = {},
- .images =
- ImageSet{
- Image::fromUrl({baseImage + '1'}, 1, BADGE_BASE_SIZE),
- Image::fromUrl({baseImage + '2'}, .5,
- BADGE_BASE_SIZE * 2),
- Image::fromUrl({baseImage + '3'}, .25,
- BADGE_BASE_SIZE * 4),
- },
- .tooltip = Tooltip{versionObj["title"].toString()},
- .homePage = Url{versionObj["url"].toString()},
- };
-
- (*badgeSets)[key][id] = std::make_shared(emote);
+ auto key = setIt.key();
+
+ for (auto versionValue : setIt.value().toArray())
+ {
+ const auto versionObj = versionValue.toObject();
+ auto id = versionObj["id"].toString();
+ auto baseImage = versionObj["image"].toString();
+ auto emote = Emote{
+ .name = {},
+ .images =
+ ImageSet{
+ Image::fromUrl({baseImage + '1'}, 1,
+ BADGE_BASE_SIZE),
+ Image::fromUrl({baseImage + '2'}, .5,
+ BADGE_BASE_SIZE * 2),
+ Image::fromUrl({baseImage + '3'}, .25,
+ BADGE_BASE_SIZE * 4),
+ },
+ .tooltip = Tooltip{versionObj["title"].toString()},
+ .homePage = Url{versionObj["url"].toString()},
+ };
+
+ (*badgeSets)[key][id] = std::make_shared(emote);
+ }
}
}
+
+ this->loaded();
}
void TwitchBadges::loaded()
diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp
index fbbbfbd5762..287e8bcae1f 100644
--- a/src/providers/twitch/TwitchBadges.hpp
+++ b/src/providers/twitch/TwitchBadges.hpp
@@ -49,7 +49,6 @@ class TwitchBadges
void loadLocalBadges();
private:
- void parseTwitchBadges(const QJsonObject &root);
void loaded();
void loadEmoteImage(const QString &name, const ImagePtr &image,
BadgeIconCallback &&callback);
diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp
index e74ff447e28..61f9c0960a8 100644
--- a/src/providers/twitch/TwitchChannel.hpp
+++ b/src/providers/twitch/TwitchChannel.hpp
@@ -27,24 +27,6 @@
namespace chatterino {
-// This is for compatibility with older Chatterino versions. Twitch didn't use
-// to allow ZERO WIDTH JOINER unicode character, so Chatterino used ESCAPE_TAG
-// instead.
-// See https://github.com/Chatterino/chatterino2/issues/3384 and
-// https://mm2pl.github.io/emoji_rfc.pdf for more details
-const QString ZERO_WIDTH_JOINER = QString(QChar(0x200D));
-
-// Here be MSVC: Do NOT replace with "\U" literal, it will fail silently.
-namespace {
- const QChar ESCAPE_TAG_CHARS[2] = {QChar::highSurrogate(0xE0002),
- QChar::lowSurrogate(0xE0002)};
-}
-const QString ESCAPE_TAG = QString(ESCAPE_TAG_CHARS, 2);
-
-const static QRegularExpression COMBINED_FIXER(
- QString("(?
#include
+namespace {
+
+const QString ZERO_WIDTH_JOINER = QStringLiteral("\u200D");
+
+// Note: \U requires /utf-8 for MSVC
+// See https://mm2pl.github.io/emoji_rfc.pdf
+const QRegularExpression ESCAPE_TAG_REGEX(
+ QStringLiteral("(?> makeConditionedOptional(bool condition,
return std::nullopt;
}
+/// @brief Unescapes zero width joiners (ZWJ; U+200D) from Twitch messages
+///
+/// Older Chatterino versions escape ZWJ with an ESCAPE TAG (U+E0002), following
+/// https://mm2pl.github.io/emoji_rfc.pdf. This function unescapes all tags with
+/// a ZWJ. See also: https://github.com/Chatterino/chatterino2/issues/3384.
+QString unescapeZeroWidthJoiner(QString escaped);
+
} // namespace chatterino
diff --git a/src/widgets/settingspages/HighlightingPage.cpp b/src/widgets/settingspages/HighlightingPage.cpp
index 2a1b6884b00..a6459d9b4fe 100644
--- a/src/widgets/settingspages/HighlightingPage.cpp
+++ b/src/widgets/settingspages/HighlightingPage.cpp
@@ -110,8 +110,8 @@ HighlightingPage::HighlightingPage()
pingUsers.emplace(
"Play notification sounds and highlight messages from "
"certain users.\n"
- "User highlights are prioritized badge highlights, but "
- "under message highlights.");
+ "User highlights are prioritized over badge highlights, "
+ "but under message highlights.");
EditableModelView *view =
pingUsers
.emplace(
diff --git a/tests/src/Helpers.cpp b/tests/src/Helpers.cpp
index 4327bf51a1e..c2cf5694007 100644
--- a/tests/src/Helpers.cpp
+++ b/tests/src/Helpers.cpp
@@ -2,6 +2,8 @@
#include "Test.hpp"
+#include
+
using namespace chatterino;
using namespace helpers::detail;
@@ -500,3 +502,57 @@ TEST(Helpers, parseDurationToSeconds)
<< c.output;
}
}
+
+TEST(Helpers, unescapeZeroWidthJoiner)
+{
+ struct TestCase {
+ QStringView input;
+ QStringView output;
+ };
+
+ std::vector tests{
+ {u"foo bar", u"foo bar"},
+ {u"", u""},
+ {u"a", u"a"},
+ {u"\U000E0002", u"\u200D"},
+ {u"foo\U000E0002bar", u"foo\u200Dbar"},
+ {u"foo \U000E0002 bar", u"foo \u200D bar"},
+ {u"\U0001F468\U000E0002\U0001F33E", u"\U0001F468\u200D\U0001F33E"},
+ // don't replace ZWJ
+ {u"\U0001F468\u200D\U0001F33E", u"\U0001F468\u200D\U0001F33E"},
+ // only replace the first escape tag in sequences
+ {
+ u"\U0001F468\U000E0002\U000E0002\U0001F33E",
+ u"\U0001F468\u200D\U000E0002\U0001F33E",
+ },
+ {
+ u"\U0001F468\U000E0002\U000E0002\U000E0002\U0001F33E",
+ u"\U0001F468\u200D\U000E0002\U000E0002\U0001F33E",
+ },
+ };
+
+ // sanity check that the compiler supports unicode string literals
+ static_assert(
+ [] {
+ constexpr std::span zwj = u"\u200D";
+ static_assert(zwj.size() == 2);
+ static_assert(zwj[0] == u'\x200D');
+ static_assert(zwj[1] == u'\0');
+
+ constexpr std::span escapeTag = u"\U000E0002";
+ static_assert(escapeTag.size() == 3);
+ static_assert(escapeTag[0] == u'\xDB40');
+ static_assert(escapeTag[1] == u'\xDC02');
+ static_assert(escapeTag[2] == u'\0');
+
+ return true;
+ }(),
+ "The compiler must support Unicode string literals");
+
+ for (const auto &c : tests)
+ {
+ const auto actual = unescapeZeroWidthJoiner(c.input.toString());
+
+ EXPECT_EQ(actual, c.output);
+ }
+}