3138 lines
101 KiB
C++
3138 lines
101 KiB
C++
#include "messages/MessageBuilder.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/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/pubsubmessages/AutoMod.hpp"
|
|
#include "providers/twitch/TwitchAccount.hpp"
|
|
#include "providers/twitch/TwitchBadge.hpp"
|
|
#include "providers/twitch/TwitchBadges.hpp"
|
|
#include "providers/twitch/TwitchChannel.hpp"
|
|
#include "providers/twitch/TwitchIrc.hpp"
|
|
#include "providers/twitch/TwitchIrcServer.hpp"
|
|
#include "providers/twitch/TwitchUsers.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 "util/Variant.hpp"
|
|
#include "widgets/Window.hpp"
|
|
|
|
#include <boost/variant.hpp>
|
|
#include <QApplication>
|
|
#include <QColor>
|
|
#include <QDateTime>
|
|
#include <QDebug>
|
|
#include <QFileInfo>
|
|
#include <QStringBuilder>
|
|
#include <QTimeZone>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <unordered_set>
|
|
|
|
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 QRegularExpression SPACE_REGEX("\\s");
|
|
|
|
const QSet<QString> zeroWidthEmotes{
|
|
"SoSnowy", "IceCold", "SantaHat", "TopHat",
|
|
"ReinDeer", "CandyCane", "cvMask", "cvHazmat",
|
|
};
|
|
|
|
struct HypeChatPaidLevel {
|
|
std::chrono::seconds duration;
|
|
uint8_t numeric;
|
|
};
|
|
|
|
const std::unordered_map<QString, HypeChatPaidLevel> HYPE_CHAT_PAID_LEVEL{
|
|
{u"ONE"_s, {30s, 1}}, {u"TWO"_s, {2min + 30s, 2}},
|
|
{u"THREE"_s, {5min, 3}}, {u"FOUR"_s, {10min, 4}},
|
|
{u"FIVE"_s, {30min, 5}}, {u"SIX"_s, {1h, 6}},
|
|
{u"SEVEN"_s, {2h, 7}}, {u"EIGHT"_s, {3h, 8}},
|
|
{u"NINE"_s, {4h, 9}}, {u"TEN"_s, {5h, 10}},
|
|
};
|
|
|
|
QString formatUpdatedEmoteList(const QString &platform,
|
|
const std::vector<QString> &emoteNames,
|
|
bool isAdd, bool isFirstWord)
|
|
{
|
|
QString text = "";
|
|
if (isAdd)
|
|
{
|
|
text += isFirstWord ? "Added" : "added";
|
|
}
|
|
else
|
|
{
|
|
text += isFirstWord ? "Removed" : "removed";
|
|
}
|
|
|
|
if (emoteNames.size() == 1)
|
|
{
|
|
text += QString(" %1 emote ").arg(platform);
|
|
}
|
|
else
|
|
{
|
|
text += QString(" %1 %2 emotes ").arg(emoteNames.size()).arg(platform);
|
|
}
|
|
|
|
size_t i = 0;
|
|
for (const auto &emoteName : emoteNames)
|
|
{
|
|
i++;
|
|
if (i > 1)
|
|
{
|
|
text += i == emoteNames.size() ? " and " : ", ";
|
|
}
|
|
text += emoteName;
|
|
}
|
|
|
|
text += ".";
|
|
|
|
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 QUrl &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)
|
|
{
|
|
QUrl soundUrl = customSoundUrl;
|
|
if (soundUrl.isEmpty())
|
|
{
|
|
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;
|
|
}
|
|
|
|
std::optional<EmotePtr> 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<Badge> &badges,
|
|
const std::unordered_map<QString, QString> &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<ModBadgeElement>(
|
|
*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<VipBadgeElement>(
|
|
*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<BadgeElement>(*badgeEmote, badge.flag_)
|
|
->setTooltip(tooltip);
|
|
}
|
|
|
|
builder->message().badges = badges;
|
|
builder->message().badgeInfos = badgeInfos;
|
|
}
|
|
|
|
bool doesWordContainATwitchEmote(
|
|
int cursor, const QString &word,
|
|
const std::vector<TwitchEmoteOccurrence> &twitchEmotes,
|
|
std::vector<TwitchEmoteOccurrence>::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>(Emote{
|
|
EmoteName{},
|
|
ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)},
|
|
Tooltip{"AutoMod"},
|
|
Url{"https://dashboard.twitch.tv/settings/moderation/automod"}});
|
|
}
|
|
|
|
EmotePtr makeSharedChatBadge(const QString &sourceName)
|
|
{
|
|
return std::make_shared<Emote>(Emote{
|
|
.name = EmoteName{},
|
|
.images = ImageSet{Image::fromResourcePixmap(
|
|
getResources().twitch.sharedChat, 0.25)},
|
|
.tooltip = Tooltip{"Shared Message" +
|
|
(sourceName.isEmpty() ? "" : " from " + sourceName)},
|
|
.homePage = Url{"https://link.twitch.tv/SharedChatViewer"},
|
|
});
|
|
}
|
|
|
|
std::tuple<std::optional<EmotePtr>, MessageElementFlags, bool> parseEmote(
|
|
TwitchChannel *twitchChannel, const EmoteName &name)
|
|
{
|
|
// Emote order:
|
|
// - FrankerFaceZ Channel
|
|
// - BetterTTV Channel
|
|
// - 7TV Channel
|
|
// - FrankerFaceZ Global
|
|
// - BetterTTV Global
|
|
// - 7TV Global
|
|
|
|
const auto *globalFfzEmotes = getApp()->getFfzEmotes();
|
|
const auto *globalBttvEmotes = getApp()->getBttvEmotes();
|
|
const auto *globalSeventvEmotes = getApp()->getSeventvEmotes();
|
|
|
|
std::optional<EmotePtr> emote{};
|
|
|
|
if (twitchChannel != nullptr)
|
|
{
|
|
// Check for channel emotes
|
|
|
|
emote = twitchChannel->ffzEmote(name);
|
|
if (emote)
|
|
{
|
|
return {
|
|
emote,
|
|
MessageElementFlag::FfzEmote,
|
|
false,
|
|
};
|
|
}
|
|
|
|
emote = twitchChannel->bttvEmote(name);
|
|
if (emote)
|
|
{
|
|
return {
|
|
emote,
|
|
MessageElementFlag::BttvEmote,
|
|
false,
|
|
};
|
|
}
|
|
|
|
emote = twitchChannel->seventvEmote(name);
|
|
if (emote)
|
|
{
|
|
return {
|
|
emote,
|
|
MessageElementFlag::SevenTVEmote,
|
|
emote.value()->zeroWidth,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check for global emotes
|
|
|
|
emote = globalFfzEmotes->emote(name);
|
|
if (emote)
|
|
{
|
|
return {
|
|
emote,
|
|
MessageElementFlag::FfzEmote,
|
|
false,
|
|
};
|
|
}
|
|
|
|
emote = globalBttvEmotes->emote(name);
|
|
if (emote)
|
|
{
|
|
return {
|
|
emote,
|
|
MessageElementFlag::BttvEmote,
|
|
zeroWidthEmotes.contains(name.string),
|
|
};
|
|
}
|
|
|
|
emote = globalSeventvEmotes->globalEmote(name);
|
|
if (emote)
|
|
{
|
|
return {
|
|
emote,
|
|
MessageElementFlag::SevenTVEmote,
|
|
emote.value()->zeroWidth,
|
|
};
|
|
}
|
|
|
|
return {
|
|
{},
|
|
{},
|
|
false,
|
|
};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
namespace chatterino {
|
|
|
|
MessagePtr makeSystemMessage(const QString &text)
|
|
{
|
|
return MessageBuilder(systemMessage, text).release();
|
|
}
|
|
|
|
MessagePtr makeSystemMessage(const QString &text, const QTime &time)
|
|
{
|
|
return MessageBuilder(systemMessage, text, time).release();
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder()
|
|
: message_(std::make_shared<Message>())
|
|
{
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(SystemMessageTag, const QString &text,
|
|
const QTime &time)
|
|
: MessageBuilder()
|
|
{
|
|
this->emplace<TimestampElement>(time);
|
|
|
|
// check system message for links
|
|
// (e.g. needed for sub ticket message in sub only mode)
|
|
const QStringList textFragments =
|
|
text.split(SPACE_REGEX, Qt::SkipEmptyParts);
|
|
for (const auto &word : textFragments)
|
|
{
|
|
auto link = linkparser::parse(word);
|
|
if (link)
|
|
{
|
|
this->addLink(*link, word);
|
|
continue;
|
|
}
|
|
|
|
this->appendOrEmplaceText(word, MessageColor::System);
|
|
}
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
this->message().messageText = text;
|
|
this->message().searchText = text;
|
|
}
|
|
|
|
MessagePtrMut MessageBuilder::makeSystemMessageWithUser(
|
|
const QString &text, const QString &loginName, const QString &displayName,
|
|
const MessageColor &userColor, const QTime &time)
|
|
{
|
|
MessageBuilder builder;
|
|
builder.emplace<TimestampElement>(time);
|
|
|
|
const auto textFragments = text.split(SPACE_REGEX, Qt::SkipEmptyParts);
|
|
for (const auto &word : textFragments)
|
|
{
|
|
if (word == displayName)
|
|
{
|
|
builder.emplace<MentionElement>(displayName, loginName,
|
|
MessageColor::System, userColor);
|
|
continue;
|
|
}
|
|
|
|
builder.appendOrEmplaceText(word, MessageColor::System);
|
|
}
|
|
|
|
builder->flags.set(MessageFlag::System);
|
|
builder->flags.set(MessageFlag::DoNotTriggerNotification);
|
|
builder->messageText = text;
|
|
builder->searchText = text;
|
|
|
|
return builder.release();
|
|
}
|
|
|
|
MessagePtrMut MessageBuilder::makeSubgiftMessage(const QString &text,
|
|
const QVariantMap &tags,
|
|
const QTime &time)
|
|
{
|
|
MessageBuilder builder;
|
|
builder.emplace<TimestampElement>(time);
|
|
|
|
auto gifterLogin = tags.value("login").toString();
|
|
auto gifterDisplayName = tags.value("display-name").toString();
|
|
if (gifterDisplayName.isEmpty())
|
|
{
|
|
gifterDisplayName = gifterLogin;
|
|
}
|
|
MessageColor gifterColor = MessageColor::System;
|
|
if (auto colorTag = tags.value("color").value<QColor>(); colorTag.isValid())
|
|
{
|
|
gifterColor = MessageColor(colorTag);
|
|
}
|
|
|
|
auto recipientLogin =
|
|
tags.value("msg-param-recipient-user-name").toString();
|
|
if (recipientLogin.isEmpty())
|
|
{
|
|
recipientLogin = tags.value("msg-param-recipient-name").toString();
|
|
}
|
|
auto recipientDisplayName =
|
|
tags.value("msg-param-recipient-display-name").toString();
|
|
if (recipientDisplayName.isEmpty())
|
|
{
|
|
recipientDisplayName = recipientLogin;
|
|
}
|
|
|
|
const auto textFragments = text.split(SPACE_REGEX, Qt::SkipEmptyParts);
|
|
for (const auto &word : textFragments)
|
|
{
|
|
if (word == gifterDisplayName)
|
|
{
|
|
builder.emplace<MentionElement>(gifterDisplayName, gifterLogin,
|
|
MessageColor::System, gifterColor);
|
|
continue;
|
|
}
|
|
if (word.endsWith('!') &&
|
|
word.size() == recipientDisplayName.size() + 1 &&
|
|
word.startsWith(recipientDisplayName))
|
|
{
|
|
builder
|
|
.emplace<MentionElement>(recipientDisplayName, recipientLogin,
|
|
MessageColor::System,
|
|
MessageColor::System)
|
|
->setTrailingSpace(false);
|
|
builder.emplace<TextElement>(u"!"_s, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
continue;
|
|
}
|
|
|
|
builder.appendOrEmplaceText(word, MessageColor::System);
|
|
}
|
|
|
|
builder->flags.set(MessageFlag::System);
|
|
builder->flags.set(MessageFlag::DoNotTriggerNotification);
|
|
builder->messageText = text;
|
|
builder->searchText = text;
|
|
|
|
return builder.release();
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &timeoutUser,
|
|
const QString &sourceUser,
|
|
const QString &channel,
|
|
const QString &systemMessageText, uint32_t times,
|
|
const QDateTime &time)
|
|
: MessageBuilder()
|
|
{
|
|
QString usernameText = systemMessageText.split(" ").at(0);
|
|
QString remainder = systemMessageText.mid(usernameText.length() + 1);
|
|
bool timeoutUserIsFirst =
|
|
usernameText == "You" || timeoutUser == usernameText;
|
|
QString messageText;
|
|
|
|
this->emplace<TimestampElement>(time.time());
|
|
this->emplaceSystemTextAndUpdate(usernameText, messageText)
|
|
->setLink(
|
|
{Link::UserInfo, timeoutUserIsFirst ? timeoutUser : sourceUser});
|
|
|
|
auto appendUser = [&](const QString &name) {
|
|
auto pos = remainder.indexOf(name);
|
|
if (pos > 0)
|
|
{
|
|
QString start = remainder.mid(0, pos - 1);
|
|
remainder = remainder.mid(pos + name.length());
|
|
|
|
this->emplaceSystemTextAndUpdate(start, messageText);
|
|
auto *el = this->emplaceSystemTextAndUpdate(name, messageText)
|
|
->setLink({Link::UserInfo, name});
|
|
if (remainder.startsWith(' '))
|
|
{
|
|
removeFirstQS(remainder);
|
|
}
|
|
else
|
|
{
|
|
assert(messageText.endsWith(' '));
|
|
removeLastQS(messageText);
|
|
el->setTrailingSpace(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (!sourceUser.isEmpty())
|
|
{
|
|
// the second username in the message
|
|
appendUser(timeoutUserIsFirst ? sourceUser : timeoutUser);
|
|
}
|
|
|
|
if (!channel.isEmpty())
|
|
{
|
|
appendUser(channel);
|
|
}
|
|
|
|
this->emplaceSystemTextAndUpdate(
|
|
QString("%1 (%2 times)").arg(remainder.trimmed()).arg(times),
|
|
messageText);
|
|
|
|
this->message().messageText = messageText;
|
|
this->message().searchText = messageText;
|
|
this->message().serverReceivedTime = time;
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &username,
|
|
const QString &durationInSeconds,
|
|
bool multipleTimes, const QDateTime &time)
|
|
: MessageBuilder()
|
|
{
|
|
QString fullText;
|
|
QString text;
|
|
|
|
this->emplace<TimestampElement>(time.time());
|
|
this->emplaceSystemTextAndUpdate(username, fullText)
|
|
->setLink({Link::UserInfo, username});
|
|
|
|
if (!durationInSeconds.isEmpty())
|
|
{
|
|
text.append("has been timed out");
|
|
|
|
// TODO: Implement who timed the user out
|
|
|
|
text.append(" for ");
|
|
bool ok = true;
|
|
int timeoutSeconds = durationInSeconds.toInt(&ok);
|
|
if (ok)
|
|
{
|
|
text.append(formatTime(timeoutSeconds));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
text.append("has been permanently banned");
|
|
}
|
|
|
|
text.append(".");
|
|
|
|
if (multipleTimes)
|
|
{
|
|
text.append(" (multiple times)");
|
|
}
|
|
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::Timeout);
|
|
this->message().flags.set(MessageFlag::ModerationAction);
|
|
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
this->message().timeoutUser = username;
|
|
|
|
this->emplaceSystemTextAndUpdate(text, fullText);
|
|
this->message().messageText = fullText;
|
|
this->message().searchText = fullText;
|
|
this->message().serverReceivedTime = time;
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(const BanAction &action, const QDateTime &time,
|
|
uint32_t count)
|
|
: MessageBuilder()
|
|
{
|
|
auto current = getApp()->getAccounts()->twitch.getCurrent();
|
|
|
|
this->emplace<TimestampElement>();
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::Timeout);
|
|
this->message().flags.set(MessageFlag::ModerationAction);
|
|
this->message().timeoutUser = action.target.login;
|
|
this->message().loginName = action.source.login;
|
|
this->message().count = count;
|
|
|
|
QString text;
|
|
|
|
if (action.target.id == current->getUserId())
|
|
{
|
|
this->emplaceSystemTextAndUpdate("You", text)
|
|
->setLink({Link::UserInfo, current->getUserName()});
|
|
this->emplaceSystemTextAndUpdate("were", text);
|
|
if (action.isBan())
|
|
{
|
|
this->appendOrEmplaceSystemTextAndUpdate("banned", text);
|
|
}
|
|
else
|
|
{
|
|
this->appendOrEmplaceSystemTextAndUpdate(
|
|
QString("timed out for %1").arg(formatTime(action.duration)),
|
|
text);
|
|
}
|
|
|
|
if (!action.source.login.isEmpty())
|
|
{
|
|
this->appendOrEmplaceSystemTextAndUpdate("by", text);
|
|
this->emplaceSystemTextAndUpdate(
|
|
action.source.login + (action.reason.isEmpty() ? "." : ":"),
|
|
text)
|
|
->setLink({Link::UserInfo, action.source.login});
|
|
}
|
|
|
|
if (!action.reason.isEmpty())
|
|
{
|
|
this->appendOrEmplaceSystemTextAndUpdate(
|
|
QString("\"%1\".").arg(action.reason), text);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (action.isBan())
|
|
{
|
|
this->emplaceSystemTextAndUpdate(action.source.login, text)
|
|
->setLink({Link::UserInfo, action.source.login});
|
|
this->emplaceSystemTextAndUpdate("banned", text);
|
|
if (action.reason.isEmpty())
|
|
{
|
|
this->emplaceSystemTextAndUpdate(action.target.login + ".",
|
|
text)
|
|
->setLink({Link::UserInfo, action.target.login});
|
|
}
|
|
else
|
|
{
|
|
this->emplaceSystemTextAndUpdate(action.target.login + ":",
|
|
text)
|
|
->setLink({Link::UserInfo, action.target.login});
|
|
this->emplaceSystemTextAndUpdate(
|
|
QString("\"%1\".").arg(action.reason), text);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
this->emplaceSystemTextAndUpdate(action.source.login, text)
|
|
->setLink({Link::UserInfo, action.source.login});
|
|
this->emplaceSystemTextAndUpdate("timed out", text);
|
|
this->emplaceSystemTextAndUpdate(action.target.login, text)
|
|
->setLink({Link::UserInfo, action.target.login});
|
|
if (action.reason.isEmpty())
|
|
{
|
|
this->emplaceSystemTextAndUpdate(
|
|
QString("for %1.").arg(formatTime(action.duration)), text);
|
|
}
|
|
else
|
|
{
|
|
this->emplaceSystemTextAndUpdate(
|
|
QString("for %1: \"%2\".")
|
|
.arg(formatTime(action.duration))
|
|
.arg(action.reason),
|
|
text);
|
|
}
|
|
|
|
if (count > 1)
|
|
{
|
|
this->appendOrEmplaceSystemTextAndUpdate(
|
|
QString("(%1 times)").arg(count), text);
|
|
}
|
|
}
|
|
}
|
|
|
|
this->message().messageText = text;
|
|
this->message().searchText = text;
|
|
|
|
this->message().serverReceivedTime = time;
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(const UnbanAction &action, const QDateTime &time)
|
|
: MessageBuilder()
|
|
{
|
|
this->emplace<TimestampElement>();
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::Untimeout);
|
|
|
|
this->message().timeoutUser = action.target.login;
|
|
|
|
QString text;
|
|
|
|
this->emplaceSystemTextAndUpdate(action.source.login, text)
|
|
->setLink({Link::UserInfo, action.source.login});
|
|
this->emplaceSystemTextAndUpdate(
|
|
action.wasBan() ? "unbanned" : "untimedout", text);
|
|
this->emplaceSystemTextAndUpdate(action.target.login + ".", text)
|
|
->setLink({Link::UserInfo, action.target.login});
|
|
|
|
this->message().messageText = text;
|
|
this->message().searchText = text;
|
|
|
|
this->message().serverReceivedTime = time;
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(const WarnAction &action)
|
|
: MessageBuilder()
|
|
{
|
|
this->emplace<TimestampElement>();
|
|
this->message().flags.set(MessageFlag::System);
|
|
|
|
QString text;
|
|
|
|
// TODO: Use MentionElement here, once WarnAction includes username/displayname
|
|
this->emplaceSystemTextAndUpdate("A moderator", text)
|
|
->setLink({Link::UserInfo, "id:" + action.source.id});
|
|
this->emplaceSystemTextAndUpdate("warned", text);
|
|
this->emplaceSystemTextAndUpdate(
|
|
action.target.login + (action.reasons.isEmpty() ? "." : ":"), text)
|
|
->setLink({Link::UserInfo, action.target.login});
|
|
|
|
if (!action.reasons.isEmpty())
|
|
{
|
|
this->emplaceSystemTextAndUpdate(action.reasons.join(", "), text);
|
|
}
|
|
|
|
this->message().messageText = text;
|
|
this->message().searchText = text;
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(const RaidAction &action)
|
|
: MessageBuilder()
|
|
{
|
|
this->emplace<TimestampElement>();
|
|
this->message().flags.set(MessageFlag::System);
|
|
|
|
QString text;
|
|
|
|
this->emplaceSystemTextAndUpdate(action.source.login, text)
|
|
->setLink({Link::UserInfo, "id:" + action.source.id});
|
|
this->emplaceSystemTextAndUpdate("initiated a raid to", text);
|
|
this->emplaceSystemTextAndUpdate(action.target + ".", text)
|
|
->setLink({Link::UserInfo, action.target});
|
|
|
|
this->message().messageText = text;
|
|
this->message().searchText = text;
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(const UnraidAction &action)
|
|
: MessageBuilder()
|
|
{
|
|
this->emplace<TimestampElement>();
|
|
this->message().flags.set(MessageFlag::System);
|
|
|
|
QString text;
|
|
|
|
this->emplaceSystemTextAndUpdate(action.source.login, text)
|
|
->setLink({Link::UserInfo, "id:" + action.source.id});
|
|
this->emplaceSystemTextAndUpdate("canceled the raid.", text);
|
|
|
|
this->message().messageText = text;
|
|
this->message().searchText = text;
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(const AutomodUserAction &action)
|
|
: MessageBuilder()
|
|
{
|
|
this->emplace<TimestampElement>();
|
|
this->message().flags.set(MessageFlag::System);
|
|
|
|
QString text;
|
|
switch (action.type)
|
|
{
|
|
case AutomodUserAction::AddPermitted: {
|
|
text = QString("%1 added \"%2\" as a permitted term on AutoMod.")
|
|
.arg(action.source.login, action.message);
|
|
}
|
|
break;
|
|
|
|
case AutomodUserAction::AddBlocked: {
|
|
text = QString("%1 added \"%2\" as a blocked term on AutoMod.")
|
|
.arg(action.source.login, action.message);
|
|
}
|
|
break;
|
|
|
|
case AutomodUserAction::RemovePermitted: {
|
|
text = QString("%1 removed \"%2\" as a permitted term on AutoMod.")
|
|
.arg(action.source.login, action.message);
|
|
}
|
|
break;
|
|
|
|
case AutomodUserAction::RemoveBlocked: {
|
|
text = QString("%1 removed \"%2\" as a blocked term on AutoMod.")
|
|
.arg(action.source.login, action.message);
|
|
}
|
|
break;
|
|
|
|
case AutomodUserAction::Properties: {
|
|
text = QString("%1 modified the AutoMod properties.")
|
|
.arg(action.source.login);
|
|
}
|
|
break;
|
|
}
|
|
this->message().messageText = text;
|
|
this->message().searchText = text;
|
|
|
|
this->emplace<TextElement>(text, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(LiveUpdatesAddEmoteMessageTag /*unused*/,
|
|
const QString &platform, const QString &actor,
|
|
const std::vector<QString> &emoteNames)
|
|
: MessageBuilder()
|
|
{
|
|
auto text =
|
|
formatUpdatedEmoteList(platform, emoteNames, true, actor.isEmpty());
|
|
|
|
this->emplace<TimestampElement>();
|
|
if (!actor.isEmpty())
|
|
{
|
|
this->emplace<TextElement>(actor, MessageElementFlag::Username,
|
|
MessageColor::System)
|
|
->setLink({Link::UserInfo, actor});
|
|
}
|
|
this->emplace<TextElement>(text, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
|
|
QString finalText;
|
|
if (actor.isEmpty())
|
|
{
|
|
finalText = text;
|
|
}
|
|
else
|
|
{
|
|
finalText = QString("%1 %2").arg(actor, text);
|
|
}
|
|
|
|
this->message().loginName = actor;
|
|
this->message().messageText = finalText;
|
|
this->message().searchText = finalText;
|
|
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::LiveUpdatesAdd);
|
|
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(LiveUpdatesRemoveEmoteMessageTag /*unused*/,
|
|
const QString &platform, const QString &actor,
|
|
const std::vector<QString> &emoteNames)
|
|
: MessageBuilder()
|
|
{
|
|
auto text =
|
|
formatUpdatedEmoteList(platform, emoteNames, false, actor.isEmpty());
|
|
|
|
this->emplace<TimestampElement>();
|
|
if (!actor.isEmpty())
|
|
{
|
|
this->emplace<TextElement>(actor, MessageElementFlag::Username,
|
|
MessageColor::System)
|
|
->setLink({Link::UserInfo, actor});
|
|
}
|
|
this->emplace<TextElement>(text, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
|
|
QString finalText;
|
|
if (actor.isEmpty())
|
|
{
|
|
finalText = text;
|
|
}
|
|
else
|
|
{
|
|
finalText = QString("%1 %2").arg(actor, text);
|
|
}
|
|
|
|
this->message().loginName = actor;
|
|
this->message().messageText = finalText;
|
|
this->message().searchText = finalText;
|
|
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::LiveUpdatesRemove);
|
|
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteMessageTag /*unused*/,
|
|
const QString &platform, const QString &actor,
|
|
const QString &emoteName,
|
|
const QString &oldEmoteName)
|
|
: MessageBuilder()
|
|
{
|
|
QString text;
|
|
if (actor.isEmpty())
|
|
{
|
|
text = "Renamed";
|
|
}
|
|
else
|
|
{
|
|
text = "renamed";
|
|
}
|
|
text +=
|
|
QString(" %1 emote %2 to %3.").arg(platform, oldEmoteName, emoteName);
|
|
|
|
this->emplace<TimestampElement>();
|
|
if (!actor.isEmpty())
|
|
{
|
|
this->emplace<TextElement>(actor, MessageElementFlag::Username,
|
|
MessageColor::System)
|
|
->setLink({Link::UserInfo, actor});
|
|
}
|
|
this->emplace<TextElement>(text, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
|
|
QString finalText;
|
|
if (actor.isEmpty())
|
|
{
|
|
finalText = text;
|
|
}
|
|
else
|
|
{
|
|
finalText = QString("%1 %2").arg(actor, text);
|
|
}
|
|
|
|
this->message().loginName = actor;
|
|
this->message().messageText = finalText;
|
|
this->message().searchText = finalText;
|
|
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::LiveUpdatesUpdate);
|
|
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag /*unused*/,
|
|
const QString &platform, const QString &actor,
|
|
const QString &emoteSetName)
|
|
: MessageBuilder()
|
|
{
|
|
auto text = QString("switched the active %1 Emote Set to \"%2\".")
|
|
.arg(platform, emoteSetName);
|
|
|
|
this->emplace<TimestampElement>();
|
|
this->emplace<TextElement>(actor, MessageElementFlag::Username,
|
|
MessageColor::System)
|
|
->setLink({Link::UserInfo, actor});
|
|
this->emplace<TextElement>(text, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
|
|
auto finalText = QString("%1 %2").arg(actor, text);
|
|
|
|
this->message().loginName = actor;
|
|
this->message().messageText = finalText;
|
|
this->message().searchText = finalText;
|
|
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::LiveUpdatesUpdate);
|
|
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
}
|
|
|
|
MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/,
|
|
const QString &imageLink,
|
|
const QString &deletionLink,
|
|
size_t imagesStillQueued, size_t secondsLeft)
|
|
: MessageBuilder()
|
|
{
|
|
this->message().flags.set(MessageFlag::System);
|
|
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
|
|
this->emplace<TimestampElement>();
|
|
|
|
using MEF = MessageElementFlag;
|
|
auto addText = [this](QString text,
|
|
MessageColor color =
|
|
MessageColor::System) -> TextElement * {
|
|
this->message().searchText += text;
|
|
this->message().messageText += text;
|
|
return this->emplace<TextElement>(text, MEF::Text, color);
|
|
};
|
|
|
|
addText("Your image has been uploaded to");
|
|
|
|
// ASSUMPTION: the user gave this uploader configuration to the program
|
|
// therefore they trust that the host is not wrong/malicious. This doesn't obey getSettings()->lowercaseDomains.
|
|
// This also ensures that the LinkResolver doesn't get these links.
|
|
addText(imageLink, MessageColor::Link)
|
|
->setLink({Link::Url, imageLink})
|
|
->setTrailingSpace(!deletionLink.isEmpty());
|
|
|
|
if (!deletionLink.isEmpty())
|
|
{
|
|
addText("(Deletion link:");
|
|
addText(deletionLink, MessageColor::Link)
|
|
->setLink({Link::Url, deletionLink})
|
|
->setTrailingSpace(false);
|
|
addText(")")->setTrailingSpace(false);
|
|
}
|
|
addText(".");
|
|
|
|
if (imagesStillQueued == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
addText(QString("%1 left. Please wait until all of them are uploaded. "
|
|
"About %2 seconds left.")
|
|
.arg(imagesStillQueued)
|
|
.arg(secondsLeft));
|
|
}
|
|
|
|
Message *MessageBuilder::operator->()
|
|
{
|
|
return this->message_.get();
|
|
}
|
|
|
|
Message &MessageBuilder::message()
|
|
{
|
|
return *this->message_;
|
|
}
|
|
|
|
MessagePtrMut MessageBuilder::release()
|
|
{
|
|
std::shared_ptr<Message> ptr;
|
|
this->message_.swap(ptr);
|
|
return ptr;
|
|
}
|
|
|
|
std::weak_ptr<const Message> MessageBuilder::weakOf()
|
|
{
|
|
return this->message_;
|
|
}
|
|
|
|
void MessageBuilder::append(std::unique_ptr<MessageElement> element)
|
|
{
|
|
this->message().elements.push_back(std::move(element));
|
|
}
|
|
|
|
void MessageBuilder::addLink(const linkparser::Parsed &parsedLink,
|
|
const QString &source)
|
|
{
|
|
QString lowercaseLinkString;
|
|
QString origLink = parsedLink.link.toString();
|
|
QString fullUrl;
|
|
|
|
if (parsedLink.protocol.isNull())
|
|
{
|
|
fullUrl = QStringLiteral("http://") + origLink;
|
|
}
|
|
else
|
|
{
|
|
lowercaseLinkString += parsedLink.protocol;
|
|
fullUrl = origLink;
|
|
}
|
|
|
|
lowercaseLinkString += parsedLink.host.toString().toLower();
|
|
lowercaseLinkString += parsedLink.rest;
|
|
|
|
auto textColor = MessageColor(MessageColor::Link);
|
|
|
|
if (parsedLink.hasPrefix(source))
|
|
{
|
|
this->emplace<TextElement>(parsedLink.prefix(source).toString(),
|
|
MessageElementFlag::Text, this->textColor_)
|
|
->setTrailingSpace(false);
|
|
}
|
|
auto *el = this->emplace<LinkElement>(
|
|
LinkElement::Parsed{.lowercase = lowercaseLinkString,
|
|
.original = origLink},
|
|
fullUrl, MessageElementFlag::Text, textColor);
|
|
if (parsedLink.hasSuffix(source))
|
|
{
|
|
el->setTrailingSpace(false);
|
|
this->emplace<TextElement>(parsedLink.suffix(source).toString(),
|
|
MessageElementFlag::Text, this->textColor_);
|
|
}
|
|
|
|
getApp()->getLinkResolver()->resolve(el->linkInfo());
|
|
}
|
|
|
|
bool MessageBuilder::isIgnored(const QString &originalMessage,
|
|
const QString &userID, const Channel *channel)
|
|
{
|
|
return isIgnoredMessage({
|
|
.message = originalMessage,
|
|
.twitchUserID = userID,
|
|
.isMod = channel->isMod(),
|
|
.isBroadcaster = channel->isBroadcaster(),
|
|
});
|
|
}
|
|
|
|
void MessageBuilder::appendOrEmplaceText(const QString &text,
|
|
MessageColor color)
|
|
{
|
|
auto fallback = [&] {
|
|
this->emplace<TextElement>(text, MessageElementFlag::Text, color);
|
|
};
|
|
if (this->message_->elements.empty())
|
|
{
|
|
fallback();
|
|
return;
|
|
}
|
|
|
|
auto *back =
|
|
dynamic_cast<TextElement *>(this->message_->elements.back().get());
|
|
if (!back || //
|
|
dynamic_cast<MentionElement *>(back) || //
|
|
dynamic_cast<LinkElement *>(back) || //
|
|
!back->hasTrailingSpace() || //
|
|
back->getFlags() != MessageElementFlag::Text || //
|
|
back->color() != color)
|
|
{
|
|
fallback();
|
|
return;
|
|
}
|
|
|
|
back->appendText(text);
|
|
}
|
|
|
|
void MessageBuilder::appendOrEmplaceSystemTextAndUpdate(const QString &text,
|
|
QString &toUpdate)
|
|
{
|
|
toUpdate.append(text);
|
|
toUpdate.append(' ');
|
|
this->appendOrEmplaceText(text, MessageColor::System);
|
|
}
|
|
|
|
void MessageBuilder::triggerHighlights(const Channel *channel,
|
|
const HighlightAlert &alert)
|
|
{
|
|
if (!alert.windowAlert && !alert.playSound)
|
|
{
|
|
return;
|
|
}
|
|
actuallyTriggerHighlights(channel->getName(), alert.playSound,
|
|
alert.customSound, alert.windowAlert);
|
|
}
|
|
|
|
void MessageBuilder::appendChannelPointRewardMessage(
|
|
const ChannelPointReward &reward, bool isMod, bool isBroadcaster)
|
|
{
|
|
if (isIgnoredMessage({
|
|
.message = {},
|
|
.twitchUserID = reward.user.id,
|
|
.isMod = isMod,
|
|
.isBroadcaster = isBroadcaster,
|
|
}))
|
|
{
|
|
return;
|
|
}
|
|
|
|
this->emplace<TimestampElement>();
|
|
QString redeemed = "Redeemed";
|
|
QStringList textList;
|
|
if (!reward.isUserInputRequired)
|
|
{
|
|
this->emplace<TextElement>(
|
|
reward.user.login, MessageElementFlag::ChannelPointReward,
|
|
MessageColor::Text, FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, reward.user.login});
|
|
redeemed = "redeemed";
|
|
textList.append(reward.user.login);
|
|
}
|
|
this->emplace<TextElement>(redeemed,
|
|
MessageElementFlag::ChannelPointReward);
|
|
if (reward.id == "CELEBRATION")
|
|
{
|
|
const auto emotePtr =
|
|
getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote(
|
|
EmoteId{reward.emoteId}, EmoteName{reward.emoteName});
|
|
this->emplace<EmoteElement>(emotePtr,
|
|
MessageElementFlag::ChannelPointReward,
|
|
MessageColor::Text);
|
|
}
|
|
this->emplace<TextElement>(reward.title,
|
|
MessageElementFlag::ChannelPointReward,
|
|
MessageColor::Text, FontStyle::ChatMediumBold);
|
|
this->emplace<ScalingImageElement>(
|
|
reward.image, MessageElementFlag::ChannelPointRewardImage);
|
|
this->emplace<TextElement>(QString::number(reward.cost),
|
|
MessageElementFlag::ChannelPointReward,
|
|
MessageColor::Text, FontStyle::ChatMediumBold);
|
|
if (reward.isBits)
|
|
{
|
|
this->emplace<TextElement>(
|
|
"bits", MessageElementFlag::ChannelPointReward, MessageColor::Text,
|
|
FontStyle::ChatMediumBold);
|
|
}
|
|
if (reward.isUserInputRequired)
|
|
{
|
|
this->emplace<LinebreakElement>(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(" ");
|
|
if (!reward.user.login.isEmpty())
|
|
{
|
|
this->message().loginName = reward.user.login;
|
|
}
|
|
|
|
this->message().reward = std::make_shared<ChannelPointReward>(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<TimestampElement>();
|
|
builder
|
|
.emplace<TextElement>(channelName, MessageElementFlag::Username,
|
|
MessageColor::Text, FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, channelName});
|
|
builder.emplace<TextElement>("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<TimestampElement>();
|
|
builder.message().flags.set(MessageFlag::System);
|
|
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
builder
|
|
.emplace<TextElement>(channelName, MessageElementFlag::Username,
|
|
MessageColor::System, FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, channelName});
|
|
builder.emplace<TextElement>("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<TimestampElement>();
|
|
builder.message().flags.set(MessageFlag::System);
|
|
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
if (hostOn)
|
|
{
|
|
builder.emplace<TextElement>("Now hosting", MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
builder
|
|
.emplace<TextElement>(
|
|
channelName + ".", MessageElementFlag::Username,
|
|
MessageColor::System, FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, channelName});
|
|
text = QString("Now hosting %1.").arg(channelName);
|
|
}
|
|
else
|
|
{
|
|
builder
|
|
.emplace<TextElement>(channelName, MessageElementFlag::Username,
|
|
MessageColor::System,
|
|
FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, channelName});
|
|
builder.emplace<TextElement>("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<TimestampElement>();
|
|
builder.message().flags.set(MessageFlag::System);
|
|
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
builder.message().flags.set(MessageFlag::ModerationAction);
|
|
// TODO(mm2pl): If or when jumping to a single message gets implemented a link,
|
|
// add a link to the originalMessage
|
|
builder.emplace<TextElement>("A message from", MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
builder
|
|
.emplace<TextElement>(originalMessage->displayName,
|
|
MessageElementFlag::Username,
|
|
MessageColor::System, FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, originalMessage->loginName});
|
|
builder.emplace<TextElement>("was deleted:", MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
if (originalMessage->messageText.length() > 50)
|
|
{
|
|
builder
|
|
.emplace<TextElement>(originalMessage->messageText.left(50) + "…",
|
|
MessageElementFlag::Text, MessageColor::Text)
|
|
->setLink({Link::JumpToMessage, originalMessage->id});
|
|
}
|
|
else
|
|
{
|
|
builder
|
|
.emplace<TextElement>(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<TimestampElement>();
|
|
builder.message().flags.set(MessageFlag::System);
|
|
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
builder.message().flags.set(MessageFlag::ModerationAction);
|
|
|
|
builder
|
|
.emplace<TextElement>(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<TextElement>(
|
|
"deleted message from", MessageElementFlag::Text, MessageColor::System);
|
|
builder
|
|
.emplace<TextElement>(action.target.login, MessageElementFlag::Username,
|
|
MessageColor::System, FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, action.target.login});
|
|
builder.emplace<TextElement>("saying:", MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
if (action.messageText.length() > 50)
|
|
{
|
|
builder
|
|
.emplace<TextElement>(action.messageText.left(50) + "…",
|
|
MessageElementFlag::Text, MessageColor::Text)
|
|
->setLink({Link::JumpToMessage, action.messageId});
|
|
}
|
|
else
|
|
{
|
|
builder
|
|
.emplace<TextElement>(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<TimestampElement>();
|
|
builder.message().flags.set(MessageFlag::System);
|
|
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
builder.emplace<TextElement>(prefix, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
bool isFirst = true;
|
|
auto *tc = dynamic_cast<TwitchChannel *>(channel);
|
|
for (const QString &username : users)
|
|
{
|
|
if (!isFirst)
|
|
{
|
|
// this is used to add the ", " after each but the last entry
|
|
builder.emplace<TextElement>(",", 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<MentionElement>(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<HelixModerator> &users, Channel *channel,
|
|
MessageFlags extraFlags)
|
|
{
|
|
MessageBuilder builder;
|
|
|
|
QString text = prefix;
|
|
|
|
builder.emplace<TimestampElement>();
|
|
builder.message().flags.set(MessageFlag::System);
|
|
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
builder.emplace<TextElement>(prefix, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
bool isFirst = true;
|
|
auto *tc = dynamic_cast<TwitchChannel *>(channel);
|
|
for (const auto &user : users)
|
|
{
|
|
if (!isFirst)
|
|
{
|
|
// this is used to add the ", " after each but the last entry
|
|
builder.emplace<TextElement>(",", 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<MentionElement>(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);
|
|
|
|
auto locale = getSystemLocale();
|
|
subtitle += locale.toCurrencyString(actualAmount, currency);
|
|
|
|
auto dt = calculateMessageTime(message);
|
|
MessageBuilder builder(systemMessage, parseTagString(subtitle), dt.time());
|
|
builder->flags.set(MessageFlag::ElevatedMessage);
|
|
return builder.release();
|
|
}
|
|
|
|
std::pair<MessagePtr, MessagePtr> MessageBuilder::makeAutomodMessage(
|
|
const AutomodAction &action, const QString &channelName)
|
|
{
|
|
MessageBuilder builder, builder2;
|
|
|
|
if (action.reasonCode == PubSubAutoModQueueMessage::Reason::BlockedTerm)
|
|
{
|
|
builder.message().flags.set(MessageFlag::AutoModBlockedTerm);
|
|
builder2.message().flags.set(MessageFlag::AutoModBlockedTerm);
|
|
}
|
|
|
|
//
|
|
// Builder for AutoMod message with explanation
|
|
builder.message().id = "automod_" + action.msgID;
|
|
builder.message().loginName = "automod";
|
|
builder.message().channelName = channelName;
|
|
builder.message().flags.set(MessageFlag::PubSub);
|
|
builder.message().flags.set(MessageFlag::ModerationAction);
|
|
builder.message().flags.set(MessageFlag::AutoMod);
|
|
builder.message().flags.set(MessageFlag::AutoModOffendingMessageHeader);
|
|
|
|
// AutoMod shield badge
|
|
builder.emplace<BadgeElement>(makeAutoModBadge(),
|
|
MessageElementFlag::BadgeChannelAuthority);
|
|
// AutoMod "username"
|
|
builder.emplace<TextElement>("AutoMod:", MessageElementFlag::Text,
|
|
AUTOMOD_USER_COLOR, FontStyle::ChatMediumBold);
|
|
// AutoMod header message
|
|
builder.emplace<TextElement>(
|
|
("Held a message for reason: " + action.reason +
|
|
". Allow will post it in chat. "),
|
|
MessageElementFlag::Text, MessageColor::Text);
|
|
// Allow link button
|
|
builder
|
|
.emplace<TextElement>("Allow", MessageElementFlag::Text,
|
|
MessageColor(QColor("green")),
|
|
FontStyle::ChatMediumBold)
|
|
->setLink({Link::AutoModAllow, action.msgID});
|
|
// Deny link button
|
|
builder
|
|
.emplace<TextElement>(" Deny", MessageElementFlag::Text,
|
|
MessageColor(QColor("red")),
|
|
FontStyle::ChatMediumBold)
|
|
->setLink({Link::AutoModDeny, action.msgID});
|
|
// ID of message caught by AutoMod
|
|
// builder.emplace<TextElement>(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<TextElement>("#" + channelName,
|
|
MessageElementFlag::ChannelName,
|
|
MessageColor::System)
|
|
->setLink({Link::JumpToChannel, channelName});
|
|
builder2.emplace<TimestampElement>();
|
|
builder2.emplace<TwitchModerationElement>();
|
|
builder2.message().loginName = action.target.login;
|
|
builder2.message().flags.set(MessageFlag::PubSub);
|
|
builder2.message().flags.set(MessageFlag::ModerationAction);
|
|
builder2.message().flags.set(MessageFlag::AutoMod);
|
|
builder2.message().flags.set(MessageFlag::AutoModOffendingMessage);
|
|
|
|
// sender username
|
|
builder2.emplace<MentionElement>(action.target.displayName + ":",
|
|
action.target.login, MessageColor::Text,
|
|
action.target.color);
|
|
// sender's message caught by AutoMod
|
|
builder2.emplace<TextElement>(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.value_or(QUrl{}),
|
|
highlightResult.alert);
|
|
}
|
|
|
|
return std::make_pair(message1, message2);
|
|
}
|
|
|
|
MessagePtr MessageBuilder::makeAutomodInfoMessage(
|
|
const AutomodInfoAction &action)
|
|
{
|
|
auto builder = MessageBuilder();
|
|
QString text("AutoMod: ");
|
|
|
|
builder.emplace<TimestampElement>();
|
|
builder.message().flags.set(MessageFlag::PubSub);
|
|
|
|
// AutoMod shield badge
|
|
builder.emplace<BadgeElement>(makeAutoModBadge(),
|
|
MessageElementFlag::BadgeChannelAuthority);
|
|
// AutoMod "username"
|
|
builder.emplace<TextElement>("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.appendOrEmplaceText(info, MessageColor::Text);
|
|
}
|
|
break;
|
|
case AutomodInfoAction::Denied: {
|
|
QString info("Mods have removed your message.");
|
|
text += info;
|
|
builder.appendOrEmplaceText(info, MessageColor::Text);
|
|
}
|
|
break;
|
|
case AutomodInfoAction::Approved: {
|
|
QString info("Mods have accepted your message.");
|
|
text += info;
|
|
builder.appendOrEmplaceText(info, 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<MessagePtr, MessagePtr> 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<BadgeElement>(makeAutoModBadge(),
|
|
MessageElementFlag::BadgeChannelAuthority);
|
|
|
|
// Suspicious user header message
|
|
QString prefix = "Suspicious User:";
|
|
builder.emplace<TextElement>(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<TextElement>(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<TextElement>("#" + channelName,
|
|
MessageElementFlag::ChannelName,
|
|
MessageColor::System)
|
|
->setLink({Link::JumpToChannel, channelName});
|
|
builder2.emplace<TimestampElement>();
|
|
builder2.emplace<TwitchModerationElement>();
|
|
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<MentionElement>(
|
|
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<TextElement>(
|
|
fragment.text, MessageElementFlag::Text, MessageColor::Text);
|
|
}
|
|
else
|
|
{
|
|
const auto emotePtr =
|
|
getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote(
|
|
EmoteId{fragment.emoteID}, EmoteName{fragment.text});
|
|
builder2.emplace<EmoteElement>(
|
|
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<TimestampElement>();
|
|
builder.message().flags.set(MessageFlag::System);
|
|
builder.message().flags.set(MessageFlag::PubSub);
|
|
builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
|
|
|
|
builder
|
|
.emplace<TextElement>(action.updatedByUserDisplayName,
|
|
MessageElementFlag::Username,
|
|
MessageColor::System, FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, action.updatedByUserLogin});
|
|
|
|
QString text;
|
|
assert(action.treatment != PubSubLowTrustUsersMessage::Treatment::INVALID);
|
|
switch (action.treatment)
|
|
{
|
|
case PubSubLowTrustUsersMessage::Treatment::NoTreatment: {
|
|
builder.emplace<TextElement>("removed", MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
builder
|
|
.emplace<TextElement>(action.suspiciousUserDisplayName,
|
|
MessageElementFlag::Username,
|
|
MessageColor::System,
|
|
FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, action.suspiciousUserLogin});
|
|
builder.emplace<TextElement>("from the suspicious user list.",
|
|
MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
text = QString("%1 removed %2 from the suspicious user list.")
|
|
.arg(action.updatedByUserDisplayName,
|
|
action.suspiciousUserDisplayName);
|
|
}
|
|
break;
|
|
|
|
case PubSubLowTrustUsersMessage::Treatment::ActiveMonitoring: {
|
|
builder.emplace<TextElement>("added", MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
builder
|
|
.emplace<TextElement>(action.suspiciousUserDisplayName,
|
|
MessageElementFlag::Username,
|
|
MessageColor::System,
|
|
FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, action.suspiciousUserLogin});
|
|
builder.emplace<TextElement>("as a monitored suspicious chatter.",
|
|
MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
text = QString("%1 added %2 as a monitored suspicious chatter.")
|
|
.arg(action.updatedByUserDisplayName,
|
|
action.suspiciousUserDisplayName);
|
|
}
|
|
break;
|
|
|
|
case PubSubLowTrustUsersMessage::Treatment::Restricted: {
|
|
builder.emplace<TextElement>("added", MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
builder
|
|
.emplace<TextElement>(action.suspiciousUserDisplayName,
|
|
MessageElementFlag::Username,
|
|
MessageColor::System,
|
|
FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, action.suspiciousUserLogin});
|
|
builder.emplace<TextElement>("as a restricted suspicious chatter.",
|
|
MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
text = QString("%1 added %2 as a restricted suspicious chatter.")
|
|
.arg(action.updatedByUserDisplayName,
|
|
action.suspiciousUserDisplayName);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
qCDebug(chatterinoTwitch) << "Unexpected suspicious treatment: "
|
|
<< action.treatmentString;
|
|
break;
|
|
}
|
|
|
|
builder->messageText = text;
|
|
builder->searchText = text;
|
|
return builder.release();
|
|
}
|
|
|
|
MessagePtrMut MessageBuilder::makeMissingScopesMessage(
|
|
const QString &missingScopes)
|
|
{
|
|
auto warnText = u"Your account is missing the following permission(s): " %
|
|
missingScopes %
|
|
u". Some features might not work correctly.";
|
|
auto linkText = u"Consider re-adding your account."_s;
|
|
|
|
MessageBuilder builder;
|
|
auto text = warnText % ' ' % linkText;
|
|
builder->messageText = text;
|
|
builder->searchText = text;
|
|
builder->flags.set(MessageFlag::System,
|
|
MessageFlag::DoNotTriggerNotification);
|
|
|
|
builder.emplace<TimestampElement>();
|
|
builder.emplace<TextElement>(warnText, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
builder
|
|
.emplace<TextElement>(linkText, MessageElementFlag::Text,
|
|
MessageColor::Link)
|
|
->setLink({Link::OpenAccountsPage, {}});
|
|
|
|
return builder.release();
|
|
}
|
|
|
|
MessagePtrMut MessageBuilder::makeClearChatMessage(const QDateTime &now,
|
|
const QString &actor,
|
|
uint32_t count)
|
|
{
|
|
MessageBuilder builder;
|
|
builder.emplace<TimestampElement>(now.time());
|
|
builder->count = count;
|
|
builder->serverReceivedTime = now;
|
|
builder.message().flags.set(
|
|
MessageFlag::System, MessageFlag::DoNotTriggerNotification,
|
|
MessageFlag::ClearChat, MessageFlag::ModerationAction);
|
|
|
|
QString messageText;
|
|
if (actor.isEmpty())
|
|
{
|
|
builder.emplaceSystemTextAndUpdate(
|
|
"Chat has been cleared by a moderator.", messageText);
|
|
}
|
|
else
|
|
{
|
|
builder.message().flags.set(MessageFlag::PubSub);
|
|
builder.emplace<MentionElement>(actor, actor, MessageColor::System,
|
|
MessageColor::System);
|
|
messageText = actor + ' ';
|
|
builder.emplaceSystemTextAndUpdate("cleared the chat.", messageText);
|
|
builder->timeoutUser = actor;
|
|
}
|
|
|
|
if (count > 1)
|
|
{
|
|
builder.appendOrEmplaceSystemTextAndUpdate(
|
|
'(' % QString::number(count) % u" times)", messageText);
|
|
}
|
|
|
|
builder->messageText = messageText;
|
|
builder->searchText = messageText;
|
|
|
|
return builder.release();
|
|
}
|
|
|
|
std::pair<MessagePtrMut, HighlightAlert> MessageBuilder::makeIrcMessage(
|
|
/* mutable */ Channel *channel, const Communi::IrcMessage *ircMessage,
|
|
const MessageParseArgs &args, /* mutable */ QString content,
|
|
const QString::size_type messageOffset,
|
|
const std::shared_ptr<MessageThread> &thread, const MessagePtr &parent)
|
|
{
|
|
assert(ircMessage != nullptr);
|
|
assert(channel != nullptr);
|
|
|
|
auto tags = ircMessage->tags();
|
|
if (args.allowIgnore)
|
|
{
|
|
bool ignored = MessageBuilder::isIgnored(
|
|
content, tags.value("user-id").toString(), channel);
|
|
if (ignored)
|
|
{
|
|
return {};
|
|
}
|
|
}
|
|
|
|
auto *twitchChannel = dynamic_cast<TwitchChannel *>(channel);
|
|
|
|
auto userID = tags.value("user-id").toString();
|
|
|
|
MessageBuilder builder;
|
|
builder.parseUsernameColor(tags, userID);
|
|
builder->userID = userID;
|
|
|
|
if (args.isAction)
|
|
{
|
|
builder.textColor_ = builder.message_->usernameColor;
|
|
builder->flags.set(MessageFlag::Action);
|
|
}
|
|
|
|
builder.parseUsername(ircMessage, twitchChannel,
|
|
args.trimSubscriberUsername);
|
|
|
|
builder->flags.set(MessageFlag::Collapsed);
|
|
|
|
bool senderIsBroadcaster = builder->loginName == channel->getName();
|
|
|
|
builder->channelName = channel->getName();
|
|
|
|
builder.parseMessageID(tags);
|
|
|
|
MessageBuilder::parseRoomID(tags, twitchChannel);
|
|
twitchChannel = builder.parseSharedChatInfo(tags, twitchChannel);
|
|
|
|
// If it is a reward it has to be appended first
|
|
if (!args.channelPointRewardId.isEmpty())
|
|
{
|
|
assert(twitchChannel != nullptr);
|
|
auto reward =
|
|
twitchChannel->channelPointReward(args.channelPointRewardId);
|
|
if (reward)
|
|
{
|
|
builder.appendChannelPointRewardMessage(*reward, channel->isMod(),
|
|
channel->isBroadcaster());
|
|
}
|
|
builder->flags.set(MessageFlag::RedeemedChannelPointReward);
|
|
}
|
|
|
|
builder.appendChannelName(channel);
|
|
|
|
if (tags.contains("rm-deleted"))
|
|
{
|
|
builder->flags.set(MessageFlag::Disabled);
|
|
}
|
|
|
|
if (tags.contains("msg-id") &&
|
|
tags["msg-id"].toString().split(';').contains("highlighted-message"))
|
|
{
|
|
builder->flags.set(MessageFlag::RedeemedHighlight);
|
|
}
|
|
|
|
if (tags.contains("first-msg") && tags["first-msg"].toString() == "1")
|
|
{
|
|
builder->flags.set(MessageFlag::FirstMessage);
|
|
}
|
|
|
|
if (tags.contains("pinned-chat-paid-amount"))
|
|
{
|
|
builder->flags.set(MessageFlag::ElevatedMessage);
|
|
}
|
|
|
|
if (tags.contains("bits"))
|
|
{
|
|
builder->flags.set(MessageFlag::CheerMessage);
|
|
}
|
|
|
|
// reply threads
|
|
builder.parseThread(content, tags, channel, thread, parent);
|
|
|
|
// timestamp
|
|
builder->serverReceivedTime = calculateMessageTime(ircMessage);
|
|
builder.emplace<TimestampElement>(builder->serverReceivedTime.time());
|
|
|
|
bool shouldAddModerationElements = [&] {
|
|
if (senderIsBroadcaster)
|
|
{
|
|
// You cannot timeout the broadcaster
|
|
return false;
|
|
}
|
|
|
|
if (tags.value("user-type").toString() == "mod" &&
|
|
!args.isStaffOrBroadcaster)
|
|
{
|
|
// You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}();
|
|
if (shouldAddModerationElements)
|
|
{
|
|
builder.emplace<TwitchModerationElement>();
|
|
}
|
|
|
|
builder.appendTwitchBadges(tags, twitchChannel);
|
|
|
|
builder.appendChatterinoBadges(userID);
|
|
builder.appendFfzBadges(twitchChannel, userID);
|
|
builder.appendSeventvBadges(userID);
|
|
|
|
builder.appendUsername(tags, args);
|
|
|
|
TextState textState{.twitchChannel = twitchChannel};
|
|
QString bits;
|
|
|
|
auto iterator = tags.find("bits");
|
|
if (iterator != tags.end())
|
|
{
|
|
textState.hasBits = true;
|
|
textState.bitsLeft = iterator.value().toInt();
|
|
bits = iterator.value().toString();
|
|
}
|
|
|
|
// Twitch emotes
|
|
auto twitchEmotes =
|
|
parseTwitchEmotes(tags, content, static_cast<int>(messageOffset));
|
|
|
|
// This runs through all ignored phrases and runs its replacements on content
|
|
processIgnorePhrases(*getSettings()->ignoredMessages.readOnly(), content,
|
|
twitchEmotes);
|
|
|
|
std::ranges::sort(twitchEmotes, [](const auto &a, const auto &b) {
|
|
return a.start < b.start;
|
|
});
|
|
auto uniqueEmotes = std::ranges::unique(
|
|
twitchEmotes, [](const auto &first, const auto &second) {
|
|
return first.start == second.start;
|
|
});
|
|
twitchEmotes.erase(uniqueEmotes.begin(), uniqueEmotes.end());
|
|
|
|
// words
|
|
QStringList splits = content.split(' ');
|
|
|
|
builder.addWords(splits, twitchEmotes, textState);
|
|
|
|
QString stylizedUsername =
|
|
stylizeUsername(builder->loginName, builder.message());
|
|
|
|
builder->messageText = content;
|
|
builder->searchText = stylizedUsername + " " + builder->localizedName +
|
|
" " + builder->loginName + ": " + content + " " +
|
|
builder->searchText;
|
|
|
|
// highlights
|
|
HighlightAlert highlight = builder.parseHighlights(tags, content, args);
|
|
if (tags.contains("historical"))
|
|
{
|
|
highlight.playSound = false;
|
|
highlight.windowAlert = false;
|
|
}
|
|
|
|
// highlighting incoming whispers if requested per setting
|
|
if (args.isReceivedWhisper && getSettings()->highlightInlineWhispers)
|
|
{
|
|
builder->flags.set(MessageFlag::HighlightedWhisper);
|
|
builder->highlightColor =
|
|
ColorProvider::instance().color(ColorType::Whisper);
|
|
}
|
|
|
|
if (!args.isReceivedWhisper && tags.value("msg-id") != "announcement")
|
|
{
|
|
if (thread)
|
|
{
|
|
auto &img = getResources().buttons.replyThreadDark;
|
|
builder
|
|
.emplace<CircularImageElement>(
|
|
Image::fromResourcePixmap(img, 0.15), 2, Qt::gray,
|
|
MessageElementFlag::ReplyButton)
|
|
->setLink({Link::ViewThread, thread->rootId()});
|
|
}
|
|
else
|
|
{
|
|
auto &img = getResources().buttons.replyDark;
|
|
builder
|
|
.emplace<CircularImageElement>(
|
|
Image::fromResourcePixmap(img, 0.15), 2, Qt::gray,
|
|
MessageElementFlag::ReplyButton)
|
|
->setLink({Link::ReplyToMessage, builder->id});
|
|
}
|
|
}
|
|
|
|
return {builder.release(), highlight};
|
|
}
|
|
|
|
void MessageBuilder::addEmoji(const EmotePtr &emote)
|
|
{
|
|
this->emplace<EmoteElement>(emote, MessageElementFlag::EmojiAll);
|
|
}
|
|
|
|
void MessageBuilder::addTextOrEmote(TextState &state, QString string)
|
|
{
|
|
if (state.hasBits && this->tryAppendCheermote(state, 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(state.twitchChannel, {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 (state.twitchChannel != nullptr)
|
|
{
|
|
if (auto userColor =
|
|
state.twitchChannel->getUserColor(username);
|
|
userColor.isValid())
|
|
{
|
|
textColor = userColor;
|
|
}
|
|
}
|
|
|
|
auto prefixedUsername = '@' + username;
|
|
auto remainder = string.remove(prefixedUsername);
|
|
this->emplace<MentionElement>(prefixedUsername, username,
|
|
originalTextColor, textColor)
|
|
->setTrailingSpace(remainder.isEmpty());
|
|
|
|
if (!remainder.isEmpty())
|
|
{
|
|
this->emplace<TextElement>(remainder, MessageElementFlag::Text,
|
|
originalTextColor);
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (state.twitchChannel != nullptr && getSettings()->findAllUsernames)
|
|
{
|
|
auto match = allUsernamesMentionRegex.match(string);
|
|
QString username = match.captured(1);
|
|
|
|
if (match.hasMatch() &&
|
|
state.twitchChannel->accessChatters()->contains(username))
|
|
{
|
|
auto originalTextColor = textColor;
|
|
|
|
if (auto userColor = state.twitchChannel->getUserColor(username);
|
|
userColor.isValid())
|
|
{
|
|
textColor = userColor;
|
|
}
|
|
|
|
auto remainder = string.remove(username);
|
|
this->emplace<MentionElement>(username, username, originalTextColor,
|
|
textColor)
|
|
->setTrailingSpace(remainder.isEmpty());
|
|
|
|
if (!remainder.isEmpty())
|
|
{
|
|
this->emplace<TextElement>(remainder, MessageElementFlag::Text,
|
|
originalTextColor);
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
this->appendOrEmplaceText(string, textColor);
|
|
}
|
|
|
|
bool MessageBuilder::isEmpty() const
|
|
{
|
|
return this->message_->elements.empty();
|
|
}
|
|
|
|
MessageElement &MessageBuilder::back()
|
|
{
|
|
assert(!this->isEmpty());
|
|
return *this->message().elements.back();
|
|
}
|
|
|
|
std::unique_ptr<MessageElement> 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,
|
|
QString &toUpdate)
|
|
{
|
|
toUpdate.append(text + " ");
|
|
return this->emplace<TextElement>(text, MessageElementFlag::Text,
|
|
MessageColor::System);
|
|
}
|
|
|
|
void MessageBuilder::parseUsernameColor(const QVariantMap &tags,
|
|
const QString &userID)
|
|
{
|
|
const auto *userData = getApp()->getUserData();
|
|
assert(userData != nullptr);
|
|
|
|
if (const auto &user = userData->getUser(userID))
|
|
{
|
|
if (user->color)
|
|
{
|
|
this->usernameColor_ = user->color.value();
|
|
this->message().usernameColor = this->usernameColor_;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const auto iterator = tags.find("color");
|
|
if (iterator != tags.end())
|
|
{
|
|
if (const auto color = iterator.value().toString(); !color.isEmpty())
|
|
{
|
|
this->usernameColor_ = QColor(color);
|
|
this->message().usernameColor = this->usernameColor_;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (getSettings()->colorizeNicknames && tags.contains("user-id"))
|
|
{
|
|
this->usernameColor_ = getRandomColor(tags.value("user-id").toString());
|
|
this->message().usernameColor = this->usernameColor_;
|
|
}
|
|
}
|
|
|
|
void MessageBuilder::parseUsername(const Communi::IrcMessage *ircMessage,
|
|
TwitchChannel *twitchChannel,
|
|
bool trimSubscriberUsername)
|
|
{
|
|
// username
|
|
auto userName = ircMessage->nick();
|
|
|
|
if (userName.isEmpty() || trimSubscriberUsername)
|
|
{
|
|
userName = ircMessage->tag("login").toString();
|
|
}
|
|
|
|
this->message_->loginName = userName;
|
|
if (twitchChannel != nullptr)
|
|
{
|
|
twitchChannel->setUserColor(userName, this->message_->usernameColor);
|
|
}
|
|
|
|
// Update current user color if this is our message
|
|
auto currentUser = getApp()->getAccounts()->twitch.getCurrent();
|
|
if (ircMessage->nick() == currentUser->getUserName())
|
|
{
|
|
currentUser->setColor(this->message_->usernameColor);
|
|
}
|
|
}
|
|
|
|
void MessageBuilder::parseMessageID(const QVariantMap &tags)
|
|
{
|
|
auto iterator = tags.find("id");
|
|
|
|
if (iterator != tags.end())
|
|
{
|
|
this->message().id = iterator.value().toString();
|
|
}
|
|
}
|
|
|
|
QString MessageBuilder::parseRoomID(const QVariantMap &tags,
|
|
TwitchChannel *twitchChannel)
|
|
{
|
|
if (twitchChannel == nullptr)
|
|
{
|
|
return {};
|
|
}
|
|
|
|
auto iterator = tags.find("room-id");
|
|
|
|
if (iterator != std::end(tags))
|
|
{
|
|
auto roomID = iterator->toString();
|
|
if (twitchChannel->roomId() != roomID)
|
|
{
|
|
if (twitchChannel->roomId().isEmpty())
|
|
{
|
|
twitchChannel->setRoomId(roomID);
|
|
}
|
|
else
|
|
{
|
|
qCWarning(chatterinoTwitch)
|
|
<< "The room-ID of the received message doesn't match the "
|
|
"room-ID of the channel - received:"
|
|
<< roomID << "channel:" << twitchChannel->roomId();
|
|
}
|
|
}
|
|
return roomID;
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
TwitchChannel *MessageBuilder::parseSharedChatInfo(const QVariantMap &tags,
|
|
TwitchChannel *twitchChannel)
|
|
{
|
|
if (!twitchChannel)
|
|
{
|
|
return twitchChannel;
|
|
}
|
|
|
|
if (auto it = tags.find("source-room-id"); it != tags.end())
|
|
{
|
|
auto sourceRoom = it.value().toString();
|
|
if (twitchChannel->roomId() != sourceRoom)
|
|
{
|
|
this->message().flags.set(MessageFlag::SharedMessage);
|
|
|
|
auto sourceChan =
|
|
getApp()->getTwitch()->getChannelOrEmptyByID(sourceRoom);
|
|
if (sourceChan && !sourceChan->isEmpty())
|
|
{
|
|
// avoid duplicate pings
|
|
this->message().flags.set(
|
|
MessageFlag::DoNotTriggerNotification);
|
|
|
|
auto *chan = dynamic_cast<TwitchChannel *>(sourceChan.get());
|
|
if (chan)
|
|
{
|
|
return chan;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return twitchChannel;
|
|
}
|
|
|
|
void MessageBuilder::parseThread(const QString &messageContent,
|
|
const QVariantMap &tags,
|
|
const Channel *channel,
|
|
const std::shared_ptr<MessageThread> &thread,
|
|
const MessagePtr &parent)
|
|
{
|
|
if (thread)
|
|
{
|
|
// set references
|
|
this->message().replyThread = thread;
|
|
this->message().replyParent = parent;
|
|
thread->addToThread(std::weak_ptr{this->message_});
|
|
|
|
if (thread->subscribed())
|
|
{
|
|
this->message().flags.set(MessageFlag::SubscribedThread);
|
|
}
|
|
|
|
// enable reply flag
|
|
this->message().flags.set(MessageFlag::ReplyMessage);
|
|
|
|
MessagePtr threadRoot;
|
|
if (!parent)
|
|
{
|
|
threadRoot = thread->root();
|
|
}
|
|
else
|
|
{
|
|
threadRoot = parent;
|
|
}
|
|
|
|
QString usernameText =
|
|
stylizeUsername(threadRoot->loginName, *threadRoot);
|
|
|
|
this->emplace<ReplyCurveElement>();
|
|
|
|
// construct reply elements
|
|
this->emplace<TextElement>(
|
|
"Replying to", MessageElementFlag::RepliedMessage,
|
|
MessageColor::System, FontStyle::ChatMediumSmall)
|
|
->setLink({Link::ViewThread, thread->rootId()});
|
|
|
|
this->emplace<TextElement>(
|
|
"@" + 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<SingleLineTextElement>(
|
|
threadRoot->messageText,
|
|
MessageElementFlags({MessageElementFlag::RepliedMessage,
|
|
MessageElementFlag::Text}),
|
|
color, FontStyle::ChatMediumSmall)
|
|
->setLink({Link::ViewThread, thread->rootId()});
|
|
}
|
|
else if (tags.find("reply-parent-msg-id") != tags.end())
|
|
{
|
|
// Message is a reply but we couldn't find the original message.
|
|
// Render the message using the additional reply tags
|
|
|
|
auto replyDisplayName = tags.find("reply-parent-display-name");
|
|
auto replyBody = tags.find("reply-parent-msg-body");
|
|
|
|
if (replyDisplayName != tags.end() && replyBody != tags.end())
|
|
{
|
|
QString body;
|
|
|
|
this->emplace<ReplyCurveElement>();
|
|
this->emplace<TextElement>(
|
|
"Replying to", MessageElementFlag::RepliedMessage,
|
|
MessageColor::System, FontStyle::ChatMediumSmall);
|
|
|
|
bool ignored = MessageBuilder::isIgnored(
|
|
messageContent, tags.value("reply-parent-user-id").toString(),
|
|
channel);
|
|
if (ignored)
|
|
{
|
|
body = QString("[Blocked user]");
|
|
}
|
|
else
|
|
{
|
|
auto name = replyDisplayName->toString();
|
|
body = parseTagString(replyBody->toString());
|
|
|
|
this->emplace<TextElement>(
|
|
"@" + name + ":", MessageElementFlag::RepliedMessage,
|
|
this->textColor_, FontStyle::ChatMediumSmall)
|
|
->setLink({Link::UserInfo, name});
|
|
}
|
|
|
|
this->emplace<SingleLineTextElement>(
|
|
body,
|
|
MessageElementFlags({MessageElementFlag::RepliedMessage,
|
|
MessageElementFlag::Text}),
|
|
this->textColor_, FontStyle::ChatMediumSmall);
|
|
}
|
|
}
|
|
}
|
|
|
|
HighlightAlert MessageBuilder::parseHighlights(const QVariantMap &tags,
|
|
const QString &originalMessage,
|
|
const MessageParseArgs &args)
|
|
{
|
|
if (getSettings()->isBlacklistedUser(this->message().loginName))
|
|
{
|
|
// Do nothing. We ignore highlights from this user.
|
|
return {};
|
|
}
|
|
|
|
auto badges = parseBadgeTag(tags);
|
|
auto [highlighted, highlightResult] = getApp()->getHighlights()->check(
|
|
args, badges, this->message().loginName, 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->message().highlightColor = highlightResult.color;
|
|
|
|
if (highlightResult.showInMentions)
|
|
{
|
|
this->message().flags.set(MessageFlag::ShowInMentions);
|
|
}
|
|
|
|
auto customSound = [&] {
|
|
if (highlightResult.customSoundUrl)
|
|
{
|
|
return *highlightResult.customSoundUrl;
|
|
}
|
|
return QUrl{};
|
|
}();
|
|
return {
|
|
.customSound = customSound,
|
|
.playSound = highlightResult.playSound,
|
|
.windowAlert = highlightResult.alert,
|
|
};
|
|
}
|
|
|
|
void MessageBuilder::appendChannelName(const Channel *channel)
|
|
{
|
|
QString channelName("#" + channel->getName());
|
|
Link link(Link::JumpToChannel, channel->getName());
|
|
|
|
this->emplace<TextElement>(channelName, MessageElementFlag::ChannelName,
|
|
MessageColor::System)
|
|
->setLink(link);
|
|
}
|
|
|
|
void MessageBuilder::appendUsername(const QVariantMap &tags,
|
|
const MessageParseArgs &args)
|
|
{
|
|
auto *app = getApp();
|
|
|
|
QString username = this->message_->loginName;
|
|
QString localizedName;
|
|
|
|
auto iterator = tags.find("display-name");
|
|
if (iterator != tags.end())
|
|
{
|
|
QString displayName =
|
|
parseTagString(iterator.value().toString()).trimmed();
|
|
|
|
if (QString::compare(displayName, 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 (args.isSentWhisper)
|
|
{
|
|
// TODO(pajlada): Re-implement
|
|
// userDisplayString +=
|
|
// IrcManager::instance().getUser().getUserName();
|
|
}
|
|
else if (args.isReceivedWhisper)
|
|
{
|
|
// Sender username
|
|
this->emplace<TextElement>(usernameText, MessageElementFlag::Username,
|
|
this->usernameColor_,
|
|
FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserWhisper, this->message().displayName});
|
|
|
|
auto currentUser = app->getAccounts()->twitch.getCurrent();
|
|
|
|
// Separator
|
|
this->emplace<TextElement>("->", MessageElementFlag::Username,
|
|
MessageColor::System, FontStyle::ChatMedium);
|
|
|
|
QColor selfColor = currentUser->color();
|
|
MessageColor selfMsgColor =
|
|
selfColor.isValid() ? selfColor : MessageColor::System;
|
|
|
|
// Your own username
|
|
this->emplace<TextElement>(currentUser->getUserName() + ":",
|
|
MessageElementFlag::Username, selfMsgColor,
|
|
FontStyle::ChatMediumBold);
|
|
}
|
|
else
|
|
{
|
|
if (!args.isAction)
|
|
{
|
|
usernameText += ":";
|
|
}
|
|
|
|
this->emplace<TextElement>(usernameText, MessageElementFlag::Username,
|
|
this->usernameColor_,
|
|
FontStyle::ChatMediumBold)
|
|
->setLink({Link::UserInfo, this->message().displayName});
|
|
}
|
|
}
|
|
|
|
Outcome MessageBuilder::tryAppendEmote(TwitchChannel *twitchChannel,
|
|
const EmoteName &name)
|
|
{
|
|
auto [emote, flags, zeroWidth] = parseEmote(twitchChannel, name);
|
|
|
|
if (!emote)
|
|
{
|
|
return Failure;
|
|
}
|
|
|
|
if (zeroWidth && getSettings()->enableZeroWidthEmotes && !this->isEmpty())
|
|
{
|
|
// Attempt to merge current zero-width emote into any previous emotes
|
|
auto *asEmote = dynamic_cast<EmoteElement *>(&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<LayeredEmoteElement::Emote> layers = {
|
|
{baseEmote, baseEmoteElement->getFlags()}, {*emote, flags}};
|
|
this->emplace<LayeredEmoteElement>(
|
|
std::move(layers), baseEmoteElement->getFlags() | flags,
|
|
this->textColor_);
|
|
return Success;
|
|
}
|
|
|
|
auto *asLayered = dynamic_cast<LayeredEmoteElement *>(&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<EmoteElement>(*emote, flags, this->textColor_);
|
|
return Success;
|
|
}
|
|
|
|
void MessageBuilder::addWords(
|
|
const QStringList &words,
|
|
const std::vector<TwitchEmoteOccurrence> &twitchEmotes, TextState &state)
|
|
{
|
|
// 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<EmoteElement>(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(variant::Overloaded{
|
|
[&](const EmotePtr &emote) {
|
|
this->addEmoji(emote);
|
|
},
|
|
[&](QString text) {
|
|
this->addTextOrEmote(
|
|
state, std::move(text));
|
|
},
|
|
},
|
|
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(variant::Overloaded{
|
|
[&](const EmotePtr &emote) {
|
|
this->addEmoji(emote);
|
|
},
|
|
[&](QString text) {
|
|
this->addTextOrEmote(state,
|
|
std::move(text));
|
|
},
|
|
},
|
|
variant);
|
|
}
|
|
|
|
cursor += word.size() + 1;
|
|
}
|
|
}
|
|
|
|
void MessageBuilder::appendTwitchBadges(const QVariantMap &tags,
|
|
TwitchChannel *twitchChannel)
|
|
{
|
|
if (twitchChannel == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (this->message().flags.has(MessageFlag::SharedMessage))
|
|
{
|
|
const QString sourceId = tags["source-room-id"].toString();
|
|
QString sourceName;
|
|
if (sourceId.isEmpty())
|
|
{
|
|
sourceName = "";
|
|
}
|
|
else if (twitchChannel->roomId() == sourceId)
|
|
{
|
|
sourceName = twitchChannel->getName();
|
|
}
|
|
else
|
|
{
|
|
sourceName =
|
|
getApp()->getTwitchUsers()->resolveID({sourceId})->displayName;
|
|
}
|
|
|
|
this->emplace<BadgeElement>(makeSharedChatBadge(sourceName),
|
|
MessageElementFlag::BadgeSharedChannel);
|
|
}
|
|
|
|
auto badgeInfos = parseBadgeInfoTag(tags);
|
|
auto badges = parseBadgeTag(tags);
|
|
appendBadges(this, badges, badgeInfos, twitchChannel);
|
|
}
|
|
|
|
void MessageBuilder::appendChatterinoBadges(const QString &userID)
|
|
{
|
|
if (auto badge = getApp()->getChatterinoBadges()->getBadge({userID}))
|
|
{
|
|
this->emplace<BadgeElement>(*badge,
|
|
MessageElementFlag::BadgeChatterino);
|
|
}
|
|
}
|
|
|
|
void MessageBuilder::appendFfzBadges(TwitchChannel *twitchChannel,
|
|
const QString &userID)
|
|
{
|
|
for (const auto &badge : getApp()->getFfzBadges()->getUserBadges({userID}))
|
|
{
|
|
this->emplace<FfzBadgeElement>(
|
|
badge.emote, MessageElementFlag::BadgeFfz, badge.color);
|
|
}
|
|
|
|
if (twitchChannel == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (const auto &badge : twitchChannel->ffzChannelBadges(userID))
|
|
{
|
|
this->emplace<FfzBadgeElement>(
|
|
badge.emote, MessageElementFlag::BadgeFfz, badge.color);
|
|
}
|
|
}
|
|
|
|
void MessageBuilder::appendSeventvBadges(const QString &userID)
|
|
{
|
|
if (auto badge = getApp()->getSeventvBadges()->getBadge({userID}))
|
|
{
|
|
this->emplace<BadgeElement>(*badge, MessageElementFlag::BadgeSevenTV);
|
|
}
|
|
}
|
|
|
|
Outcome MessageBuilder::tryAppendCheermote(TextState &state,
|
|
const QString &string)
|
|
{
|
|
if (state.bitsLeft == 0)
|
|
{
|
|
return Failure;
|
|
}
|
|
|
|
auto cheerOpt = state.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 (state.bitsStacked)
|
|
{
|
|
return Success;
|
|
}
|
|
if (cheerEmote.staticEmote)
|
|
{
|
|
this->emplace<EmoteElement>(cheerEmote.staticEmote,
|
|
MessageElementFlag::BitsStatic,
|
|
this->textColor_);
|
|
}
|
|
if (cheerEmote.animatedEmote)
|
|
{
|
|
this->emplace<EmoteElement>(cheerEmote.animatedEmote,
|
|
MessageElementFlag::BitsAnimated,
|
|
this->textColor_);
|
|
}
|
|
if (cheerEmote.color != QColor())
|
|
{
|
|
this->emplace<TextElement>(QString::number(state.bitsLeft),
|
|
MessageElementFlag::BitsAmount,
|
|
cheerEmote.color);
|
|
}
|
|
state.bitsStacked = true;
|
|
return Success;
|
|
}
|
|
|
|
if (state.bitsLeft >= cheerValue)
|
|
{
|
|
state.bitsLeft -= cheerValue;
|
|
}
|
|
else
|
|
{
|
|
QString newString = string;
|
|
newString.chop(QString::number(cheerValue).length());
|
|
newString += QString::number(cheerValue - state.bitsLeft);
|
|
|
|
return this->tryAppendCheermote(state, newString);
|
|
}
|
|
|
|
if (cheerEmote.staticEmote)
|
|
{
|
|
this->emplace<EmoteElement>(cheerEmote.staticEmote,
|
|
MessageElementFlag::BitsStatic,
|
|
this->textColor_);
|
|
}
|
|
if (cheerEmote.animatedEmote)
|
|
{
|
|
this->emplace<EmoteElement>(cheerEmote.animatedEmote,
|
|
MessageElementFlag::BitsAnimated,
|
|
this->textColor_);
|
|
}
|
|
if (cheerEmote.color != QColor())
|
|
{
|
|
this->emplace<TextElement>(match.captured(1),
|
|
MessageElementFlag::BitsAmount,
|
|
cheerEmote.color);
|
|
}
|
|
|
|
return Success;
|
|
}
|
|
|
|
} // namespace chatterino
|