diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5d734b0..b703355d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ - Dev: Refactored a few `#define`s into `const(expr)` and cleaned includes. (#5527) - Dev: Added `FlagsEnum::isEmpty`. (#5550) - Dev: Prepared for Qt 6.8 by addressing some deprecations. (#5529) +- Dev: Refactored `MessageBuilder` to be a single class. (#5548) - Dev: Recent changes are now shown in the nightly release description. (#5553, #5554) ## 2.5.1 diff --git a/benchmarks/src/Highlights.cpp b/benchmarks/src/Highlights.cpp index 12b70d33a..9eb4a7370 100644 --- a/benchmarks/src/Highlights.cpp +++ b/benchmarks/src/Highlights.cpp @@ -4,7 +4,7 @@ #include "controllers/highlights/HighlightController.hpp" #include "controllers/highlights/HighlightPhrase.hpp" #include "messages/Message.hpp" -#include "messages/SharedMessageBuilder.hpp" +#include "messages/MessageBuilder.hpp" #include "mocks/BaseApplication.hpp" #include "singletons/Settings.hpp" #include "util/Helpers.hpp" @@ -16,15 +16,16 @@ using namespace chatterino; -class BenchmarkMessageBuilder : public SharedMessageBuilder +class BenchmarkMessageBuilder : public MessageBuilder { public: explicit BenchmarkMessageBuilder( Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, const MessageParseArgs &_args) - : SharedMessageBuilder(_channel, _ircMessage, _args) + : MessageBuilder(_channel, _ircMessage, _args) { } + virtual MessagePtr build() { // PARSE diff --git a/src/Application.cpp b/src/Application.cpp index 4ae2862d6..bdaa7637e 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -42,7 +42,6 @@ #include "providers/twitch/PubSubMessages.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/CrashHandler.hpp" #include "singletons/Emotes.hpp" #include "singletons/Fonts.hpp" @@ -738,11 +737,9 @@ void Application::initPubSub() return; } - MessageBuilder msg; - TwitchMessageBuilder::deletionMessage(action, &msg); - msg->flags.set(MessageFlag::PubSub); + auto msg = MessageBuilder::makeDeletionMessageFromPubSub(action); - postToThread([chan, msg = msg.release()] { + postToThread([chan, msg] { auto replaced = false; LimitedQueueSnapshot snapshot = chan->getMessageSnapshot(); @@ -827,10 +824,8 @@ void Application::initPubSub() } postToThread([twitchChannel, action] { - const auto p = - TwitchMessageBuilder::makeLowTrustUserMessage( - action, twitchChannel->getName(), - twitchChannel.get()); + const auto p = MessageBuilder::makeLowTrustUserMessage( + action, twitchChannel->getName(), twitchChannel.get()); twitchChannel->addMessage(p.first, MessageContext::Original); twitchChannel->addMessage(p.second, @@ -871,7 +866,7 @@ void Application::initPubSub() postToThread([chan, action] { auto msg = - TwitchMessageBuilder::makeLowTrustUpdateMessage(action); + MessageBuilder::makeLowTrustUpdateMessage(action); chan->addMessage(msg, MessageContext::Original); }); }); @@ -951,9 +946,8 @@ void Application::initPubSub() ActionUser{msg.senderUserID, msg.senderUserLogin, senderDisplayName, senderColor}; postToThread([chan, action] { - const auto p = - TwitchMessageBuilder::makeAutomodMessage( - action, chan->getName()); + const auto p = MessageBuilder::makeAutomodMessage( + action, chan->getName()); chan->addMessage(p.first, MessageContext::Original); chan->addMessage(p.second, MessageContext::Original); @@ -1004,8 +998,8 @@ void Application::initPubSub() } postToThread([chan, action] { - const auto p = TwitchMessageBuilder::makeAutomodMessage( - action, chan->getName()); + const auto p = + MessageBuilder::makeAutomodMessage(action, chan->getName()); chan->addMessage(p.first, MessageContext::Original); chan->addMessage(p.second, MessageContext::Original); }); @@ -1043,8 +1037,7 @@ void Application::initPubSub() } postToThread([chan, action] { - const auto p = - TwitchMessageBuilder::makeAutomodInfoMessage(action); + const auto p = MessageBuilder::makeAutomodInfoMessage(action); chan->addMessage(p, MessageContext::Original); }); }); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index cd2298976..17fae303c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -280,9 +280,6 @@ set(SOURCE_FILES messages/MessageThread.cpp messages/MessageThread.hpp - messages/SharedMessageBuilder.cpp - messages/SharedMessageBuilder.hpp - messages/layouts/MessageLayout.cpp messages/layouts/MessageLayout.hpp messages/layouts/MessageLayoutContainer.cpp @@ -405,8 +402,6 @@ set(SOURCE_FILES providers/twitch/TwitchHelpers.hpp providers/twitch/TwitchIrcServer.cpp providers/twitch/TwitchIrcServer.hpp - providers/twitch/TwitchMessageBuilder.cpp - providers/twitch/TwitchMessageBuilder.hpp providers/twitch/TwitchUser.cpp providers/twitch/TwitchUser.hpp diff --git a/src/common/ChannelChatters.cpp b/src/common/ChannelChatters.cpp index b66a3cc45..575b90302 100644 --- a/src/common/ChannelChatters.cpp +++ b/src/common/ChannelChatters.cpp @@ -3,7 +3,6 @@ #include "common/Channel.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include @@ -39,12 +38,11 @@ void ChannelChatters::addJoinedUser(const QString &user) auto joinedUsers = this->joinedUsers_.access(); joinedUsers->sort(); - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - "Users joined:", *joinedUsers, &this->channel_, &builder); - builder->flags.set(MessageFlag::Collapsed); - this->channel_.addMessage(builder.release(), - MessageContext::Original); + this->channel_.addMessage( + MessageBuilder::makeListOfUsersMessage( + "Users joined:", *joinedUsers, &this->channel_, + {MessageFlag::Collapsed}), + MessageContext::Original); joinedUsers->clear(); this->joinedUsersMergeQueued_ = false; @@ -65,12 +63,11 @@ void ChannelChatters::addPartedUser(const QString &user) auto partedUsers = this->partedUsers_.access(); partedUsers->sort(); - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - "Users parted:", *partedUsers, &this->channel_, &builder); - builder->flags.set(MessageFlag::Collapsed); - this->channel_.addMessage(builder.release(), - MessageContext::Original); + this->channel_.addMessage( + MessageBuilder::makeListOfUsersMessage( + "Users parted:", *partedUsers, &this->channel_, + {MessageFlag::Collapsed}), + MessageContext::Original); partedUsers->clear(); this->partedUsersMergeQueued_ = false; diff --git a/src/controllers/commands/builtin/twitch/AddModerator.cpp b/src/controllers/commands/builtin/twitch/AddModerator.cpp index bedd74631..18d4a76aa 100644 --- a/src/controllers/commands/builtin/twitch/AddModerator.cpp +++ b/src/controllers/commands/builtin/twitch/AddModerator.cpp @@ -6,7 +6,6 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "util/Twitch.hpp" namespace chatterino::commands { diff --git a/src/controllers/commands/builtin/twitch/AddVIP.cpp b/src/controllers/commands/builtin/twitch/AddVIP.cpp index 9a50e8313..0c36f98b0 100644 --- a/src/controllers/commands/builtin/twitch/AddVIP.cpp +++ b/src/controllers/commands/builtin/twitch/AddVIP.cpp @@ -6,7 +6,6 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "util/Twitch.hpp" namespace chatterino::commands { diff --git a/src/controllers/commands/builtin/twitch/Chatters.cpp b/src/controllers/commands/builtin/twitch/Chatters.cpp index 7c5f21fb8..e2f2932c7 100644 --- a/src/controllers/commands/builtin/twitch/Chatters.cpp +++ b/src/controllers/commands/builtin/twitch/Chatters.cpp @@ -11,7 +11,6 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Theme.hpp" #include @@ -125,11 +124,9 @@ QString testChatters(const CommandContext &ctx) prefix += QString("(%1):").arg(result.total); } - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - prefix, entries, twitchChannel, &builder); - - channel->addMessage(builder.release(), MessageContext::Original); + channel->addMessage(MessageBuilder::makeListOfUsersMessage( + prefix, entries, twitchChannel), + MessageContext::Original); }, [channel{ctx.channel}](auto error, auto message) { auto errorMessage = formatChattersError(error, message); diff --git a/src/controllers/commands/builtin/twitch/GetModerators.cpp b/src/controllers/commands/builtin/twitch/GetModerators.cpp index 8a70f8fa6..ea0bfdf3b 100644 --- a/src/controllers/commands/builtin/twitch/GetModerators.cpp +++ b/src/controllers/commands/builtin/twitch/GetModerators.cpp @@ -5,7 +5,6 @@ #include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" namespace { @@ -77,11 +76,10 @@ QString getModerators(const CommandContext &ctx) // TODO: sort results? - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - "The moderators of this channel are", result, twitchChannel, - &builder); - channel->addMessage(builder.release(), MessageContext::Original); + channel->addMessage(MessageBuilder::makeListOfUsersMessage( + "The moderators of this channel are", + result, twitchChannel), + MessageContext::Original); }, [channel{ctx.channel}](auto error, auto message) { auto errorMessage = formatModsError(error, message); diff --git a/src/controllers/commands/builtin/twitch/GetVIPs.cpp b/src/controllers/commands/builtin/twitch/GetVIPs.cpp index 794c5c0b9..ced19ff09 100644 --- a/src/controllers/commands/builtin/twitch/GetVIPs.cpp +++ b/src/controllers/commands/builtin/twitch/GetVIPs.cpp @@ -7,7 +7,6 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" namespace { @@ -106,11 +105,10 @@ QString getVIPs(const CommandContext &ctx) auto messagePrefix = QString("The VIPs of this channel are"); // TODO: sort results? - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - messagePrefix, vipList, twitchChannel, &builder); - channel->addMessage(builder.release(), MessageContext::Original); + channel->addMessage(MessageBuilder::makeListOfUsersMessage( + messagePrefix, vipList, twitchChannel), + MessageContext::Original); }, [channel{ctx.channel}](auto error, auto message) { auto errorMessage = formatGetVIPsError(error, message); diff --git a/src/controllers/commands/builtin/twitch/RemoveModerator.cpp b/src/controllers/commands/builtin/twitch/RemoveModerator.cpp index 3d6d8e365..8b8da22ca 100644 --- a/src/controllers/commands/builtin/twitch/RemoveModerator.cpp +++ b/src/controllers/commands/builtin/twitch/RemoveModerator.cpp @@ -6,7 +6,6 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "util/Twitch.hpp" namespace chatterino::commands { diff --git a/src/controllers/commands/builtin/twitch/RemoveVIP.cpp b/src/controllers/commands/builtin/twitch/RemoveVIP.cpp index 22e0a709c..9a682a20e 100644 --- a/src/controllers/commands/builtin/twitch/RemoveVIP.cpp +++ b/src/controllers/commands/builtin/twitch/RemoveVIP.cpp @@ -8,7 +8,6 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "util/Twitch.hpp" namespace chatterino::commands { diff --git a/src/controllers/commands/builtin/twitch/StartCommercial.cpp b/src/controllers/commands/builtin/twitch/StartCommercial.cpp index 15b620440..8e6aa120c 100644 --- a/src/controllers/commands/builtin/twitch/StartCommercial.cpp +++ b/src/controllers/commands/builtin/twitch/StartCommercial.cpp @@ -6,7 +6,6 @@ #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" namespace { diff --git a/src/controllers/highlights/HighlightBadge.cpp b/src/controllers/highlights/HighlightBadge.cpp index 7958c8faa..4fe7f44d5 100644 --- a/src/controllers/highlights/HighlightBadge.cpp +++ b/src/controllers/highlights/HighlightBadge.cpp @@ -1,8 +1,7 @@ #include "controllers/highlights/HighlightBadge.hpp" -#include "messages/SharedMessageBuilder.hpp" #include "providers/twitch/TwitchBadge.hpp" -#include "singletons/Resources.hpp" +#include "util/IrcHelpers.hpp" namespace chatterino { @@ -97,7 +96,7 @@ bool HighlightBadge::compare(const QString &id, const Badge &badge) const { if (this->hasVersions_) { - auto parts = SharedMessageBuilder::slashKeyValue(id); + auto parts = slashKeyValue(id); return parts.first.compare(badge.key_, Qt::CaseInsensitive) == 0 && parts.second.compare(badge.value_, Qt::CaseInsensitive) == 0; } diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index b3800dd21..5f9d33e0d 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -5,9 +5,9 @@ #include "controllers/notifications/NotificationModel.hpp" #include "controllers/sound/ISoundController.hpp" #include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchIrcServer.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Settings.hpp" #include "singletons/StreamerMode.hpp" #include "singletons/Toasts.hpp" @@ -137,11 +137,9 @@ void NotificationController::notifyTwitchChannelLive( } // Message in /live channel - MessageBuilder builder; - TwitchMessageBuilder::liveMessage(payload.displayName, &builder); - builder.message().id = payload.channelId; getApp()->getTwitch()->getLiveChannel()->addMessage( - builder.release(), MessageContext::Original); + MessageBuilder::makeLiveMessage(payload.displayName, payload.channelId), + MessageContext::Original); // Notify on all channels with a ping sound if (showNotification && !playedSound && diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 29f4abb7f..5966802c4 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -2,20 +2,91 @@ #include "Application.hpp" #include "common/LinkParser.hpp" +#include "common/Literals.hpp" +#include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/highlights/HighlightController.hpp" +#include "controllers/ignores/IgnoreController.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" +#include "controllers/userdata/UserDataController.hpp" +#include "messages/Emote.hpp" +#include "messages/Image.hpp" #include "messages/Message.hpp" #include "messages/MessageColor.hpp" #include "messages/MessageElement.hpp" +#include "messages/MessageThread.hpp" +#include "providers/bttv/BttvEmotes.hpp" +#include "providers/chatterino/ChatterinoBadges.hpp" +#include "providers/colors/ColorProvider.hpp" +#include "providers/ffz/FfzBadges.hpp" +#include "providers/ffz/FfzEmotes.hpp" #include "providers/links/LinkResolver.hpp" +#include "providers/seventv/SeventvBadges.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchBadge.hpp" +#include "providers/twitch/TwitchBadges.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Emotes.hpp" +#include "singletons/Resources.hpp" +#include "singletons/Settings.hpp" +#include "singletons/StreamerMode.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 "widgets/Window.hpp" +#include +#include #include +#include +#include + +#include +#include + +using namespace chatterino::literals; namespace { +using namespace chatterino; +using namespace std::chrono_literals; + +const QColor AUTOMOD_USER_COLOR{"blue"}; + +const QString regexHelpString("(\\w+)[.,!?;:]*?$"); + +// matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username" +const QRegularExpression mentionRegex("^@" + regexHelpString); + +// if findAllUsernames setting is enabled, matches strings like in the examples above, but without @ symbol at the beginning +const QRegularExpression allUsernamesMentionRegex("^" + regexHelpString); + +const QSet zeroWidthEmotes{ + "SoSnowy", "IceCold", "SantaHat", "TopHat", + "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}}, +}; + QString formatUpdatedEmoteList(const QString &platform, const std::vector &emoteNames, bool isAdd, bool isFirstWord) @@ -55,6 +126,446 @@ QString formatUpdatedEmoteList(const QString &platform, return text; } +/** + * Gets the default sound url if the user set one, + * or the chatterino default ping sound if no url is set. + */ +QUrl getFallbackHighlightSound() +{ + QString path = getSettings()->pathHighlightSound; + bool fileExists = + !path.isEmpty() && QFileInfo::exists(path) && QFileInfo(path).isFile(); + + if (fileExists) + { + return QUrl::fromLocalFile(path); + } + + return QUrl("qrc:/sounds/ping2.wav"); +} + +void actuallyTriggerHighlights(const QString &channelName, bool playSound, + const std::optional &customSoundUrl, + bool windowAlert) +{ + if (getApp()->getStreamerMode()->isEnabled() && + getSettings()->streamerModeMuteMentions) + { + // We are in streamer mode with muting mention sounds enabled. Do nothing. + return; + } + + if (getSettings()->isMutedChannel(channelName)) + { + // Do nothing. Pings are muted in this channel. + return; + } + + const bool hasFocus = (QApplication::focusWidget() != nullptr); + const bool resolveFocus = + !hasFocus || getSettings()->highlightAlwaysPlaySound; + + if (playSound && resolveFocus) + { + // TODO(C++23): optional or_else + QUrl soundUrl; + if (customSoundUrl) + { + soundUrl = *customSoundUrl; + } + else + { + soundUrl = getFallbackHighlightSound(); + } + getApp()->getSound()->play(soundUrl); + } + + if (windowAlert) + { + getApp()->getWindows()->sendAlert(); + } +} + +QString stylizeUsername(const QString &username, const Message &message) +{ + const QString &localizedName = message.localizedName; + bool hasLocalizedName = !localizedName.isEmpty(); + + // The full string that will be rendered in the chat widget + QString usernameText; + + switch (getSettings()->usernameDisplayMode.getValue()) + { + case UsernameDisplayMode::Username: { + usernameText = username; + } + break; + + case UsernameDisplayMode::LocalizedName: { + if (hasLocalizedName) + { + usernameText = localizedName; + } + else + { + usernameText = username; + } + } + break; + + default: + case UsernameDisplayMode::UsernameAndLocalizedName: { + if (hasLocalizedName) + { + usernameText = username + "(" + localizedName + ")"; + } + else + { + usernameText = username; + } + } + break; + } + + if (auto nicknameText = getSettings()->matchNickname(usernameText)) + { + usernameText = *nicknameText; + } + + return usernameText; +} + +void appendTwitchEmoteOccurrences(const QString &emote, + std::vector &vec, + const std::vector &correctPositions, + const QString &originalMessage, + int messageOffset) +{ + auto *app = getApp(); + if (!emote.contains(':')) + { + return; + } + + auto parameters = emote.split(':'); + + if (parameters.length() < 2) + { + return; + } + + auto id = EmoteId{parameters.at(0)}; + + auto occurrences = parameters.at(1).split(','); + + for (const QString &occurrence : occurrences) + { + auto coords = occurrence.split('-'); + + if (coords.length() < 2) + { + return; + } + + auto from = coords.at(0).toUInt() - messageOffset; + auto to = coords.at(1).toUInt() - messageOffset; + auto maxPositions = correctPositions.size(); + if (from > to || to >= maxPositions) + { + // Emote coords are out of range + qCDebug(chatterinoTwitch) + << "Emote coords" << from << "-" << to << "are out of range (" + << maxPositions << ")"; + return; + } + + auto start = correctPositions[from]; + auto end = correctPositions[to]; + if (start > end || start < 0 || end > originalMessage.length()) + { + // Emote coords are out of range from the modified character positions + qCDebug(chatterinoTwitch) << "Emote coords" << from << "-" << to + << "are out of range after offsets (" + << originalMessage.length() << ")"; + return; + } + + auto name = EmoteName{originalMessage.mid(start, end - start + 1)}; + TwitchEmoteOccurrence emoteOccurrence{ + start, + end, + app->getEmotes()->getTwitchEmotes()->getOrCreateEmote(id, name), + name, + }; + if (emoteOccurrence.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "nullptr" << emoteOccurrence.name.string; + } + vec.push_back(std::move(emoteOccurrence)); + } +} + +std::optional getTwitchBadge(const Badge &badge, + const TwitchChannel *twitchChannel) +{ + if (auto channelBadge = + twitchChannel->twitchBadge(badge.key_, badge.value_)) + { + return channelBadge; + } + + if (auto globalBadge = + getApp()->getTwitchBadges()->badge(badge.key_, badge.value_)) + { + return globalBadge; + } + + return std::nullopt; +} + +void appendBadges(MessageBuilder *builder, const std::vector &badges, + const std::unordered_map &badgeInfos, + const TwitchChannel *twitchChannel) +{ + if (twitchChannel == nullptr) + { + return; + } + + for (const auto &badge : badges) + { + auto badgeEmote = getTwitchBadge(badge, twitchChannel); + if (!badgeEmote) + { + continue; + } + auto tooltip = (*badgeEmote)->tooltip.string; + + if (badge.key_ == "bits") + { + const auto &cheerAmount = badge.value_; + tooltip = QString("Twitch cheer %0").arg(cheerAmount); + } + else if (badge.key_ == "moderator" && + getSettings()->useCustomFfzModeratorBadges) + { + if (auto customModBadge = twitchChannel->ffzCustomModBadge()) + { + builder + ->emplace( + *customModBadge, + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip((*customModBadge)->tooltip.string); + // early out, since we have to add a custom badge element here + continue; + } + } + else if (badge.key_ == "vip" && getSettings()->useCustomFfzVipBadges) + { + if (auto customVipBadge = twitchChannel->ffzCustomVipBadge()) + { + builder + ->emplace( + *customVipBadge, + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip((*customVipBadge)->tooltip.string); + // early out, since we have to add a custom badge element here + continue; + } + } + else if (badge.flag_ == MessageElementFlag::BadgeSubscription) + { + auto badgeInfoIt = badgeInfos.find(badge.key_); + if (badgeInfoIt != badgeInfos.end()) + { + // badge.value_ is 4 chars long if user is subbed on higher tier + // (tier + amount of months with leading zero if less than 100) + // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub + const auto &subTier = + badge.value_.length() > 3 ? badge.value_.at(0) : '1'; + const auto &subMonths = badgeInfoIt->second; + tooltip += + QString(" (%1%2 months)") + .arg(subTier != '1' ? QString("Tier %1, ").arg(subTier) + : "") + .arg(subMonths); + } + } + else if (badge.flag_ == MessageElementFlag::BadgePredictions) + { + auto badgeInfoIt = badgeInfos.find(badge.key_); + if (badgeInfoIt != badgeInfos.end()) + { + auto infoValue = badgeInfoIt->second; + auto predictionText = + infoValue + .replace(R"(\s)", " ") // standard IRC escapes + .replace(R"(\:)", ";") + .replace(R"(\\)", R"(\)") + .replace("⸝", ","); // twitch's comma escape + // Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma + + tooltip = QString("Predicted %1").arg(predictionText); + } + } + + builder->emplace(*badgeEmote, badge.flag_) + ->setTooltip(tooltip); + } + + builder->message().badges = badges; + builder->message().badgeInfos = badgeInfos; +} + +/** + * Computes (only) the replacement of @a match in @a source. + * The parts before and after the match in @a source are ignored. + * + * Occurrences of \b{\\1}, \b{\\2}, ..., in @a replacement are replaced + * with the string captured by the corresponding capturing group. + * This function should only be used if the regex contains capturing groups. + * + * Since Qt doesn't provide a way of replacing a single match with some replacement + * while supporting both capturing groups and lookahead/-behind in the regex, + * this is included here. It's essentially the implementation of + * QString::replace(const QRegularExpression &, const QString &). + * @see https://github.com/qt/qtbase/blob/97bb0ecfe628b5bb78e798563212adf02129c6f6/src/corelib/text/qstring.cpp#L4594-L4703 + */ +QString makeRegexReplacement(QStringView source, + const QRegularExpression ®ex, + const QRegularExpressionMatch &match, + const QString &replacement) +{ + using SizeType = QString::size_type; + struct QStringCapture { + SizeType pos; + SizeType len; + int captureNumber; + }; + + qsizetype numCaptures = regex.captureCount(); + + // 1. build the backreferences list, holding where the backreferences + // are in the replacement string + QVarLengthArray backReferences; + + SizeType replacementLength = replacement.size(); + for (SizeType i = 0; i < replacementLength - 1; i++) + { + if (replacement[i] != u'\\') + { + continue; + } + + int no = replacement[i + 1].digitValue(); + if (no <= 0 || no > numCaptures) + { + continue; + } + + QStringCapture backReference{.pos = i, .len = 2}; + + if (i < replacementLength - 2) + { + int secondDigit = replacement[i + 2].digitValue(); + if (secondDigit != -1 && ((no * 10) + secondDigit) <= numCaptures) + { + no = (no * 10) + secondDigit; + ++backReference.len; + } + } + + backReference.captureNumber = no; + backReferences.append(backReference); + } + + // 2. iterate on the matches. + // For every match, copy the replacement string in chunks + // with the proper replacements for the backreferences + + // length of the new string, with all the replacements + SizeType newLength = 0; + QVarLengthArray chunks; + QStringView replacementView{replacement}; + + // Initially: empty, as we only care about the replacement + SizeType len = 0; + SizeType lastEnd = 0; + for (const QStringCapture &backReference : std::as_const(backReferences)) + { + // part of "replacement" before the backreference + len = backReference.pos - lastEnd; + if (len > 0) + { + chunks << replacementView.mid(lastEnd, len); + newLength += len; + } + + // backreference itself + len = match.capturedLength(backReference.captureNumber); + if (len > 0) + { + chunks << source.mid( + match.capturedStart(backReference.captureNumber), len); + newLength += len; + } + + lastEnd = backReference.pos + backReference.len; + } + + // add the last part of the replacement string + len = replacementView.size() - lastEnd; + if (len > 0) + { + chunks << replacementView.mid(lastEnd, len); + newLength += len; + } + + // 3. assemble the chunks together + QString dst; + dst.reserve(newLength); + for (const QStringView &chunk : std::as_const(chunks)) + { + dst += chunk; + } + return dst; +} + +bool doesWordContainATwitchEmote( + int cursor, const QString &word, + const std::vector &twitchEmotes, + std::vector::const_iterator ¤tTwitchEmoteIt) +{ + if (currentTwitchEmoteIt == twitchEmotes.end()) + { + // No emote to add! + return false; + } + + const auto ¤tTwitchEmote = *currentTwitchEmoteIt; + + auto wordEnd = cursor + word.length(); + + // Check if this emote fits within the word boundaries + if (currentTwitchEmote.start < cursor || currentTwitchEmote.end > wordEnd) + { + // this emote does not fit xd + return false; + } + + return true; +} + +EmotePtr makeAutoModBadge() +{ + return std::make_shared(Emote{ + EmoteName{}, + ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)}, + Tooltip{"AutoMod"}, + Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); +} + } // namespace namespace chatterino { @@ -71,6 +582,36 @@ MessagePtr makeSystemMessage(const QString &text, const QTime &time) MessageBuilder::MessageBuilder() : message_(std::make_shared()) + , ircMessage(nullptr) +{ +} + +MessageBuilder::MessageBuilder(Channel *_channel, + const Communi::IrcPrivateMessage *_ircMessage, + const MessageParseArgs &_args) + : twitchChannel(dynamic_cast(_channel)) + , message_(std::make_shared()) + , channel(_channel) + , ircMessage(_ircMessage) + , args(_args) + , tags(this->ircMessage->tags()) + , originalMessage_(_ircMessage->content()) + , action_(_ircMessage->isAction()) +{ +} + +MessageBuilder::MessageBuilder(Channel *_channel, + const Communi::IrcMessage *_ircMessage, + const MessageParseArgs &_args, QString content, + bool isAction) + : twitchChannel(dynamic_cast(_channel)) + , message_(std::make_shared()) + , channel(_channel) + , ircMessage(_ircMessage) + , args(_args) + , tags(this->ircMessage->tags()) + , originalMessage_(content) + , action_(isAction) { } @@ -191,7 +732,6 @@ MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &username, this->message().searchText = fullText; } -// XXX: This does not belong in the MessageBuilder, this should be part of the TwitchMessageBuilder MessageBuilder::MessageBuilder(const BanAction &action, uint32_t count) : MessageBuilder() { @@ -609,26 +1149,6 @@ void MessageBuilder::append(std::unique_ptr element) this->message().elements.push_back(std::move(element)); } -bool MessageBuilder::isEmpty() const -{ - return this->message_->elements.empty(); -} - -MessageElement &MessageBuilder::back() -{ - assert(!this->isEmpty()); - return *this->message().elements.back(); -} - -std::unique_ptr MessageBuilder::releaseBack() -{ - assert(!this->isEmpty()); - - auto ptr = std::move(this->message().elements.back()); - this->message().elements.pop_back(); - return ptr; -} - void MessageBuilder::addLink(const linkparser::Parsed &parsedLink, const QString &source) { @@ -671,30 +1191,1339 @@ void MessageBuilder::addLink(const linkparser::Parsed &parsedLink, getApp()->getLinkResolver()->resolve(el->linkInfo()); } +bool MessageBuilder::isIgnored() const +{ + return isIgnoredMessage({ + /*.message = */ this->originalMessage_, + /*.twitchUserID = */ this->tags.value("user-id").toString(), + /*.isMod = */ this->channel->isMod(), + /*.isBroadcaster = */ this->channel->isBroadcaster(), + }); +} + +bool MessageBuilder::isIgnoredReply() const +{ + return isIgnoredMessage({ + /*.message = */ this->originalMessage_, + /*.twitchUserID = */ + this->tags.value("reply-parent-user-id").toString(), + /*.isMod = */ this->channel->isMod(), + /*.isBroadcaster = */ this->channel->isBroadcaster(), + }); +} + +void MessageBuilder::triggerHighlights() +{ + if (this->historicalMessage_) + { + // Do nothing. Highlights should not be triggered on historical messages. + return; + } + + actuallyTriggerHighlights(this->channel->getName(), this->highlightSound_, + this->highlightSoundCustomUrl_, + this->highlightAlert_); +} + +MessagePtr MessageBuilder::build() +{ + assert(this->ircMessage != nullptr); + assert(this->channel != nullptr); + + // PARSE + this->userId_ = this->ircMessage->tag("user-id").toString(); + + this->parse(); + + if (this->userName == this->channel->getName()) + { + this->senderIsBroadcaster = true; + } + + this->message().channelName = this->channel->getName(); + + this->parseMessageID(); + + this->parseRoomID(); + + // If it is a reward it has to be appended first + if (this->args.channelPointRewardId != "") + { + assert(this->twitchChannel != nullptr); + const auto &reward = this->twitchChannel->channelPointReward( + this->args.channelPointRewardId); + if (reward) + { + this->appendChannelPointRewardMessage( + *reward, this->channel->isMod(), + this->channel->isBroadcaster()); + } + } + + this->appendChannelName(); + + if (this->tags.contains("rm-deleted")) + { + this->message().flags.set(MessageFlag::Disabled); + } + + this->historicalMessage_ = this->tags.contains("historical"); + + if (this->tags.contains("msg-id") && + this->tags["msg-id"].toString().split(';').contains( + "highlighted-message")) + { + this->message().flags.set(MessageFlag::RedeemedHighlight); + } + + if (this->tags.contains("first-msg") && + this->tags["first-msg"].toString() == "1") + { + this->message().flags.set(MessageFlag::FirstMessage); + } + + if (this->tags.contains("pinned-chat-paid-amount")) + { + this->message().flags.set(MessageFlag::ElevatedMessage); + } + + if (this->tags.contains("bits")) + { + this->message().flags.set(MessageFlag::CheerMessage); + } + + // reply threads + this->parseThread(); + + // timestamp + this->message().serverReceivedTime = calculateMessageTime(this->ircMessage); + this->emplace(this->message().serverReceivedTime.time()); + + if (this->shouldAddModerationElements()) + { + this->emplace(); + } + + this->appendTwitchBadges(); + + this->appendChatterinoBadges(); + this->appendFfzBadges(); + this->appendSeventvBadges(); + + this->appendUsername(); + + // QString bits; + auto iterator = this->tags.find("bits"); + if (iterator != this->tags.end()) + { + this->hasBits_ = true; + this->bitsLeft = iterator.value().toInt(); + this->bits = iterator.value().toString(); + } + + // Twitch emotes + auto twitchEmotes = MessageBuilder::parseTwitchEmotes( + this->tags, this->originalMessage_, this->messageOffset_); + + // This runs through all ignored phrases and runs its replacements on this->originalMessage_ + MessageBuilder::processIgnorePhrases( + *getSettings()->ignoredMessages.readOnly(), this->originalMessage_, + twitchEmotes); + + std::sort(twitchEmotes.begin(), twitchEmotes.end(), + [](const auto &a, const auto &b) { + return a.start < b.start; + }); + twitchEmotes.erase(std::unique(twitchEmotes.begin(), twitchEmotes.end(), + [](const auto &first, const auto &second) { + return first.start == second.start; + }), + twitchEmotes.end()); + + // words + QStringList splits = this->originalMessage_.split(' '); + + this->addWords(splits, twitchEmotes); + + QString stylizedUsername = stylizeUsername(this->userName, this->message()); + + this->message().messageText = this->originalMessage_; + this->message().searchText = + stylizedUsername + " " + this->message().localizedName + " " + + this->userName + ": " + this->originalMessage_ + " " + + this->message().searchText; + + // highlights + this->parseHighlights(); + + // highlighting incoming whispers if requested per setting + if (this->args.isReceivedWhisper && getSettings()->highlightInlineWhispers) + { + this->message().flags.set(MessageFlag::HighlightedWhisper, true); + this->message().highlightColor = + ColorProvider::instance().color(ColorType::Whisper); + } + + if (this->thread_) + { + auto &img = getResources().buttons.replyThreadDark; + this->emplace( + Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, + MessageElementFlag::ReplyButton) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + } + else + { + auto &img = getResources().buttons.replyDark; + this->emplace( + Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, + MessageElementFlag::ReplyButton) + ->setLink({Link::ReplyToMessage, this->message().id}); + } + + return this->release(); +} + +void MessageBuilder::setThread(std::shared_ptr thread) +{ + this->thread_ = std::move(thread); +} + +void MessageBuilder::setParent(MessagePtr parent) +{ + this->parent_ = std::move(parent); +} + +void MessageBuilder::setMessageOffset(int offset) +{ + this->messageOffset_ = offset; +} + +void MessageBuilder::appendChannelPointRewardMessage( + const ChannelPointReward &reward, bool isMod, bool isBroadcaster) +{ + if (isIgnoredMessage({ + /*.message = */ "", + /*.twitchUserID = */ reward.user.id, + /*.isMod = */ isMod, + /*.isBroadcaster = */ isBroadcaster, + })) + { + return; + } + + this->emplace(); + QString redeemed = "Redeemed"; + QStringList textList; + if (!reward.isUserInputRequired) + { + this->emplace( + reward.user.login, MessageElementFlag::ChannelPointReward, + MessageColor::Text, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, reward.user.login}); + redeemed = "redeemed"; + textList.append(reward.user.login); + } + this->emplace(redeemed, + MessageElementFlag::ChannelPointReward); + if (reward.id == "CELEBRATION") + { + const auto emotePtr = + getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( + EmoteId{reward.emoteId}, EmoteName{reward.emoteName}); + this->emplace(emotePtr, + MessageElementFlag::ChannelPointReward, + MessageColor::Text); + } + this->emplace(reward.title, + MessageElementFlag::ChannelPointReward, + MessageColor::Text, FontStyle::ChatMediumBold); + this->emplace( + reward.image, MessageElementFlag::ChannelPointRewardImage); + this->emplace(QString::number(reward.cost), + MessageElementFlag::ChannelPointReward, + MessageColor::Text, FontStyle::ChatMediumBold); + if (reward.isBits) + { + this->emplace( + "bits", MessageElementFlag::ChannelPointReward, MessageColor::Text, + FontStyle::ChatMediumBold); + } + if (reward.isUserInputRequired) + { + this->emplace(MessageElementFlag::ChannelPointReward); + } + + this->message().flags.set(MessageFlag::RedeemedChannelPointReward); + + textList.append({redeemed, reward.title, QString::number(reward.cost)}); + this->message().messageText = textList.join(" "); + this->message().searchText = textList.join(" "); + this->message().loginName = reward.user.login; + + this->message().reward = std::make_shared(reward); +} + +MessagePtr MessageBuilder::makeChannelPointRewardMessage( + const ChannelPointReward &reward, bool isMod, bool isBroadcaster) +{ + MessageBuilder builder; + + builder.appendChannelPointRewardMessage(reward, isMod, isBroadcaster); + + return builder.release(); +} + +MessagePtr MessageBuilder::makeLiveMessage(const QString &channelName, + const QString &channelID, + MessageFlags extraFlags) +{ + MessageBuilder builder; + + builder.emplace(); + builder + .emplace(channelName, MessageElementFlag::Username, + MessageColor::Text, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, channelName}); + builder.emplace("is live!", MessageElementFlag::Text, + MessageColor::Text); + auto text = QString("%1 is live!").arg(channelName); + builder.message().messageText = text; + builder.message().searchText = text; + builder.message().id = channelID; + + if (!extraFlags.isEmpty()) + { + builder.message().flags.set(extraFlags); + } + + return builder.release(); +} + +MessagePtr MessageBuilder::makeOfflineSystemMessage(const QString &channelName, + const QString &channelID) +{ + MessageBuilder builder; + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + builder + .emplace(channelName, MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, channelName}); + builder.emplace("is now offline.", MessageElementFlag::Text, + MessageColor::System); + auto text = QString("%1 is now offline.").arg(channelName); + builder.message().messageText = text; + builder.message().searchText = text; + builder.message().id = channelID; + + return builder.release(); +} + +MessagePtr MessageBuilder::makeHostingSystemMessage(const QString &channelName, + bool hostOn) +{ + MessageBuilder builder; + QString text; + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + if (hostOn) + { + builder.emplace("Now hosting", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace( + channelName + ".", MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, channelName}); + text = QString("Now hosting %1.").arg(channelName); + } + else + { + builder + .emplace(channelName, MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, channelName}); + builder.emplace("has gone offline. Exiting host mode.", + MessageElementFlag::Text, + MessageColor::System); + text = + QString("%1 has gone offline. Exiting host mode.").arg(channelName); + } + builder.message().messageText = text; + builder.message().searchText = text; + return builder.release(); +} + +MessagePtr MessageBuilder::makeDeletionMessageFromIRC( + const MessagePtr &originalMessage) +{ + MessageBuilder builder; + + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + builder.message().flags.set(MessageFlag::Timeout); + // TODO(mm2pl): If or when jumping to a single message gets implemented a link, + // add a link to the originalMessage + builder.emplace("A message from", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(originalMessage->displayName, + MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, originalMessage->loginName}); + builder.emplace("was deleted:", MessageElementFlag::Text, + MessageColor::System); + if (originalMessage->messageText.length() > 50) + { + builder + .emplace(originalMessage->messageText.left(50) + "…", + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, originalMessage->id}); + } + else + { + builder + .emplace(originalMessage->messageText, + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, originalMessage->id}); + } + builder.message().timeoutUser = "msg:" + originalMessage->id; + + return builder.release(); +} + +MessagePtr MessageBuilder::makeDeletionMessageFromPubSub( + const DeleteAction &action) +{ + MessageBuilder builder; + + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + builder.message().flags.set(MessageFlag::Timeout); + + builder + .emplace(action.source.login, MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.source.login}); + // TODO(mm2pl): If or when jumping to a single message gets implemented a link, + // add a link to the originalMessage + builder.emplace( + "deleted message from", MessageElementFlag::Text, MessageColor::System); + builder + .emplace(action.target.login, MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.target.login}); + builder.emplace("saying:", MessageElementFlag::Text, + MessageColor::System); + if (action.messageText.length() > 50) + { + builder + .emplace(action.messageText.left(50) + "…", + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, action.messageId}); + } + else + { + builder + .emplace(action.messageText, MessageElementFlag::Text, + MessageColor::Text) + ->setLink({Link::JumpToMessage, action.messageId}); + } + builder.message().timeoutUser = "msg:" + action.messageId; + builder.message().flags.set(MessageFlag::PubSub); + + return builder.release(); +} + +MessagePtr MessageBuilder::makeListOfUsersMessage(QString prefix, + QStringList users, + Channel *channel, + MessageFlags extraFlags) +{ + MessageBuilder builder; + + QString text = prefix + users.join(", "); + + builder.message().messageText = text; + builder.message().searchText = text; + + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + builder.emplace(prefix, MessageElementFlag::Text, + MessageColor::System); + bool isFirst = true; + auto *tc = dynamic_cast(channel); + for (const QString &username : users) + { + if (!isFirst) + { + // this is used to add the ", " after each but the last entry + builder.emplace(",", MessageElementFlag::Text, + MessageColor::System); + } + isFirst = false; + + MessageColor color = MessageColor::System; + + if (tc) + { + if (auto userColor = tc->getUserColor(username); + userColor.isValid()) + { + color = MessageColor(userColor); + } + } + + // TODO: Ensure we make use of display name / username(login name) correctly here + builder + .emplace(username, username, MessageColor::System, + color) + ->setTrailingSpace(false); + } + + if (!extraFlags.isEmpty()) + { + builder.message().flags.set(extraFlags); + } + + return builder.release(); +} + +MessagePtr MessageBuilder::makeListOfUsersMessage( + QString prefix, const std::vector &users, Channel *channel, + MessageFlags extraFlags) +{ + MessageBuilder builder; + + QString text = prefix; + + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + builder.emplace(prefix, MessageElementFlag::Text, + MessageColor::System); + bool isFirst = true; + auto *tc = dynamic_cast(channel); + for (const auto &user : users) + { + if (!isFirst) + { + // this is used to add the ", " after each but the last entry + builder.emplace(",", MessageElementFlag::Text, + MessageColor::System); + text += QString(", %1").arg(user.userName); + } + else + { + text += user.userName; + } + isFirst = false; + + MessageColor color = MessageColor::System; + + if (tc) + { + if (auto userColor = tc->getUserColor(user.userLogin); + userColor.isValid()) + { + color = MessageColor(userColor); + } + } + + builder + .emplace(user.userName, user.userLogin, + MessageColor::System, color) + ->setTrailingSpace(false); + } + + builder.message().messageText = text; + builder.message().searchText = text; + + if (!extraFlags.isEmpty()) + { + builder.message().flags.set(extraFlags); + } + + return builder.release(); +} + +MessagePtr MessageBuilder::buildHypeChatMessage( + Communi::IrcPrivateMessage *message) +{ + auto levelID = 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(levelID); + 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(); +} + +std::pair MessageBuilder::makeAutomodMessage( + const AutomodAction &action, const QString &channelName) +{ + MessageBuilder builder, builder2; + + // + // Builder for AutoMod message with explanation + builder.message().loginName = "automod"; + builder.message().channelName = channelName; + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::Timeout); + builder.message().flags.set(MessageFlag::AutoMod); + builder.message().flags.set(MessageFlag::AutoModOffendingMessageHeader); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + // AutoMod "username" + builder2.emplace("AutoMod:", MessageElementFlag::Text, + AUTOMOD_USER_COLOR, + FontStyle::ChatMediumBold); + // AutoMod header message + builder.emplace( + ("Held a message for reason: " + action.reason + + ". Allow will post it in chat. "), + MessageElementFlag::Text, MessageColor::Text); + // Allow link button + builder + .emplace("Allow", MessageElementFlag::Text, + MessageColor(QColor("green")), + FontStyle::ChatMediumBold) + ->setLink({Link::AutoModAllow, action.msgID}); + // Deny link button + builder + .emplace(" Deny", MessageElementFlag::Text, + MessageColor(QColor("red")), + FontStyle::ChatMediumBold) + ->setLink({Link::AutoModDeny, action.msgID}); + // ID of message caught by AutoMod + // builder.emplace(action.msgID, MessageElementFlag::Text, + // MessageColor::Text); + auto text1 = + QString("AutoMod: Held a message for reason: %1. Allow will post " + "it in chat. Allow Deny") + .arg(action.reason); + builder.message().messageText = text1; + builder.message().searchText = text1; + + auto message1 = builder.release(); + + // + // Builder for offender's message + builder2.message().channelName = channelName; + builder2 + .emplace("#" + channelName, + MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink({Link::JumpToChannel, channelName}); + builder2.emplace(); + builder2.emplace(); + builder2.message().loginName = action.target.login; + builder2.message().flags.set(MessageFlag::PubSub); + builder2.message().flags.set(MessageFlag::Timeout); + builder2.message().flags.set(MessageFlag::AutoMod); + builder2.message().flags.set(MessageFlag::AutoModOffendingMessage); + + // sender username + builder2.emplace(action.target.displayName + ":", + action.target.login, MessageColor::Text, + action.target.color); + // sender's message caught by AutoMod + builder2.emplace(action.message, MessageElementFlag::Text, + MessageColor::Text); + auto text2 = + QString("%1: %2").arg(action.target.displayName, action.message); + builder2.message().messageText = text2; + builder2.message().searchText = text2; + + auto message2 = builder2.release(); + + // Normally highlights would be checked & triggered during the builder parse steps + // and when the message is added to the channel + // We do this a bit weird since the message comes in from PubSub and not the normal message route + auto [highlighted, highlightResult] = getApp()->getHighlights()->check( + {}, {}, action.target.login, action.message, message2->flags); + if (highlighted) + { + actuallyTriggerHighlights(channelName, highlightResult.playSound, + highlightResult.customSoundUrl, + highlightResult.alert); + } + + return std::make_pair(message1, message2); +} + +MessagePtr MessageBuilder::makeAutomodInfoMessage( + const AutomodInfoAction &action) +{ + auto builder = MessageBuilder(); + QString text("AutoMod: "); + + builder.emplace(); + builder.message().flags.set(MessageFlag::PubSub); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + // AutoMod "username" + builder.emplace("AutoMod:", MessageElementFlag::Text, + AUTOMOD_USER_COLOR, FontStyle::ChatMediumBold); + switch (action.type) + { + case AutomodInfoAction::OnHold: { + QString info("Hey! Your message is being checked " + "by mods and has not been sent."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + case AutomodInfoAction::Denied: { + QString info("Mods have removed your message."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + case AutomodInfoAction::Approved: { + QString info("Mods have accepted your message."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + } + + builder.message().flags.set(MessageFlag::AutoMod); + builder.message().messageText = text; + builder.message().searchText = text; + + auto message = builder.release(); + + return message; +} + +std::pair MessageBuilder::makeLowTrustUserMessage( + const PubSubLowTrustUsersMessage &action, const QString &channelName, + const TwitchChannel *twitchChannel) +{ + MessageBuilder builder, builder2; + + // Builder for low trust user message with explanation + builder.message().channelName = channelName; + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::LowTrustUsers); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + + // Suspicious user header message + QString prefix = "Suspicious User:"; + builder.emplace(prefix, MessageElementFlag::Text, + MessageColor(QColor("blue")), + FontStyle::ChatMediumBold); + + QString headerMessage; + if (action.treatment == PubSubLowTrustUsersMessage::Treatment::Restricted) + { + headerMessage = "Restricted"; + builder2.message().flags.set(MessageFlag::RestrictedMessage); + } + else + { + headerMessage = "Monitored"; + builder2.message().flags.set(MessageFlag::MonitoredMessage); + } + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::ManuallyAdded)) + { + headerMessage += " by " + action.updatedByUserLogin; + } + + headerMessage += " at " + action.updatedAt; + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::DetectedBanEvader)) + { + QString evader; + if (action.evasionEvaluation == + PubSubLowTrustUsersMessage::EvasionEvaluation::LikelyEvader) + { + evader = "likely"; + } + else + { + evader = "possible"; + } + + headerMessage += ". Detected as " + evader + " ban evader"; + } + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::BannedInSharedChannel)) + { + headerMessage += ". Banned in " + + QString::number(action.sharedBanChannelIDs.size()) + + " shared channels"; + } + + builder.emplace(headerMessage, MessageElementFlag::Text, + MessageColor::Text); + builder.message().messageText = prefix + " " + headerMessage; + builder.message().searchText = prefix + " " + headerMessage; + + auto message1 = builder.release(); + + // + // Builder for offender's message + builder2.message().channelName = channelName; + builder2 + .emplace("#" + channelName, + MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink({Link::JumpToChannel, channelName}); + builder2.emplace(); + builder2.emplace(); + builder2.message().loginName = action.suspiciousUserLogin; + builder2.message().flags.set(MessageFlag::PubSub); + builder2.message().flags.set(MessageFlag::LowTrustUsers); + + // sender badges + appendBadges(&builder2, action.senderBadges, {}, twitchChannel); + + // sender username + builder2.emplace( + action.suspiciousUserDisplayName + ":", action.suspiciousUserLogin, + MessageColor::Text, action.suspiciousUserColor); + + // sender's message caught by AutoMod + for (const auto &fragment : action.fragments) + { + if (fragment.emoteID.isEmpty()) + { + builder2.emplace( + fragment.text, MessageElementFlag::Text, MessageColor::Text); + } + else + { + const auto emotePtr = + getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( + EmoteId{fragment.emoteID}, EmoteName{fragment.text}); + builder2.emplace( + emotePtr, MessageElementFlag::TwitchEmote, MessageColor::Text); + } + } + + auto text = + QString("%1: %2").arg(action.suspiciousUserDisplayName, action.text); + builder2.message().messageText = text; + builder2.message().searchText = text; + + auto message2 = builder2.release(); + + return std::make_pair(message1, message2); +} + +MessagePtr MessageBuilder::makeLowTrustUpdateMessage( + const PubSubLowTrustUsersMessage &action) +{ + /** + * Known issues: + * - Non-Twitch badges are not shown + * - Non-Twitch emotes are not shown + */ + + MessageBuilder builder; + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + + builder + .emplace(action.updatedByUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.updatedByUserLogin}); + + assert(action.treatment != PubSubLowTrustUsersMessage::Treatment::INVALID); + switch (action.treatment) + { + case PubSubLowTrustUsersMessage::Treatment::NoTreatment: { + builder.emplace("removed", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("from the suspicious user list.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + case PubSubLowTrustUsersMessage::Treatment::ActiveMonitoring: { + builder.emplace("added", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("as a monitored suspicious chatter.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + case PubSubLowTrustUsersMessage::Treatment::Restricted: { + builder.emplace("added", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("as a restricted suspicious chatter.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + default: + qCDebug(chatterinoTwitch) << "Unexpected suspicious treatment: " + << action.treatmentString; + break; + } + + return builder.release(); +} + +std::unordered_map MessageBuilder::parseBadgeInfoTag( + const QVariantMap &tags) +{ + std::unordered_map infoMap; + + auto infoIt = tags.constFind("badge-info"); + if (infoIt == tags.end()) + { + return infoMap; + } + + auto info = infoIt.value().toString().split(',', Qt::SkipEmptyParts); + + for (const QString &badge : info) + { + infoMap.emplace(slashKeyValue(badge)); + } + + return infoMap; +} + +std::vector MessageBuilder::parseBadgeTag(const QVariantMap &tags) +{ + std::vector b; + + auto badgesIt = tags.constFind("badges"); + if (badgesIt == tags.end()) + { + return b; + } + + auto badges = badgesIt.value().toString().split(',', Qt::SkipEmptyParts); + + for (const QString &badge : badges) + { + if (!badge.contains('/')) + { + continue; + } + + auto pair = slashKeyValue(badge); + b.emplace_back(Badge{pair.first, pair.second}); + } + + return b; +} + +std::vector MessageBuilder::parseTwitchEmotes( + const QVariantMap &tags, const QString &originalMessage, int messageOffset) +{ + // Twitch emotes + std::vector twitchEmotes; + + auto emotesTag = tags.find("emotes"); + + if (emotesTag == tags.end()) + { + return twitchEmotes; + } + + QStringList emoteString = emotesTag.value().toString().split('/'); + std::vector correctPositions; + for (int i = 0; i < originalMessage.size(); ++i) + { + if (!originalMessage.at(i).isLowSurrogate()) + { + correctPositions.push_back(i); + } + } + for (const QString &emote : emoteString) + { + appendTwitchEmoteOccurrences(emote, twitchEmotes, correctPositions, + originalMessage, messageOffset); + } + + return twitchEmotes; +} + +void MessageBuilder::processIgnorePhrases( + const std::vector &phrases, QString &originalMessage, + std::vector &twitchEmotes) +{ + using SizeType = QString::size_type; + + auto removeEmotesInRange = [&twitchEmotes](SizeType pos, SizeType len) { + // all emotes outside the range come before `it` + // all emotes in the range start at `it` + auto it = std::partition( + twitchEmotes.begin(), twitchEmotes.end(), + [pos, len](const auto &item) { + // returns true for emotes outside the range + return !((item.start >= pos) && item.start < (pos + len)); + }); + std::vector emotesInRange(it, + twitchEmotes.end()); + twitchEmotes.erase(it, twitchEmotes.end()); + return emotesInRange; + }; + + auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) { + for (auto &item : twitchEmotes) + { + auto &index = item.start; + if (index >= pos) + { + index += by; + item.end += by; + } + } + }; + + auto addReplEmotes = [&twitchEmotes](const IgnorePhrase &phrase, + const auto &midrepl, + SizeType startIndex) { + if (!phrase.containsEmote()) + { + return; + } + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + auto words = midrepl.tokenize(u' '); +#else + auto words = midrepl.split(' '); +#endif + SizeType pos = 0; + for (const auto &word : words) + { + for (const auto &emote : phrase.getEmotes()) + { + if (word == emote.first.string) + { + if (emote.second == nullptr) + { + qCDebug(chatterinoTwitch) + << "emote null" << emote.first.string; + } + twitchEmotes.push_back(TwitchEmoteOccurrence{ + static_cast(startIndex + pos), + static_cast(startIndex + pos + + emote.first.string.length()), + emote.second, + emote.first, + }); + } + } + pos += word.length() + 1; + } + }; + + auto replaceMessageAt = [&](const IgnorePhrase &phrase, SizeType from, + SizeType length, const QString &replacement) { + auto removedEmotes = removeEmotesInRange(from, length); + originalMessage.replace(from, length, replacement); + auto wordStart = from; + while (wordStart > 0) + { + if (originalMessage[wordStart - 1] == ' ') + { + break; + } + --wordStart; + } + auto wordEnd = from + replacement.length(); + while (wordEnd < originalMessage.length()) + { + if (originalMessage[wordEnd] == ' ') + { + break; + } + ++wordEnd; + } + + shiftIndicesAfter(static_cast(from + length), + static_cast(replacement.length() - length)); + + auto midExtendedRef = + QStringView{originalMessage}.mid(wordStart, wordEnd - wordStart); + + for (auto &emote : removedEmotes) + { + if (emote.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "Invalid emote occurrence" << emote.name.string; + continue; + } + QRegularExpression emoteregex( + "\\b" + emote.name.string + "\\b", + QRegularExpression::UseUnicodePropertiesOption); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + auto match = emoteregex.matchView(midExtendedRef); +#else + auto match = emoteregex.match(midExtendedRef); +#endif + if (match.hasMatch()) + { + emote.start = static_cast(from + match.capturedStart()); + emote.end = static_cast(from + match.capturedEnd()); + twitchEmotes.push_back(std::move(emote)); + } + } + + addReplEmotes(phrase, midExtendedRef, wordStart); + }; + + for (const auto &phrase : phrases) + { + if (phrase.isBlock()) + { + continue; + } + const auto &pattern = phrase.getPattern(); + if (pattern.isEmpty()) + { + continue; + } + if (phrase.isRegex()) + { + const auto ®ex = phrase.getRegex(); + if (!regex.isValid()) + { + continue; + } + + QRegularExpressionMatch match; + size_t iterations = 0; + SizeType from = 0; + while ((from = originalMessage.indexOf(regex, from, &match)) != -1) + { + auto replacement = phrase.getReplace(); + if (regex.captureCount() > 0) + { + replacement = makeRegexReplacement(originalMessage, regex, + match, replacement); + } + + replaceMessageAt(phrase, from, match.capturedLength(), + replacement); + from += phrase.getReplace().length(); + iterations++; + if (iterations >= 128) + { + originalMessage = + u"Too many replacements - check your ignores!"_s; + return; + } + } + + continue; + } + + SizeType from = 0; + while ((from = originalMessage.indexOf(pattern, from, + phrase.caseSensitivity())) != -1) + { + replaceMessageAt(phrase, from, pattern.length(), + phrase.getReplace()); + from += phrase.getReplace().length(); + } + } +} + void MessageBuilder::addTextOrEmoji(EmotePtr emote) { this->emplace(emote, MessageElementFlag::EmojiAll); } -void MessageBuilder::addTextOrEmoji(const QString &string) +void MessageBuilder::addTextOrEmoji(const QString &string_) { + auto string = QString(string_); + + if (this->hasBits_ && this->tryParseCheermote(string)) + { + // This string was parsed as a cheermote + return; + } + + // TODO: Implement ignored emotes + // Format of ignored emotes: + // Emote name: "forsenPuke" - if string in ignoredEmotes + // Will match emote regardless of source (i.e. bttv, ffz) + // Emote source + name: "bttv:nyanPls" + if (this->tryAppendEmote({string})) + { + // Successfully appended an emote + return; + } + // Actually just text auto link = linkparser::parse(string); + auto textColor = this->textColor_; + if (link) { this->addLink(*link, string); return; } - auto &&textColor = this->textColor_; if (string.startsWith('@')) { - this->emplace(string, "", textColor, textColor); + auto match = mentionRegex.match(string); + // Only treat as @mention if valid username + if (match.hasMatch()) + { + QString username = match.captured(1); + auto originalTextColor = textColor; + + if (this->twitchChannel != nullptr) + { + if (auto userColor = + this->twitchChannel->getUserColor(username); + userColor.isValid()) + { + textColor = userColor; + } + } + + auto prefixedUsername = '@' + username; + auto remainder = string.remove(prefixedUsername); + this->emplace(prefixedUsername, username, + originalTextColor, textColor) + ->setTrailingSpace(remainder.isEmpty()); + + if (!remainder.isEmpty()) + { + this->emplace(remainder, MessageElementFlag::Text, + originalTextColor); + } + + return; + } } - else + + if (this->twitchChannel != nullptr && getSettings()->findAllUsernames) { - this->emplace(string, MessageElementFlag::Text, textColor); + auto match = allUsernamesMentionRegex.match(string); + QString username = match.captured(1); + + if (match.hasMatch() && + this->twitchChannel->accessChatters()->contains(username)) + { + auto originalTextColor = textColor; + + if (auto userColor = this->twitchChannel->getUserColor(username); + userColor.isValid()) + { + textColor = userColor; + } + + auto remainder = string.remove(username); + this->emplace(username, username, originalTextColor, + textColor) + ->setTrailingSpace(remainder.isEmpty()); + + if (!remainder.isEmpty()) + { + this->emplace(remainder, MessageElementFlag::Text, + originalTextColor); + } + + return; + } } + + this->emplace(string, MessageElementFlag::Text, textColor); +} + +bool MessageBuilder::isEmpty() const +{ + return this->message_->elements.empty(); +} + +MessageElement &MessageBuilder::back() +{ + assert(!this->isEmpty()); + return *this->message().elements.back(); +} + +std::unique_ptr MessageBuilder::releaseBack() +{ + assert(!this->isEmpty()); + + auto ptr = std::move(this->message().elements.back()); + this->message().elements.pop_back(); + return ptr; } TextElement *MessageBuilder::emplaceSystemTextAndUpdate(const QString &text, @@ -705,4 +2534,661 @@ TextElement *MessageBuilder::emplaceSystemTextAndUpdate(const QString &text, MessageColor::System); } +void MessageBuilder::parse() +{ + this->parseUsernameColor(); + + if (this->action_) + { + this->textColor_ = this->usernameColor_; + this->message().flags.set(MessageFlag::Action); + } + + this->parseUsername(); + + this->message().flags.set(MessageFlag::Collapsed); +} + +void MessageBuilder::parseUsernameColor() +{ + const auto *userData = getApp()->getUserData(); + assert(userData != nullptr); + + if (const auto &user = userData->getUser(this->userId_)) + { + if (user->color) + { + this->usernameColor_ = user->color.value(); + return; + } + } + + const auto iterator = this->tags.find("color"); + if (iterator != this->tags.end()) + { + if (const auto color = iterator.value().toString(); !color.isEmpty()) + { + this->usernameColor_ = QColor(color); + this->message().usernameColor = this->usernameColor_; + return; + } + } + + if (getSettings()->colorizeNicknames && this->tags.contains("user-id")) + { + this->usernameColor_ = + getRandomColor(this->tags.value("user-id").toString()); + this->message().usernameColor = this->usernameColor_; + } +} + +void MessageBuilder::parseUsername() +{ + // username + this->userName = this->ircMessage->nick(); + + this->message().loginName = this->userName; + + if (this->userName.isEmpty() || this->args.trimSubscriberUsername) + { + this->userName = this->tags.value(QLatin1String("login")).toString(); + } + + // display name + // auto displayNameVariant = this->tags.value("display-name"); + // if (displayNameVariant.isValid()) { + // this->userName = displayNameVariant.toString() + " (" + + // this->userName + ")"; + // } + + this->message().loginName = this->userName; + if (this->twitchChannel != nullptr) + { + this->twitchChannel->setUserColor(this->userName, this->usernameColor_); + } + + // Update current user color if this is our message + auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); + if (this->ircMessage->nick() == currentUser->getUserName()) + { + currentUser->setColor(this->usernameColor_); + } +} + +void MessageBuilder::parseMessageID() +{ + auto iterator = this->tags.find("id"); + + if (iterator != this->tags.end()) + { + this->message().id = iterator.value().toString(); + } +} + +void MessageBuilder::parseRoomID() +{ + if (this->twitchChannel == nullptr) + { + return; + } + + auto iterator = this->tags.find("room-id"); + + if (iterator != std::end(this->tags)) + { + this->roomID_ = iterator.value().toString(); + + if (this->twitchChannel->roomId().isEmpty()) + { + this->twitchChannel->setRoomId(this->roomID_); + } + } +} + +void MessageBuilder::parseThread() +{ + if (this->thread_) + { + // set references + this->message().replyThread = this->thread_; + this->message().replyParent = this->parent_; + this->thread_->addToThread(this->weakOf()); + + // enable reply flag + this->message().flags.set(MessageFlag::ReplyMessage); + + MessagePtr threadRoot; + if (!this->parent_) + { + threadRoot = this->thread_->root(); + } + else + { + threadRoot = this->parent_; + } + + QString usernameText = + stylizeUsername(threadRoot->loginName, *threadRoot); + + this->emplace(); + + // construct reply elements + this->emplace( + "Replying to", MessageElementFlag::RepliedMessage, + MessageColor::System, FontStyle::ChatMediumSmall) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + + this->emplace( + "@" + usernameText + + (threadRoot->flags.has(MessageFlag::Action) ? "" : ":"), + MessageElementFlag::RepliedMessage, threadRoot->usernameColor, + FontStyle::ChatMediumSmall) + ->setLink({Link::UserInfo, threadRoot->displayName}); + + MessageColor color = MessageColor::Text; + if (threadRoot->flags.has(MessageFlag::Action)) + { + color = threadRoot->usernameColor; + } + this->emplace( + threadRoot->messageText, + MessageElementFlags({MessageElementFlag::RepliedMessage, + MessageElementFlag::Text}), + color, FontStyle::ChatMediumSmall) + ->setLink({Link::ViewThread, this->thread_->rootId()}); + } + else if (this->tags.find("reply-parent-msg-id") != this->tags.end()) + { + // Message is a reply but we couldn't find the original message. + // Render the message using the additional reply tags + + auto replyDisplayName = this->tags.find("reply-parent-display-name"); + auto replyBody = this->tags.find("reply-parent-msg-body"); + + if (replyDisplayName != this->tags.end() && + replyBody != this->tags.end()) + { + QString body; + + this->emplace(); + this->emplace( + "Replying to", MessageElementFlag::RepliedMessage, + MessageColor::System, FontStyle::ChatMediumSmall); + + if (this->isIgnoredReply()) + { + body = QString("[Blocked user]"); + } + else + { + auto name = replyDisplayName->toString(); + body = parseTagString(replyBody->toString()); + + this->emplace( + "@" + name + ":", MessageElementFlag::RepliedMessage, + this->textColor_, FontStyle::ChatMediumSmall) + ->setLink({Link::UserInfo, name}); + } + + this->emplace( + body, + MessageElementFlags({MessageElementFlag::RepliedMessage, + MessageElementFlag::Text}), + this->textColor_, FontStyle::ChatMediumSmall); + } + } +} + +void MessageBuilder::parseHighlights() +{ + if (getSettings()->isBlacklistedUser(this->message().loginName)) + { + // Do nothing. We ignore highlights from this user. + return; + } + + auto badges = parseBadgeTag(this->tags); + auto [highlighted, highlightResult] = getApp()->getHighlights()->check( + this->args, badges, this->message().loginName, this->originalMessage_, + this->message().flags); + + if (!highlighted) + { + return; + } + + // This message triggered one or more highlights, act upon the highlight result + + this->message().flags.set(MessageFlag::Highlighted); + + this->highlightAlert_ = highlightResult.alert; + + this->highlightSound_ = highlightResult.playSound; + this->highlightSoundCustomUrl_ = highlightResult.customSoundUrl; + + this->message().highlightColor = highlightResult.color; + + if (highlightResult.showInMentions) + { + this->message().flags.set(MessageFlag::ShowInMentions); + } +} + +void MessageBuilder::appendChannelName() +{ + QString channelName("#" + this->channel->getName()); + Link link(Link::JumpToChannel, this->channel->getName()); + + this->emplace(channelName, MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink(link); +} + +void MessageBuilder::appendUsername() +{ + auto *app = getApp(); + + QString username = this->userName; + this->message().loginName = username; + QString localizedName; + + auto iterator = this->tags.find("display-name"); + if (iterator != this->tags.end()) + { + QString displayName = + parseTagString(iterator.value().toString()).trimmed(); + + if (QString::compare(displayName, this->userName, + Qt::CaseInsensitive) == 0) + { + username = displayName; + + this->message().displayName = displayName; + } + else + { + localizedName = displayName; + + this->message().displayName = username; + this->message().localizedName = displayName; + } + } + + QString usernameText = stylizeUsername(username, this->message()); + + if (this->args.isSentWhisper) + { + // TODO(pajlada): Re-implement + // userDisplayString += + // IrcManager::instance().getUser().getUserName(); + } + else if (this->args.isReceivedWhisper) + { + // Sender username + this->emplace(usernameText, MessageElementFlag::Username, + this->usernameColor_, + FontStyle::ChatMediumBold) + ->setLink({Link::UserWhisper, this->message().displayName}); + + auto currentUser = app->getAccounts()->twitch.getCurrent(); + + // Separator + this->emplace("->", MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMedium); + + QColor selfColor = currentUser->color(); + MessageColor selfMsgColor = + selfColor.isValid() ? selfColor : MessageColor::System; + + // Your own username + this->emplace(currentUser->getUserName() + ":", + MessageElementFlag::Username, selfMsgColor, + FontStyle::ChatMediumBold); + } + else + { + if (!this->action_) + { + usernameText += ":"; + } + + this->emplace(usernameText, MessageElementFlag::Username, + this->usernameColor_, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, this->message().displayName}); + } +} + +Outcome MessageBuilder::tryAppendEmote(const EmoteName &name) +{ + auto *app = getApp(); + + const auto *globalBttvEmotes = app->getBttvEmotes(); + const auto *globalFfzEmotes = app->getFfzEmotes(); + const auto *globalSeventvEmotes = app->getSeventvEmotes(); + + auto flags = MessageElementFlags(); + auto emote = std::optional{}; + bool zeroWidth = false; + + // Emote order: + // - FrankerFaceZ Channel + // - BetterTTV Channel + // - 7TV Channel + // - FrankerFaceZ Global + // - BetterTTV Global + // - 7TV Global + if (this->twitchChannel && (emote = this->twitchChannel->ffzEmote(name))) + { + flags = MessageElementFlag::FfzEmote; + } + else if (this->twitchChannel && + (emote = this->twitchChannel->bttvEmote(name))) + { + flags = MessageElementFlag::BttvEmote; + } + else if (this->twitchChannel != nullptr && + (emote = this->twitchChannel->seventvEmote(name))) + { + flags = MessageElementFlag::SevenTVEmote; + zeroWidth = emote.value()->zeroWidth; + } + else if ((emote = globalFfzEmotes->emote(name))) + { + flags = MessageElementFlag::FfzEmote; + } + else if ((emote = globalBttvEmotes->emote(name))) + { + flags = MessageElementFlag::BttvEmote; + zeroWidth = zeroWidthEmotes.contains(name.string); + } + else if ((emote = globalSeventvEmotes->globalEmote(name))) + { + flags = MessageElementFlag::SevenTVEmote; + zeroWidth = emote.value()->zeroWidth; + } + + if (emote) + { + if (zeroWidth && getSettings()->enableZeroWidthEmotes && + !this->isEmpty()) + { + // Attempt to merge current zero-width emote into any previous emotes + auto *asEmote = dynamic_cast(&this->back()); + if (asEmote) + { + // Make sure to access asEmote before taking ownership when releasing + auto baseEmote = asEmote->getEmote(); + // Need to remove EmoteElement and replace with LayeredEmoteElement + auto baseEmoteElement = this->releaseBack(); + + std::vector layers = { + {baseEmote, baseEmoteElement->getFlags()}, {*emote, flags}}; + this->emplace( + std::move(layers), baseEmoteElement->getFlags() | flags, + this->textColor_); + return Success; + } + + auto *asLayered = + dynamic_cast(&this->back()); + if (asLayered) + { + asLayered->addEmoteLayer({*emote, flags}); + asLayered->addFlags(flags); + return Success; + } + + // No emote to merge with, just show as regular emote + } + + this->emplace(*emote, flags, this->textColor_); + return Success; + } + + return Failure; +} + +void MessageBuilder::addWords( + const QStringList &words, + const std::vector &twitchEmotes) +{ + // cursor currently indicates what character index we're currently operating in the full list of words + int cursor = 0; + auto currentTwitchEmoteIt = twitchEmotes.begin(); + + for (auto word : words) + { + if (word.isEmpty()) + { + cursor++; + continue; + } + + while (doesWordContainATwitchEmote(cursor, word, twitchEmotes, + currentTwitchEmoteIt)) + { + const auto ¤tTwitchEmote = *currentTwitchEmoteIt; + + if (currentTwitchEmote.start == cursor) + { + // This emote exists right at the start of the word! + this->emplace(currentTwitchEmote.ptr, + MessageElementFlag::TwitchEmote, + this->textColor_); + + auto len = currentTwitchEmote.name.string.length(); + cursor += len; + word = word.mid(len); + + ++currentTwitchEmoteIt; + + if (word.isEmpty()) + { + // space + cursor += 1; + break; + } + else + { + this->message().elements.back()->setTrailingSpace(false); + } + + continue; + } + + // Emote is not at the start + + // 1. Add text before the emote + QString preText = word.left(currentTwitchEmote.start - cursor); + for (auto &variant : + getApp()->getEmotes()->getEmojis()->parse(preText)) + { + boost::apply_visitor( + [&](auto &&arg) { + this->addTextOrEmoji(arg); + }, + variant); + } + + cursor += preText.size(); + + word = word.mid(preText.size()); + } + + if (word.isEmpty()) + { + continue; + } + + // split words + for (auto &variant : getApp()->getEmotes()->getEmojis()->parse(word)) + { + boost::apply_visitor( + [&](auto &&arg) { + this->addTextOrEmoji(arg); + }, + variant); + } + + cursor += word.size() + 1; + } +} + +void MessageBuilder::appendTwitchBadges() +{ + if (this->twitchChannel == nullptr) + { + return; + } + + auto badgeInfos = MessageBuilder::parseBadgeInfoTag(this->tags); + auto badges = parseBadgeTag(this->tags); + appendBadges(this, badges, badgeInfos, this->twitchChannel); +} + +void MessageBuilder::appendChatterinoBadges() +{ + if (auto badge = getApp()->getChatterinoBadges()->getBadge({this->userId_})) + { + this->emplace(*badge, + MessageElementFlag::BadgeChatterino); + } +} + +void MessageBuilder::appendFfzBadges() +{ + for (const auto &badge : + getApp()->getFfzBadges()->getUserBadges({this->userId_})) + { + this->emplace( + badge.emote, MessageElementFlag::BadgeFfz, badge.color); + } + + if (this->twitchChannel == nullptr) + { + return; + } + + for (const auto &badge : + this->twitchChannel->ffzChannelBadges(this->userId_)) + { + this->emplace( + badge.emote, MessageElementFlag::BadgeFfz, badge.color); + } +} + +void MessageBuilder::appendSeventvBadges() +{ + if (auto badge = getApp()->getSeventvBadges()->getBadge({this->userId_})) + { + this->emplace(*badge, MessageElementFlag::BadgeSevenTV); + } +} + +Outcome MessageBuilder::tryParseCheermote(const QString &string) +{ + if (this->bitsLeft == 0) + { + return Failure; + } + + auto cheerOpt = this->twitchChannel->cheerEmote(string); + + if (!cheerOpt) + { + return Failure; + } + + auto &cheerEmote = *cheerOpt; + auto match = cheerEmote.regex.match(string); + + if (!match.hasMatch()) + { + return Failure; + } + + int cheerValue = match.captured(1).toInt(); + + if (getSettings()->stackBits) + { + if (this->bitsStacked) + { + return Success; + } + if (cheerEmote.staticEmote) + { + this->emplace(cheerEmote.staticEmote, + MessageElementFlag::BitsStatic, + this->textColor_); + } + if (cheerEmote.animatedEmote) + { + this->emplace(cheerEmote.animatedEmote, + MessageElementFlag::BitsAnimated, + this->textColor_); + } + if (cheerEmote.color != QColor()) + { + this->emplace(QString::number(this->bitsLeft), + MessageElementFlag::BitsAmount, + cheerEmote.color); + } + this->bitsStacked = true; + return Success; + } + + if (this->bitsLeft >= cheerValue) + { + this->bitsLeft -= cheerValue; + } + else + { + QString newString = string; + newString.chop(QString::number(cheerValue).length()); + newString += QString::number(cheerValue - this->bitsLeft); + + return tryParseCheermote(newString); + } + + if (cheerEmote.staticEmote) + { + this->emplace(cheerEmote.staticEmote, + MessageElementFlag::BitsStatic, + this->textColor_); + } + if (cheerEmote.animatedEmote) + { + this->emplace(cheerEmote.animatedEmote, + MessageElementFlag::BitsAnimated, + this->textColor_); + } + if (cheerEmote.color != QColor()) + { + this->emplace(match.captured(1), + MessageElementFlag::BitsAmount, + cheerEmote.color); + } + + return Success; +} + +bool MessageBuilder::shouldAddModerationElements() const +{ + if (this->senderIsBroadcaster) + { + // You cannot timeout the broadcaster + return false; + } + + if (this->tags.value("user-type").toString() == "mod" && + !this->args.isStaffOrBroadcaster) + { + // You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel + return false; + } + + return true; +} + } // namespace chatterino diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index e9844467d..325abafbd 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -1,15 +1,25 @@ #pragma once +#include "common/Aliases.hpp" +#include "common/Outcome.hpp" #include "messages/MessageColor.hpp" +#include "messages/MessageFlag.hpp" +#include "providers/twitch/pubsubmessages/LowTrustUsers.hpp" +#include #include +#include #include +#include #include #include +#include +#include #include namespace chatterino { + struct BanAction; struct UnbanAction; struct WarnAction; @@ -24,6 +34,15 @@ class TextElement; struct Emote; using EmotePtr = std::shared_ptr; +class Channel; +class TwitchChannel; +class MessageThread; +class IgnorePhrase; +struct HelixVip; +using HelixModerator = HelixVip; +struct ChannelPointReward; +struct DeleteAction; + namespace linkparser { struct Parsed; } // namespace linkparser @@ -67,10 +86,36 @@ struct MessageParseArgs { QString channelPointRewardId = ""; }; +struct TwitchEmoteOccurrence { + int start; + int end; + EmotePtr ptr; + EmoteName name; + + bool operator==(const TwitchEmoteOccurrence &other) const + { + return std::tie(this->start, this->end, this->ptr, this->name) == + std::tie(other.start, other.end, other.ptr, other.name); + } +}; + class MessageBuilder { public: + /// Build a message without a base IRC message. MessageBuilder(); + + /// Build a message based on an incoming IRC PRIVMSG + explicit MessageBuilder(Channel *_channel, + const Communi::IrcPrivateMessage *_ircMessage, + const MessageParseArgs &_args); + + /// Build a message based on an incoming IRC message (e.g. notice) + explicit MessageBuilder(Channel *_channel, + const Communi::IrcMessage *_ircMessage, + const MessageParseArgs &_args, QString content, + bool isAction); + MessageBuilder(SystemMessageTag, const QString &text, const QTime &time = QTime::currentTime()); MessageBuilder(TimeoutMessageTag, const QString &timeoutUser, @@ -106,7 +151,16 @@ public: const QString &deletionLink, size_t imagesStillQueued = 0, size_t secondsLeft = 0); - virtual ~MessageBuilder() = default; + MessageBuilder(const MessageBuilder &) = delete; + MessageBuilder(MessageBuilder &&) = delete; + MessageBuilder &operator=(const MessageBuilder &) = delete; + MessageBuilder &operator=(MessageBuilder &&) = delete; + + ~MessageBuilder() = default; + + QString userName; + + TwitchChannel *twitchChannel = nullptr; Message *operator->(); Message &message(); @@ -117,10 +171,7 @@ public: void addLink(const linkparser::Parsed &parsedLink, const QString &source); template - // clang-format off - // clang-format can be enabled once clang-format v11+ has been installed in CI T *emplace(Args &&...args) - // clang-format on { static_assert(std::is_base_of::value, "T must extend MessageElement"); @@ -131,9 +182,70 @@ public: return pointer; } + [[nodiscard]] bool isIgnored() const; + bool isIgnoredReply() const; + void triggerHighlights(); + MessagePtr build(); + + void setThread(std::shared_ptr thread); + void setParent(MessagePtr parent); + void setMessageOffset(int offset); + + void appendChannelPointRewardMessage(const ChannelPointReward &reward, + bool isMod, bool isBroadcaster); + + static MessagePtr makeChannelPointRewardMessage( + const ChannelPointReward &reward, bool isMod, bool isBroadcaster); + + /// Make a "CHANNEL_NAME has gone live!" message + static MessagePtr makeLiveMessage(const QString &channelName, + const QString &channelID, + MessageFlags extraFlags = {}); + + // Messages in normal chat for channel stuff + static MessagePtr makeOfflineSystemMessage(const QString &channelName, + const QString &channelID); + static MessagePtr makeHostingSystemMessage(const QString &channelName, + bool hostOn); + static MessagePtr makeDeletionMessageFromIRC( + const MessagePtr &originalMessage); + static MessagePtr makeDeletionMessageFromPubSub(const DeleteAction &action); + static MessagePtr makeListOfUsersMessage(QString prefix, QStringList users, + Channel *channel, + MessageFlags extraFlags = {}); + static MessagePtr makeListOfUsersMessage( + QString prefix, const std::vector &users, + Channel *channel, MessageFlags extraFlags = {}); + + static MessagePtr buildHypeChatMessage(Communi::IrcPrivateMessage *message); + + static std::pair makeAutomodMessage( + const AutomodAction &action, const QString &channelName); + static MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action); + + static std::pair makeLowTrustUserMessage( + const PubSubLowTrustUsersMessage &action, const QString &channelName, + const TwitchChannel *twitchChannel); + static MessagePtr makeLowTrustUpdateMessage( + const PubSubLowTrustUsersMessage &action); + + static std::unordered_map parseBadgeInfoTag( + const QVariantMap &tags); + + // Parses "badges" tag which contains a comma separated list of key-value elements + static std::vector parseBadgeTag(const QVariantMap &tags); + + static std::vector parseTwitchEmotes( + const QVariantMap &tags, const QString &originalMessage, + int messageOffset); + + static void processIgnorePhrases( + const std::vector &phrases, QString &originalMessage, + std::vector &twitchEmotes); + protected: - virtual void addTextOrEmoji(EmotePtr emote); - virtual void addTextOrEmoji(const QString &value); + void addTextOrEmoji(EmotePtr emote); + void addTextOrEmoji(const QString &string_); bool isEmpty() const; MessageElement &back(); @@ -141,7 +253,6 @@ protected: MessageColor textColor_ = MessageColor::Text; -private: // Helper method that emplaces some text stylized as system text // and then appends that text to the QString parameter "toUpdate". // Returns the TextElement that was emplaced. @@ -149,6 +260,70 @@ private: QString &toUpdate); std::shared_ptr message_; + + void parse(); + void parseUsernameColor(); + void parseUsername(); + void parseMessageID(); + void parseRoomID(); + // Parse & build thread information into the message + // Will read information from thread_ or from IRC tags + void parseThread(); + // parseHighlights only updates the visual state of the message, but leaves the playing of alerts and sounds to the triggerHighlights function + void parseHighlights(); + void appendChannelName(); + void appendUsername(); + + Outcome tryAppendEmote(const EmoteName &name); + + void addWords(const QStringList &words, + const std::vector &twitchEmotes); + + void appendTwitchBadges(); + void appendChatterinoBadges(); + void appendFfzBadges(); + void appendSeventvBadges(); + Outcome tryParseCheermote(const QString &string); + + bool shouldAddModerationElements() const; + + QString roomID_; + bool hasBits_ = false; + QString bits; + int bitsLeft{}; + bool bitsStacked = false; + bool historicalMessage_ = false; + std::shared_ptr thread_; + MessagePtr parent_; + + /** + * Starting offset to be used on index-based operations on `originalMessage_`. + * + * For example: + * originalMessage_ = "there" + * messageOffset_ = 4 + * (the irc message is "hey there") + * + * then the index 6 would resolve to 6 - 4 = 2 => 'e' + */ + int messageOffset_ = 0; + + QString userId_; + bool senderIsBroadcaster{}; + + Channel *channel = nullptr; + const Communi::IrcMessage *ircMessage; + MessageParseArgs args; + const QVariantMap tags; + QString originalMessage_; + + const bool action_{}; + + QColor usernameColor_ = {153, 153, 153}; + + bool highlightAlert_ = false; + bool highlightSound_ = false; + std::optional highlightSoundCustomUrl_{}; }; } // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp deleted file mode 100644 index 69a216502..000000000 --- a/src/messages/SharedMessageBuilder.cpp +++ /dev/null @@ -1,294 +0,0 @@ -#include "messages/SharedMessageBuilder.hpp" - -#include "Application.hpp" -#include "common/QLogging.hpp" -#include "controllers/highlights/HighlightController.hpp" -#include "controllers/ignores/IgnoreController.hpp" -#include "controllers/ignores/IgnorePhrase.hpp" -#include "controllers/nicknames/Nickname.hpp" -#include "controllers/sound/ISoundController.hpp" -#include "messages/Message.hpp" -#include "messages/MessageElement.hpp" -#include "providers/twitch/TwitchBadge.hpp" -#include "singletons/Settings.hpp" -#include "singletons/StreamerMode.hpp" -#include "singletons/WindowManager.hpp" -#include "util/Helpers.hpp" - -#include - -#include - -namespace { - -using namespace chatterino; - -/** - * Gets the default sound url if the user set one, - * or the chatterino default ping sound if no url is set. - */ -QUrl getFallbackHighlightSound() -{ - QString path = getSettings()->pathHighlightSound; - bool fileExists = - !path.isEmpty() && QFileInfo::exists(path) && QFileInfo(path).isFile(); - - if (fileExists) - { - return QUrl::fromLocalFile(path); - } - - return QUrl("qrc:/sounds/ping2.wav"); -} - -} // namespace - -namespace chatterino { - -SharedMessageBuilder::SharedMessageBuilder( - Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, - const MessageParseArgs &_args) - : channel(_channel) - , ircMessage(_ircMessage) - , args(_args) - , tags(this->ircMessage->tags()) - , originalMessage_(_ircMessage->content()) - , action_(_ircMessage->isAction()) -{ -} - -SharedMessageBuilder::SharedMessageBuilder( - Channel *_channel, const Communi::IrcMessage *_ircMessage, - const MessageParseArgs &_args, QString content, bool isAction) - : channel(_channel) - , ircMessage(_ircMessage) - , args(_args) - , tags(this->ircMessage->tags()) - , originalMessage_(content) - , action_(isAction) -{ -} - -void SharedMessageBuilder::parse() -{ - this->parseUsernameColor(); - - if (this->action_) - { - this->textColor_ = this->usernameColor_; - this->message().flags.set(MessageFlag::Action); - } - - this->parseUsername(); - - this->message().flags.set(MessageFlag::Collapsed); -} - -// "foo/bar/baz,tri/hard" can be a valid badge-info tag -// In that case, valid map content should be 'split by slash' only once: -// {"foo": "bar/baz", "tri": "hard"} -std::pair SharedMessageBuilder::slashKeyValue( - const QString &kvStr) -{ - return { - // part before first slash (index 0 of section) - kvStr.section('/', 0, 0), - // part after first slash (index 1 of section) - kvStr.section('/', 1, -1), - }; -} - -std::vector SharedMessageBuilder::parseBadgeTag(const QVariantMap &tags) -{ - std::vector b; - - auto badgesIt = tags.constFind("badges"); - if (badgesIt == tags.end()) - { - return b; - } - - auto badges = badgesIt.value().toString().split(',', Qt::SkipEmptyParts); - - for (const QString &badge : badges) - { - if (!badge.contains('/')) - { - continue; - } - - auto pair = SharedMessageBuilder::slashKeyValue(badge); - b.emplace_back(Badge{pair.first, pair.second}); - } - - return b; -} - -bool SharedMessageBuilder::isIgnored() const -{ - return isIgnoredMessage({ - /*.message = */ this->originalMessage_, - }); -} - -void SharedMessageBuilder::parseUsernameColor() -{ - if (getSettings()->colorizeNicknames) - { - this->usernameColor_ = getRandomColor(this->ircMessage->nick()); - } -} - -void SharedMessageBuilder::parseUsername() -{ - // username - this->userName = this->ircMessage->nick(); - - this->message().loginName = this->userName; -} - -void SharedMessageBuilder::parseHighlights() -{ - if (getSettings()->isBlacklistedUser(this->message().loginName)) - { - // Do nothing. We ignore highlights from this user. - return; - } - - auto badges = SharedMessageBuilder::parseBadgeTag(this->tags); - auto [highlighted, highlightResult] = getApp()->getHighlights()->check( - this->args, badges, this->message().loginName, this->originalMessage_, - this->message().flags); - - if (!highlighted) - { - return; - } - - // This message triggered one or more highlights, act upon the highlight result - - this->message().flags.set(MessageFlag::Highlighted); - - this->highlightAlert_ = highlightResult.alert; - - this->highlightSound_ = highlightResult.playSound; - this->highlightSoundCustomUrl_ = highlightResult.customSoundUrl; - - this->message().highlightColor = highlightResult.color; - - if (highlightResult.showInMentions) - { - this->message().flags.set(MessageFlag::ShowInMentions); - } -} - -void SharedMessageBuilder::appendChannelName() -{ - QString channelName("#" + this->channel->getName()); - Link link(Link::JumpToChannel, this->channel->getName()); - - this->emplace(channelName, MessageElementFlag::ChannelName, - MessageColor::System) - ->setLink(link); -} - -void SharedMessageBuilder::triggerHighlights() -{ - SharedMessageBuilder::triggerHighlights( - this->channel->getName(), this->highlightSound_, - this->highlightSoundCustomUrl_, this->highlightAlert_); -} - -void SharedMessageBuilder::triggerHighlights( - const QString &channelName, bool playSound, - const std::optional &customSoundUrl, bool windowAlert) -{ - if (getApp()->getStreamerMode()->isEnabled() && - getSettings()->streamerModeMuteMentions) - { - // We are in streamer mode with muting mention sounds enabled. Do nothing. - return; - } - - if (getSettings()->isMutedChannel(channelName)) - { - // Do nothing. Pings are muted in this channel. - return; - } - - const bool hasFocus = (QApplication::focusWidget() != nullptr); - const bool resolveFocus = - !hasFocus || getSettings()->highlightAlwaysPlaySound; - - if (playSound && resolveFocus) - { - // TODO(C++23): optional or_else - QUrl soundUrl; - if (customSoundUrl) - { - soundUrl = *customSoundUrl; - } - else - { - soundUrl = getFallbackHighlightSound(); - } - getApp()->getSound()->play(soundUrl); - } - - if (windowAlert) - { - getApp()->getWindows()->sendAlert(); - } -} - -QString SharedMessageBuilder::stylizeUsername(const QString &username, - const Message &message) -{ - const QString &localizedName = message.localizedName; - bool hasLocalizedName = !localizedName.isEmpty(); - - // The full string that will be rendered in the chat widget - QString usernameText; - - switch (getSettings()->usernameDisplayMode.getValue()) - { - case UsernameDisplayMode::Username: { - usernameText = username; - } - break; - - case UsernameDisplayMode::LocalizedName: { - if (hasLocalizedName) - { - usernameText = localizedName; - } - else - { - usernameText = username; - } - } - break; - - default: - case UsernameDisplayMode::UsernameAndLocalizedName: { - if (hasLocalizedName) - { - usernameText = username + "(" + localizedName + ")"; - } - else - { - usernameText = username; - } - } - break; - } - - if (auto nicknameText = getSettings()->matchNickname(usernameText)) - { - usernameText = *nicknameText; - } - - return usernameText; -} - -} // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.hpp b/src/messages/SharedMessageBuilder.hpp deleted file mode 100644 index a8a2b7df4..000000000 --- a/src/messages/SharedMessageBuilder.hpp +++ /dev/null @@ -1,84 +0,0 @@ -#pragma once - -#include "common/Aliases.hpp" -#include "common/Outcome.hpp" -#include "messages/MessageBuilder.hpp" - -#include -#include -#include - -#include - -namespace chatterino { - -class Badge; -class Channel; - -class SharedMessageBuilder : public MessageBuilder -{ -public: - SharedMessageBuilder() = delete; - - explicit SharedMessageBuilder(Channel *_channel, - const Communi::IrcPrivateMessage *_ircMessage, - const MessageParseArgs &_args); - - explicit SharedMessageBuilder(Channel *_channel, - const Communi::IrcMessage *_ircMessage, - const MessageParseArgs &_args, - QString content, bool isAction); - - QString userName; - - [[nodiscard]] virtual bool isIgnored() const; - - // triggerHighlights triggers any alerts or sounds parsed by parseHighlights - virtual void triggerHighlights(); - virtual MessagePtr build() = 0; - - static std::pair slashKeyValue(const QString &kvStr); - - // Parses "badges" tag which contains a comma separated list of key-value elements - static std::vector parseBadgeTag(const QVariantMap &tags); - - static QString stylizeUsername(const QString &username, - const Message &message); - -protected: - virtual void parse(); - - virtual void parseUsernameColor(); - - virtual void parseUsername(); - - virtual Outcome tryAppendEmote(const EmoteName &name) - { - (void)name; - return Failure; - } - - // parseHighlights only updates the visual state of the message, but leaves the playing of alerts and sounds to the triggerHighlights function - virtual void parseHighlights(); - static void triggerHighlights(const QString &channelName, bool playSound, - const std::optional &customSoundUrl, - bool windowAlert); - - void appendChannelName(); - - Channel *channel; - const Communi::IrcMessage *ircMessage; - MessageParseArgs args; - const QVariantMap tags; - QString originalMessage_; - - const bool action_{}; - - QColor usernameColor_ = {153, 153, 153}; - - bool highlightAlert_ = false; - bool highlightSound_ = false; - std::optional highlightSoundCustomUrl_{}; -}; - -} // namespace chatterino diff --git a/src/providers/recentmessages/Api.cpp b/src/providers/recentmessages/Api.cpp index e1b0945b6..855e31f47 100644 --- a/src/providers/recentmessages/Api.cpp +++ b/src/providers/recentmessages/Api.cpp @@ -4,7 +4,6 @@ #include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "providers/recentmessages/Impl.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "util/PostToThread.hpp" namespace { diff --git a/src/providers/recentmessages/Impl.cpp b/src/providers/recentmessages/Impl.cpp index 2f784ef35..6971b5547 100644 --- a/src/providers/recentmessages/Impl.cpp +++ b/src/providers/recentmessages/Impl.cpp @@ -2,9 +2,9 @@ #include "common/Env.hpp" #include "common/QLogging.hpp" +#include "messages/MessageBuilder.hpp" #include "providers/twitch/IrcMessageHandler.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "util/FormatTime.hpp" #include diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index d0b284bb2..81a0cc7d1 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -20,7 +20,6 @@ #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchHelpers.hpp" #include "providers/twitch/TwitchIrcServer.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" #include "singletons/StreamerMode.hpp" @@ -126,7 +125,7 @@ int stripLeadingReplyMention(const QVariantMap &tags, QString &content) void updateReplyParticipatedStatus(const QVariantMap &tags, const QString &senderLogin, - TwitchMessageBuilder &builder, + MessageBuilder &builder, std::shared_ptr &thread, bool isNew) { @@ -245,7 +244,7 @@ QMap parseBadges(const QString &badgesString) void populateReply(TwitchChannel *channel, Communi::IrcMessage *message, const std::vector &otherLoaded, - TwitchMessageBuilder &builder) + MessageBuilder &builder) { const auto &tags = message->tags(); if (const auto it = tags.find("reply-thread-parent-msg-id"); @@ -481,8 +480,7 @@ std::vector parseUserNoticeMessage(Channel *channel, MessageParseArgs args; args.trimSubscriberUsername = true; - TwitchMessageBuilder builder(channel, message, args, content, - false); + MessageBuilder builder(channel, message, args, content, false); builder->flags.set(MessageFlag::Subscription); builder->flags.unset(MessageFlag::Highlighted); builtMessages.emplace_back(builder.build()); @@ -566,8 +564,8 @@ std::vector parsePrivMessage(Channel *channel, std::vector builtMessages; MessageParseArgs args; - TwitchMessageBuilder builder(channel, message, args, message->content(), - message->isAction()); + MessageBuilder builder(channel, message, args, message->content(), + message->isAction()); if (!builder.isIgnored()) { builtMessages.emplace_back(builder.build()); @@ -576,7 +574,7 @@ std::vector parsePrivMessage(Channel *channel, if (message->tags().contains(u"pinned-chat-paid-amount"_s)) { - auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + auto ptr = MessageBuilder::buildHypeChatMessage(message); if (ptr) { builtMessages.emplace_back(std::move(ptr)); @@ -618,8 +616,8 @@ std::vector IrcMessageHandler::parseMessageWithReply( QString content = privMsg->content(); int messageOffset = stripLeadingReplyMention(privMsg->tags(), content); MessageParseArgs args; - TwitchMessageBuilder builder(channel, message, args, content, - privMsg->isAction()); + MessageBuilder builder(channel, message, args, content, + privMsg->isAction()); builder.setMessageOffset(messageOffset); populateReply(tc, message, otherLoaded, builder); @@ -716,7 +714,7 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, if (message->tags().contains(u"pinned-chat-paid-amount"_s)) { - auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + auto ptr = MessageBuilder::buildHypeChatMessage(message); if (ptr) { chan->addMessage(ptr, MessageContext::Original); @@ -865,9 +863,8 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message) msg->flags.set(MessageFlag::Disabled); if (!getSettings()->hideDeletionActions) { - MessageBuilder builder; - TwitchMessageBuilder::deletionMessage(msg, &builder); - chan->addMessage(builder.release(), MessageContext::Original); + chan->addMessage(MessageBuilder::makeDeletionMessageFromIRC(msg), + MessageContext::Original); } } @@ -947,7 +944,7 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) auto *c = getApp()->getTwitch()->getWhispersChannel().get(); - TwitchMessageBuilder builder( + MessageBuilder builder( c, ircMessage, args, ircMessage->parameter(1).replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), false); @@ -1163,10 +1160,9 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) { hostedChannelName.chop(1); } - MessageBuilder builder; - TwitchMessageBuilder::hostingSystemMessage(hostedChannelName, - &builder, hostOn); - channel->addMessage(builder.release(), MessageContext::Original); + channel->addMessage(MessageBuilder::makeHostingSystemMessage( + hostedChannelName, hostOn), + MessageContext::Original); } else if (tags == "room_mods" || tags == "vips_success") { @@ -1193,9 +1189,9 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) .mid(1) // there is a space before the first user .split(", "); users.sort(Qt::CaseInsensitive); - TwitchMessageBuilder::listOfUsersSystemMessage(msgParts.at(0), - users, tc, &builder); - channel->addMessage(builder.release(), MessageContext::Original); + channel->addMessage(MessageBuilder::makeListOfUsersMessage( + msgParts.at(0), users, tc), + MessageContext::Original); } else { @@ -1367,7 +1363,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message, QString content = originalContent; int messageOffset = stripLeadingReplyMention(tags, content); - TwitchMessageBuilder builder(channel, message, args, content, isAction); + MessageBuilder builder(channel, message, args, content, isAction); builder.setMessageOffset(messageOffset); if (const auto it = tags.find("reply-thread-parent-msg-id"); diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 13ee42335..c9c105176 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -12,6 +12,7 @@ #include "messages/Image.hpp" #include "messages/Link.hpp" #include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" #include "messages/MessageThread.hpp" #include "providers/bttv/BttvEmotes.hpp" @@ -31,7 +32,6 @@ #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" #include "singletons/StreamerMode.hpp" @@ -311,10 +311,9 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) if (!reward.isUserInputRequired) { - MessageBuilder builder; - TwitchMessageBuilder::appendChannelPointRewardMessage( - reward, &builder, this->isMod(), this->isBroadcaster()); - this->addMessage(builder.release(), MessageContext::Original); + this->addMessage(MessageBuilder::makeChannelPointRewardMessage( + reward, this->isMod(), this->isBroadcaster()), + MessageContext::Original); return; } @@ -434,11 +433,11 @@ void TwitchChannel::onLiveStatusChanged(bool isLive, bool isInitialUpdate) }); // Channel live message - MessageBuilder builder; - TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), - &builder); - builder.message().id = this->roomId(); - this->addMessage(builder.release(), MessageContext::Original); + this->addMessage( + MessageBuilder::makeLiveMessage( + this->getDisplayName(), this->roomId(), + {MessageFlag::System, MessageFlag::DoNotTriggerNotification}), + MessageContext::Original); } else { @@ -446,10 +445,9 @@ void TwitchChannel::onLiveStatusChanged(bool isLive, bool isInitialUpdate) << "[TwitchChannel " << this->getName() << "] Offline"; // Channel offline message - MessageBuilder builder; - TwitchMessageBuilder::offlineSystemMessage(this->getDisplayName(), - &builder); - this->addMessage(builder.release(), MessageContext::Original); + this->addMessage(MessageBuilder::makeOfflineSystemMessage( + this->getDisplayName(), this->roomId()), + MessageContext::Original); getApp()->getNotifications()->notifyTwitchChannelOffline( this->roomId()); @@ -1077,19 +1075,27 @@ bool TwitchChannel::tryReplaceLastLiveUpdateAddOrRemove( // Update the message this->lastLiveUpdateEmoteNames_.push_back(emoteName); - MessageBuilder replacement; - if (op == MessageFlag::LiveUpdatesAdd) - { - replacement = - MessageBuilder(liveUpdatesAddEmoteMessage, platform, - last->loginName, this->lastLiveUpdateEmoteNames_); - } - else // op == RemoveEmoteMessage - { - replacement = - MessageBuilder(liveUpdatesRemoveEmoteMessage, platform, - last->loginName, this->lastLiveUpdateEmoteNames_); - } + auto makeReplacement = [&](MessageFlag op) -> MessageBuilder { + if (op == MessageFlag::LiveUpdatesAdd) + { + return { + liveUpdatesAddEmoteMessage, + platform, + last->loginName, + this->lastLiveUpdateEmoteNames_, + }; + } + + // op == RemoveEmoteMessage + return { + liveUpdatesRemoveEmoteMessage, + platform, + last->loginName, + this->lastLiveUpdateEmoteNames_, + }; + }; + + auto replacement = makeReplacement(op); replacement->flags = last->flags; diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 2617d95a6..d031ee5b6 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -458,7 +458,7 @@ private: std::vector bSignals_; friend class TwitchIrcServer; - friend class TwitchMessageBuilder; + friend class MessageBuilder; friend class IrcMessageHandler; friend class Commands_E2E_Test; }; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp deleted file mode 100644 index 02e469d23..000000000 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ /dev/null @@ -1,2294 +0,0 @@ -#include "providers/twitch/TwitchMessageBuilder.hpp" - -#include "Application.hpp" -#include "common/LinkParser.hpp" -#include "common/Literals.hpp" -#include "common/QLogging.hpp" -#include "controllers/accounts/AccountController.hpp" -#include "controllers/highlights/HighlightController.hpp" -#include "controllers/ignores/IgnoreController.hpp" -#include "controllers/ignores/IgnorePhrase.hpp" -#include "controllers/userdata/UserDataController.hpp" -#include "messages/Emote.hpp" -#include "messages/Image.hpp" -#include "messages/Message.hpp" -#include "messages/MessageThread.hpp" -#include "providers/bttv/BttvEmotes.hpp" -#include "providers/chatterino/ChatterinoBadges.hpp" -#include "providers/colors/ColorProvider.hpp" -#include "providers/ffz/FfzBadges.hpp" -#include "providers/ffz/FfzEmotes.hpp" -#include "providers/seventv/SeventvBadges.hpp" -#include "providers/seventv/SeventvEmotes.hpp" -#include "providers/twitch/api/Helix.hpp" -#include "providers/twitch/ChannelPointReward.hpp" -#include "providers/twitch/PubSubActions.hpp" -#include "providers/twitch/TwitchAccount.hpp" -#include "providers/twitch/TwitchBadge.hpp" -#include "providers/twitch/TwitchBadges.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" -#include "singletons/Emotes.hpp" -#include "singletons/Resources.hpp" -#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 "widgets/Window.hpp" - -#include -#include -#include - -#include -#include - -using namespace chatterino::literals; - -namespace { - -const QColor AUTOMOD_USER_COLOR{"blue"}; - -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" -const QRegularExpression mentionRegex("^@" + regexHelpString); - -// if findAllUsernames setting is enabled, matches strings like in the examples above, but without @ symbol at the beginning -const QRegularExpression allUsernamesMentionRegex("^" + regexHelpString); - -const QSet zeroWidthEmotes{ - "SoSnowy", "IceCold", "SantaHat", "TopHat", - "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 { - -namespace { - - void appendTwitchEmoteOccurrences(const QString &emote, - std::vector &vec, - const std::vector &correctPositions, - const QString &originalMessage, - int messageOffset) - { - auto *app = getApp(); - if (!emote.contains(':')) - { - return; - } - - auto parameters = emote.split(':'); - - if (parameters.length() < 2) - { - return; - } - - auto id = EmoteId{parameters.at(0)}; - - auto occurrences = parameters.at(1).split(','); - - for (const QString &occurrence : occurrences) - { - auto coords = occurrence.split('-'); - - if (coords.length() < 2) - { - return; - } - - auto from = coords.at(0).toUInt() - messageOffset; - auto to = coords.at(1).toUInt() - messageOffset; - auto maxPositions = correctPositions.size(); - if (from > to || to >= maxPositions) - { - // Emote coords are out of range - qCDebug(chatterinoTwitch) - << "Emote coords" << from << "-" << to - << "are out of range (" << maxPositions << ")"; - return; - } - - auto start = correctPositions[from]; - auto end = correctPositions[to]; - if (start > end || start < 0 || end > originalMessage.length()) - { - // Emote coords are out of range from the modified character positions - qCDebug(chatterinoTwitch) << "Emote coords" << from << "-" << to - << "are out of range after offsets (" - << originalMessage.length() << ")"; - return; - } - - auto name = EmoteName{originalMessage.mid(start, end - start + 1)}; - TwitchEmoteOccurrence emoteOccurrence{ - start, - end, - app->getEmotes()->getTwitchEmotes()->getOrCreateEmote(id, name), - name, - }; - if (emoteOccurrence.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "nullptr" << emoteOccurrence.name.string; - } - vec.push_back(std::move(emoteOccurrence)); - } - } - - std::optional getTwitchBadge(const Badge &badge, - const TwitchChannel *twitchChannel) - { - if (auto channelBadge = - twitchChannel->twitchBadge(badge.key_, badge.value_)) - { - return channelBadge; - } - - if (auto globalBadge = - getApp()->getTwitchBadges()->badge(badge.key_, badge.value_)) - { - return globalBadge; - } - - return std::nullopt; - } - - void appendBadges(MessageBuilder *builder, const std::vector &badges, - const std::unordered_map &badgeInfos, - const TwitchChannel *twitchChannel) - { - if (twitchChannel == nullptr) - { - return; - } - - for (const auto &badge : badges) - { - auto badgeEmote = getTwitchBadge(badge, twitchChannel); - if (!badgeEmote) - { - continue; - } - auto tooltip = (*badgeEmote)->tooltip.string; - - if (badge.key_ == "bits") - { - const auto &cheerAmount = badge.value_; - tooltip = QString("Twitch cheer %0").arg(cheerAmount); - } - else if (badge.key_ == "moderator" && - getSettings()->useCustomFfzModeratorBadges) - { - if (auto customModBadge = twitchChannel->ffzCustomModBadge()) - { - builder - ->emplace( - *customModBadge, - MessageElementFlag::BadgeChannelAuthority) - ->setTooltip((*customModBadge)->tooltip.string); - // early out, since we have to add a custom badge element here - continue; - } - } - else if (badge.key_ == "vip" && - getSettings()->useCustomFfzVipBadges) - { - if (auto customVipBadge = twitchChannel->ffzCustomVipBadge()) - { - builder - ->emplace( - *customVipBadge, - MessageElementFlag::BadgeChannelAuthority) - ->setTooltip((*customVipBadge)->tooltip.string); - // early out, since we have to add a custom badge element here - continue; - } - } - else if (badge.flag_ == MessageElementFlag::BadgeSubscription) - { - auto badgeInfoIt = badgeInfos.find(badge.key_); - if (badgeInfoIt != badgeInfos.end()) - { - // badge.value_ is 4 chars long if user is subbed on higher tier - // (tier + amount of months with leading zero if less than 100) - // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub - const auto &subTier = - badge.value_.length() > 3 ? badge.value_.at(0) : '1'; - const auto &subMonths = badgeInfoIt->second; - tooltip += QString(" (%1%2 months)") - .arg(subTier != '1' - ? QString("Tier %1, ").arg(subTier) - : "") - .arg(subMonths); - } - } - else if (badge.flag_ == MessageElementFlag::BadgePredictions) - { - auto badgeInfoIt = badgeInfos.find(badge.key_); - if (badgeInfoIt != badgeInfos.end()) - { - auto infoValue = badgeInfoIt->second; - auto predictionText = - infoValue - .replace(R"(\s)", " ") // standard IRC escapes - .replace(R"(\:)", ";") - .replace(R"(\\)", R"(\)") - .replace("⸝", ","); // twitch's comma escape - // Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma - - tooltip = QString("Predicted %1").arg(predictionText); - } - } - - builder->emplace(*badgeEmote, badge.flag_) - ->setTooltip(tooltip); - } - - builder->message().badges = badges; - builder->message().badgeInfos = badgeInfos; - } - - /** - * Computes (only) the replacement of @a match in @a source. - * The parts before and after the match in @a source are ignored. - * - * Occurrences of \b{\\1}, \b{\\2}, ..., in @a replacement are replaced - * with the string captured by the corresponding capturing group. - * This function should only be used if the regex contains capturing groups. - * - * Since Qt doesn't provide a way of replacing a single match with some replacement - * while supporting both capturing groups and lookahead/-behind in the regex, - * this is included here. It's essentially the implementation of - * QString::replace(const QRegularExpression &, const QString &). - * @see https://github.com/qt/qtbase/blob/97bb0ecfe628b5bb78e798563212adf02129c6f6/src/corelib/text/qstring.cpp#L4594-L4703 - */ - QString makeRegexReplacement(QStringView source, - const QRegularExpression ®ex, - const QRegularExpressionMatch &match, - const QString &replacement) - { - using SizeType = QString::size_type; - struct QStringCapture { - SizeType pos; - SizeType len; - int captureNumber; - }; - - qsizetype numCaptures = regex.captureCount(); - - // 1. build the backreferences list, holding where the backreferences - // are in the replacement string - QVarLengthArray backReferences; - - SizeType replacementLength = replacement.size(); - for (SizeType i = 0; i < replacementLength - 1; i++) - { - if (replacement[i] != u'\\') - { - continue; - } - - int no = replacement[i + 1].digitValue(); - if (no <= 0 || no > numCaptures) - { - continue; - } - - QStringCapture backReference{.pos = i, .len = 2}; - - if (i < replacementLength - 2) - { - int secondDigit = replacement[i + 2].digitValue(); - if (secondDigit != -1 && - ((no * 10) + secondDigit) <= numCaptures) - { - no = (no * 10) + secondDigit; - ++backReference.len; - } - } - - backReference.captureNumber = no; - backReferences.append(backReference); - } - - // 2. iterate on the matches. - // For every match, copy the replacement string in chunks - // with the proper replacements for the backreferences - - // length of the new string, with all the replacements - SizeType newLength = 0; - QVarLengthArray chunks; - QStringView replacementView{replacement}; - - // Initially: empty, as we only care about the replacement - SizeType len = 0; - SizeType lastEnd = 0; - for (const QStringCapture &backReference : - std::as_const(backReferences)) - { - // part of "replacement" before the backreference - len = backReference.pos - lastEnd; - if (len > 0) - { - chunks << replacementView.mid(lastEnd, len); - newLength += len; - } - - // backreference itself - len = match.capturedLength(backReference.captureNumber); - if (len > 0) - { - chunks << source.mid( - match.capturedStart(backReference.captureNumber), len); - newLength += len; - } - - lastEnd = backReference.pos + backReference.len; - } - - // add the last part of the replacement string - len = replacementView.size() - lastEnd; - if (len > 0) - { - chunks << replacementView.mid(lastEnd, len); - newLength += len; - } - - // 3. assemble the chunks together - QString dst; - dst.reserve(newLength); - for (const QStringView &chunk : std::as_const(chunks)) - { - dst += chunk; - } - return dst; - } - -} // namespace - -TwitchMessageBuilder::TwitchMessageBuilder( - Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, - const MessageParseArgs &_args) - : SharedMessageBuilder(_channel, _ircMessage, _args) - , twitchChannel(dynamic_cast(_channel)) -{ -} - -TwitchMessageBuilder::TwitchMessageBuilder( - Channel *_channel, const Communi::IrcMessage *_ircMessage, - const MessageParseArgs &_args, QString content, bool isAction) - : SharedMessageBuilder(_channel, _ircMessage, _args, content, isAction) - , twitchChannel(dynamic_cast(_channel)) -{ -} - -bool TwitchMessageBuilder::isIgnored() const -{ - return isIgnoredMessage({ - /*.message = */ this->originalMessage_, - /*.twitchUserID = */ this->tags.value("user-id").toString(), - /*.isMod = */ this->channel->isMod(), - /*.isBroadcaster = */ this->channel->isBroadcaster(), - }); -} - -bool TwitchMessageBuilder::isIgnoredReply() const -{ - return isIgnoredMessage({ - /*.message = */ this->originalMessage_, - /*.twitchUserID = */ - this->tags.value("reply-parent-user-id").toString(), - /*.isMod = */ this->channel->isMod(), - /*.isBroadcaster = */ this->channel->isBroadcaster(), - }); -} - -void TwitchMessageBuilder::triggerHighlights() -{ - if (this->historicalMessage_) - { - // Do nothing. Highlights should not be triggered on historical messages. - return; - } - - SharedMessageBuilder::triggerHighlights(); -} - -MessagePtr TwitchMessageBuilder::build() -{ - assert(this->ircMessage != nullptr); - assert(this->channel != nullptr); - - // PARSE - this->userId_ = this->ircMessage->tag("user-id").toString(); - - this->parse(); - - if (this->userName == this->channel->getName()) - { - this->senderIsBroadcaster = true; - } - - this->message().channelName = this->channel->getName(); - - this->parseMessageID(); - - this->parseRoomID(); - - // If it is a reward it has to be appended first - if (this->args.channelPointRewardId != "") - { - const auto &reward = this->twitchChannel->channelPointReward( - this->args.channelPointRewardId); - if (reward) - { - TwitchMessageBuilder::appendChannelPointRewardMessage( - *reward, this, this->channel->isMod(), - this->channel->isBroadcaster()); - } - } - - this->appendChannelName(); - - if (this->tags.contains("rm-deleted")) - { - this->message().flags.set(MessageFlag::Disabled); - } - - this->historicalMessage_ = this->tags.contains("historical"); - - if (this->tags.contains("msg-id") && - this->tags["msg-id"].toString().split(';').contains( - "highlighted-message")) - { - this->message().flags.set(MessageFlag::RedeemedHighlight); - } - - if (this->tags.contains("first-msg") && - this->tags["first-msg"].toString() == "1") - { - this->message().flags.set(MessageFlag::FirstMessage); - } - - if (this->tags.contains("pinned-chat-paid-amount")) - { - this->message().flags.set(MessageFlag::ElevatedMessage); - } - - if (this->tags.contains("bits")) - { - this->message().flags.set(MessageFlag::CheerMessage); - } - - // reply threads - this->parseThread(); - - // timestamp - this->message().serverReceivedTime = calculateMessageTime(this->ircMessage); - this->emplace(this->message().serverReceivedTime.time()); - - if (this->shouldAddModerationElements()) - { - this->emplace(); - } - - this->appendTwitchBadges(); - - this->appendChatterinoBadges(); - this->appendFfzBadges(); - this->appendSeventvBadges(); - - this->appendUsername(); - - // QString bits; - auto iterator = this->tags.find("bits"); - if (iterator != this->tags.end()) - { - this->hasBits_ = true; - this->bitsLeft = iterator.value().toInt(); - this->bits = iterator.value().toString(); - } - - // Twitch emotes - auto twitchEmotes = TwitchMessageBuilder::parseTwitchEmotes( - this->tags, this->originalMessage_, this->messageOffset_); - - // This runs through all ignored phrases and runs its replacements on this->originalMessage_ - TwitchMessageBuilder::processIgnorePhrases( - *getSettings()->ignoredMessages.readOnly(), this->originalMessage_, - twitchEmotes); - - std::sort(twitchEmotes.begin(), twitchEmotes.end(), - [](const auto &a, const auto &b) { - return a.start < b.start; - }); - twitchEmotes.erase(std::unique(twitchEmotes.begin(), twitchEmotes.end(), - [](const auto &first, const auto &second) { - return first.start == second.start; - }), - twitchEmotes.end()); - - // words - QStringList splits = this->originalMessage_.split(' '); - - this->addWords(splits, twitchEmotes); - - QString stylizedUsername = - this->stylizeUsername(this->userName, this->message()); - - this->message().messageText = this->originalMessage_; - this->message().searchText = - stylizedUsername + " " + this->message().localizedName + " " + - this->userName + ": " + this->originalMessage_ + " " + - this->message().searchText; - - // highlights - this->parseHighlights(); - - // highlighting incoming whispers if requested per setting - if (this->args.isReceivedWhisper && getSettings()->highlightInlineWhispers) - { - this->message().flags.set(MessageFlag::HighlightedWhisper, true); - this->message().highlightColor = - ColorProvider::instance().color(ColorType::Whisper); - } - - if (this->thread_) - { - auto &img = getResources().buttons.replyThreadDark; - this->emplace( - Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, - MessageElementFlag::ReplyButton) - ->setLink({Link::ViewThread, this->thread_->rootId()}); - } - else - { - auto &img = getResources().buttons.replyDark; - this->emplace( - Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, - MessageElementFlag::ReplyButton) - ->setLink({Link::ReplyToMessage, this->message().id}); - } - - return this->release(); -} - -bool doesWordContainATwitchEmote( - int cursor, const QString &word, - const std::vector &twitchEmotes, - std::vector::const_iterator ¤tTwitchEmoteIt) -{ - if (currentTwitchEmoteIt == twitchEmotes.end()) - { - // No emote to add! - return false; - } - - const auto ¤tTwitchEmote = *currentTwitchEmoteIt; - - auto wordEnd = cursor + word.length(); - - // Check if this emote fits within the word boundaries - if (currentTwitchEmote.start < cursor || currentTwitchEmote.end > wordEnd) - { - // this emote does not fit xd - return false; - } - - return true; -} - -void TwitchMessageBuilder::addWords( - const QStringList &words, - const std::vector &twitchEmotes) -{ - // cursor currently indicates what character index we're currently operating in the full list of words - int cursor = 0; - auto currentTwitchEmoteIt = twitchEmotes.begin(); - - for (auto word : words) - { - if (word.isEmpty()) - { - cursor++; - continue; - } - - while (doesWordContainATwitchEmote(cursor, word, twitchEmotes, - currentTwitchEmoteIt)) - { - const auto ¤tTwitchEmote = *currentTwitchEmoteIt; - - if (currentTwitchEmote.start == cursor) - { - // This emote exists right at the start of the word! - this->emplace(currentTwitchEmote.ptr, - MessageElementFlag::TwitchEmote, - this->textColor_); - - auto len = currentTwitchEmote.name.string.length(); - cursor += len; - word = word.mid(len); - - ++currentTwitchEmoteIt; - - if (word.isEmpty()) - { - // space - cursor += 1; - break; - } - else - { - this->message().elements.back()->setTrailingSpace(false); - } - - continue; - } - - // Emote is not at the start - - // 1. Add text before the emote - QString preText = word.left(currentTwitchEmote.start - cursor); - for (auto &variant : - getApp()->getEmotes()->getEmojis()->parse(preText)) - { - boost::apply_visitor( - [&](auto &&arg) { - this->addTextOrEmoji(arg); - }, - variant); - } - - cursor += preText.size(); - - word = word.mid(preText.size()); - } - - if (word.isEmpty()) - { - continue; - } - - // split words - for (auto &variant : getApp()->getEmotes()->getEmojis()->parse(word)) - { - boost::apply_visitor( - [&](auto &&arg) { - this->addTextOrEmoji(arg); - }, - variant); - } - - cursor += word.size() + 1; - } -} - -void TwitchMessageBuilder::addTextOrEmoji(EmotePtr emote) -{ - return SharedMessageBuilder::addTextOrEmoji(emote); -} - -void TwitchMessageBuilder::addTextOrEmoji(const QString &string_) -{ - auto string = QString(string_); - - if (this->hasBits_ && this->tryParseCheermote(string)) - { - // This string was parsed as a cheermote - return; - } - - // TODO: Implement ignored emotes - // Format of ignored emotes: - // Emote name: "forsenPuke" - if string in ignoredEmotes - // Will match emote regardless of source (i.e. bttv, ffz) - // Emote source + name: "bttv:nyanPls" - if (this->tryAppendEmote({string})) - { - // Successfully appended an emote - return; - } - - // Actually just text - auto link = linkparser::parse(string); - auto textColor = this->textColor_; - - if (link) - { - this->addLink(*link, string); - return; - } - - if (string.startsWith('@')) - { - auto match = mentionRegex.match(string); - // Only treat as @mention if valid username - if (match.hasMatch()) - { - QString username = match.captured(1); - auto originalTextColor = textColor; - - if (this->twitchChannel != nullptr) - { - if (auto userColor = - this->twitchChannel->getUserColor(username); - userColor.isValid()) - { - textColor = userColor; - } - } - - auto prefixedUsername = '@' + username; - auto remainder = string.remove(prefixedUsername); - this->emplace(prefixedUsername, username, - originalTextColor, textColor) - ->setTrailingSpace(remainder.isEmpty()); - - if (!remainder.isEmpty()) - { - this->emplace(remainder, MessageElementFlag::Text, - originalTextColor); - } - - return; - } - } - - if (this->twitchChannel != nullptr && getSettings()->findAllUsernames) - { - auto match = allUsernamesMentionRegex.match(string); - QString username = match.captured(1); - - if (match.hasMatch() && - this->twitchChannel->accessChatters()->contains(username)) - { - auto originalTextColor = textColor; - - if (auto userColor = this->twitchChannel->getUserColor(username); - userColor.isValid()) - { - textColor = userColor; - } - - auto remainder = string.remove(username); - this->emplace(username, username, originalTextColor, - textColor) - ->setTrailingSpace(remainder.isEmpty()); - - if (!remainder.isEmpty()) - { - this->emplace(remainder, MessageElementFlag::Text, - originalTextColor); - } - - return; - } - } - - this->emplace(string, MessageElementFlag::Text, textColor); -} - -void TwitchMessageBuilder::parseMessageID() -{ - auto iterator = this->tags.find("id"); - - if (iterator != this->tags.end()) - { - this->message().id = iterator.value().toString(); - } -} - -void TwitchMessageBuilder::parseRoomID() -{ - if (this->twitchChannel == nullptr) - { - return; - } - - auto iterator = this->tags.find("room-id"); - - if (iterator != std::end(this->tags)) - { - this->roomID_ = iterator.value().toString(); - - if (this->twitchChannel->roomId().isEmpty()) - { - this->twitchChannel->setRoomId(this->roomID_); - } - } -} - -void TwitchMessageBuilder::parseThread() -{ - if (this->thread_) - { - // set references - this->message().replyThread = this->thread_; - this->message().replyParent = this->parent_; - this->thread_->addToThread(this->weakOf()); - - // enable reply flag - this->message().flags.set(MessageFlag::ReplyMessage); - - MessagePtr threadRoot; - if (!this->parent_) - { - threadRoot = this->thread_->root(); - } - else - { - threadRoot = this->parent_; - } - - QString usernameText = SharedMessageBuilder::stylizeUsername( - threadRoot->loginName, *threadRoot); - - this->emplace(); - - // construct reply elements - this->emplace( - "Replying to", MessageElementFlag::RepliedMessage, - MessageColor::System, FontStyle::ChatMediumSmall) - ->setLink({Link::ViewThread, this->thread_->rootId()}); - - this->emplace( - "@" + usernameText + - (threadRoot->flags.has(MessageFlag::Action) ? "" : ":"), - MessageElementFlag::RepliedMessage, threadRoot->usernameColor, - FontStyle::ChatMediumSmall) - ->setLink({Link::UserInfo, threadRoot->displayName}); - - MessageColor color = MessageColor::Text; - if (threadRoot->flags.has(MessageFlag::Action)) - { - color = threadRoot->usernameColor; - } - this->emplace( - threadRoot->messageText, - MessageElementFlags({MessageElementFlag::RepliedMessage, - MessageElementFlag::Text}), - color, FontStyle::ChatMediumSmall) - ->setLink({Link::ViewThread, this->thread_->rootId()}); - } - else if (this->tags.find("reply-parent-msg-id") != this->tags.end()) - { - // Message is a reply but we couldn't find the original message. - // Render the message using the additional reply tags - - auto replyDisplayName = this->tags.find("reply-parent-display-name"); - auto replyBody = this->tags.find("reply-parent-msg-body"); - - if (replyDisplayName != this->tags.end() && - replyBody != this->tags.end()) - { - QString body; - - this->emplace(); - this->emplace( - "Replying to", MessageElementFlag::RepliedMessage, - MessageColor::System, FontStyle::ChatMediumSmall); - - if (this->isIgnoredReply()) - { - body = QString("[Blocked user]"); - } - else - { - auto name = replyDisplayName->toString(); - body = parseTagString(replyBody->toString()); - - this->emplace( - "@" + name + ":", MessageElementFlag::RepliedMessage, - this->textColor_, FontStyle::ChatMediumSmall) - ->setLink({Link::UserInfo, name}); - } - - this->emplace( - body, - MessageElementFlags({MessageElementFlag::RepliedMessage, - MessageElementFlag::Text}), - this->textColor_, FontStyle::ChatMediumSmall); - } - } -} - -void TwitchMessageBuilder::parseUsernameColor() -{ - const auto *userData = getApp()->getUserData(); - assert(userData != nullptr); - - if (const auto &user = userData->getUser(this->userId_)) - { - if (user->color) - { - this->usernameColor_ = user->color.value(); - return; - } - } - - const auto iterator = this->tags.find("color"); - if (iterator != this->tags.end()) - { - if (const auto color = iterator.value().toString(); !color.isEmpty()) - { - this->usernameColor_ = QColor(color); - this->message().usernameColor = this->usernameColor_; - return; - } - } - - if (getSettings()->colorizeNicknames && this->tags.contains("user-id")) - { - this->usernameColor_ = - getRandomColor(this->tags.value("user-id").toString()); - this->message().usernameColor = this->usernameColor_; - } -} - -void TwitchMessageBuilder::parseUsername() -{ - SharedMessageBuilder::parseUsername(); - - if (this->userName.isEmpty() || this->args.trimSubscriberUsername) - { - this->userName = this->tags.value(QLatin1String("login")).toString(); - } - - // display name - // auto displayNameVariant = this->tags.value("display-name"); - // if (displayNameVariant.isValid()) { - // this->userName = displayNameVariant.toString() + " (" + - // this->userName + ")"; - // } - - this->message().loginName = this->userName; - if (this->twitchChannel != nullptr) - { - this->twitchChannel->setUserColor(this->userName, this->usernameColor_); - } - - // Update current user color if this is our message - auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); - if (this->ircMessage->nick() == currentUser->getUserName()) - { - currentUser->setColor(this->usernameColor_); - } -} - -void TwitchMessageBuilder::appendUsername() -{ - auto *app = getApp(); - - QString username = this->userName; - this->message().loginName = username; - QString localizedName; - - auto iterator = this->tags.find("display-name"); - if (iterator != this->tags.end()) - { - QString displayName = - parseTagString(iterator.value().toString()).trimmed(); - - if (QString::compare(displayName, this->userName, - Qt::CaseInsensitive) == 0) - { - username = displayName; - - this->message().displayName = displayName; - } - else - { - localizedName = displayName; - - this->message().displayName = username; - this->message().localizedName = displayName; - } - } - - QString usernameText = - SharedMessageBuilder::stylizeUsername(username, this->message()); - - if (this->args.isSentWhisper) - { - // TODO(pajlada): Re-implement - // userDisplayString += - // IrcManager::instance().getUser().getUserName(); - } - else if (this->args.isReceivedWhisper) - { - // Sender username - this->emplace(usernameText, MessageElementFlag::Username, - this->usernameColor_, - FontStyle::ChatMediumBold) - ->setLink({Link::UserWhisper, this->message().displayName}); - - auto currentUser = app->getAccounts()->twitch.getCurrent(); - - // Separator - this->emplace("->", MessageElementFlag::Username, - MessageColor::System, FontStyle::ChatMedium); - - QColor selfColor = currentUser->color(); - MessageColor selfMsgColor = - selfColor.isValid() ? selfColor : MessageColor::System; - - // Your own username - this->emplace(currentUser->getUserName() + ":", - MessageElementFlag::Username, selfMsgColor, - FontStyle::ChatMediumBold); - } - else - { - if (!this->action_) - { - usernameText += ":"; - } - - this->emplace(usernameText, MessageElementFlag::Username, - this->usernameColor_, - FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, this->message().displayName}); - } -} - -void TwitchMessageBuilder::processIgnorePhrases( - const std::vector &phrases, QString &originalMessage, - std::vector &twitchEmotes) -{ - using SizeType = QString::size_type; - - auto removeEmotesInRange = [&twitchEmotes](SizeType pos, SizeType len) { - // all emotes outside the range come before `it` - // all emotes in the range start at `it` - auto it = std::partition( - twitchEmotes.begin(), twitchEmotes.end(), - [pos, len](const auto &item) { - // returns true for emotes outside the range - return !((item.start >= pos) && item.start < (pos + len)); - }); - std::vector emotesInRange(it, - twitchEmotes.end()); - twitchEmotes.erase(it, twitchEmotes.end()); - return emotesInRange; - }; - - auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) { - for (auto &item : twitchEmotes) - { - auto &index = item.start; - if (index >= pos) - { - index += by; - item.end += by; - } - } - }; - - auto addReplEmotes = [&twitchEmotes](const IgnorePhrase &phrase, - const auto &midrepl, - SizeType startIndex) { - if (!phrase.containsEmote()) - { - return; - } - -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - auto words = midrepl.tokenize(u' '); -#else - auto words = midrepl.split(' '); -#endif - SizeType pos = 0; - for (const auto &word : words) - { - for (const auto &emote : phrase.getEmotes()) - { - if (word == emote.first.string) - { - if (emote.second == nullptr) - { - qCDebug(chatterinoTwitch) - << "emote null" << emote.first.string; - } - twitchEmotes.push_back(TwitchEmoteOccurrence{ - static_cast(startIndex + pos), - static_cast(startIndex + pos + - emote.first.string.length()), - emote.second, - emote.first, - }); - } - } - pos += word.length() + 1; - } - }; - - auto replaceMessageAt = [&](const IgnorePhrase &phrase, SizeType from, - SizeType length, const QString &replacement) { - auto removedEmotes = removeEmotesInRange(from, length); - originalMessage.replace(from, length, replacement); - auto wordStart = from; - while (wordStart > 0) - { - if (originalMessage[wordStart - 1] == ' ') - { - break; - } - --wordStart; - } - auto wordEnd = from + replacement.length(); - while (wordEnd < originalMessage.length()) - { - if (originalMessage[wordEnd] == ' ') - { - break; - } - ++wordEnd; - } - - shiftIndicesAfter(static_cast(from + length), - static_cast(replacement.length() - length)); - - auto midExtendedRef = - QStringView{originalMessage}.mid(wordStart, wordEnd - wordStart); - - for (auto &emote : removedEmotes) - { - if (emote.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "Invalid emote occurrence" << emote.name.string; - continue; - } - QRegularExpression emoteregex( - "\\b" + emote.name.string + "\\b", - QRegularExpression::UseUnicodePropertiesOption); -#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) - auto match = emoteregex.matchView(midExtendedRef); -#else - auto match = emoteregex.match(midExtendedRef); -#endif - if (match.hasMatch()) - { - emote.start = static_cast(from + match.capturedStart()); - emote.end = static_cast(from + match.capturedEnd()); - twitchEmotes.push_back(std::move(emote)); - } - } - - addReplEmotes(phrase, midExtendedRef, wordStart); - }; - - for (const auto &phrase : phrases) - { - if (phrase.isBlock()) - { - continue; - } - const auto &pattern = phrase.getPattern(); - if (pattern.isEmpty()) - { - continue; - } - if (phrase.isRegex()) - { - const auto ®ex = phrase.getRegex(); - if (!regex.isValid()) - { - continue; - } - - QRegularExpressionMatch match; - size_t iterations = 0; - SizeType from = 0; - while ((from = originalMessage.indexOf(regex, from, &match)) != -1) - { - auto replacement = phrase.getReplace(); - if (regex.captureCount() > 0) - { - replacement = makeRegexReplacement(originalMessage, regex, - match, replacement); - } - - replaceMessageAt(phrase, from, match.capturedLength(), - replacement); - from += phrase.getReplace().length(); - iterations++; - if (iterations >= 128) - { - originalMessage = - u"Too many replacements - check your ignores!"_s; - return; - } - } - - continue; - } - - SizeType from = 0; - while ((from = originalMessage.indexOf(pattern, from, - phrase.caseSensitivity())) != -1) - { - replaceMessageAt(phrase, from, pattern.length(), - phrase.getReplace()); - from += phrase.getReplace().length(); - } - } -} - -Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) -{ - auto *app = getApp(); - - const auto *globalBttvEmotes = app->getBttvEmotes(); - const auto *globalFfzEmotes = app->getFfzEmotes(); - const auto *globalSeventvEmotes = app->getSeventvEmotes(); - - auto flags = MessageElementFlags(); - auto emote = std::optional{}; - bool zeroWidth = false; - - // Emote order: - // - FrankerFaceZ Channel - // - BetterTTV Channel - // - 7TV Channel - // - FrankerFaceZ Global - // - BetterTTV Global - // - 7TV Global - if (this->twitchChannel && (emote = this->twitchChannel->ffzEmote(name))) - { - flags = MessageElementFlag::FfzEmote; - } - else if (this->twitchChannel && - (emote = this->twitchChannel->bttvEmote(name))) - { - flags = MessageElementFlag::BttvEmote; - } - else if (this->twitchChannel != nullptr && - (emote = this->twitchChannel->seventvEmote(name))) - { - flags = MessageElementFlag::SevenTVEmote; - zeroWidth = emote.value()->zeroWidth; - } - else if ((emote = globalFfzEmotes->emote(name))) - { - flags = MessageElementFlag::FfzEmote; - } - else if ((emote = globalBttvEmotes->emote(name))) - { - flags = MessageElementFlag::BttvEmote; - zeroWidth = zeroWidthEmotes.contains(name.string); - } - else if ((emote = globalSeventvEmotes->globalEmote(name))) - { - flags = MessageElementFlag::SevenTVEmote; - zeroWidth = emote.value()->zeroWidth; - } - - if (emote) - { - if (zeroWidth && getSettings()->enableZeroWidthEmotes && - !this->isEmpty()) - { - // Attempt to merge current zero-width emote into any previous emotes - auto *asEmote = dynamic_cast(&this->back()); - if (asEmote) - { - // Make sure to access asEmote before taking ownership when releasing - auto baseEmote = asEmote->getEmote(); - // Need to remove EmoteElement and replace with LayeredEmoteElement - auto baseEmoteElement = this->releaseBack(); - - std::vector layers = { - {baseEmote, baseEmoteElement->getFlags()}, {*emote, flags}}; - this->emplace( - std::move(layers), baseEmoteElement->getFlags() | flags, - this->textColor_); - return Success; - } - - auto *asLayered = - dynamic_cast(&this->back()); - if (asLayered) - { - asLayered->addEmoteLayer({*emote, flags}); - asLayered->addFlags(flags); - return Success; - } - - // No emote to merge with, just show as regular emote - } - - this->emplace(*emote, flags, this->textColor_); - return Success; - } - - return Failure; -} - -std::unordered_map TwitchMessageBuilder::parseBadgeInfoTag( - const QVariantMap &tags) -{ - std::unordered_map infoMap; - - auto infoIt = tags.constFind("badge-info"); - if (infoIt == tags.end()) - { - return infoMap; - } - - auto info = infoIt.value().toString().split(',', Qt::SkipEmptyParts); - - for (const QString &badge : info) - { - infoMap.emplace(SharedMessageBuilder::slashKeyValue(badge)); - } - - return infoMap; -} - -std::vector TwitchMessageBuilder::parseTwitchEmotes( - const QVariantMap &tags, const QString &originalMessage, int messageOffset) -{ - // Twitch emotes - std::vector twitchEmotes; - - auto emotesTag = tags.find("emotes"); - - if (emotesTag == tags.end()) - { - return twitchEmotes; - } - - QStringList emoteString = emotesTag.value().toString().split('/'); - std::vector correctPositions; - for (int i = 0; i < originalMessage.size(); ++i) - { - if (!originalMessage.at(i).isLowSurrogate()) - { - correctPositions.push_back(i); - } - } - for (const QString &emote : emoteString) - { - appendTwitchEmoteOccurrences(emote, twitchEmotes, correctPositions, - originalMessage, messageOffset); - } - - return twitchEmotes; -} - -void TwitchMessageBuilder::appendTwitchBadges() -{ - if (this->twitchChannel == nullptr) - { - return; - } - - auto badgeInfos = TwitchMessageBuilder::parseBadgeInfoTag(this->tags); - auto badges = TwitchMessageBuilder::parseBadgeTag(this->tags); - appendBadges(this, badges, badgeInfos, this->twitchChannel); -} - -void TwitchMessageBuilder::appendChatterinoBadges() -{ - if (auto badge = getApp()->getChatterinoBadges()->getBadge({this->userId_})) - { - this->emplace(*badge, - MessageElementFlag::BadgeChatterino); - } -} - -void TwitchMessageBuilder::appendFfzBadges() -{ - for (const auto &badge : - getApp()->getFfzBadges()->getUserBadges({this->userId_})) - { - this->emplace( - badge.emote, MessageElementFlag::BadgeFfz, badge.color); - } - - if (this->twitchChannel == nullptr) - { - return; - } - - for (const auto &badge : - this->twitchChannel->ffzChannelBadges(this->userId_)) - { - this->emplace( - badge.emote, MessageElementFlag::BadgeFfz, badge.color); - } -} - -void TwitchMessageBuilder::appendSeventvBadges() -{ - if (auto badge = getApp()->getSeventvBadges()->getBadge({this->userId_})) - { - this->emplace(*badge, MessageElementFlag::BadgeSevenTV); - } -} - -Outcome TwitchMessageBuilder::tryParseCheermote(const QString &string) -{ - if (this->bitsLeft == 0) - { - return Failure; - } - - auto cheerOpt = this->twitchChannel->cheerEmote(string); - - if (!cheerOpt) - { - return Failure; - } - - auto &cheerEmote = *cheerOpt; - auto match = cheerEmote.regex.match(string); - - if (!match.hasMatch()) - { - return Failure; - } - - int cheerValue = match.captured(1).toInt(); - - if (getSettings()->stackBits) - { - if (this->bitsStacked) - { - return Success; - } - if (cheerEmote.staticEmote) - { - this->emplace(cheerEmote.staticEmote, - MessageElementFlag::BitsStatic, - this->textColor_); - } - if (cheerEmote.animatedEmote) - { - this->emplace(cheerEmote.animatedEmote, - MessageElementFlag::BitsAnimated, - this->textColor_); - } - if (cheerEmote.color != QColor()) - { - this->emplace(QString::number(this->bitsLeft), - MessageElementFlag::BitsAmount, - cheerEmote.color); - } - this->bitsStacked = true; - return Success; - } - - if (this->bitsLeft >= cheerValue) - { - this->bitsLeft -= cheerValue; - } - else - { - QString newString = string; - newString.chop(QString::number(cheerValue).length()); - newString += QString::number(cheerValue - this->bitsLeft); - - return tryParseCheermote(newString); - } - - if (cheerEmote.staticEmote) - { - this->emplace(cheerEmote.staticEmote, - MessageElementFlag::BitsStatic, - this->textColor_); - } - if (cheerEmote.animatedEmote) - { - this->emplace(cheerEmote.animatedEmote, - MessageElementFlag::BitsAnimated, - this->textColor_); - } - if (cheerEmote.color != QColor()) - { - this->emplace(match.captured(1), - MessageElementFlag::BitsAmount, - cheerEmote.color); - } - - return Success; -} - -bool TwitchMessageBuilder::shouldAddModerationElements() const -{ - if (this->senderIsBroadcaster) - { - // You cannot timeout the broadcaster - return false; - } - - if (this->tags.value("user-type").toString() == "mod" && - !this->args.isStaffOrBroadcaster) - { - // You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel - return false; - } - - return true; -} - -void TwitchMessageBuilder::appendChannelPointRewardMessage( - const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, - bool isBroadcaster) -{ - if (isIgnoredMessage({ - /*.message = */ "", - /*.twitchUserID = */ reward.user.id, - /*.isMod = */ isMod, - /*.isBroadcaster = */ isBroadcaster, - })) - { - return; - } - - builder->emplace(); - QString redeemed = "Redeemed"; - QStringList textList; - if (!reward.isUserInputRequired) - { - builder - ->emplace( - reward.user.login, MessageElementFlag::ChannelPointReward, - MessageColor::Text, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, reward.user.login}); - redeemed = "redeemed"; - textList.append(reward.user.login); - } - builder->emplace(redeemed, - MessageElementFlag::ChannelPointReward); - if (reward.id == "CELEBRATION") - { - const auto emotePtr = - getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( - EmoteId{reward.emoteId}, EmoteName{reward.emoteName}); - builder->emplace(emotePtr, - MessageElementFlag::ChannelPointReward, - MessageColor::Text); - } - builder->emplace( - reward.title, MessageElementFlag::ChannelPointReward, - MessageColor::Text, FontStyle::ChatMediumBold); - builder->emplace( - reward.image, MessageElementFlag::ChannelPointRewardImage); - builder->emplace( - QString::number(reward.cost), MessageElementFlag::ChannelPointReward, - MessageColor::Text, FontStyle::ChatMediumBold); - if (reward.isBits) - { - builder->emplace( - "bits", MessageElementFlag::ChannelPointReward, MessageColor::Text, - FontStyle::ChatMediumBold); - } - if (reward.isUserInputRequired) - { - builder->emplace( - MessageElementFlag::ChannelPointReward); - } - - builder->message().flags.set(MessageFlag::RedeemedChannelPointReward); - - textList.append({redeemed, reward.title, QString::number(reward.cost)}); - builder->message().messageText = textList.join(" "); - builder->message().searchText = textList.join(" "); - builder->message().loginName = reward.user.login; - - builder->message().reward = std::make_shared(reward); -} - -void TwitchMessageBuilder::liveMessage(const QString &channelName, - MessageBuilder *builder) -{ - builder->emplace(); - builder - ->emplace(channelName, MessageElementFlag::Username, - MessageColor::Text, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, channelName}); - builder->emplace("is live!", MessageElementFlag::Text, - MessageColor::Text); - auto text = QString("%1 is live!").arg(channelName); - builder->message().messageText = text; - builder->message().searchText = text; -} - -void TwitchMessageBuilder::liveSystemMessage(const QString &channelName, - MessageBuilder *builder) -{ - builder->emplace(); - builder->message().flags.set(MessageFlag::System); - builder->message().flags.set(MessageFlag::DoNotTriggerNotification); - builder - ->emplace(channelName, MessageElementFlag::Username, - MessageColor::System, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, channelName}); - builder->emplace("is live!", MessageElementFlag::Text, - MessageColor::System); - auto text = QString("%1 is live!").arg(channelName); - builder->message().messageText = text; - builder->message().searchText = text; -} - -void TwitchMessageBuilder::offlineSystemMessage(const QString &channelName, - MessageBuilder *builder) -{ - builder->emplace(); - builder->message().flags.set(MessageFlag::System); - builder->message().flags.set(MessageFlag::DoNotTriggerNotification); - builder - ->emplace(channelName, MessageElementFlag::Username, - MessageColor::System, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, channelName}); - builder->emplace("is now offline.", MessageElementFlag::Text, - MessageColor::System); - auto text = QString("%1 is now offline.").arg(channelName); - builder->message().messageText = text; - builder->message().searchText = text; -} - -void TwitchMessageBuilder::hostingSystemMessage(const QString &channelName, - MessageBuilder *builder, - bool hostOn) -{ - QString text; - builder->emplace(); - builder->message().flags.set(MessageFlag::System); - builder->message().flags.set(MessageFlag::DoNotTriggerNotification); - if (hostOn) - { - builder->emplace("Now hosting", MessageElementFlag::Text, - MessageColor::System); - builder - ->emplace( - channelName + ".", MessageElementFlag::Username, - MessageColor::System, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, channelName}); - text = QString("Now hosting %1.").arg(channelName); - } - else - { - builder - ->emplace(channelName, MessageElementFlag::Username, - MessageColor::System, - FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, channelName}); - builder->emplace("has gone offline. Exiting host mode.", - MessageElementFlag::Text, - MessageColor::System); - text = - QString("%1 has gone offline. Exiting host mode.").arg(channelName); - } - builder->message().messageText = text; - builder->message().searchText = text; -} - -// IRC variant -void TwitchMessageBuilder::deletionMessage(const MessagePtr originalMessage, - MessageBuilder *builder) -{ - builder->emplace(); - builder->message().flags.set(MessageFlag::System); - builder->message().flags.set(MessageFlag::DoNotTriggerNotification); - builder->message().flags.set(MessageFlag::Timeout); - // TODO(mm2pl): If or when jumping to a single message gets implemented a link, - // add a link to the originalMessage - builder->emplace("A message from", MessageElementFlag::Text, - MessageColor::System); - builder - ->emplace(originalMessage->displayName, - MessageElementFlag::Username, - MessageColor::System, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, originalMessage->loginName}); - builder->emplace("was deleted:", MessageElementFlag::Text, - MessageColor::System); - if (originalMessage->messageText.length() > 50) - { - builder - ->emplace(originalMessage->messageText.left(50) + "…", - MessageElementFlag::Text, MessageColor::Text) - ->setLink({Link::JumpToMessage, originalMessage->id}); - } - else - { - builder - ->emplace(originalMessage->messageText, - MessageElementFlag::Text, MessageColor::Text) - ->setLink({Link::JumpToMessage, originalMessage->id}); - } - builder->message().timeoutUser = "msg:" + originalMessage->id; -} - -// pubsub variant -void TwitchMessageBuilder::deletionMessage(const DeleteAction &action, - MessageBuilder *builder) -{ - builder->emplace(); - builder->message().flags.set(MessageFlag::System); - builder->message().flags.set(MessageFlag::DoNotTriggerNotification); - builder->message().flags.set(MessageFlag::Timeout); - - builder - ->emplace(action.source.login, - MessageElementFlag::Username, - MessageColor::System, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.source.login}); - // TODO(mm2pl): If or when jumping to a single message gets implemented a link, - // add a link to the originalMessage - builder->emplace( - "deleted message from", MessageElementFlag::Text, MessageColor::System); - builder - ->emplace(action.target.login, - MessageElementFlag::Username, - MessageColor::System, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.target.login}); - builder->emplace("saying:", MessageElementFlag::Text, - MessageColor::System); - if (action.messageText.length() > 50) - { - builder - ->emplace(action.messageText.left(50) + "…", - MessageElementFlag::Text, MessageColor::Text) - ->setLink({Link::JumpToMessage, action.messageId}); - } - else - { - builder - ->emplace(action.messageText, MessageElementFlag::Text, - MessageColor::Text) - ->setLink({Link::JumpToMessage, action.messageId}); - } - builder->message().timeoutUser = "msg:" + action.messageId; -} - -void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix, - QStringList users, - Channel *channel, - MessageBuilder *builder) -{ - QString text = prefix + users.join(", "); - - builder->message().messageText = text; - builder->message().searchText = text; - - builder->emplace(); - builder->message().flags.set(MessageFlag::System); - builder->message().flags.set(MessageFlag::DoNotTriggerNotification); - builder->emplace(prefix, MessageElementFlag::Text, - MessageColor::System); - bool isFirst = true; - auto *tc = dynamic_cast(channel); - for (const QString &username : users) - { - if (!isFirst) - { - // this is used to add the ", " after each but the last entry - builder->emplace(",", MessageElementFlag::Text, - MessageColor::System); - } - isFirst = false; - - MessageColor color = MessageColor::System; - - if (tc) - { - if (auto userColor = tc->getUserColor(username); - userColor.isValid()) - { - color = MessageColor(userColor); - } - } - - // TODO: Ensure we make use of display name / username(login name) correctly here - builder - ->emplace(username, username, MessageColor::System, - color) - ->setTrailingSpace(false); - } -} - -void TwitchMessageBuilder::listOfUsersSystemMessage( - QString prefix, const std::vector &users, Channel *channel, - MessageBuilder *builder) -{ - QString text = prefix; - - builder->emplace(); - builder->message().flags.set(MessageFlag::System); - builder->message().flags.set(MessageFlag::DoNotTriggerNotification); - builder->emplace(prefix, MessageElementFlag::Text, - MessageColor::System); - bool isFirst = true; - auto *tc = dynamic_cast(channel); - for (const auto &user : users) - { - if (!isFirst) - { - // this is used to add the ", " after each but the last entry - builder->emplace(",", MessageElementFlag::Text, - MessageColor::System); - text += QString(", %1").arg(user.userName); - } - else - { - text += user.userName; - } - isFirst = false; - - MessageColor color = MessageColor::System; - - if (tc) - { - if (auto userColor = tc->getUserColor(user.userLogin); - userColor.isValid()) - { - color = MessageColor(userColor); - } - } - - builder - ->emplace(user.userName, user.userLogin, - MessageColor::System, color) - ->setTrailingSpace(false); - } - - builder->message().messageText = text; - builder->message().searchText = text; -} - -MessagePtr TwitchMessageBuilder::buildHypeChatMessage( - Communi::IrcPrivateMessage *message) -{ - auto levelID = 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(levelID); - 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(); -} - -EmotePtr makeAutoModBadge() -{ - return std::make_shared(Emote{ - EmoteName{}, - ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)}, - Tooltip{"AutoMod"}, - Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); -} - -MessagePtr TwitchMessageBuilder::makeAutomodInfoMessage( - const AutomodInfoAction &action) -{ - auto builder = MessageBuilder(); - QString text("AutoMod: "); - - builder.emplace(); - builder.message().flags.set(MessageFlag::PubSub); - - // AutoMod shield badge - builder.emplace(makeAutoModBadge(), - MessageElementFlag::BadgeChannelAuthority); - // AutoMod "username" - builder.emplace("AutoMod:", MessageElementFlag::Text, - AUTOMOD_USER_COLOR, FontStyle::ChatMediumBold); - switch (action.type) - { - case AutomodInfoAction::OnHold: { - QString info("Hey! Your message is being checked " - "by mods and has not been sent."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - case AutomodInfoAction::Denied: { - QString info("Mods have removed your message."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - case AutomodInfoAction::Approved: { - QString info("Mods have accepted your message."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - } - - builder.message().flags.set(MessageFlag::AutoMod); - builder.message().messageText = text; - builder.message().searchText = text; - - auto message = builder.release(); - - return message; -} - -std::pair TwitchMessageBuilder::makeAutomodMessage( - const AutomodAction &action, const QString &channelName) -{ - MessageBuilder builder, builder2; - - // - // Builder for AutoMod message with explanation - builder.message().loginName = "automod"; - builder.message().channelName = channelName; - builder.message().flags.set(MessageFlag::PubSub); - builder.message().flags.set(MessageFlag::Timeout); - builder.message().flags.set(MessageFlag::AutoMod); - builder.message().flags.set(MessageFlag::AutoModOffendingMessageHeader); - - // AutoMod shield badge - builder.emplace(makeAutoModBadge(), - MessageElementFlag::BadgeChannelAuthority); - // AutoMod "username" - builder2.emplace("AutoMod:", MessageElementFlag::Text, - AUTOMOD_USER_COLOR, - FontStyle::ChatMediumBold); - // AutoMod header message - builder.emplace( - ("Held a message for reason: " + action.reason + - ". Allow will post it in chat. "), - MessageElementFlag::Text, MessageColor::Text); - // Allow link button - builder - .emplace("Allow", MessageElementFlag::Text, - MessageColor(QColor("green")), - FontStyle::ChatMediumBold) - ->setLink({Link::AutoModAllow, action.msgID}); - // Deny link button - builder - .emplace(" Deny", MessageElementFlag::Text, - MessageColor(QColor("red")), - FontStyle::ChatMediumBold) - ->setLink({Link::AutoModDeny, action.msgID}); - // ID of message caught by AutoMod - // builder.emplace(action.msgID, MessageElementFlag::Text, - // MessageColor::Text); - auto text1 = - QString("AutoMod: Held a message for reason: %1. Allow will post " - "it in chat. Allow Deny") - .arg(action.reason); - builder.message().messageText = text1; - builder.message().searchText = text1; - - auto message1 = builder.release(); - - // - // Builder for offender's message - builder2.message().channelName = channelName; - builder2 - .emplace("#" + channelName, - MessageElementFlag::ChannelName, - MessageColor::System) - ->setLink({Link::JumpToChannel, channelName}); - builder2.emplace(); - builder2.emplace(); - builder2.message().loginName = action.target.login; - builder2.message().flags.set(MessageFlag::PubSub); - builder2.message().flags.set(MessageFlag::Timeout); - builder2.message().flags.set(MessageFlag::AutoMod); - builder2.message().flags.set(MessageFlag::AutoModOffendingMessage); - - // sender username - builder2.emplace(action.target.displayName + ":", - action.target.login, MessageColor::Text, - action.target.color); - // sender's message caught by AutoMod - builder2.emplace(action.message, MessageElementFlag::Text, - MessageColor::Text); - auto text2 = - QString("%1: %2").arg(action.target.displayName, action.message); - builder2.message().messageText = text2; - builder2.message().searchText = text2; - - auto message2 = builder2.release(); - - // Normally highlights would be checked & triggered during the builder parse steps - // and when the message is added to the channel - // We do this a bit weird since the message comes in from PubSub and not the normal message route - auto [highlighted, highlightResult] = getApp()->getHighlights()->check( - {}, {}, action.target.login, action.message, message2->flags); - if (highlighted) - { - SharedMessageBuilder::triggerHighlights( - channelName, highlightResult.playSound, - highlightResult.customSoundUrl, highlightResult.alert); - } - - return std::make_pair(message1, message2); -} - -MessagePtr TwitchMessageBuilder::makeLowTrustUpdateMessage( - const PubSubLowTrustUsersMessage &action) -{ - /** - * Known issues: - * - Non-Twitch badges are not shown - * - Non-Twitch emotes are not shown - */ - - MessageBuilder builder; - builder.emplace(); - builder.message().flags.set(MessageFlag::System); - builder.message().flags.set(MessageFlag::PubSub); - builder.message().flags.set(MessageFlag::DoNotTriggerNotification); - - builder - .emplace(action.updatedByUserDisplayName, - MessageElementFlag::Username, - MessageColor::System, FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.updatedByUserLogin}); - - assert(action.treatment != PubSubLowTrustUsersMessage::Treatment::INVALID); - switch (action.treatment) - { - case PubSubLowTrustUsersMessage::Treatment::NoTreatment: { - builder.emplace("removed", MessageElementFlag::Text, - MessageColor::System); - builder - .emplace(action.suspiciousUserDisplayName, - MessageElementFlag::Username, - MessageColor::System, - FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.suspiciousUserLogin}); - builder.emplace("from the suspicious user list.", - MessageElementFlag::Text, - MessageColor::System); - } - break; - - case PubSubLowTrustUsersMessage::Treatment::ActiveMonitoring: { - builder.emplace("added", MessageElementFlag::Text, - MessageColor::System); - builder - .emplace(action.suspiciousUserDisplayName, - MessageElementFlag::Username, - MessageColor::System, - FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.suspiciousUserLogin}); - builder.emplace("as a monitored suspicious chatter.", - MessageElementFlag::Text, - MessageColor::System); - } - break; - - case PubSubLowTrustUsersMessage::Treatment::Restricted: { - builder.emplace("added", MessageElementFlag::Text, - MessageColor::System); - builder - .emplace(action.suspiciousUserDisplayName, - MessageElementFlag::Username, - MessageColor::System, - FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.suspiciousUserLogin}); - builder.emplace("as a restricted suspicious chatter.", - MessageElementFlag::Text, - MessageColor::System); - } - break; - - default: - qCDebug(chatterinoTwitch) << "Unexpected suspicious treatment: " - << action.treatmentString; - break; - } - - return builder.release(); -} - -std::pair TwitchMessageBuilder::makeLowTrustUserMessage( - const PubSubLowTrustUsersMessage &action, const QString &channelName, - const TwitchChannel *twitchChannel) -{ - MessageBuilder builder, builder2; - - // Builder for low trust user message with explanation - builder.message().channelName = channelName; - builder.message().flags.set(MessageFlag::PubSub); - builder.message().flags.set(MessageFlag::LowTrustUsers); - - // AutoMod shield badge - builder.emplace(makeAutoModBadge(), - MessageElementFlag::BadgeChannelAuthority); - - // Suspicious user header message - QString prefix = "Suspicious User:"; - builder.emplace(prefix, MessageElementFlag::Text, - MessageColor(QColor("blue")), - FontStyle::ChatMediumBold); - - QString headerMessage; - if (action.treatment == PubSubLowTrustUsersMessage::Treatment::Restricted) - { - headerMessage = "Restricted"; - builder2.message().flags.set(MessageFlag::RestrictedMessage); - } - else - { - headerMessage = "Monitored"; - builder2.message().flags.set(MessageFlag::MonitoredMessage); - } - - if (action.restrictionTypes.has( - PubSubLowTrustUsersMessage::RestrictionType::ManuallyAdded)) - { - headerMessage += " by " + action.updatedByUserLogin; - } - - headerMessage += " at " + action.updatedAt; - - if (action.restrictionTypes.has( - PubSubLowTrustUsersMessage::RestrictionType::DetectedBanEvader)) - { - QString evader; - if (action.evasionEvaluation == - PubSubLowTrustUsersMessage::EvasionEvaluation::LikelyEvader) - { - evader = "likely"; - } - else - { - evader = "possible"; - } - - headerMessage += ". Detected as " + evader + " ban evader"; - } - - if (action.restrictionTypes.has( - PubSubLowTrustUsersMessage::RestrictionType::BannedInSharedChannel)) - { - headerMessage += ". Banned in " + - QString::number(action.sharedBanChannelIDs.size()) + - " shared channels"; - } - - builder.emplace(headerMessage, MessageElementFlag::Text, - MessageColor::Text); - builder.message().messageText = prefix + " " + headerMessage; - builder.message().searchText = prefix + " " + headerMessage; - - auto message1 = builder.release(); - - // - // Builder for offender's message - builder2.message().channelName = channelName; - builder2 - .emplace("#" + channelName, - MessageElementFlag::ChannelName, - MessageColor::System) - ->setLink({Link::JumpToChannel, channelName}); - builder2.emplace(); - builder2.emplace(); - builder2.message().loginName = action.suspiciousUserLogin; - builder2.message().flags.set(MessageFlag::PubSub); - builder2.message().flags.set(MessageFlag::LowTrustUsers); - - // sender badges - appendBadges(&builder2, action.senderBadges, {}, twitchChannel); - - // sender username - builder2.emplace( - action.suspiciousUserDisplayName + ":", action.suspiciousUserLogin, - MessageColor::Text, action.suspiciousUserColor); - - // sender's message caught by AutoMod - for (const auto &fragment : action.fragments) - { - if (fragment.emoteID.isEmpty()) - { - builder2.emplace( - fragment.text, MessageElementFlag::Text, MessageColor::Text); - } - else - { - const auto emotePtr = - getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( - EmoteId{fragment.emoteID}, EmoteName{fragment.text}); - builder2.emplace( - emotePtr, MessageElementFlag::TwitchEmote, MessageColor::Text); - } - } - - auto text = - QString("%1: %2").arg(action.suspiciousUserDisplayName, action.text); - builder2.message().messageText = text; - builder2.message().searchText = text; - - auto message2 = builder2.release(); - - return std::make_pair(message1, message2); -} - -void TwitchMessageBuilder::setThread(std::shared_ptr thread) -{ - this->thread_ = std::move(thread); -} - -void TwitchMessageBuilder::setParent(MessagePtr parent) -{ - this->parent_ = std::move(parent); -} - -void TwitchMessageBuilder::setMessageOffset(int offset) -{ - this->messageOffset_ = offset; -} - -} // namespace chatterino diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp deleted file mode 100644 index dd38fc790..000000000 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ /dev/null @@ -1,166 +0,0 @@ -#pragma once - -#include "common/Aliases.hpp" -#include "common/Outcome.hpp" -#include "messages/SharedMessageBuilder.hpp" -#include "pubsubmessages/LowTrustUsers.hpp" - -#include -#include -#include - -#include -#include - -namespace chatterino { - -struct Emote; -using EmotePtr = std::shared_ptr; - -class Channel; -class TwitchChannel; -class MessageThread; -class IgnorePhrase; -struct HelixVip; -using HelixModerator = HelixVip; -struct ChannelPointReward; -struct DeleteAction; - -struct TwitchEmoteOccurrence { - int start; - int end; - EmotePtr ptr; - EmoteName name; - - bool operator==(const TwitchEmoteOccurrence &other) const - { - return std::tie(this->start, this->end, this->ptr, this->name) == - std::tie(other.start, other.end, other.ptr, other.name); - } -}; - -class TwitchMessageBuilder : public SharedMessageBuilder -{ -public: - TwitchMessageBuilder() = delete; - - explicit TwitchMessageBuilder(Channel *_channel, - const Communi::IrcPrivateMessage *_ircMessage, - const MessageParseArgs &_args); - explicit TwitchMessageBuilder(Channel *_channel, - const Communi::IrcMessage *_ircMessage, - const MessageParseArgs &_args, - QString content, bool isAction); - - TwitchChannel *twitchChannel; - - [[nodiscard]] bool isIgnored() const override; - bool isIgnoredReply() const; - void triggerHighlights() override; - MessagePtr build() override; - - void setThread(std::shared_ptr thread); - void setParent(MessagePtr parent); - void setMessageOffset(int offset); - - static void appendChannelPointRewardMessage( - const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, - bool isBroadcaster); - - // Message in the /live chat for channel going live - static void liveMessage(const QString &channelName, - MessageBuilder *builder); - - // Messages in normal chat for channel stuff - static void liveSystemMessage(const QString &channelName, - MessageBuilder *builder); - static void offlineSystemMessage(const QString &channelName, - MessageBuilder *builder); - static void hostingSystemMessage(const QString &channelName, - MessageBuilder *builder, bool hostOn); - static void deletionMessage(const MessagePtr originalMessage, - MessageBuilder *builder); - static void deletionMessage(const DeleteAction &action, - MessageBuilder *builder); - static void listOfUsersSystemMessage(QString prefix, QStringList users, - Channel *channel, - MessageBuilder *builder); - static void listOfUsersSystemMessage( - QString prefix, const std::vector &users, - Channel *channel, MessageBuilder *builder); - - static MessagePtr buildHypeChatMessage(Communi::IrcPrivateMessage *message); - - static std::pair makeAutomodMessage( - const AutomodAction &action, const QString &channelName); - static MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action); - - static std::pair makeLowTrustUserMessage( - const PubSubLowTrustUsersMessage &action, const QString &channelName, - const TwitchChannel *twitchChannel); - static MessagePtr makeLowTrustUpdateMessage( - const PubSubLowTrustUsersMessage &action); - - // Shares some common logic from SharedMessageBuilder::parseBadgeTag - static std::unordered_map parseBadgeInfoTag( - const QVariantMap &tags); - - static std::vector parseTwitchEmotes( - const QVariantMap &tags, const QString &originalMessage, - int messageOffset); - - static void processIgnorePhrases( - const std::vector &phrases, QString &originalMessage, - std::vector &twitchEmotes); - -private: - void parseUsernameColor() override; - void parseUsername() override; - void parseMessageID(); - void parseRoomID(); - // Parse & build thread information into the message - // Will read information from thread_ or from IRC tags - void parseThread(); - void appendUsername(); - - Outcome tryAppendEmote(const EmoteName &name) override; - - void addWords(const QStringList &words, - const std::vector &twitchEmotes); - void addTextOrEmoji(EmotePtr emote) override; - void addTextOrEmoji(const QString &value) override; - - void appendTwitchBadges(); - void appendChatterinoBadges(); - void appendFfzBadges(); - void appendSeventvBadges(); - Outcome tryParseCheermote(const QString &string); - - bool shouldAddModerationElements() const; - - QString roomID_; - bool hasBits_ = false; - QString bits; - int bitsLeft{}; - bool bitsStacked = false; - bool historicalMessage_ = false; - std::shared_ptr thread_; - MessagePtr parent_; - - /** - * Starting offset to be used on index-based operations on `originalMessage_`. - * - * For example: - * originalMessage_ = "there" - * messageOffset_ = 4 - * (the irc message is "hey there") - * - * then the index 6 would resolve to 6 - 4 = 2 => 'e' - */ - int messageOffset_ = 0; - - QString userId_; - bool senderIsBroadcaster{}; -}; - -} // namespace chatterino diff --git a/src/singletons/ImageUploader.cpp b/src/singletons/ImageUploader.cpp index ebf39a958..ca082a723 100644 --- a/src/singletons/ImageUploader.cpp +++ b/src/singletons/ImageUploader.cpp @@ -7,7 +7,6 @@ #include "common/QLogging.hpp" #include "debug/Benchmark.hpp" #include "messages/MessageBuilder.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" #include "util/CombinePath.hpp" diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index 02bf572e9..d499e5fc0 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace chatterino { diff --git a/src/util/IrcHelpers.hpp b/src/util/IrcHelpers.hpp index 77e5c4ca6..52691b350 100644 --- a/src/util/IrcHelpers.hpp +++ b/src/util/IrcHelpers.hpp @@ -97,4 +97,17 @@ inline QDateTime calculateMessageTime(const Communi::IrcMessage *message) return QDateTime::currentDateTime(); } +// "foo/bar/baz,tri/hard" can be a valid badge-info tag +// In that case, valid map content should be 'split by slash' only once: +// {"foo": "bar/baz", "tri": "hard"} +inline std::pair slashKeyValue(const QString &kvStr) +{ + return { + // part before first slash (index 0 of section) + kvStr.section('/', 0, 0), + // part after first slash (index 1 of section) + kvStr.section('/', 1, -1), + }; +} + } // namespace chatterino diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index f267fa4d6..6fbfc12c5 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -15,7 +15,6 @@ #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Fonts.hpp" #include "singletons/ImageUploader.hpp" #include "singletons/Settings.hpp" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9f79b77eb..40c688621 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,7 +22,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/UtilTwitch.cpp ${CMAKE_CURRENT_LIST_DIR}/src/IrcHelpers.cpp ${CMAKE_CURRENT_LIST_DIR}/src/TwitchPubSubClient.cpp - ${CMAKE_CURRENT_LIST_DIR}/src/TwitchMessageBuilder.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/MessageBuilder.cpp ${CMAKE_CURRENT_LIST_DIR}/src/HighlightController.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FormatTime.cpp ${CMAKE_CURRENT_LIST_DIR}/src/LimitedQueue.cpp diff --git a/tests/src/Filters.cpp b/tests/src/Filters.cpp index cb8c07c74..132bd18cb 100644 --- a/tests/src/Filters.cpp +++ b/tests/src/Filters.cpp @@ -3,6 +3,7 @@ #include "controllers/filters/lang/Filter.hpp" #include "controllers/filters/lang/Types.hpp" #include "controllers/highlights/HighlightController.hpp" +#include "messages/MessageBuilder.hpp" #include "mocks/Channel.hpp" #include "mocks/ChatterinoBadges.hpp" #include "mocks/EmptyApplication.hpp" @@ -11,7 +12,6 @@ #include "providers/ffz/FfzBadges.hpp" #include "providers/seventv/SeventvBadges.hpp" #include "providers/twitch/TwitchBadge.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Emotes.hpp" #include "Test.hpp" @@ -280,7 +280,7 @@ TEST_F(FiltersF, TypingContextChecks) QString originalMessage = privmsg->content(); - TwitchMessageBuilder builder(&channel, privmsg, MessageParseArgs{}); + MessageBuilder builder(&channel, privmsg, MessageParseArgs{}); auto msg = builder.build(); EXPECT_NE(msg.get(), nullptr); diff --git a/tests/src/TwitchMessageBuilder.cpp b/tests/src/MessageBuilder.cpp similarity index 96% rename from tests/src/TwitchMessageBuilder.cpp rename to tests/src/MessageBuilder.cpp index 7c29a4ae4..8a8ea66ac 100644 --- a/tests/src/TwitchMessageBuilder.cpp +++ b/tests/src/MessageBuilder.cpp @@ -1,10 +1,8 @@ -#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "messages/MessageBuilder.hpp" -#include "common/Channel.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/highlights/HighlightController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" -#include "messages/MessageBuilder.hpp" #include "mocks/Channel.hpp" #include "mocks/ChatterinoBadges.hpp" #include "mocks/DisabledStreamerMode.hpp" @@ -16,6 +14,7 @@ #include "providers/twitch/TwitchBadge.hpp" #include "singletons/Emotes.hpp" #include "Test.hpp" +#include "util/IrcHelpers.hpp" #include #include @@ -115,7 +114,7 @@ public: } // namespace -TEST(TwitchMessageBuilder, CommaSeparatedListTagParsing) +TEST(MessageBuilder, CommaSeparatedListTagParsing) { struct TestCase { QString input; @@ -151,14 +150,14 @@ TEST(TwitchMessageBuilder, CommaSeparatedListTagParsing) for (const auto &test : testCases) { - auto output = TwitchMessageBuilder::slashKeyValue(test.input); + auto output = slashKeyValue(test.input); EXPECT_EQ(output, test.expectedOutput) << "Input " << test.input << " failed"; } } -class TestTwitchMessageBuilder : public ::testing::Test +class TestMessageBuilder : public ::testing::Test { protected: void SetUp() override @@ -174,7 +173,7 @@ protected: std::unique_ptr mockApplication; }; -TEST(TwitchMessageBuilder, BadgeInfoParsing) +TEST(MessageBuilder, BadgeInfoParsing) { struct TestCase { QByteArray input; @@ -235,12 +234,11 @@ TEST(TwitchMessageBuilder, BadgeInfoParsing) Communi::IrcPrivateMessage::fromData(test.input, nullptr); auto outputBadgeInfo = - TwitchMessageBuilder::parseBadgeInfoTag(privmsg->tags()); + MessageBuilder::parseBadgeInfoTag(privmsg->tags()); EXPECT_EQ(outputBadgeInfo, test.expectedBadgeInfo) << "Input for badgeInfo " << test.input << " failed"; - auto outputBadges = - SharedMessageBuilder::parseBadgeTag(privmsg->tags()); + auto outputBadges = MessageBuilder::parseBadgeTag(privmsg->tags()); EXPECT_EQ(outputBadges, test.expectedBadges) << "Input for badges " << test.input << " failed"; @@ -248,7 +246,7 @@ TEST(TwitchMessageBuilder, BadgeInfoParsing) } } -TEST_F(TestTwitchMessageBuilder, ParseTwitchEmotes) +TEST_F(TestMessageBuilder, ParseTwitchEmotes) { struct TestCase { QByteArray input; @@ -416,7 +414,7 @@ TEST_F(TestTwitchMessageBuilder, ParseTwitchEmotes) QString originalMessage = privmsg->content(); // TODO: Add tests with replies - auto actualTwitchEmotes = TwitchMessageBuilder::parseTwitchEmotes( + auto actualTwitchEmotes = MessageBuilder::parseTwitchEmotes( privmsg->tags(), originalMessage, 0); EXPECT_EQ(actualTwitchEmotes, test.expectedTwitchEmotes) @@ -426,7 +424,7 @@ TEST_F(TestTwitchMessageBuilder, ParseTwitchEmotes) } } -TEST_F(TestTwitchMessageBuilder, ParseMessage) +TEST_F(TestMessageBuilder, ParseMessage) { MockChannel channel("pajlada"); @@ -484,7 +482,7 @@ TEST_F(TestTwitchMessageBuilder, ParseMessage) QString originalMessage = privmsg->content(); - TwitchMessageBuilder builder(&channel, privmsg, MessageParseArgs{}); + MessageBuilder builder(&channel, privmsg, MessageParseArgs{}); auto msg = builder.build(); EXPECT_NE(msg.get(), nullptr); @@ -493,7 +491,7 @@ TEST_F(TestTwitchMessageBuilder, ParseMessage) } } -TEST_F(TestTwitchMessageBuilder, IgnoresReplace) +TEST_F(TestMessageBuilder, IgnoresReplace) { struct TestCase { std::vector phrases; @@ -619,8 +617,7 @@ TEST_F(TestTwitchMessageBuilder, IgnoresReplace) { auto message = test.input; auto emotes = test.twitchEmotes; - TwitchMessageBuilder::processIgnorePhrases(test.phrases, message, - emotes); + MessageBuilder::processIgnorePhrases(test.phrases, message, emotes); EXPECT_EQ(message, test.expectedMessage) << "Message not equal for input '" << test.input